From cd565b73c7db39b2a279c0875e6ef84433c8d66d Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 17 Oct 2026 13:27:53 -0400 Subject: [PATCH] Taxes stage 2: Tax Collected report rebuilt for filing with an Invoiced basis (each sent invoice in the month of its date) or Paid basis (each payment carrying its share of its invoice), tax by part of each rate with registration numbers so a state's and a county's parts count apart and parts named alike add up in one row, sales before tax and net of discounts split into taxable, exempt and non-taxable, tax paid on purchases by part and net tax collected less paid, exempt sales by client with certificate and expiry, quarterly or monthly with a chart and CSV export of the tax table or the exempt sales, archived payments left out; expenses and recurring expenses record the tax included in them with a rate worked out from the amount as typed or typed over from the receipt, a tax amount with no rate allowed, refunds giving their tax back, recurring expenses copying it to each expense recorded, vendor defaults filling it in, and the list and quick looks showing it; exports gain invoice subtotal, discount, tax and tax by part, quote and recurring invoice tax, expense before tax, tax and tax rate, and client tax rate and exemption columns; API adds a taxes read endpoint with each rate's parts, subtotal, tax, tax by part and registration numbers on invoice and quote reads, and each line's rate on invoice item reads; Admin > Taxes explains naming parts alike and links the report; demo data names Allegheny County's state part PA Sales Tax and taxes the company's own-use purchases; removes the unused getMonthlyTax and getQuarterlyTax; database 3.1.2 adds expense and recurring expense tax rate and amount; changelog updated --- CHANGELOG.md | 35 +- admin/database_updates/3.1.2.php | 20 ++ admin/modals/tax/tax_form.php | 2 +- admin/post/demo_data_model.php | 10 +- admin/tax_rates.php | 3 +- agent/expenses.php | 1 + agent/modals/expense/expense.php | 9 + agent/modals/expense/expense_form.php | 23 +- agent/modals/expense/expense_tax_fields.php | 90 ++++++ .../recurring_expense/recurring_expense.php | 3 + .../recurring_expense_form.php | 17 +- agent/post/client.php | 5 + agent/post/expense.php | 2 + agent/post/invoice.php | 12 + agent/post/quote.php | 7 +- agent/post/recurring_invoice.php | 5 +- agent/reports/tax_summary.php | 304 ++++++++++-------- api/v1/enforce_api_rbac.php | 1 + api/v1/invoice_items/read.php | 9 +- api/v1/invoices/read.php | 10 + api/v1/quotes/read.php | 10 + api/v1/taxes/read.php | 62 ++++ db.sql | 4 + functions/app.php | 41 --- functions/billing.php | 51 ++- functions/expenses.php | 72 ++++- functions/export.php | 13 + functions/reports.php | 2 +- functions/tax.php | 198 ++++++++++++ 29 files changed, 797 insertions(+), 224 deletions(-) create mode 100644 admin/database_updates/3.1.2.php create mode 100644 agent/modals/expense/expense_tax_fields.php create mode 100644 api/v1/taxes/read.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bac4601..973f8e306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.1) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.2) in the same pass. Nothing else is required. -This is a big release, with thirty-two database steps in one go, so take a backup before you update. +This is a big release, with thirty-three database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -31,7 +31,7 @@ Other income already entered (money in that isn't for an invoice, which used to Every account is given a kind - checking, savings, credit card, cash or payment processor - guessed from its name: Visa, Amex or card is a credit card, savings or reserve is savings, cash or petty cash is cash, and Stripe, PayPal, Square or merchant is a payment processor. An account given a liability type back when accounts had types (2023) is a credit card, so what is on it reads as owed. Anything else is checking. Change any that came out wrong with Edit on the Accounts page. An account's balance now leaves out archived payments, income and expenses everywhere, so the Accounts page can show less or more than before (see Bug Fixes). No account is reconciled after the update. The first statement you reconcile on each can start from the bank's balance on the day it starts, bringing everything before it forward as reconciled, or from the account's opening balance (see New Features, Accounts and transactions). Transfers already made out of a cash account count as having taken everything in it up to then to the bank, so the first deposit you make after the update only offers the checks and cash taken in since the last one. -Taxes keep their names and rates, now kept to four decimal places. No client has a rate of its own or is tax exempt, and there is no default rate, so new lines start untaxed as before until you pick one in Admin > Taxes. Billed time, trips and expenses stay untaxed until you switch them on there. Discounts now come off before tax on invoices and quotes that are still open, the next time they are edited (see Breaking Changes, Taxes). +Taxes keep their names and rates, now kept to four decimal places. No client has a rate of its own or is tax exempt, and there is no default rate, so new lines start untaxed as before until you pick one in Admin > Taxes. Billed time, trips and expenses stay untaxed until you switch them on there. Discounts now come off before tax on invoices and quotes that are still open, the next time they are edited (see Breaking Changes, Taxes). No expense has tax recorded on it until you add it, so Tax Collected shows no tax paid on purchases for the months before. If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. @@ -127,6 +127,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - A rate can only be deleted when nothing has ever used it - archive it otherwise. Archiving asks where to move the products, clients, recurring invoices, drafts and default that use it. Left where they are, recurring invoices and drafts keep charging it, and new lines for its products and clients take the next rate along. - Tax Rates in the Admin menu is now Taxes. - A line on a rate made of parts is taxed part by part, each to the cent, so its tax can be a cent off the whole rate times the line. + - Tax Collected now shows tax by part, so a rate made of a state's and a county's part shows two rows, and a part named the same on two rates shows one. Its sales are net of discounts, where they used to be the lines before any discount, and archived payments no longer count. It still counts as payments come in unless you switch it to Invoiced. - Notifications now follow access too. An agent only sees notifications for clients and areas they can open, so someone without sales access no longer sees invoice and quote notifications. - Drop-downs: Tom Select is gone, replaced by ITFlow's own picker. It works the way you are used to: type to search, tick several in a multi-select, and type a new value in fields that allow it, such as ticket watchers. See Developer Updates if you have custom pages that used Tom Select. - Several small windows are gone because the change is now made where the value is shown: @@ -231,7 +232,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Spend by Vendor and Income by Client: the period against the one before, with a switch for those paid 600 or more for 1099s. Spend by Vendor adds each vendor's main category and recurring spend. Income by Client adds recurring revenue and the share of your top five clients. - Receivables Aging: what each client owes, split into not due yet, 1 to 30, 31 to 60, 61 to 90 and over 90 days late, and the overdue invoices, longest overdue first. - Recurring Revenue: monthly and yearly recurring revenue by client and category, and every recurring invoice with its next date. With Financial access, what is left after recurring expenses. - - Tax Collected: tax and the sales it was charged on, per tax, by quarter or by month. + - Tax Collected: tax by part of each rate, as invoiced or as paid, with taxable, exempt and non-taxable sales, the tax paid on purchases and what is left, by quarter or by month (see Taxes). - Unbilled Work: billable tickets that are done but not invoiced, with the hours to bill (rounded the way Billing Time in Admin > Settings > Invoice says, as Unbilled Time does) and what they are worth at the client's rate. - Time Detail by Client: per client, each ticket and the time on every reply, with the hours to bill. - Ticket Overview: tickets opened against resolved, the typical first reply and time to resolve, sources, priorities, categories, top clients and contacts, and a weekday by hour chart of when tickets come in. @@ -731,8 +732,18 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Each client can have its own rate, on the Billing tab of its Edit window, for where it is. New lines start on the client's rate, then the product's, then the default, whether typed in, picked from the catalog, or made from tickets, Unbilled Time, trips and expenses. A product set to no tax stays untaxed. Unbilled Time bills each line at its client's usual rate unless you pick one, and the ticket's Invoice and Quote windows, Add Tickets, Add Expenses and Add Trips start on it. - Tax exempt clients, with the certificate number and when it expires, on the Billing tab. Their new lines start untaxed, and their pages say Tax exempt, or that the certificate has run out. An open invoice, quote or recurring invoice that still has taxed lines for them says so, with Take the tax off. An invoice or quote with no tax on it says Tax exempt with the certificate number under the client's name. - The client overview's Terms shows the client's rate, or that it is tax exempt. - - API: clients take `client_default_tax_id`, `client_tax_exempt`, `client_tax_exempt_number` and `client_tax_exempt_expires`, and a new invoice line takes `tax_id` `default` for the client's usual rate. - - Demo data: both taxes have a registration number, Allegheny County Sales Tax is made of Pennsylvania's 6% and the county's 1%, each client has its rate, and the church and the charter school are tax exempt, one with a certificate that has run out. + - Tax Collected (Reports) is rebuilt for filing: + - Invoiced counts each invoice once it is sent, in the month of its date. Paid counts each payment with its share of its invoice's tax and sales, so a part-paid invoice counts only the part paid. + - Tax shows by part of each rate, with its registration number, so a state's and a county's tax show apart. Name a part the same on two rates and they add up together. + - Sales, before tax and net of discounts, split into taxable, exempt (no tax, for a client marked tax exempt) and non-taxable (no tax, for anyone else). + - The tax paid on purchases, by part, and the net tax - what was collected less what was paid - for GST, HST and VAT returns. + - Exempt sales by client, with each certificate and when it expires, expired ones flagged. + - By quarter or month, with a chart of each month's tax. Export the table, or the exempt sales by client, as CSV. + - Expenses record the tax included in them: pick the rate and the tax is worked out from the amount as you type, or type over it with what the receipt says. A tax with no rate of yours can be typed in on its own. Money back gives its tax back. The list and the quick look show the tax, and picking a vendor fills in the tax of the last expense with them along with the rest. + - Recurring expenses take a tax too, and put it on each expense they record. + - Exports: invoices can add Subtotal, Discount, Tax and Tax by Part; quotes and recurring invoices Tax; expenses Before Tax, Tax and Tax Rate; and clients Tax Rate, Tax Exempt, Exemption Certificate and Exemption Expires. They are off until ticked. + - API: clients take `client_default_tax_id`, `client_tax_exempt`, `client_tax_exempt_number` and `client_tax_exempt_expires`, and a new invoice line takes `tax_id` `default` for the client's usual rate. A new endpoint reads the taxes with their parts. Invoices and quotes add their subtotal, tax, tax by part and registration numbers, invoice lines name their rate, and expenses include their tax. + - Demo data: both taxes have a registration number, Allegheny County Sales Tax is made of the state's 6% - named PA Sales Tax, so Tax Collected adds the two up together - and the county's 1%, each client has its rate, the church and the charter school are tax exempt, one with a certificate that has run out, and what the company buys for its own use pays sales tax. ### Bug Fixes @@ -914,7 +925,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `reportTable()` columns take `label`, `type` (money, count, hours, duration, decimal, percent, date), `html`, `csv`, `csv_label`, `screen => false` (export only), `sort` and `title`. A row of `['_heading' => ...]` draws a heading row. Times export as decimal hours and the heading says so. - Charts are drawn by `js/reports.js` from the JSON in `canvas[data-report-chart]`, with Chart.js. It also sorts `table[data-report-sort]`, submits `[data-report-autosubmit]` filters and runs the budget editor. - `agent/reports/includes/inc_all_reports.php` is kept for custom report pages and now loads the two new includes. - - Tax Collected works out every tax for the period in one query (`taxCollected()` in the page), with the same figures as `getMonthlyTax()`. + - Tax Collected is worked out by `taxReportSales()` and `taxReportPurchases()` in `functions/tax.php` (see Taxes, database 3.1.2). - Budgets: `agent/post/budget.php` handles `save_budget` and `copy_budget`, needing Financial at level 2 and a CSRF token. A blank or zero amount removes that month's budget rather than storing 0. - `dashboardRevenueSql()` in `functions/dashboard.php` is the one rule for which revenues count as income, used by the dashboard and the reports. It leaves out the incoming side of transfers. It now returns `incomeRevenueSql('revenues.revenue_id')` (see Income below), so uncategorised revenues count. - CSS: the `.itflow-report-*` classes in `css/itflow_custom.css`, with print rules that keep side-by-side cards together. @@ -1169,9 +1180,17 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `api/v1/invoice_items/create.php` takes `tax_id` `default` (a product's line by `taxDefaultFor()` with `product`) and works the invoice out again with `billingRecalc()`. - Seeds: `demoDataTaxes()` rows add a description, registration number and parts, demo clients get `client_default_tax_id`, and a profile's `tax_exempt` (number, expiry) makes it exempt. `demoDataRemove()` counts clients using a rate and removes its parts. - CSS: `.itflow-paper-tax-label`, so a line's rate wraps under its tax. +- Tax reporting and tax on expenses (database 3.1.2): + - New `expenses.expense_tax_id` and `expense_tax_amount` (`decimal(15,2)`, part of `expense_amount`, negative on money back), and `recurring_expenses.recurring_expense_tax_id` and `recurring_expense_tax_amount`, copied to each expense by `recurringExpenseRecord()`. + - `functions/tax.php`: `taxIncluded($gross, $tax)`, `taxSplit($amount, $row, $base, $no_rate_label)` (a tax amount shared over a rate's parts - `parts`, `registrations` and `numbers`, the registration number by part label), `TAX_REPORT_BASES`, `taxReportMonths()`, `taxReportInvoice($items, $discount, $exempt)`, `taxReportSales($from, $to, $basis)` (`invoiced` or `paid`; `parts`, `numbers`, `sales`, `taxable`, `nontaxable`, `exempt`, `exempt_clients`, `invoices`) and `taxReportPurchases($from, $to)` (`parts`, `purchases`, `expenses`). `billingTaxParts()` uses `taxSplit()`. + - `functions/expenses.php`: `expenseTaxFromInput($in, $amount, $keep, &$error)` reads `tax_id` and `tax_amount` for `expenseSave()` and `recurringExpenseSave()`; a refund starts from the rate of what it refunds. `expenseRow()` and `recurringExpenseRow()` add `tax_name` and `tax_percent`, and `expenseVendorDefaults()` adds `tax`. The fields are `agent/modals/expense/expense_tax_fields.php`, on `expense_form.php` and `recurring_expense_form.php`. + - `agent/reports/tax_summary.php` takes `basis` (`invoiced`, `paid`), `view` and `year`, and `export=csv` with `table` `tax` (Section, Figure, Registration, the periods and the year) or `exempt`. + - Exports: `invoice_subtotal`, `invoice_discount_amount`, `invoice_tax` and `invoice_tax_parts`, `quote_tax` (up front, from `quoteLineGroups()`), `recurring_invoice_tax`, `expense_before_tax`, `expense_tax_amount` and `expense_tax_rate`, and `client_tax_rate`, `client_tax_exempt_display`, `client_tax_exempt_number` and `client_tax_exempt_expires`, all `default => false`. The quote and recurring invoice exports select their ids for it. + - API: `api/v1/taxes/read.php` (`tax_id`, `archived`; each rate with `tax_components` and `tax_default`), mapped to `module_sales`. `billingApiTaxFields($type, $row)` adds `invoice_subtotal`, `invoice_tax`, `invoice_taxes` (`name`, `amount`) and `invoice_tax_registrations` to `api/v1/invoices/read.php`, and the `quote_` ones to `api/v1/quotes/read.php`. `api/v1/invoice_items/read.php` adds `tax_name` and `tax_percent`. + - Seeds: `demoDataTaxes()` names Allegheny County's state part PA Sales Tax, and the company's own-use purchases (office supplies, tools, equipment, the van) carry PA Sales Tax. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()` and `clientOverviewPersonInitials()` + - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name - `agent/modals/asset/asset_documents.php`, `document_bulk_move.php`, `document_add_file_relation.php` and `project_link_closed_ticket.php` - `agent/js/file_delete_modal.js`, `certificate_fetch_ssl.js`, `credential_show_password_via_id.js` and `credential_show_otp_via_id.js` - `title` attributes were added to about 300 icon-only buttons, and `aria-label` to the show-password buttons and the portal's mobile menu button. diff --git a/admin/database_updates/3.1.2.php b/admin/database_updates/3.1.2.php new file mode 100644 index 000000000..32fce5021 --- /dev/null +++ b/admin/database_updates/3.1.2.php @@ -0,0 +1,20 @@ +Add a part Comes to % - Each part is shown on its own on invoices, with its registration number - or the tax's. On top charges a part on the line plus the parts above it. + Each part is shown on its own on invoices, with its registration number - or the tax's. On top charges a part on the line plus the parts above it. Name a part the same as another tax's (PA Sales Tax at 6%) and Tax Collected adds them up together. diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php index 75164fbd3..c5e777f0f 100644 --- a/admin/post/demo_data_model.php +++ b/admin/post/demo_data_model.php @@ -93,7 +93,8 @@ function demoDataSlas() { function demoDataTaxes() { return [ ['PA Sales Tax', 6.0, 'Pennsylvania state sales tax', '75520193', []], - ['Allegheny County Sales Tax', 7.0, 'Pennsylvania 6% plus the Allegheny County local 1%', '75520193', [['Pennsylvania', 6.0], ['Allegheny County', 1.0]]], + // The state's part is named as the state's own rate, so Tax Collected adds the two up in one row + ['Allegheny County Sales Tax', 7.0, 'Pennsylvania 6% plus the Allegheny County local 1%', '75520193', [['PA Sales Tax', 6.0], ['Allegheny County', 1.0]]], ]; } @@ -3396,6 +3397,10 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { $card_categories = ['Software', 'Cloud and Hosting', 'Office Supplies', 'Meals', 'Shipping and Postage', 'Dues and Subscriptions', 'Telecom and Internet']; $card_spent = []; + // What we buy for our own use pays sales tax; stock to resell doesn't + $taxed_categories = ['Office Supplies', 'Tools and Test Equipment', 'Equipment', 'Vehicle and Fuel']; + $purchase_tax_id = intval($context['taxes']['PA Sales Tax']['id'] ?? 0); + // Which purchases put hardware on the shelf, and what they bring in $stock_lines = [ 'UniFi shelf stock for the workshop' => ['Ubiquiti U6-Pro Access Point', 4], @@ -3416,9 +3421,12 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { $card_spent[$month] = ($card_spent[$month] ?? 0) + round($cost[1] * $drift, 2); } + $taxed = $purchase_tax_id && in_array($cost[2], $taxed_categories, true); $expense_id = starterInsert($mysqli, 'expenses', [ 'expense_description' => $cost[0], 'expense_amount' => number_format($cost[1] * $drift, 2, '.', ''), + 'expense_tax_id' => $taxed ? $purchase_tax_id : 0, + 'expense_tax_amount' => number_format($taxed ? taxIncluded(round($cost[1] * $drift, 2), $purchase_tax_id) : 0, 2, '.', ''), 'expense_currency_code' => $currency, 'expense_date' => demoMonthDate($month, $cost[4]), 'expense_reference' => 'BILL-' . demoSerial('company' . $cost[0] . $month, 8), diff --git a/admin/tax_rates.php b/admin/tax_rates.php index 4f17cdbdd..bc6a933e8 100644 --- a/admin/tax_rates.php +++ b/admin/tax_rates.php @@ -210,7 +210,8 @@ $used_text = function (array $u) {

Changing a rate. Once a rate is on an invoice or quote, saving a new percent or parts makes a new rate with the same name. What was already billed keeps the old rate; products, clients, the default and recurring invoices move to the new one.

-

Exempt clients. Mark a client tax exempt, with its certificate, on the client's Billing tab. Its new lines get no tax, and its invoices say why.

+

Exempt clients. Mark a client tax exempt, with its certificate, on the client's Billing tab. Its new lines get no tax, and its invoices say why.

+

Filing. The Tax Collected report adds up each part by month or quarter, invoiced or paid, with exempt sales and the tax paid on expenses.

diff --git a/agent/expenses.php b/agent/expenses.php index d35aaa9ac..65bfeff81 100644 --- a/agent/expenses.php +++ b/agent/expenses.php @@ -406,6 +406,7 @@ $month_change = $stats['last_to_date'] > 0 ? round(($stats['month'] - $stats['la
Refund
+
incl. tax
+ +
incl. tax
+
@@ -130,6 +133,12 @@ ob_start(); + +
+ + tax, on +
+
diff --git a/agent/modals/expense/expense_form.php b/agent/modals/expense/expense_form.php index d0d51e70c..70edeb424 100644 --- a/agent/modals/expense/expense_form.php +++ b/agent/modals/expense/expense_form.php @@ -9,11 +9,12 @@ * $form_vendor_id the vendor a new one starts with (0 to pick) * $form_client_id the client a new one starts with (0 for none) * - * Posts date, amount, vendor, category, account ('personal' with paid_by - * when an agent paid it themselves), payment_method, client_id, reference, - * description, billable and markup, the receipt as `file`, and refund_of - * (refund) or receipt_remove (edit). Picking a vendor on a new expense fills in the - * category, account and payment method of the last expense with them, + * Posts date, amount, tax_id and tax_amount (expense_tax_fields.php), + * vendor, category, account ('personal' with paid_by when an agent paid it + * themselves), payment_method, client_id, reference, description, billable + * and markup, the receipt as `file`, and refund_of (refund) or + * receipt_remove (edit). Picking a vendor on a new expense fills in the + * category, account, payment method and tax of the last expense with them, * unless those were changed by hand. * * Should not be accessed directly. @@ -165,6 +166,15 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup']
+ +
@@ -389,7 +399,8 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup'] var fields = { category: root.querySelector('[data-expense-category]'), account: root.querySelector('[data-expense-account]'), - method: root.querySelector('[data-expense-method]') + method: root.querySelector('[data-expense-method]'), + tax: root.querySelector('[data-expense-tax-rate]') }; var touched = {}; var filling = false; diff --git a/agent/modals/expense/expense_tax_fields.php b/agent/modals/expense/expense_tax_fields.php new file mode 100644 index 000000000..a280f03e6 --- /dev/null +++ b/agent/modals/expense/expense_tax_fields.php @@ -0,0 +1,90 @@ + +
+ +
+ + +
+
+ +
+ +
+ + > + +
+
Part of the amount - change it to what the receipt says.
+
+ + diff --git a/agent/modals/recurring_expense/recurring_expense.php b/agent/modals/recurring_expense/recurring_expense.php index 89fccf2fd..f573162db 100644 --- a/agent/modals/recurring_expense/recurring_expense.php +++ b/agent/modals/recurring_expense/recurring_expense.php @@ -56,6 +56,9 @@ ob_start();
+ +
incl. tax
+
diff --git a/agent/modals/recurring_expense/recurring_expense_form.php b/agent/modals/recurring_expense/recurring_expense_form.php index 0ffbe15be..4ebfda617 100644 --- a/agent/modals/recurring_expense/recurring_expense_form.php +++ b/agent/modals/recurring_expense/recurring_expense_form.php @@ -8,8 +8,9 @@ * $form_vendor_id the vendor a new one starts with (0 to pick) * $form_client_id the client a new one starts with (0 for none) * - * Posts vendor, amount, frequency, next_date, end_date, category, account, - * payment_method, client_id, reference and description. The schedule reads + * Posts vendor, amount, tax_id and tax_amount (expense_tax_fields.php), + * frequency, next_date, end_date, category, account, payment_method, + * client_id, reference and description. The schedule reads * back in words as it is set; picking a vendor on a new one fills in what * the last expense with them used, unless that was changed by hand. * @@ -98,6 +99,15 @@ while ($row = mysqli_fetch_assoc($sql)) { + +
@@ -323,7 +333,8 @@ while ($row = mysqli_fetch_assoc($sql)) { var fields = { category: root.querySelector('[data-expense-category]'), account: root.querySelector('[data-expense-account]'), - method: root.querySelector('[data-expense-method]') + method: root.querySelector('[data-expense-method]'), + tax: root.querySelector('[data-expense-tax-rate]') }; var touched = {}; var filling = false; diff --git a/agent/post/client.php b/agent/post/client.php index bf9eed60d..4e6b028a7 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -485,6 +485,11 @@ if (isExportRequest('export_clients')) { $export = beginExport('clients', $format, "$session_company_name-$export_label", $export_label, summarizeExportFilters($filter_summary)); while ($row = mysqli_fetch_assoc($sql)) { + $tax_client = taxClient($row['client_id']); + $row['client_tax_rate'] = intval($tax_client['default_id']) ? taxLabel(taxRow($tax_client['default_id'])) : ''; + $row['client_tax_exempt_display'] = $tax_client['exempt'] ? 'Yes' : ''; + $row['client_tax_exempt_number'] = $tax_client['exempt'] ? $tax_client['number'] : ''; + $row['client_tax_exempt_expires'] = $tax_client['exempt'] ? $tax_client['expires'] : ''; addExportRow($export, $row); } diff --git a/agent/post/expense.php b/agent/post/expense.php index 6742a5bc3..8afc60f7f 100644 --- a/agent/post/expense.php +++ b/agent/post/expense.php @@ -368,6 +368,8 @@ if (isExportRequest('export_expenses')) { while ($row = mysqli_fetch_assoc($sql)) { $row['expense_receipt_display'] = $row['expense_receipt'] ? 'Yes' : 'No'; + $row['expense_tax_rate'] = intval($row['expense_tax_id']) ? taxLabel(taxRow($row['expense_tax_id'])) : (floatval($row['expense_tax_amount']) != 0 ? 'Other' : ''); + $row['expense_before_tax'] = round(floatval($row['expense_amount']) - floatval($row['expense_tax_amount']), 2); $markup = rtrim(rtrim(number_format(floatval($row['expense_markup']), 2, '.', ''), '0'), '.') . '%'; $row['expense_billing_display'] = $row['expense_invoice_id'] ? "Billed, $markup markup" : (intval($row['expense_billable']) ? "To bill, $markup markup" : ''); $row['expense_billed_on'] = $row['expense_invoice_id'] && $row['invoice_number'] !== null ? $row['invoice_prefix'] . $row['invoice_number'] : ''; diff --git a/agent/post/invoice.php b/agent/post/invoice.php index 900cdba8a..06347a5a3 100644 --- a/agent/post/invoice.php +++ b/agent/post/invoice.php @@ -561,6 +561,18 @@ if (isExportRequest('export_invoices')) { while ($row = mysqli_fetch_assoc($sql)) { $row['invoice_number_display'] = $row['invoice_prefix'] . $row['invoice_number']; + // Subtotal, tax and tax by part are opt-in too + if (isset($export['columns']['invoice_subtotal']) || isset($export['columns']['invoice_tax']) || isset($export['columns']['invoice_tax_parts'])) { + $invoice_items = billingItems('invoice', $row['invoice_id']); + $row['invoice_subtotal'] = round(array_sum(array_column($invoice_items, 'item_subtotal')), 2); + $row['invoice_tax'] = round(array_sum(array_column($invoice_items, 'item_tax')), 2); + $tax_parts = []; + foreach (billingTaxParts($invoice_items)['parts'] as $label => $amount) { + $tax_parts[] = "$label: " . number_format($amount, 2, '.', ''); + } + $row['invoice_tax_parts'] = implode('; ', $tax_parts); + } + // Paid / balance are opt-in columns - only pay for the lookup if asked if (isset($export['columns']['amount_paid']) || isset($export['columns']['invoice_balance'])) { $invoice_id = intval($row['invoice_id']); diff --git a/agent/post/quote.php b/agent/post/quote.php index 030d1c7b6..8acfa3b1a 100644 --- a/agent/post/quote.php +++ b/agent/post/quote.php @@ -489,7 +489,7 @@ if (isExportRequest('export_quotes')) { $sql = mysqli_query( $mysqli, - "SELECT client_name, quote_amount, quote_monthly_amount, quote_date, quote_number, quote_prefix, quote_scope, + "SELECT client_name, quote_id, quote_amount, quote_discount_amount, quote_monthly_amount, quote_date, quote_number, quote_prefix, quote_scope, quote_status FROM quotes LEFT JOIN clients ON quote_client_id = client_id LEFT JOIN categories ON quote_category_id = category_id @@ -510,6 +510,11 @@ if (isExportRequest('export_quotes')) { while ($row = mysqli_fetch_assoc($sql)) { $row['quote_number_display'] = $row['quote_prefix'] . $row['quote_number']; + if (isset($export['columns']['quote_tax'])) { + // The tax on what it bills up front, beside its amount + $quote_groups = quoteLineGroups(billingItems('quote', $row['quote_id']), floatval($row['quote_discount_amount'])); + $row['quote_tax'] = round(floatval($quote_groups['']['tax'] ?? 0), 2); + } addExportRow($export, $row); } diff --git a/agent/post/recurring_invoice.php b/agent/post/recurring_invoice.php index 4564866a8..911a52565 100644 --- a/agent/post/recurring_invoice.php +++ b/agent/post/recurring_invoice.php @@ -360,7 +360,7 @@ if (isExportRequest('export_recurring_invoices')) { $sql = mysqli_query( $mysqli, - "SELECT client_name, recurring_invoice_amount, recurring_invoice_created_at, + "SELECT client_name, recurring_invoice_id, recurring_invoice_amount, recurring_invoice_created_at, recurring_invoice_frequency, recurring_invoice_number, recurring_invoice_prefix, recurring_invoice_scope FROM recurring_invoices LEFT JOIN clients ON recurring_invoice_client_id = client_id @@ -384,6 +384,9 @@ if (isExportRequest('export_recurring_invoices')) { while ($row = mysqli_fetch_assoc($sql)) { $row['recurring_invoice_number_display'] = $row['recurring_invoice_prefix'] . $row['recurring_invoice_number']; $row['recurring_invoice_frequency_display'] = recurringInvoiceFrequencies()[$row['recurring_invoice_frequency']] ?? ucfirst((string) $row['recurring_invoice_frequency']); + if (isset($export['columns']['recurring_invoice_tax'])) { + $row['recurring_invoice_tax'] = round(array_sum(array_column(billingItems('recurring', $row['recurring_invoice_id']), 'item_tax')), 2); + } addExportRow($export, $row); } diff --git a/agent/reports/tax_summary.php b/agent/reports/tax_summary.php index cc42c3f68..b6771dcab 100644 --- a/agent/reports/tax_summary.php +++ b/agent/reports/tax_summary.php @@ -1,116 +1,119 @@ ['tax' => [1..12], 'sales' => [1..12]]]. */ -function taxCollected($from, $to) { - global $mysqli; - $sql = mysqli_query($mysqli, " - SELECT item_tax.item_tax_id AS tax_id, MONTH(payment_date) AS m, - SUM(item_tax.tax_total * payment_amount / invoice_amount) AS tax, - SUM(item_tax.sales_total * payment_amount / invoice_amount) AS sales - FROM payments - JOIN invoices ON invoice_id = payment_invoice_id - JOIN ( - SELECT item_invoice_id, item_tax_id, SUM(item_tax) AS tax_total, SUM(item_subtotal) AS sales_total - FROM invoice_items - WHERE item_tax_id > 0 - GROUP BY item_invoice_id, item_tax_id - ) AS item_tax ON item_tax.item_invoice_id = invoice_id - WHERE payment_date >= '$from' AND payment_date < '$to' + INTERVAL 1 DAY AND invoice_amount > 0 - GROUP BY item_tax.item_tax_id, m"); - $taxes = []; - while ($row = mysqli_fetch_assoc($sql)) { - $id = intval($row['tax_id']); - $taxes[$id] = $taxes[$id] ?? ['tax' => array_fill(1, 12, 0.0), 'sales' => array_fill(1, 12, 0.0)]; - $taxes[$id]['tax'][intval($row['m'])] += floatval($row['tax']); - $taxes[$id]['sales'][intval($row['m'])] += floatval($row['sales']); - } - return $taxes; -} - $to = $current_year ? date('Y-m-d') : "$year-12-31"; -$collected = taxCollected("$year-01-01", $to); -$prev_collected = taxCollected(($year - 1) . '-01-01', $current_year ? dashboardShiftMonths(date('Y-m-d'), -12) : ($year - 1) . '-12-31'); + +$sales = taxReportSales("$year-01-01", $to, $basis); +$purchases = taxReportPurchases("$year-01-01", $to); +$prev = taxReportSales(($year - 1) . '-01-01', $current_year ? dashboardShiftMonths(date('Y-m-d'), -12) : ($year - 1) . '-12-31', $basis); $compare = $current_year ? 'the same time last year' : (string) ($year - 1); -// Every tax not archived, and any archived one that collected something -$taxes = []; -$ids = array_keys($collected); -$sql = mysqli_query($mysqli, "SELECT tax_id, tax_name, tax_percent, tax_archived_at FROM taxes - WHERE tax_archived_at IS NULL" . ($ids ? " OR tax_id IN (" . implode(',', array_map('intval', $ids)) . ")" : '') . " - ORDER BY tax_name ASC"); -while ($row = mysqli_fetch_assoc($sql)) { - $taxes[intval($row['tax_id'])] = $row; +$sum = function (array $months) { + return array_sum($months); +}; +$tax_months = taxReportMonths(); +foreach ($sales['parts'] as $part) { + foreach ($part['months'] as $m => $amount) { + $tax_months[$m] += $amount; + } +} +$paid_months = taxReportMonths(); +foreach ($purchases['parts'] as $part) { + foreach ($part['months'] as $m => $amount) { + $paid_months[$m] += $amount; + } +} +$net_months = array_map(function ($collected, $paid) { + return $collected - $paid; +}, $tax_months, $paid_months); +$net_months = array_combine(range(1, 12), $net_months); +$tax_total = $sum($tax_months); +$paid_total = $sum($paid_months); +$prev_tax_total = array_sum(array_column($prev['parts'], 'total')); +$has_purchases = (bool) $purchases['parts']; + +// A table row for each figure: [group, name, sub, months, emphasis] +$grid = []; +foreach ($sales['parts'] as $label => $part) { + $grid[] = ['Tax collected', $label, '', $part['months'], false]; +} +$grid[] = ['Tax collected', 'Total tax collected', '', $tax_months, true]; +$grid[] = ['Sales, before tax and net of discounts', 'Total sales', '', $sales['sales'], true]; +$grid[] = ['Sales, before tax and net of discounts', 'Taxable', 'Lines with tax on them', $sales['taxable'], false]; +$grid[] = ['Sales, before tax and net of discounts', 'Exempt', 'No tax, for clients who are tax exempt', $sales['exempt'], false]; +$grid[] = ['Sales, before tax and net of discounts', 'Non-taxable', 'No tax, for clients who are not exempt', $sales['nontaxable'], false]; +if ($has_purchases) { + foreach ($purchases['parts'] as $label => $part) { + $grid[] = ['Tax paid on purchases', $label, '', $part['months'], false]; + } + $grid[] = ['Tax paid on purchases', 'Total tax paid', '', $paid_months, true]; + $grid[] = ['Tax paid on purchases', 'Purchases it was paid on', 'Before tax', $purchases['purchases'], false]; + $grid[] = ['Collected less paid', 'Net tax', 'For GST, HST and VAT returns that take off tax paid', $net_months, true]; } -$lines = []; -$tax_total = 0.0; -$sales_total = 0.0; -$prev_tax_total = 0.0; -foreach ($taxes as $id => $tax) { - $amounts = $collected[$id] ?? ['tax' => array_fill(1, 12, 0.0), 'sales' => array_fill(1, 12, 0.0)]; - $prev = array_sum($prev_collected[$id]['tax'] ?? []); - $lines[$id] = [ - 'name' => $tax['tax_name'], 'percent' => floatval($tax['tax_percent']), 'archived' => !empty($tax['tax_archived_at']), - 'tax' => reportSumColumns($amounts['tax'], $columns), 'sales' => reportSumColumns($amounts['sales'], $columns), - 'tax_total' => array_sum($amounts['tax']), 'sales_total' => array_sum($amounts['sales']), 'prev' => $prev, - 'tax_months' => $amounts['tax'], - ]; - $tax_total += $lines[$id]['tax_total']; - $sales_total += $lines[$id]['sales_total']; - $prev_tax_total += $prev; -} - -if (reportExporting()) { - $csv_columns = ['name' => ['label' => 'Tax'], 'what' => ['label' => 'Figure']]; +if (reportExporting() && ($_GET['table'] ?? 'tax') === 'tax') { + $csv_columns = ['group' => ['label' => 'Section'], 'name' => ['label' => 'Figure'], 'registration' => ['label' => 'Registration']]; foreach ($columns as $i => [$label]) { $csv_columns["c$i"] = ['label' => $label, 'type' => 'money']; } $csv_columns['total'] = ['label' => (string) $year, 'type' => 'money']; $rows = []; - foreach ($lines as $line) { - foreach (['tax' => 'Tax collected', 'sales' => 'Taxable sales'] as $key => $what) { - $row = ['name' => $line['name'] . ' (' . taxPercentText($line['percent']) . '%)', 'what' => $what, 'total' => $line[$key . '_total']]; - foreach ($columns as $i => $column) { - $row["c$i"] = $line[$key][$i]; - } - $rows[] = $row; + foreach ($grid as [$group, $name, $sub, $months]) { + $row = ['group' => $group, 'name' => $name, 'registration' => $group === 'Tax collected' ? ($sales['numbers'][$name] ?? '') : '', 'total' => round(array_sum($months), 2)]; + foreach (reportSumColumns($months, $columns) as $i => $value) { + $row["c$i"] = round($value, 2); } + $rows[] = $row; } - reportCsv("Tax Collected $year", $csv_columns, $rows); + reportCsv('Tax Collected ' . $year . ' ' . TAX_REPORT_BASES[$basis], $csv_columns, $rows); +} +if (reportExporting('exempt')) { + $rows = []; + foreach ($sales['exempt_clients'] as $client) { + $rows[] = ['client' => $client['name'], 'number' => $client['number'], 'expires' => $client['expires'], 'amount' => round($client['amount'], 2)]; + } + reportCsv("Exempt Sales $year", [ + 'client' => ['label' => 'Client'], 'number' => ['label' => 'Certificate'], 'expires' => ['label' => 'Expires'], 'amount' => ['label' => 'Exempt sales', 'type' => 'money'], + ], $rows); } require_once "includes/inc_report_layout.php"; -$percent_text = function ($percent) { - return taxPercentText($percent) . '%'; -}; $span = count($columns) + 2; +$basis_note = $basis === 'invoiced' + ? 'Counted on each invoice\'s date once it is sent, paid or not. Drafts, cancelled and non-billable invoices are left out.' + : 'Counted as payments come in: each payment carries its share of its invoice\'s tax and sales, so a part-paid invoice counts only the part paid.'; $stats = [ ['label' => 'Tax collected', 'value' => reportMoney($tax_total), 'sub' => reportChangeHtml($tax_total, $prev_tax_total, 'up', $compare)], - ['label' => 'Taxable sales', 'value' => reportMoney($sales_total), 'sub' => 'The paid sales the tax was on'], + ['label' => 'Taxable sales', 'value' => reportMoney($sum($sales['taxable'])), 'sub' => 'Of ' . escapeHtml(reportMoney($sum($sales['sales']))) . ' in sales'], + ['label' => 'Exempt sales', 'value' => reportMoney($sum($sales['exempt'])), 'sub' => '' . count($sales['exempt_clients']) . ' exempt client' . (count($sales['exempt_clients']) === 1 ? '' : 's') . ''], + ['label' => 'Non-taxable sales', 'value' => reportMoney($sum($sales['nontaxable'])), 'sub' => 'No tax on the line'], ]; -foreach (array_slice($lines, 0, 3, true) as $line) { - $stats[] = ['label' => $line['name'], 'value' => reportMoney($line['tax_total']), 'sub' => 'At ' . escapeHtml($percent_text($line['percent'])) . '']; +if ($has_purchases) { + $stats[] = ['label' => 'Tax paid on purchases', 'value' => reportMoney($paid_total), 'sub' => 'On ' . $purchases['expenses'] . ' expense' . ($purchases['expenses'] === 1 ? '' : 's') . '']; + $stats[] = ['label' => 'Net tax', 'value' => reportMoney($tax_total - $paid_total), 'sub' => 'Collected less paid']; } ?> @@ -119,47 +122,52 @@ foreach (array_slice($lines, 0, 3, true) as $line) { [$year, $years], - 'controls' => reportSegmentControl('view', $view, ['quarter' => 'Quarterly', 'month' => 'Monthly']), - 'subtitle' => $current_year ? "$year to date" : (string) $year, + 'controls' => reportSegmentControl('basis', $basis, TAX_REPORT_BASES) . reportSegmentControl('view', $view, ['quarter' => 'Quarterly', 'month' => 'Monthly']), + 'subtitle' => ($current_year ? "$year to date" : (string) $year) . ' · ' . TAX_REPORT_BASES[$basis], + 'export' => $sales['exempt_clients'] ? ['tax' => 'Tax by ' . $view, 'exempt' => 'Exempt sales by client'] : ['tax' => 'Tax by ' . $view], + 'note' => escapeHtml($basis_note) . ' Sales are before tax and net of discounts.', + 'print_meta' => TAX_REPORT_BASES[$basis] . ' basis', ]); ?> - -
Taxes, and the tax on paid invoices shows here.', 'fa-percent', 'secondary') ?>
+ +
- -
- + +
+ $part) { + if (abs($part['total']) < 0.005) { + continue; + } + $series[] = ['label' => $label, 'data' => array_map(function ($m) use ($part, $months_shown) { + return $m <= $months_shown ? round($part['months'][$m], 2) : null; + }, range(1, 12)), 'tone' => reportPalette($i++), 'stack' => 'tax']; } - $series[] = ['label' => $line['name'], 'data' => array_map(function ($m) use ($line, $months_shown) { - return $m <= $months_shown ? round($line['tax_months'][$m], 2) : null; - }, range(1, 12)), 'tone' => reportPalette($i++), 'stack' => 'tax']; - } - echo reportChart([ - 'type' => 'bar', - 'labels' => array_map(function ($m) { return date('M', mktime(0, 0, 0, $m, 1)); }, range(1, 12)), - 'series' => $series, - 'stacked' => true, - 'format' => 'money', - ], 'sm', 'Tax collected per month, by tax'); - ?> + echo reportChart([ + 'type' => 'bar', + 'labels' => array_map(function ($m) { return date('M', mktime(0, 0, 0, $m, 1)); }, range(1, 12)), + 'series' => $series, + 'stacked' => true, + 'format' => 'money', + ], 'sm', 'Tax collected per month, by part'); + ?> +
-
+ - +
- + @@ -167,42 +175,70 @@ foreach (array_slice($lines, 0, 3, true) as $line) { - - - - - $column) { ?> - + + + + > + + + - - - - - - $column) { ?> - - - - - - - - - $column) { ?> - - - +
Tax basis title=" – ">
Tax collected
Archived–' : reportMoney($line['tax'][$i]) ?>
+ +
+
No.
+
–' : reportMoney($value) ?>
Total tax
Sales the tax was charged on
–' : reportMoney($line['sales'][$i]) ?>
+ + +
+ + + + + + + + + + + $client) { ?> + + + + + + + + +
ClientCertificateExpiresExempt sales
None on file' ?> + + – + + + Expired + +
+
+ + + diff --git a/api/v1/enforce_api_rbac.php b/api/v1/enforce_api_rbac.php index 869e45baa..c349b60a6 100644 --- a/api/v1/enforce_api_rbac.php +++ b/api/v1/enforce_api_rbac.php @@ -146,6 +146,7 @@ $resource_module = [ 'invoice_items' => 'module_sales', 'quotes' => 'module_sales', 'products' => 'module_sales', + 'taxes' => 'module_sales', 'expenses' => 'module_financial', 'credentials' => 'module_credential', ]; diff --git a/api/v1/invoice_items/read.php b/api/v1/invoice_items/read.php index c8acec549..f37572564 100644 --- a/api/v1/invoice_items/read.php +++ b/api/v1/invoice_items/read.php @@ -3,7 +3,8 @@ * API - Invoice Items - Read * GET /api/v1/invoice_items/read.php * - * Returns line items belonging to invoices scoped to the API key's client. + * Returns line items belonging to invoices scoped to the API key's client, + * each with its rate's tax_name and tax_percent. * * Parameters (GET): * api_key required - Your API key @@ -29,9 +30,10 @@ if (isset($_GET['item_id'])) { // Single line item by item_id — still JOIN to invoices to enforce client scope $item_id = intval($_GET['item_id']); $sql = mysqli_query($mysqli, - "SELECT ii.* + "SELECT ii.*, t.tax_name, t.tax_percent FROM invoice_items ii INNER JOIN invoices i ON i.invoice_id = ii.item_invoice_id + LEFT JOIN taxes t ON t.tax_id = ii.item_tax_id WHERE ii.item_id = '$item_id' AND 1=1 " . apiClientScopeSql('i.invoice_client_id') . " LIMIT 1" @@ -40,9 +42,10 @@ if (isset($_GET['item_id'])) { // All items on a specific invoice $invoice_id = intval($_GET['invoice_id']); $sql = mysqli_query($mysqli, - "SELECT ii.* + "SELECT ii.*, t.tax_name, t.tax_percent FROM invoice_items ii INNER JOIN invoices i ON i.invoice_id = ii.item_invoice_id + LEFT JOIN taxes t ON t.tax_id = ii.item_tax_id WHERE ii.item_invoice_id = '$invoice_id' AND 1=1 " . apiClientScopeSql('i.invoice_client_id') . " ORDER BY ii.item_order ASC, ii.item_id ASC diff --git a/api/v1/invoices/read.php b/api/v1/invoices/read.php index 072489e6f..44000658e 100644 --- a/api/v1/invoices/read.php +++ b/api/v1/invoices/read.php @@ -15,6 +15,16 @@ if (isset($_GET['invoice_id'])) { $sql = mysqli_query($mysqli, "SELECT * FROM invoices WHERE 1=1 " . apiClientScopeSql('invoice_client_id') . " ORDER BY invoice_id LIMIT $limit OFFSET $offset"); } +// Each with its subtotal, tax, tax by part and registration numbers (billingApiTaxFields()) +if ($sql && mysqli_num_rows($sql) > 0) { + $rows = []; + while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row + billingApiTaxFields('invoice', $row); + } + echo json_encode(['success' => 'True', 'count' => count($rows), 'data' => $rows]); + exit(); +} + // Output require_once "../read_output.php"; diff --git a/api/v1/quotes/read.php b/api/v1/quotes/read.php index 0dd5a913b..c2dc22ef6 100644 --- a/api/v1/quotes/read.php +++ b/api/v1/quotes/read.php @@ -15,6 +15,16 @@ if (isset($_GET['quote_id'])) { $sql = mysqli_query($mysqli, "SELECT * FROM quotes WHERE 1=1 " . apiClientScopeSql('quote_client_id') . " ORDER BY quote_id LIMIT $limit OFFSET $offset"); } +// Each with its subtotal, tax, tax by part and registration numbers (billingApiTaxFields()) +if ($sql && mysqli_num_rows($sql) > 0) { + $rows = []; + while ($row = mysqli_fetch_assoc($sql)) { + $rows[] = $row + billingApiTaxFields('quote', $row); + } + echo json_encode(['success' => 'True', 'count' => count($rows), 'data' => $rows]); + exit(); +} + // Output require_once "../read_output.php"; diff --git a/api/v1/taxes/read.php b/api/v1/taxes/read.php new file mode 100644 index 000000000..ab40a21c0 --- /dev/null +++ b/api/v1/taxes/read.php @@ -0,0 +1,62 @@ + 0) { + $default_id = taxSettings()['default_id']; + $rows = []; + while ($id_row = mysqli_fetch_row($sql)) { + $tax = taxRow($id_row[0]); + $rows[] = [ + 'tax_id' => intval($tax['tax_id']), + 'tax_name' => $tax['tax_name'], + 'tax_percent' => floatval($tax['tax_percent']), + 'tax_description' => $tax['tax_description'], + 'tax_registration_number' => $tax['tax_registration_number'], + 'tax_archived_at' => $tax['tax_archived_at'], + 'tax_replaced_by' => intval($tax['tax_replaced_by']), + 'tax_default' => intval($tax['tax_id']) === $default_id, + 'tax_components' => array_map(function ($part) use ($tax) { + return ['name' => $part['name'], 'percent' => $part['percent'], 'compound' => $part['compound'], + 'registration_number' => $part['registration'] !== '' ? $part['registration'] : (string) $tax['tax_registration_number']]; + }, $tax['components']), + ]; + } + echo json_encode(['success' => 'True', 'count' => count($rows), 'data' => $rows]); + exit(); +} + +// Output +require_once "../read_output.php"; diff --git a/db.sql b/db.sql index 34bb1fa51..aef995653 100644 --- a/db.sql +++ b/db.sql @@ -1395,6 +1395,8 @@ CREATE TABLE `expenses` ( `expense_id` int(11) NOT NULL AUTO_INCREMENT, `expense_description` text DEFAULT NULL, `expense_amount` decimal(15,2) NOT NULL, + `expense_tax_id` int(11) NOT NULL DEFAULT 0, + `expense_tax_amount` decimal(15,2) NOT NULL DEFAULT 0.00, `expense_currency_code` varchar(200) NOT NULL, `expense_date` date NOT NULL, `expense_reference` varchar(200) DEFAULT NULL, @@ -2203,6 +2205,8 @@ CREATE TABLE `recurring_expenses` ( `recurring_expense_status` tinyint(1) NOT NULL DEFAULT 1, `recurring_expense_description` mediumtext DEFAULT NULL, `recurring_expense_amount` decimal(15,2) NOT NULL, + `recurring_expense_tax_id` int(11) NOT NULL DEFAULT 0, + `recurring_expense_tax_amount` decimal(15,2) NOT NULL DEFAULT 0.00, `recurring_expense_payment_method` varchar(200) DEFAULT NULL, `recurring_expense_reference` varchar(255) DEFAULT NULL, `recurring_expense_currency_code` varchar(200) NOT NULL, diff --git a/functions/app.php b/functions/app.php index 8359dbbc6..8b2db3af5 100644 --- a/functions/app.php +++ b/functions/app.php @@ -643,47 +643,6 @@ function checkForUpdates() { } -function getMonthlyTax($tax_name, $month, $year, $mysqli) { - // Cash basis - tax is booked to the month the money arrived, in proportion to - // how much of the invoice that payment covered. Driving off payments (rather - // than invoice_items) counts each payment exactly once, and pre-aggregating - // the line items stops a multi-payment invoice multiplying its own tax. - $sql = "SELECT SUM(invoice_tax.tax_total * (payments.payment_amount / invoices.invoice_amount)) AS monthly_tax - FROM payments - INNER JOIN invoices ON invoices.invoice_id = payments.payment_invoice_id - INNER JOIN (SELECT item_invoice_id, SUM(item_tax) AS tax_total - FROM invoice_items - WHERE item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name') - GROUP BY item_invoice_id) AS invoice_tax - ON invoice_tax.item_invoice_id = invoices.invoice_id - WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) = $month - AND invoices.invoice_amount > 0"; - $result = mysqli_query($mysqli, $sql); - $row = mysqli_fetch_assoc($result); - return $row['monthly_tax'] ?? 0; -} - -function getQuarterlyTax($tax_name, $quarter, $year, $mysqli) { - // Calculate start and end months for the quarter - $start_month = ($quarter - 1) * 3 + 1; - $end_month = $start_month + 2; - - // SQL to calculate quarterly tax - see getMonthlyTax for why it is shaped this way - $sql = "SELECT SUM(invoice_tax.tax_total * (payments.payment_amount / invoices.invoice_amount)) AS quarterly_tax - FROM payments - INNER JOIN invoices ON invoices.invoice_id = payments.payment_invoice_id - INNER JOIN (SELECT item_invoice_id, SUM(item_tax) AS tax_total - FROM invoice_items - WHERE item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name') - GROUP BY item_invoice_id) AS invoice_tax - ON invoice_tax.item_invoice_id = invoices.invoice_id - WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) BETWEEN $start_month AND $end_month - AND invoices.invoice_amount > 0"; - $result = mysqli_query($mysqli, $sql); - $row = mysqli_fetch_assoc($result); - return $row['quarterly_tax'] ?? 0; -} - function addToMailQueue($data) { global $mysqli; diff --git a/functions/billing.php b/functions/billing.php index 5285b0849..d776fc0b9 100644 --- a/functions/billing.php +++ b/functions/billing.php @@ -288,23 +288,13 @@ function billingTaxParts(array $items) { if ($tax == 0) { continue; } - $row = taxRow($item['item_tax_id'] ?? 0); - if (!$row) { - $label = trim(($item['tax_name'] ?? 'Tax') . (isset($item['tax_percent']) && $item['tax_percent'] !== null ? ' ' . taxPercentText($item['tax_percent']) . '%' : '')); - $parts[$label] = round(($parts[$label] ?? 0) + $tax, 2); - continue; - } - $split = taxAmounts(floatval($item['item_subtotal']), $row)['parts']; - $whole = array_sum(array_column($split, 'amount')); - $left = $tax; - foreach ($split as $i => $part) { - if ($part['registration'] !== '') { - $registrations[$part['registration_for']] = $part['registration']; - } - $amount = $i === count($split) - 1 || $whole == 0 ? $left : round($tax * $part['amount'] / $whole, 2); - $left = round($left - $amount, 2); - $parts[$part['label']] = round(($parts[$part['label']] ?? 0) + $amount, 2); + // 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]; } @@ -569,6 +559,35 @@ function billingRemoveTax($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']; diff --git a/functions/expenses.php b/functions/expenses.php index ce5c5c9a2..18756aabf 100644 --- a/functions/expenses.php +++ b/functions/expenses.php @@ -36,7 +36,7 @@ function expenseRow($expense_id, $scoped = true) { $scope = $scoped ? clientScopeSql('expenses.expense_client_id') : ''; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT expenses.*, vendor_name, vendor_description, vendor_archived_at, category_name, category_color, category_icon, category_archived_at, accounts.account_name, accounts.account_archived_at, accounts.account_currency_code, - client_name, client_abbreviation, creator.user_name AS created_by_name, transfer_id, + client_name, client_abbreviation, creator.user_name AS created_by_name, transfer_id, taxes.tax_name, taxes.tax_percent, recurring_expense_frequency, recurring_expense_amount, recurring_expense_currency_code, invoice_prefix, invoice_number, invoice_status, item_subtotal AS billed_subtotal, refunded.expense_date AS refund_of_date, refunded.expense_amount AS refund_of_amount, refunded.expense_description AS refund_of_description, @@ -49,6 +49,7 @@ function expenseRow($expense_id, $scoped = true) { LEFT JOIN categories ON category_id = expenses.expense_category_id LEFT JOIN accounts ON accounts.account_id = expenses.expense_account_id LEFT JOIN clients ON client_id = expenses.expense_client_id + LEFT JOIN taxes ON taxes.tax_id = expenses.expense_tax_id LEFT JOIN users AS creator ON creator.user_id = expenses.expense_created_by LEFT JOIN users AS payer ON payer.user_id = expenses.expense_paid_by LEFT JOIN transfers ON transfer_expense_id = expenses.expense_id @@ -203,13 +204,14 @@ function expenseCategoryDot($color) { } /** - * The last expense with each vendor - its category, account and payment - * method, by vendor id. New Expense fills these in when a vendor is picked. + * The last expense with each vendor - its category, account, payment + * method and tax, by vendor id. New Expense fills these in when a vendor is + * picked. */ function expenseVendorDefaults() { global $mysqli; $defaults = []; - $sql = mysqli_query($mysqli, "SELECT e.expense_vendor_id, e.expense_category_id, e.expense_account_id, e.expense_payment_method + $sql = mysqli_query($mysqli, "SELECT e.expense_vendor_id, e.expense_category_id, e.expense_account_id, e.expense_payment_method, e.expense_tax_id FROM expenses AS e JOIN (SELECT expense_vendor_id, MAX(expense_id) AS last_id FROM expenses WHERE expense_vendor_id > 0 AND expense_amount > 0 GROUP BY expense_vendor_id) AS last ON last.last_id = e.expense_id"); @@ -218,6 +220,7 @@ function expenseVendorDefaults() { 'category' => intval($row['expense_category_id']), 'account' => intval($row['expense_account_id']), 'method' => (string) $row['expense_payment_method'], + 'tax' => intval($row['expense_tax_id']), ]; } return $defaults; @@ -260,6 +263,46 @@ function expenseCategories($keep_id = 0) { * category or account is only kept when it is already the expense's. * Returns the expense id, or 0 with $error. */ +/** + * The tax included in what an expense cost, from a form - [rate id, amount], + * or null with $error. tax_id is one of the taxes (0 for none, or a tax with + * no rate of ours); tax_amount is worked out from the rate when left empty. + * The tax takes the amount's sign, so money back gives its tax back too. + * Without either field, $keep stands - unless the amount changed and the tax + * came from the rate, when it is worked out again. + */ +function expenseTaxFromInput(array $in, $amount, array $keep = [0, 0.0], &$error = '') { + $amount = round(floatval($amount), 2); + if (!array_key_exists('tax_id', $in) && !array_key_exists('tax_amount', $in)) { + [$tax_id, $tax_amount] = $keep; + $tax_id = intval($tax_id); + $tax_amount = round(floatval($tax_amount), 2); + if ($tax_id && taxRow($tax_id) && abs($tax_amount) >= abs($amount)) { + $tax_amount = taxIncluded(abs($amount), $tax_id) * ($amount < 0 ? -1 : 1); + } + return [$tax_id, $tax_amount]; + } + $tax_id = intval($in['tax_id'] ?? 0); + if ($tax_id && !taxRow($tax_id)) { + $error = 'Pick one of your taxes'; + return null; + } + $text = str_replace([',', ' ', '$'], '', trim((string) ($in['tax_amount'] ?? ''))); + if ($text === '') { + $tax_amount = $tax_id ? taxIncluded(abs($amount), $tax_id) : 0.0; + } elseif (!is_numeric($text)) { + $error = 'The tax is an amount, such as 12.50'; + return null; + } else { + $tax_amount = round(abs(floatval($text)), 2); + } + if ($tax_amount >= abs($amount)) { + $error = 'The tax is part of what it cost, so less than the amount'; + return null; + } + return [$tax_id, $amount < 0 ? -$tax_amount : $tax_amount]; +} + function expenseSave(array $in, $expense_id = 0, &$error = '') { global $mysqli, $session_company_currency, $session_user_id; $error = ''; @@ -352,6 +395,14 @@ function expenseSave(array $in, $expense_id = 0, &$error = '') { $reference = mb_substr(trim((string) ($in['reference'] ?? '')), 0, 200); $description = trim((string) ($in['description'] ?? '')); + // The tax included in it - money back starts on the rate of what it refunds + $tax_keep = $existing ? [$existing['expense_tax_id'], $existing['expense_tax_amount']] : ($refund_of ? [$original['expense_tax_id'], -taxIncluded(abs($amount), $original['expense_tax_id'])] : [0, 0.0]); + $tax = expenseTaxFromInput($in, $amount, $tax_keep, $error); + if ($tax === null) { + return 0; + } + [$tax_id, $tax_amount] = $tax; + // Billed on to the client - only an expense for a client, not a refund. One // already on an invoice stays billed, to that client, until it comes off it. $billed = $existing && !empty($existing['expense_invoice_id']); @@ -368,7 +419,7 @@ function expenseSave(array $in, $expense_id = 0, &$error = '') { } $markup = round(max(0, min(1000, $markup)), 2); - $set = "expense_date = '$date', expense_amount = $amount, expense_vendor_id = $vendor, expense_category_id = $category, + $set = "expense_date = '$date', expense_amount = $amount, expense_tax_id = $tax_id, expense_tax_amount = $tax_amount, expense_vendor_id = $vendor, expense_category_id = $category, expense_account_id = $account, expense_paid_by = $paid_by, expense_client_id = $client_id, expense_payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", expense_reference = '" . escapeSql($reference) . "', expense_description = '" . escapeSql($description) . "', expense_billable = $billable, expense_markup = $markup"; @@ -567,7 +618,7 @@ function recurringExpenseRow($recurring_expense_id, $scoped = true) { $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT recurring_expenses.*, vendor_name, vendor_description, vendor_archived_at, category_name, category_color, category_icon, category_archived_at, account_name, account_archived_at, account_currency_code, client_name, client_abbreviation, recurring_invoice_prefix, recurring_invoice_number, recurring_invoice_amount, recurring_invoice_frequency, - recurring_invoice_status, recurring_invoice_archived_at, + recurring_invoice_status, recurring_invoice_archived_at, tax_name, tax_percent, (SELECT COUNT(*) FROM expenses WHERE expense_recurring_expense_id = $id) AS made_count, (SELECT COALESCE(SUM(expense_amount), 0) FROM expenses WHERE expense_recurring_expense_id = $id AND YEAR(expense_date) = YEAR(CURDATE())) AS made_year FROM recurring_expenses @@ -576,6 +627,7 @@ function recurringExpenseRow($recurring_expense_id, $scoped = true) { LEFT JOIN accounts ON account_id = recurring_expense_account_id LEFT JOIN clients ON client_id = recurring_expense_client_id LEFT JOIN recurring_invoices ON recurring_invoice_id = recurring_expense_recurring_invoice_id + LEFT JOIN taxes ON tax_id = recurring_expense_tax_id WHERE recurring_expense_id = $id $scope LIMIT 1")); return $row ?: null; @@ -781,6 +833,11 @@ function recurringExpenseSave(array $in, $recurring_expense_id = 0, &$error = '' } $reference = mb_substr(trim((string) ($in['reference'] ?? '')), 0, 255); $description = trim((string) ($in['description'] ?? '')); + $tax = expenseTaxFromInput($in, $amount, $existing ? [$existing['recurring_expense_tax_id'], $existing['recurring_expense_tax_amount']] : [0, 0.0], $error); + if ($tax === null) { + return 0; + } + [$tax_id, $tax_amount] = $tax; // For a client: billed on as each one is recorded, or covered by one of their recurring invoices $billing = $client_id ? (string) ($in['billing'] ?? '') : ''; @@ -814,7 +871,7 @@ function recurringExpenseSave(array $in, $recurring_expense_id = 0, &$error = '' $status = intval($existing['recurring_expense_status']); } - $set = "recurring_expense_vendor_id = $vendor, recurring_expense_amount = $amount, recurring_expense_frequency = '$frequency', recurring_expense_anchor_day = $anchor, + $set = "recurring_expense_vendor_id = $vendor, recurring_expense_amount = $amount, recurring_expense_tax_id = $tax_id, recurring_expense_tax_amount = $tax_amount, recurring_expense_frequency = '$frequency', recurring_expense_anchor_day = $anchor, recurring_expense_next_date = '$next', recurring_expense_end_date = " . ($end === '' ? 'NULL' : "'$end'") . ", recurring_expense_status = $status, recurring_expense_category_id = $category, recurring_expense_account_id = $account, recurring_expense_client_id = $client_id, recurring_expense_payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", @@ -866,6 +923,7 @@ function recurringExpenseRecord($recurring_expense_id, $by_user_id = 0, $run = n $method = (string) $row['recurring_expense_payment_method']; mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$date', expense_amount = " . floatval($row['recurring_expense_amount']) . ", + expense_tax_id = " . intval($row['recurring_expense_tax_id']) . ", expense_tax_amount = " . floatval($row['recurring_expense_tax_amount']) . ", expense_currency_code = '" . escapeSql($row['recurring_expense_currency_code']) . "', expense_vendor_id = " . intval($row['recurring_expense_vendor_id']) . ", expense_client_id = " . intval($row['recurring_expense_client_id']) . ", expense_category_id = " . intval($row['recurring_expense_category_id']) . ", expense_account_id = " . intval($row['recurring_expense_account_id']) . ", expense_payment_method = " . ($method === '' ? 'NULL' : "'" . escapeSql($method) . "'") . ", diff --git a/functions/export.php b/functions/export.php index 9f453a2c4..aa584b77f 100644 --- a/functions/export.php +++ b/functions/export.php @@ -66,6 +66,10 @@ function getExportColumns($export_type) { 'client_currency_code' => ['label' => 'Currency'], 'client_net_terms' => ['label' => 'Payment Terms', 'format' => 'number'], 'client_tax_id_number' => ['label' => 'Tax ID'], + 'client_tax_rate' => ['label' => 'Tax Rate', 'default' => false], + 'client_tax_exempt_display' => ['label' => 'Tax Exempt', 'default' => false], + 'client_tax_exempt_number' => ['label' => 'Exemption Certificate', 'default' => false], + 'client_tax_exempt_expires' => ['label' => 'Exemption Expires', 'default' => false], 'client_abbreviation' => ['label' => 'Abbreviation'], ], @@ -321,6 +325,10 @@ function getExportColumns($export_type) { 'invoices' => [ 'invoice_number_display' => ['label' => 'Invoice Number'], 'invoice_scope' => ['label' => 'Scope', 'weight' => 3], + 'invoice_subtotal' => ['label' => 'Subtotal', 'format' => 'money', 'default' => false], + 'invoice_discount_amount' => ['label' => 'Discount', 'format' => 'money', 'default' => false], + 'invoice_tax' => ['label' => 'Tax', 'format' => 'money', 'default' => false], + 'invoice_tax_parts' => ['label' => 'Tax by Part', 'weight' => 2, 'default' => false], 'invoice_amount' => ['label' => 'Amount', 'format' => 'money'], 'invoice_date' => ['label' => 'Issued Date'], 'invoice_due' => ['label' => 'Due Date'], @@ -335,6 +343,7 @@ function getExportColumns($export_type) { 'quote_number_display' => ['label' => 'Quote Number'], 'quote_scope' => ['label' => 'Scope', 'weight' => 3], 'quote_amount' => ['label' => 'Amount', 'format' => 'money'], + 'quote_tax' => ['label' => 'Tax', 'format' => 'money', 'default' => false], 'quote_monthly_amount' => ['label' => 'Monthly', 'format' => 'money'], 'quote_date' => ['label' => 'Date'], 'quote_status' => ['label' => 'Status'], @@ -345,6 +354,7 @@ function getExportColumns($export_type) { 'recurring_invoice_number_display' => ['label' => 'Recurring Number'], 'recurring_invoice_scope' => ['label' => 'Scope', 'weight' => 3], 'recurring_invoice_amount' => ['label' => 'Amount', 'format' => 'money'], + 'recurring_invoice_tax' => ['label' => 'Tax', 'format' => 'money', 'default' => false], 'recurring_invoice_frequency_display' => ['label' => 'Frequency'], 'recurring_invoice_created_at' => ['label' => 'Date Created'], 'client_name' => ['label' => 'Client', 'weight' => 2, 'default' => false], @@ -379,6 +389,9 @@ function getExportColumns($export_type) { 'expenses' => [ 'expense_date' => ['label' => 'Date'], 'expense_amount' => ['label' => 'Amount', 'format' => 'money'], + 'expense_before_tax' => ['label' => 'Before Tax', 'format' => 'money', 'default' => false], + 'expense_tax_amount' => ['label' => 'Tax', 'format' => 'money', 'default' => false], + 'expense_tax_rate' => ['label' => 'Tax Rate', 'default' => false], 'vendor_name' => ['label' => 'Vendor', 'weight' => 2], 'expense_description' => ['label' => 'Description', 'weight' => 3], 'category_name' => ['label' => 'Category'], diff --git a/functions/reports.php b/functions/reports.php index fd9c11624..af03cc6e3 100644 --- a/functions/reports.php +++ b/functions/reports.php @@ -71,7 +71,7 @@ function reportCatalog() { ], 'tax_summary' => [ 'title' => 'Tax Collected', 'icon' => 'fa-percent', 'module' => 'module_financial', 'feature' => ['accounting'], - 'description' => 'Tax collected on paid invoices, and the sales it was collected on, by month or quarter.', + 'description' => 'Tax on sales by part of each rate, invoiced or paid, with taxable, exempt and non-taxable sales and the tax paid on purchases, by month or quarter.', ], 'mileage' => [ 'title' => 'Mileage', 'icon' => 'fa-route', 'module' => 'module_financial', 'feature' => ['accounting'], diff --git a/functions/tax.php b/functions/tax.php index 1860bdab3..a9920400f 100644 --- a/functions/tax.php +++ b/functions/tax.php @@ -139,6 +139,48 @@ function taxAmounts($base, $tax) { 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 = []; @@ -665,3 +707,159 @@ function taxOptionsHtml(array $taxes, $selected = 0, $usual_label = '', $usual_p 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; +}