diff --git a/CHANGELOG.md b/CHANGELOG.md index ad070faa6..199afe473 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.0.7) 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.0.8) in the same pass. Nothing else is required. -This is a big release, with twenty-eight database steps in one go, so take a backup before you update. +This is a big release, with twenty-nine 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: @@ -29,7 +29,7 @@ Trips already logged keep the miles they were entered with (see Breaking Changes Other income already entered (money in that isn't for an invoice, which used to be called revenue) takes who entered it from the audit log, where it has a record of it. Income with no category now counts in the dashboard and reports, so their totals can go up by that much (see Breaking Changes, Income). -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). +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 starts from its opening balance (see New Features, Accounts and transactions). 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. @@ -112,6 +112,12 @@ If you are coming from 26.08 or earlier, the database update still has to be run - An account can only be archived once it holds nothing and no payment provider pays into it, and only an account that was never used can be deleted. The Accounts page used to hide Archive otherwise, but the link itself archived any account, and Delete removed an account with its history still pointing at it. Archive one you no longer use instead. - The Transactions export's Type column says Invoice payment, Other income, Transfer in, Expense, Transfer out or Reimbursement (it was Payment, Revenue, Transfer In, Expense, Transfer Out or Reimbursement). Money in and out are now separate In and Out columns, with Account, Who or What and Client added. The signed Amount column is still there, but off unless ticked. - Links to `transactions.php?type=Payment` and the other old type names still work. + - Once a payment, other income, expense, transfer or reimbursement has cleared on a reconciled statement, its amount, date and account stay as they are and it can't be deleted, until that reconciliation is undone. Its category, client, description, method and reference can still change. This also stops: + - deleting the invoice a cleared payment is on (cancel the invoice instead), or refunding a cleared Stripe payment from ITFlow, which deleted the payment + - deleting a client with cleared payments or other income, from ITFlow or through the API (keep the client archived instead) + - Bulk Set Account and Bulk Delete on the Income and Expenses lists, which leave cleared ones as they are and say how many + - undoing a reimbursement whose money out has cleared + - An account's opening balance can't be changed once a statement on it is reconciled, as the first statement starts from it. - 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: @@ -644,6 +650,18 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The dashboard's accounts open their ledgers, and a card shows what is owed on it. - Adding and changing accounts and transfers needs write access to Financial, and deleting full access, as before. Restoring an archived account needs write access. - The demo data adds a Business Visa for software, hosting, supplies, meals and the other small costs, paid off from Operating Checking each month. + - **Reconcile**, from an account's menu on the Accounts page or Transactions, checks the account against its bank or card statement: + - Enter the statement's ending date and balance (for a card, what the statement says is owed), with notes such as the statement's number. It starts from where the last statement ended, or the opening balance the first time. + - Every line on the account up to the statement's date that hasn't cleared yet is listed, oldest first. Tick each one the statement shows. Lines brought in with Import Statement or Import Deposits start ticked. + - The cards show where it started, the statement, what is ticked (cleared) and the difference, and the bar at the bottom keeps the difference and Finish in view down a long statement. Finish opens once the difference is nothing. + - Pills for Money in, Money out, Not ticked and Ticked, a search by who, reference or amount, and Tick all shown and Untick all shown for what they leave. + - Ticks are saved as they are made, so a statement can be left and finished later. Change the statement's date or balance from its menu, or Discard it. + - Add what the statement has and ITFlow doesn't - a bank fee, interest - with New Expense, New Income or New Transfer from its menu, starting on the account. + - With nothing in progress, Reconcile shows where the account is reconciled to, how much hasn't cleared yet, and the statements reconciled before - each with its ending balance, the lines it cleared, what came in and went out, and who reconciled it when. The latest can be undone, which puts it back in progress with what was ticked. + - On Transactions, a line cleared on a statement has a tick by its date, opening the lines that statement cleared. The Uncleared pill lists what no statement has cleared yet, and the export has a Cleared column with the statement's date. The Accounts page shows where each account is reconciled to, or that one is being reconciled. + - A cleared line's quick look says which statement it cleared on, and its Edit window keeps its amount, date and account as they are (see Breaking Changes, Accounts, transactions and transfers). + - Reconciling needs write access to Financial, and undoing a reconciled statement full access. Agents with read access can look at the statements. + - The demo data reconciles Operating Checking and the Business Visa to each month's statement through last month. ### Bug Fixes @@ -717,7 +735,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run ### Developer Updates -- Database 2.8.0 to 3.0.7, one file per step under `admin/database_updates/`, with `db.sql` matching 3.0.7: +- Database 2.8.0 to 3.0.8, one file per step under `admin/database_updates/`, with `db.sql` matching 3.0.8: - 2.8.0: `racks.rack_units_descending`, `rack_power_capacity` and `rack_photo_rear`. On `rack_units`: `unit_face`, `unit_full_depth`, `unit_type`, `unit_power_watts`, `unit_notes` and a `unit_asset_id` key. Unlinked items move from asset id 0 to NULL. - 2.8.1: `location_documents` and `location_files`. - 2.8.2: `vendor_tags` (tag type 6), and `vendor_contact_phone_country_code`, `vendor_contact_phone` and `vendor_contact_email`. @@ -758,6 +776,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 3.0.5: `revenues.revenue_description` becomes `text`, with `revenue_created_by` (0, backfilled from the audit log's Revenue Create entries) and a `revenue_date` key. `payments` gets a `payment_date` key. - 3.0.6: `payments.payment_import_key` and `revenues.revenue_import_key` (keyed), and `income_import_rules` (`import_rule_match` unique, `import_rule_action` - payment, income or transfer - `import_rule_client_id`, `import_rule_category_id`, `import_rule_account_id`, `import_rule_used_count`). - 3.0.7: no schema change. `accounts.account_type` is set from the name for every account without a kind from 1 to 6 (1 checking, 2 savings, 3 credit card, 4 cash, 5 payment processor, 6 other), the same way as `accountTypeGuess()`, with the 2023 liability types (20 to 29) as 3. Setup and `scripts/setup_cli.php` create the Cash account as type 4. + - 3.0.8: `account_reconciliations` (`reconciliation_account_id` and `reconciliation_statement_date` keyed together, `reconciliation_starting_balance`, `reconciliation_ending_balance`, `reconciliation_notes`, `reconciliation_created_by`, `reconciliation_created_at`, `reconciliation_finished_by`, `reconciliation_finished_at` - NULL while in progress), and `payments.payment_reconciliation_id`, `revenues.revenue_reconciliation_id` and `expenses.expense_reconciliation_id` (keyed). - New shared function files, loaded by `functions.php`: `functions/asset.php`, `asset_interface.php`, `certificate.php`, `client_overview.php`, `contact.php`, `credential.php`, `dashboard.php`, `links.php`, `location.php`, `notifications.php`, `project.php`, `rack.php`, `search.php`, `service.php`, `software.php`, `tags.php`, `ticket.php`, `ticket_fields.php`, `user.php` and `vendor.php`. They hold the queries and rules, so the pages, the API and cron share one definition. - Removed post models: `agent/post/{asset_interface,certificate,credential,domain,event,location,network_ip,network,recurring_ticket,vendor}_model.php`, `admin/post/user_model.php` and `api/v1/certificates/certificate_model.php`. Their handlers call the shared functions above. Interface handlers moved to `agent/post/asset_interface.php`. - New scripts: `js/itflow_select.js`, `quick_pick.js`, `tag_picker.js`, `global_search.js`, `notifications.js`, `dashboard.js`, `client_overview.js`, `client_switcher.js`, `credentials.js` and `autosubmit.js`, and `agent/js/calendar.js`, `calendar_event_modal.js`, `files.js`, `interface_form.js`, `network_form.js`, `project_modal.js` and `ticket_schedule_modal.js`. @@ -996,6 +1015,15 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Seeds: `demoDataAccounts()` adds Business Visa, and accounts take `accountTypeGuess()`. `demoBuildCompanyFinancials()` puts the card's categories on it and pays it off from Operating Checking each month with `demoTransfer()`. Company costs paid from the bank are ACH. - CSS: `.itflow-account-name` and `.itflow-ledger-what`. - Removed: `agent/post/transfer_model.php`. +- Reconcile, in the new `functions/reconcile.php` (loaded after `accounts.php`): + - `reconcileKinds()` (the payments, revenues and expenses columns), `reconcileLineRef('payment-12')`, `reconcileRow($id)` (with `account`, `finished` and `card`), `reconcileOpen($account_id)`, `reconcileLast($account_id)`, `reconcileStartingBalance($account_id)`, `reconcileTotals($row)` (`starting`, `ending`, `in`, `in_count`, `out`, `out_count`, `count`, `cleared`, `difference`), `reconcileLines($row, &$later)` and `reconcileHistory($account_id)`. + - `reconcileStart($account_id, $in, &$error)` (`statement_date`, `ending_balance` - owed, for a card - and `notes`; after the last finished one, not after today; pre-ticks lines with an import key), `reconcileUpdate()`, `reconcileTick($id, $keys, $tick, &$error)`, `reconcileFinish()` (only at a difference of 0.00, storing the starting balance), `reconcileDiscard()` and `reconcileUndo()` (the latest finished one on its account, with none in progress). Ticking sets `*_updated_at` to itself, so it doesn't count as a change to the line. + - Locks: `reconcileLock($kind, $id)` is the finished reconciliation a line cleared on. `reconcileCheckChange($kind, $id, $date, $amount, $account_id, &$error, $release = true)` refuses a change to a cleared line's date, amount or account, and takes a line ticked on one in progress off it when it moves account. `reconcileCheckDelete()`, `reconcileInvoiceLock($invoice_id)`, `reconcileClientLock($client_id)` and `reconcileAccountReconciled($account_id)`. They are called from `expenseSave()`, `expenseDelete()`, `incomeRevenueSave()`, `incomeRevenueDelete()`, `transferSave()` (both sides checked before either changes), `transferDelete()`, `reimbursementDelete($id, &$error)` (which now gives the error), `accountSave()` (the opening balance), and the `edit_payment`, `delete_payment`, `refund_payment_stripe`, `delete_invoice`, `delete_client`, `bulk_edit_income`, `bulk_delete_income`, `bulk_edit_expenses` and `bulk_delete_expenses` handlers and `api/v1/clients/delete.php`. `reconcileGlanceHtml()`, `reconcileFormNoteHtml()` and `reconcileLockedButtonHtml()` show it in the quick looks and Edit windows. + - `accountLedgerSql()` adds `txn_line` (payment-12, revenue-5, expense-9), `txn_reconciliation_id`, `txn_cleared_id` and `txn_cleared_date`. `accountLedgerShows()` adds `uncleared`, and `accountLedgerFiltersSql()` takes `reconciliation` (one statement's lines). `accountLedgerWhat($row)` is a line's who or what, shared by Transactions and Reconcile. `accountsList()` rows add `reconciled_to` and `reconciling_id`, and `accountInUse()` counts reconciliations. + - New page `agent/reconcile.php?account=` and `js/reconcile.js` (`itflowReconcile()`). Handlers in the new `agent/post/reconcile.php`: `reconcile_start` (`account_id`), `reconcile_update` and `reconcile_finish` (`reconciliation_id`) at Financial 2, GET `reconcile_discard` at 2 and `reconcile_undo` at 3. Ticking is POST `reconcile_tick` to `agent/ajax.php` (`reconciliation_id`, `lines[]`, `tick`), answering with the totals. + - `agent/transactions.php` takes `?reconciliation=` and `?show=uncleared`, and the `transactions` export adds `transaction_cleared` (off by default). `agent/modals/expense/expense_add.php` and `agent/modals/revenue/revenue_add.php` take `?account_id=`. + - Seeds: `demoReconcileAccounts()` reconciles Operating Checking and the Business Visa monthly, and `demoDataRemove()` removes the reconciliations on the demo's accounts. + - CSS: `.itflow-reconcile-table`, `.itflow-reconcile-ticked`, `.itflow-reconcile-balanced` and `.itflow-reconcile-bar`, which sticks to the bottom of the window - `.app-main` is `overflow: visible` on that page so it can. - The billing helpers (`billingTimeInvoice()`, `quoteConvert()`, `recurringInvoiceGenerate()` and the rest) take the agent's name as plain text. Their handlers now pass `stripslashes($session_name)`, so a name with an apostrophe no longer shows a backslash in history and the audit log. - Sidebar brand: `agent/css/sidebar_brand_fix.css` and `agent/js/sidebar_brand_fix.js` from 26.10 are replaced by CSS in `css/itflow_custom.css`. `includes/load_company_settings.php` now sets `$session_company_logo`. - `alertMessageHtml()` no longer double-encodes, and save messages built from `escapeSql()` values use `stripslashes()` at the flash. diff --git a/admin/database_updates/3.0.8.php b/admin/database_updates/3.0.8.php new file mode 100644 index 000000000..0293a87d9 --- /dev/null +++ b/admin/database_updates/3.0.8.php @@ -0,0 +1,38 @@ + 'payment', 'revenues' => 'revenue', 'expenses' => 'expense'] as $table => $prefix) { + mysqli_query($mysqli, "UPDATE $table SET {$prefix}_reconciliation_id = NULL + WHERE {$prefix}_reconciliation_id IN (SELECT reconciliation_id FROM account_reconciliations WHERE reconciliation_account_id = $account_id)"); + } + mysqli_query($mysqli, "DELETE FROM account_reconciliations WHERE reconciliation_account_id = $account_id"); + } + // Accounts, SLAs and tax rates are shared company config, so each one only // goes if nothing at all is left pointing at it // Reuse matches on name so a second run does not create duplicates, but @@ -3584,6 +3600,47 @@ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { } +// ------------------------------ +// demoReconcileAccounts +// Operating Checking and the Business Visa are reconciled to each month's +// statement through last month (functions/reconcile.php), so Reconcile and the +// ledger's cleared marks have history to show and this month is left to do. +// Only an account with no reconciliations yet - a second run adds nothing. +// ------------------------------ +function demoReconcileAccounts($mysqli, $context) { + foreach (['Operating Checking', 'Business Visa'] as $name) { + $account_id = intval($context['accounts'][$name] ?? 0); + if (!$account_id || mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM account_reconciliations WHERE reconciliation_account_id = $account_id LIMIT 1"))) { + continue; + } + $balance = floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT opening_balance FROM accounts WHERE account_id = $account_id"))[0] ?? 0); + for ($month = 24; $month >= 1; $month--) { + $statement = date('Y-m-t', strtotime(demoMonthDate($month, 1))); + $ending = round($balance + floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT + COALESCE((SELECT SUM(payment_amount) FROM payments WHERE payment_account_id = $account_id AND payment_reconciliation_id IS NULL AND payment_date <= '$statement'), 0) + + COALESCE((SELECT SUM(revenue_amount) FROM revenues WHERE revenue_account_id = $account_id AND revenue_reconciliation_id IS NULL AND revenue_date <= '$statement'), 0) + - COALESCE((SELECT SUM(expense_amount) FROM expenses WHERE expense_account_id = $account_id AND expense_reconciliation_id IS NULL AND expense_date <= '$statement'), 0)"))[0]), 2); + $finished = min(time(), strtotime($statement . ' +4 days 10:15')); + $reconciliation_id = starterInsert($mysqli, 'account_reconciliations', [ + 'reconciliation_account_id' => $account_id, + 'reconciliation_statement_date' => $statement, + 'reconciliation_starting_balance' => number_format($balance, 2, '.', ''), + 'reconciliation_ending_balance' => number_format($ending, 2, '.', ''), + 'reconciliation_notes' => 'Statement for ' . date('F Y', strtotime($statement)), + 'reconciliation_created_by' => intval($context['user_id']), + 'reconciliation_created_at' => date('Y-m-d H:i:s', $finished - 1800), + 'reconciliation_finished_by' => intval($context['user_id']), + 'reconciliation_finished_at' => date('Y-m-d H:i:s', $finished), + ]); + foreach (['payments' => 'payment', 'revenues' => 'revenue', 'expenses' => 'expense'] as $table => $prefix) { + mysqli_query($mysqli, "UPDATE $table SET {$prefix}_reconciliation_id = $reconciliation_id, {$prefix}_updated_at = {$prefix}_updated_at + WHERE {$prefix}_account_id = $account_id AND {$prefix}_reconciliation_id IS NULL AND {$prefix}_date <= '$statement'"); + } + $balance = $ending; + } + } +} + // ------------------------------ // demoDataHolidays // Closure days, so the SLA clock has something to pause on. Only the fixed date diff --git a/agent/accounts.php b/agent/accounts.php index e1993c6ad..d7b415e8b 100644 --- a/agent/accounts.php +++ b/agent/accounts.php @@ -8,8 +8,9 @@ * on the cards, and what came in and went out this month against the same * days of last month, money moved between the accounts left out. Each * account shows its kind, what it holds (owed, for a card), this month in - * and out, its latest line and what it is set as by default; clicking it - * opens its ledger. Archived ones are on their own pill, to restore. + * and out, its latest line, where it is reconciled to and what it is set as + * by default; clicking it opens its ledger. Archived ones are on their own + * pill, to restore. */ require_once "includes/inc_all.php"; @@ -194,6 +195,11 @@ $month_url = 'canned_date=custom&dtf=' . $stats['month_start'] . '&dtt=' . date( Nothing yet + + Reconciling + + To +
diff --git a/agent/modals/expense/expense.php b/agent/modals/expense/expense.php index 1de60a089..9e7fd2fa3 100644 --- a/agent/modals/expense/expense.php +++ b/agent/modals/expense/expense.php @@ -22,6 +22,7 @@ if (!$row) { } $expense_id = intval($row['expense_id']); +$cleared = reconcileLock('expense', $expense_id); $client_id = intval($row['expense_client_id']); $can_edit = lookupUserPermission('module_financial') >= 2; $can_delete = lookupUserPermission('module_financial') >= 3; @@ -173,6 +174,7 @@ ob_start(); by
+
@@ -273,7 +275,9 @@ ob_start(); Take off Invoice - + + + Delete diff --git a/agent/modals/expense/expense_add.php b/agent/modals/expense/expense_add.php index 270b13861..6c1dd13d4 100644 --- a/agent/modals/expense/expense_add.php +++ b/agent/modals/expense/expense_add.php @@ -3,7 +3,8 @@ /* * New Expense - who it was paid to, how much, what for, from which account * and how, for a client or not, with the receipt. ?vendor_id= starts it with - * one of our vendors (from the vendor's page), ?client_id= with a client. + * one of our vendors (from the vendor's page), ?client_id= with a client, + * ?account_id= from an account (from Reconcile). */ require_once '../../../includes/modal_header.php'; @@ -14,6 +15,7 @@ $form_mode = 'new'; $form_row = null; $form_vendor_id = intval($_GET['vendor_id'] ?? 0); $form_client_id = intval($_GET['client_id'] ?? 0); +$form_account_id = intval($_GET['account_id'] ?? 0); if ($form_client_id) { $client_id = $form_client_id; enforceClientAccess(); diff --git a/agent/modals/expense/expense_form.php b/agent/modals/expense/expense_form.php index 9bef87c95..d0d51e70c 100644 --- a/agent/modals/expense/expense_form.php +++ b/agent/modals/expense/expense_form.php @@ -31,11 +31,11 @@ $form_value = function ($column, $default = '') use ($form_row) { $form_defaults = expenseVendorDefaults(); $form_vendor = $form_row ? intval($form_row['expense_vendor_id']) : intval($form_vendor_id ?? 0); $form_category = intval($form_value('expense_category_id', '0')); -$form_account = $form_row ? intval($form_row['expense_account_id']) : intval($config_default_expense_account); +$form_account = $form_row ? intval($form_row['expense_account_id']) : (intval($form_account_id ?? 0) ?: intval($config_default_expense_account)); $form_method = $form_row ? $form_value('expense_payment_method') : (string) $config_default_expense_payment_method; if (!$form_row && $form_vendor && isset($form_defaults[$form_vendor])) { $form_category = $form_defaults[$form_vendor]['category']; - $form_account = $form_defaults[$form_vendor]['account'] ?: $form_account; + $form_account = intval($form_account_id ?? 0) ?: ($form_defaults[$form_vendor]['account'] ?: $form_account); $form_method = $form_defaults[$form_vendor]['method'] !== '' ? $form_defaults[$form_vendor]['method'] : $form_method; } $form_client = $form_row ? intval($form_row['expense_client_id']) : intval($form_client_id ?? 0); @@ -57,6 +57,8 @@ $form_currency = $form_row ? $form_value('expense_currency_code') : (string) $se $form_vendors = expenseVendors($form_edit ? $form_vendor : 0); $form_categories = expenseCategories($form_edit ? $form_category : 0); $form_accounts = paymentAccounts($form_edit ? $form_account : 0); +// Cleared on a reconciled statement - its amount, date and account stay +$form_lock = $form_edit ? reconcileLock('expense', $form_row['expense_id']) : null; $form_methods = paymentMethods(); if ($form_method !== '' && !in_array($form_method, $form_methods, true)) { $form_methods[] = $form_method; @@ -84,6 +86,7 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup'] ?>
+ @@ -128,7 +131,7 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup']
> + value="" >
@@ -158,7 +161,7 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup']
- + >
@@ -166,7 +169,7 @@ $form_markup = $form_row && !$form_refund ? floatval($form_row['expense_markup']
- > $account) { ?>
diff --git a/agent/modals/expense/expense_reimbursement.php b/agent/modals/expense/expense_reimbursement.php index eed619202..7b3dd33b2 100644 --- a/agent/modals/expense/expense_reimbursement.php +++ b/agent/modals/expense/expense_reimbursement.php @@ -17,6 +17,7 @@ if (!$row) { } $reimbursement_id = intval($row['reimbursement_id']); +$cleared = reconcileLock('expense', $row['reimbursement_expense_id']); $currency = (string) $row['reimbursement_currency_code']; $expenses = reimbursementExpenses($reimbursement_id); $can_undo = lookupUserPermission('module_financial') >= 3; @@ -56,6 +57,7 @@ ob_start(); by + @@ -78,7 +80,9 @@ ob_start(); +
@@ -169,8 +171,12 @@ ob_start(); - Delete + + + + Delete +
diff --git a/agent/modals/payment/payment_edit.php b/agent/modals/payment/payment_edit.php index 39579caf1..a6cac42f4 100644 --- a/agent/modals/payment/payment_edit.php +++ b/agent/modals/payment/payment_edit.php @@ -31,6 +31,7 @@ $pay = [ 'method' => $payment['payment_method'], 'reference' => $payment['payment_reference'], 'email' => '', + 'lock' => reconcileLock('payment', $payment_id), ]; ob_start(); diff --git a/agent/modals/payment/payment_form.php b/agent/modals/payment/payment_form.php index 8b4193228..f3e47a414 100644 --- a/agent/modals/payment/payment_form.php +++ b/agent/modals/payment/payment_form.php @@ -9,6 +9,7 @@ * currency the currency * date, account_id, method, reference to start with (editing) * email the primary contact's email, for the receipt ('' to leave out) + * lock the statement it cleared on (reconcileLock()) - its amount, date and account stay * full_text what to say when the amount pays everything owed * id a unique id for the form's parts * @@ -24,15 +25,17 @@ $pay_methods = paymentMethods(); $pay_account_id = intval($pay['account_id'] ?? $config_default_payment_account); $pay_method = (string) ($pay['method'] ?? $config_default_payment_method); $pay_owed = $pay['owed'] ?? null; +$pay_lock = $pay['lock'] ?? null; ?>
+
+ value="" placeholder="0.00" required data-pay-amount >
@@ -45,7 +48,7 @@ $pay_owed = $pay['owed'] ?? null;
- + >
@@ -71,13 +74,16 @@ $pay_owed = $pay['owed'] ?? null;
- > $account) { ?> + + +
diff --git a/agent/modals/revenue/revenue.php b/agent/modals/revenue/revenue.php index 5611519fb..dae191a48 100644 --- a/agent/modals/revenue/revenue.php +++ b/agent/modals/revenue/revenue.php @@ -18,6 +18,7 @@ if (!$row) { } $revenue_id = intval($row['revenue_id']); +$cleared = reconcileLock('revenue', $revenue_id); $client_id = intval($row['revenue_client_id']); $amount = floatval($row['revenue_amount']); $currency = (string) $row['revenue_currency_code']; @@ -77,6 +78,7 @@ ob_start(); by from a bank statement
+
@@ -118,7 +120,9 @@ ob_start(); - + + + Delete diff --git a/agent/modals/revenue/revenue_add.php b/agent/modals/revenue/revenue_add.php index 475223779..0b20589e3 100644 --- a/agent/modals/revenue/revenue_add.php +++ b/agent/modals/revenue/revenue_add.php @@ -3,7 +3,8 @@ /* * New Income - money in that isn't for an invoice: a deposit, a rebate, * interest, a referral fee. ?client_id= starts it with a client (from their - * Income page). A payment against an invoice is added on the invoice. + * Income page), ?account_id= going into an account (from Reconcile). A + * payment against an invoice is added on the invoice. */ require_once '../../../includes/modal_header.php'; @@ -13,6 +14,7 @@ enforceUserPermission('module_financial', 2); $form_row = null; $form_mode = 'new'; $form_client_id = intval($_GET['client_id'] ?? 0); +$form_account_id = intval($_GET['account_id'] ?? 0); if ($form_client_id) { $client_id = $form_client_id; enforceClientAccess(); diff --git a/agent/modals/revenue/revenue_form.php b/agent/modals/revenue/revenue_form.php index ad29a7889..d36d0f0d7 100644 --- a/agent/modals/revenue/revenue_form.php +++ b/agent/modals/revenue/revenue_form.php @@ -25,7 +25,7 @@ $form_value = function ($column, $default = '') use ($form_row) { $form_date = $form_edit ? $form_value('revenue_date') : date('Y-m-d'); $form_amount = $form_row ? floatval($form_row['revenue_amount']) : null; -$form_account = $form_row ? intval($form_row['revenue_account_id']) : intval($config_default_payment_account); +$form_account = $form_row ? intval($form_row['revenue_account_id']) : (intval($form_account_id ?? 0) ?: intval($config_default_payment_account)); $form_category = intval($form_value('revenue_category_id', '0')); $form_client = $form_row ? intval($form_row['revenue_client_id']) : intval($form_client_id ?? 0); $form_method = $form_row ? $form_value('revenue_payment_method') : (string) ($config_default_payment_method ?? ''); @@ -33,6 +33,8 @@ $form_reference = $form_edit ? $form_value('revenue_reference') : ''; $form_description = $form_value('revenue_description'); $form_accounts = paymentAccounts($form_edit ? $form_account : 0); +// Cleared on a reconciled statement - its amount, date and account stay +$form_lock = $form_edit ? reconcileLock('revenue', $form_row['revenue_id']) : null; if (!isset($form_accounts[$form_account])) { $form_account = 0; } @@ -55,6 +57,7 @@ $form_currency = $form_account ? ($form_accounts[$form_account]['account_currenc ?>
+
@@ -77,7 +80,7 @@ $form_currency = $form_account ? ($form_accounts[$form_account]['account_currenc
> + value="" >
@@ -89,13 +92,16 @@ $form_currency = $form_account ? ($form_accounts[$form_account]['account_currenc
- > $account) { ?> + + +
@@ -103,7 +109,7 @@ $form_currency = $form_account ? ($form_accounts[$form_account]['account_currenc
- + >
diff --git a/agent/modals/transaction/transaction_export.php b/agent/modals/transaction/transaction_export.php index 55fcae6e4..c3ba3485b 100644 --- a/agent/modals/transaction/transaction_export.php +++ b/agent/modals/transaction/transaction_export.php @@ -22,6 +22,8 @@ $method_filter = (string) ($_GET['method'] ?? ($_GET['payment_method'] ?? '')); $amount_min = (string) ($_GET['amount_min'] ?? ''); $amount_max = (string) ($_GET['amount_max'] ?? ''); $q_filter = (string) ($_GET['q'] ?? ''); +$reconciliation_filter = intval($_GET['reconciliation'] ?? 0); +$reconciliation_date = $reconciliation_filter ? getFieldById('account_reconciliations', $reconciliation_filter, 'reconciliation_statement_date') : null; $date_from_filter = (!empty($_GET['dtf']) && $_GET['dtf'] !== '1970-01-01') ? escapeHtml($_GET['dtf']) : ''; $date_to_filter = (!empty($_GET['dtt']) && $_GET['dtt'] !== '2099-12-31') ? escapeHtml($_GET['dtt']) : ''; $types = accountTypes(); @@ -39,11 +41,18 @@ ob_start();
+ + + @@ -87,8 +91,12 @@ ob_start(); = 3) { ?> - Delete + + + + Delete +
diff --git a/agent/modals/transfer/transfer_form.php b/agent/modals/transfer/transfer_form.php index 77a40f602..4f20053df 100644 --- a/agent/modals/transfer/transfer_form.php +++ b/agent/modals/transfer/transfer_form.php @@ -41,6 +41,10 @@ if ($form_method !== '' && !in_array($form_method, $form_methods, true)) { $form_methods[] = $form_method; } $form_types = accountTypes(); +// Either side cleared on a reconciled statement - the date, amount and that side's account stay +$form_lock_out = $form_row ? reconcileLock('expense', $form_row['transfer_expense_id']) : null; +$form_lock_in = $form_row ? reconcileLock('revenue', $form_row['transfer_revenue_id']) : null; +$form_lock = $form_lock_out ?: $form_lock_in; $form_currency = isset($form_accounts[$form_from]) ? ($form_accounts[$form_from]['account_currency_code'] ?: $session_company_currency) : $session_company_currency; $form_account_options = function ($selected) use ($form_accounts, $form_types) { $html = ''; @@ -54,28 +58,35 @@ $form_account_options = function ($selected) use ($form_accounts, $form_types) { ?>
+
- > + + +
- > + + +
@@ -85,7 +96,7 @@ $form_account_options = function ($selected) use ($form_accounts, $form_types) {
> + value="" >
@@ -94,7 +105,7 @@ $form_account_options = function ($selected) use ($form_accounts, $form_types) {
- + >
diff --git a/agent/post/client.php b/agent/post/client.php index ea74817ce..ce55a4adf 100644 --- a/agent/post/client.php +++ b/agent/post/client.php @@ -257,6 +257,13 @@ if (isset($_GET['delete_client'])) { // Get Client Name $client_name = escapeSql(getFieldById('clients', $client_id, 'client_name')); + // Their payments and other income go with them - not once any has cleared on a reconciled statement + $lock = reconcileClientLock($client_id); + if ($lock) { + flashAlert(escapeHtml('Money from ' . stripslashes($client_name) . " cleared on the {$lock['account_name']} statement to " . date('M j, Y', strtotime($lock['reconciliation_statement_date'])) . ' - keep the client archived instead, or undo that reconciliation to delete them'), 'error'); + redirect(); + } + // Delete Associations // Delete Client Data mysqli_query($mysqli, "DELETE FROM certificates WHERE certificate_client_id = $client_id"); diff --git a/agent/post/expense.php b/agent/post/expense.php index 55e22583c..6742a5bc3 100644 --- a/agent/post/expense.php +++ b/agent/post/expense.php @@ -196,11 +196,17 @@ if (isset($_POST['bulk_edit_expenses'])) { $count = 0; $skipped = 0; + $cleared = 0; foreach ($expense_ids as $expense_id) { $row = expenseRow($expense_id); if (!$row || $row['transfer_id'] || !intval($row['expense_vendor_id'])) { continue; } + // Cleared on a reconciled statement - it stays on its account + if ($field === 'account' && !reconcileCheckChange('expense', $expense_id, null, null, intval($value), $cleared_error)) { + $cleared++; + continue; + } // Billed ones keep their client and stay billed; only a client's expenses can be billed on if (($row['expense_invoice_id'] && in_array($field, ['client', 'billable'], true)) || ($row['expense_reimbursement_id'] && $field === 'account') @@ -216,8 +222,9 @@ if (isset($_POST['bulk_edit_expenses'])) { logAudit("Expense", "Bulk Edit", "$session_name set " . escapeSql($label) . " on $count expense" . ($count === 1 ? '' : 's')); flashAlert("Set " . escapeHtml($label) . " on $count expense" . ($count === 1 ? '' : 's') - . ($skipped ? " - $skipped left as " . ($skipped === 1 ? 'it was' : 'they were') . ($field === 'billable' && $value === 'yes' ? ' (on an invoice already, or not for a client)' : ($field === 'account' ? ' (paid back to an agent already)' : ' (on an invoice already)')) : ''), - $count ? 'success' : 'warning'); + . ($skipped ? " - $skipped left as " . ($skipped === 1 ? 'it was' : 'they were') . ($field === 'billable' && $value === 'yes' ? ' (on an invoice already, or not for a client)' : ($field === 'account' ? ' (paid back to an agent already)' : ' (on an invoice already)')) : '') + . ($cleared ? " - $cleared left on " . ($cleared === 1 ? 'its account (it' : 'their accounts (they') . ' cleared on a reconciled statement)' : ''), + $count && !$cleared ? 'success' : 'warning'); redirect(); @@ -260,9 +267,11 @@ if (isset($_POST['bulk_delete_expenses'])) { enforceUserPermission('module_financial', 3); $count = 0; + $cleared = 0; foreach (array_unique(array_filter(array_map('intval', (array) ($_POST['expense_ids'] ?? [])))) as $expense_id) { - $row = expenseDelete($expense_id); + $row = expenseDelete($expense_id, $delete_error); if (!$row) { + $cleared += reconcileLock('expense', $expense_id) ? 1 : 0; continue; } logAudit("Expense", "Delete", "$session_name deleted the expense " . escapeSql(expenseLabel($row)), intval($row['expense_client_id'])); @@ -271,7 +280,7 @@ if (isset($_POST['bulk_delete_expenses'])) { logAudit("Expense", "Bulk Delete", "$session_name deleted $count expense" . ($count === 1 ? '' : 's')); - flashAlert("Deleted $count expense" . ($count === 1 ? '' : 's'), 'error'); + flashAlert("Deleted $count expense" . ($count === 1 ? '' : 's') . ($cleared ? " - $cleared kept, cleared on a reconciled statement" : ''), 'error'); redirect(); @@ -314,9 +323,9 @@ if (isset($_GET['expense_reimbursement_delete'])) { enforceUserPermission('module_financial', 3); - $row = reimbursementDelete(intval($_GET['expense_reimbursement_delete'])); + $row = reimbursementDelete(intval($_GET['expense_reimbursement_delete']), $error); if (!$row) { - flashAlert('That reimbursement no longer exists', 'error'); + flashAlert(escapeHtml($error), 'error'); redirect(); } diff --git a/agent/post/income.php b/agent/post/income.php index 115387c84..452bfb829 100644 --- a/agent/post/income.php +++ b/agent/post/income.php @@ -63,13 +63,18 @@ if (isset($_POST['bulk_edit_income'])) { } $label_sql = escapeSql($label); - // Other income + // Other income - one cleared on a reconciled statement keeps its account $count = 0; + $cleared = 0; foreach ($ids['revenue'] as $revenue_id) { $row = incomeRevenueRow($revenue_id); if (!$row) { continue; } + if ($field === 'account' && !reconcileCheckChange('revenue', $revenue_id, null, null, $value, $cleared_error)) { + $cleared++; + continue; + } mysqli_query($mysqli, "UPDATE revenues SET $revenue_set WHERE revenue_id = $revenue_id"); logAudit("Revenue", "Edit", "$session_name set $label_sql on other income " . escapeSql(incomeRevenueLabel($row)), intval($row['revenue_client_id']), $revenue_id); $count++; @@ -102,6 +107,10 @@ if (isset($_POST['bulk_edit_income'])) { if (!$row) { continue; } + if ($field === 'account' && !reconcileCheckChange('payment', $payment_id, null, null, $value, $cleared_error)) { + $cleared++; + continue; + } mysqli_query($mysqli, "UPDATE payments SET $payment_set WHERE payment_id = $payment_id"); logAudit("Payment", "Edit", "$session_name set $label_sql on the payment " . escapeSql(incomePaymentLabel($row)), intval($row['invoice_client_id']), $payment_id); $count++; @@ -117,7 +126,10 @@ if (isset($_POST['bulk_edit_income'])) { $notes[] = "$kept payment" . ($kept === 1 ? '' : 's') . ' left as ' . ($kept === 1 ? 'it was' : 'they were') . ($field === 'client' ? " - a payment's client is its invoice's" : ($can_payments ? ' - not on an invoice' : ' - changing a payment takes full access to Sales and Financial')); } - flashAlert("Set " . escapeHtml($label) . " on $count" . ($notes ? ' - ' . escapeHtml(implode('; ', $notes)) : ''), $kept ? 'warning' : 'success'); + if ($cleared) { + $notes[] = "$cleared left on " . ($cleared === 1 ? 'its account - it' : 'their accounts - they') . ' cleared on a reconciled statement'; + } + flashAlert("Set " . escapeHtml($label) . " on $count" . ($notes ? ' - ' . escapeHtml(implode('; ', $notes)) : ''), $kept || $cleared ? 'warning' : 'success'); redirect(); @@ -131,9 +143,11 @@ if (isset($_POST['bulk_delete_income'])) { $ids = incomeBulkIds($_POST['income_ids'] ?? []); $count = 0; + $cleared = 0; foreach ($ids['revenue'] as $revenue_id) { $row = incomeRevenueDelete($revenue_id, $delete_error); if (!$row) { + $cleared += reconcileLock('revenue', $revenue_id) ? 1 : 0; continue; } logAudit("Revenue", "Delete", "$session_name deleted other income " . escapeSql(incomeRevenueLabel($row)), intval($row['revenue_client_id'])); @@ -142,7 +156,8 @@ if (isset($_POST['bulk_delete_income'])) { logAudit("Income", "Bulk Delete", "$session_name deleted $count other income record" . ($count === 1 ? '' : 's')); $kept = count($ids['payment']); - flashAlert("$count other income deleted" . ($kept ? " - $kept payment" . ($kept === 1 ? ' was' : 's were') . ' kept; a payment is deleted from its invoice' : ''), 'error'); + flashAlert("$count other income deleted" . ($kept ? " - $kept payment" . ($kept === 1 ? ' was' : 's were') . ' kept; a payment is deleted from its invoice' : '') + . ($cleared ? " - $cleared kept, cleared on a reconciled statement" : ''), 'error'); redirect(); diff --git a/agent/post/invoice.php b/agent/post/invoice.php index c9c17533a..900cdba8a 100644 --- a/agent/post/invoice.php +++ b/agent/post/invoice.php @@ -276,6 +276,13 @@ if (isset($_GET['delete_invoice'])) { enforceClientAccess(); + // Its payments go with it - not once one has cleared on a reconciled statement + $lock = reconcileInvoiceLock($invoice_id); + if ($lock) { + flashAlert(escapeHtml("A payment on $invoice_prefix$invoice_number cleared on the {$lock['account_name']} statement to " . date('M j, Y', strtotime($lock['reconciliation_statement_date'])) . ' - cancel the invoice instead, or undo that reconciliation to delete it'), 'error'); + redirect(); + } + // What it took off the shelf goes back before its lines go productStockReturnInvoice($invoice_id, stripslashes("$invoice_prefix$invoice_number deleted")); diff --git a/agent/post/payment.php b/agent/post/payment.php index 455290ef3..94a6baeb9 100644 --- a/agent/post/payment.php +++ b/agent/post/payment.php @@ -101,6 +101,12 @@ if (isset($_POST['edit_payment'])) { enforceClientAccess(); + // Cleared on a reconciled statement - its date, amount and account stay + if (!reconcileCheckChange('payment', $payment_id, $date, $amount, $account, $error)) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + mysqli_query($mysqli,"UPDATE payments SET payment_date = '$date', payment_amount = $amount, payment_account_id = $account, payment_method = '$payment_method', payment_reference = '$reference' WHERE payment_id = $payment_id"); // A changed amount can leave the invoice paid, part paid or owing again @@ -528,6 +534,11 @@ if (isset($_GET['delete_payment'])) { enforceClientAccess(); + if (!reconcileCheckDelete('payment', $payment_id, $error)) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + mysqli_query($mysqli,"DELETE FROM payments WHERE payment_id = $payment_id"); // Recalculate from what is left rather than from the pre-delete total @@ -567,6 +578,12 @@ if (isset($_GET['refund_payment_stripe'])) { enforceClientAccess(); + // Refunding deletes the payment - not one cleared on a reconciled statement + if (!reconcileCheckDelete('payment', $payment_id, $error)) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + // Get Stripe Payment Intent ID $sql = mysqli_query($mysqli,"SELECT payment_reference FROM payments WHERE payment_id = $payment_id LIMIT 1"); $row = mysqli_fetch_assoc($sql); diff --git a/agent/post/reconcile.php b/agent/post/reconcile.php new file mode 100644 index 000000000..75b8a61d8 --- /dev/null +++ b/agent/post/reconcile.php @@ -0,0 +1,134 @@ + (string) ($_POST['statement_date'] ?? ''), + 'ending_balance' => (string) ($_POST['ending_balance'] ?? ''), + 'notes' => (string) ($_POST['notes'] ?? ''), + ]; +} + +/** "Operating Checking statement to Sep 30, 2026" */ +function reconcileLabel(array $row) { + return $row['account']['account_name'] . ' statement to ' . date('M j, Y', strtotime($row['reconciliation_statement_date'])); +} + +if (isset($_POST['reconcile_start'])) { + + validateCSRFToken(); + + enforceUserPermission('module_financial', 2); + + $account_id = intval($_POST['account_id'] ?? 0); + $id = reconcileStart($account_id, reconcilePostInput(), $error); + if (!$id) { + flashAlert(escapeHtml($error), 'error'); + redirect("reconcile.php?account=$account_id"); + } + $row = reconcileRow($id); + $totals = reconcileTotals($row); + + logAudit("Reconciliation", "Create", "$session_name started reconciling the " . escapeSql(reconcileLabel($row)), 0, $id); + + flashAlert("Reconciling the " . escapeHtml(reconcileLabel($row)) . "" . ($totals['count'] ? " - {$totals['count']} line" . ($totals['count'] === 1 ? '' : 's') . ' from bank statements ticked already' : '')); + + redirect("reconcile.php?account=$account_id"); + +} + +if (isset($_POST['reconcile_update'])) { + + validateCSRFToken(); + + enforceUserPermission('module_financial', 2); + + $id = intval($_POST['reconciliation_id'] ?? 0); + $before = reconcileRow($id); + if (!reconcileUpdate($id, reconcilePostInput(), $error)) { + flashAlert(escapeHtml($error), 'error'); + redirect($before ? 'reconcile.php?account=' . intval($before['reconciliation_account_id']) : 'accounts.php'); + } + $row = reconcileRow($id); + + logAudit("Reconciliation", "Edit", "$session_name changed the statement being reconciled to the " . escapeSql(reconcileLabel($row)) . ", ending at " + . escapeSql(reconcileBalanceText($row['account'], $row['reconciliation_ending_balance'])), 0, $id); + + flashAlert("Statement changed - " . escapeHtml(reconcileLabel($row)) . ""); + + redirect('reconcile.php?account=' . intval($row['reconciliation_account_id'])); + +} + +if (isset($_POST['reconcile_finish'])) { + + validateCSRFToken(); + + enforceUserPermission('module_financial', 2); + + $id = intval($_POST['reconciliation_id'] ?? 0); + $row = reconcileRow($id); + $totals = $row ? reconcileFinish($id, $error) : null; + if (!$totals) { + flashAlert(escapeHtml($row ? $error : 'That reconciliation no longer exists'), 'error'); + redirect($row ? 'reconcile.php?account=' . intval($row['reconciliation_account_id']) : 'accounts.php'); + } + + logAudit("Reconciliation", "Finish", "$session_name reconciled the " . escapeSql(reconcileLabel($row)) . " - {$totals['count']} lines cleared, ending at " + . escapeSql(reconcileBalanceText($row['account'], $totals['ending'])), 0, $id); + + flashAlert("" . escapeHtml($row['account']['account_name']) . " is reconciled to " . escapeHtml(date('M j, Y', strtotime($row['reconciliation_statement_date']))) + . " - {$totals['count']} line" . ($totals['count'] === 1 ? '' : 's') . " cleared"); + + redirect('reconcile.php?account=' . intval($row['reconciliation_account_id'])); + +} + +if (isset($_GET['reconcile_discard'])) { + + validateCSRFToken(); + + enforceUserPermission('module_financial', 2); + + $row = reconcileDiscard(intval($_GET['reconcile_discard']), $error); + if (!$row) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + + logAudit("Reconciliation", "Delete", "$session_name discarded reconciling the " . escapeSql(reconcileLabel($row)), 0, intval($row['reconciliation_id'])); + + flashAlert("Reconciling the " . escapeHtml(reconcileLabel($row)) . " discarded - its ticks are off", 'error'); + + redirect('reconcile.php?account=' . intval($row['reconciliation_account_id'])); + +} + +if (isset($_GET['reconcile_undo'])) { + + validateCSRFToken(); + + enforceUserPermission('module_financial', 3); + + $row = reconcileUndo(intval($_GET['reconcile_undo']), $error); + if (!$row) { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } + + logAudit("Reconciliation", "Undo", "$session_name undid the reconciliation of the " . escapeSql(reconcileLabel($row)) . " - back to in progress", 0, intval($row['reconciliation_id'])); + + flashAlert("The " . escapeHtml(reconcileLabel($row)) . " is being reconciled again - its lines can change, and it can be finished or discarded", 'warning'); + + redirect('reconcile.php?account=' . intval($row['reconciliation_account_id'])); + +} diff --git a/agent/post/transaction.php b/agent/post/transaction.php index 17052b0a4..b92bb5e59 100644 --- a/agent/post/transaction.php +++ b/agent/post/transaction.php @@ -59,6 +59,7 @@ if (isExportRequest('export_transactions')) { 'transaction_out' => $amount < 0 ? -$amount : '', 'transaction_amount' => $amount, 'transaction_balance' => $account ? floatval($row['txn_balance']) : '', + 'transaction_cleared' => (string) ($row['txn_cleared_date'] ?? ''), ]); } diff --git a/agent/reconcile.php b/agent/reconcile.php new file mode 100644 index 000000000..8e9a88a2d --- /dev/null +++ b/agent/reconcile.php @@ -0,0 +1,427 @@ += 2; +$can_undo = lookupUserPermission('module_financial') >= 3; +$types = accountTypes(); +$kinds = accountLedgerKinds(); +$open_accounts = accountsList(false); + +$account_id = intval($_GET['account'] ?? 0); +$account = $account_id ? accountRow($account_id) : null; +$missing_account = $account_id && !$account; +if (!$account) { + $pick = 0; + foreach ($open_accounts as $id => $option) { + if ($option['reconciling_id']) { + $pick = $id; + break; + } + } + if (!$pick && isset($open_accounts[intval($config_default_payment_account)])) { + $pick = intval($config_default_payment_account); + } + if (!$pick && $open_accounts) { + $pick = array_key_first($open_accounts); + } + $account = $pick ? $open_accounts[$pick] : null; +} +$account_id = $account ? intval($account['account_id']) : 0; +$is_card = $account && $account['type'] === 3; +$currency = $account ? (string) $account['account_currency_code'] : (string) $session_company_currency; +$money = function ($amount) use ($currency) { + return escapeHtml(billingMoney($amount, $currency)); +}; +// A balance as the statement reads it - a card's as what is owed +$statement_money = function ($balance) use ($account, $money) { + return $account ? escapeHtml(reconcileBalanceText($account, $balance)) : $money($balance); +}; + +$open = $account ? reconcileOpen($account_id) : null; +$last = $account ? reconcileLast($account_id) : null; +$history = $account && !$open ? reconcileHistory($account_id) : []; +$starting = $account ? reconcileStartingBalance($account_id) : 0.0; + +// The next statement most likely ends at the end of last month +$suggest = date('Y-m-t', strtotime('first day of last month')); +if ($last && $suggest <= $last['reconciliation_statement_date']) { + $suggest = date('Y-m-d'); +} +if ($open) { + $totals = reconcileTotals($open); + $lines = reconcileLines($open, $later); + $statement_date = $open['reconciliation_statement_date']; +} else { + $uncleared = $account ? mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*), COALESCE(SUM(ledger.txn_amount), 0) FROM (" . accountLedgerSql($account_id) . ") AS ledger WHERE ledger.txn_cleared_id IS NULL")) : [0, 0]; +} + +$statement_form = function ($row) use ($account, $is_card, $suggest, $last, $starting, $money, $statement_money) { + $date = $row ? $row['reconciliation_statement_date'] : $suggest; + $ending = $row ? floatval($row['reconciliation_ending_balance']) * ($is_card ? -1 : 1) : null; + ?> +
+
+ +
+ + value="" required> +
+
+
+ +
+ + + +
+
+
+
+ +
+ + +
+
+
+
+ It starts from - + . +
+ + + +
That account no longer exists.
+ + + +
+
+ +
Add an account to reconcile first.
+ Accounts +
+
+ + + + + +
+
+
+
Starting from
+
+
+
+
+
+
+
The statement
+
+
Ending
+
+
+
+
+
Cleared
+
+
ticked - in, out
+
+
+
+
+
Difference
+
+
+
+
+
+ +
+
+

+ Reconcile + statement to +

+
+ + + + +
+ + + +
+ + +
+
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
ClearedDateWho or whatInOut
+ aria-label="Cleared"> + +
-
+
+ + +
+
0 ? $money($amount) : '' ?>
+
+ +
+ +
Nothing on up to is left to clear.
+
+ + + +
+ + + + + + + +
+
+ +
+
+
·
+
+
+
+
+
Reconciled to
+
+
+
+
+
+ +
Not cleared yet
+
line
+
+
+
+
+ +
+
+

Reconcile

+ +
+
+
+
+
+ + +
+
+
+
+ +
+ + +
The next statement
+ + +
+ + +
+ + + + + + + + + + + + + + $row) { + $t = $row['totals']; ?> + + + + + + + + + + + +
Statement toClearedInOutReconciled
+
+
line +
from
+
+ +
+
+ + Undo + +
+
+ +
+ +
hasn't been reconciled yet.
+
+ +
+ + + + null, 'txn_amount' => 'ledger.txn_amount', 'category_name' => 'ledger.category_name', 'client_name' => 'ledger.client_name', @@ -99,7 +105,7 @@ $filter_url = function ($changes) { unset($params['page'], $params['type'], $params['payment_method']); return '?' . http_build_query(array_filter($params, function ($v) { return $v !== '' && $v !== null && $v !== 0; })); }; -$any_filter = $q || $category_filter || $client_filter || $more_filters || $show !== '' || $dtf !== '1970-01-01'; +$any_filter = $q || $category_filter || $client_filter || $more_filters || $reconciliation_filter || $show !== '' || $dtf !== '1970-01-01'; $dated = $dtf !== '1970-01-01' || $dtt !== '2099-12-31'; $cash = 0.0; $cash_accounts = 0; @@ -138,7 +144,8 @@ if ($transfer_cards) {
-
·
+
· + ·
@@ -214,9 +221,12 @@ if ($transfer_cards) {