mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
527 lines
22 KiB
PHP
527 lines
22 KiB
PHP
<?php
|
|
|
|
/*
|
|
* ITFlow - line items on invoices, quotes and recurring invoices, and the
|
|
* small things the billing pages do in place.
|
|
*
|
|
* One handler for the lines on all three - which kind is in the form's
|
|
* "type" (invoice, quote, recurring); functions/billing.php does the work.
|
|
* A line can only change while the document is open for it: not once an
|
|
* invoice is paid, cancelled or non-billable, nor once a quote has been
|
|
* answered or invoiced.
|
|
*/
|
|
|
|
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
|
|
|
|
/**
|
|
* The document a line request is for, checked: the kind, the row (scoped to
|
|
* the clients this agent may open) and that its lines may change. Flashes
|
|
* and goes back when not.
|
|
*/
|
|
function billingPostDocument($type, $id) {
|
|
global $client_id;
|
|
$t = billingType($type);
|
|
$row = $t ? billingRow($type, $id) : null;
|
|
if (!$row) {
|
|
flashAlert('That document no longer exists, or is not one you can open.', 'error');
|
|
redirect();
|
|
}
|
|
$client_id = intval($row['client_id']);
|
|
enforceClientAccess();
|
|
if (!billingCanEditItems($type, $row)) {
|
|
flashAlert(billingNumber($type, $row) . " can't be changed now - " . ($type === 'quote' ? 'it has been answered or invoiced.' : 'it is paid, cancelled or non-billable.'), 'error');
|
|
redirect();
|
|
}
|
|
return $row;
|
|
}
|
|
|
|
/** Back to the document, at the lines. */
|
|
function billingPostBack($type, $id, $hash = 'items') {
|
|
$t = billingType($type);
|
|
$referer = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
|
$path = (string) parse_url($referer, PHP_URL_PATH);
|
|
$url = basename($path) === $t['page'] ? strtok($referer, '#') : $t['page'] . '?' . $t['param'] . '=' . intval($id);
|
|
redirect($url . ($hash !== '' ? "#$hash" : ''));
|
|
}
|
|
|
|
/** What billing time did, for the flash - "TCK-12 and 3 more billed on INV-45 - 6.5 h on 4 lines". */
|
|
function unbilledResultText(array $result) {
|
|
$tickets = $result['tickets'];
|
|
$what = count($tickets) === 1 ? $tickets[0] : $tickets[0] . ' and ' . (count($tickets) - 1) . ' more';
|
|
$text = '<strong>' . escapeHtml($what) . '</strong> billed on <strong>' . escapeHtml($result['number']) . '</strong> - ' . billingQuantity($result['hours']) . ' h'
|
|
. ($result['lines'] > 1 ? ' on ' . $result['lines'] . ' lines' : '');
|
|
if ($result['no_charge']) {
|
|
$text .= '. ' . escapeHtml(implode(', ', $result['no_charge'])) . (count($result['no_charge']) === 1 ? ' had' : ' had') . ' no hours, so went on with no charge';
|
|
}
|
|
return $text;
|
|
}
|
|
|
|
/** What billing tickets, expenses and trips together did, for the flash - the time's text, then the expenses', then the trips'. */
|
|
function unbilledBothText(array $result, $currency) {
|
|
$parts = [];
|
|
if ($result['time']) {
|
|
$parts[] = unbilledResultText($result['time']);
|
|
}
|
|
if ($result['expenses']) {
|
|
$e = $result['expenses'];
|
|
$parts[] = ($result['time'] ? '' : '<strong>' . escapeHtml($e['number']) . '</strong>: ') . '<strong>' . $e['expenses'] . ' expense' . ($e['expenses'] === 1 ? '' : 's') . '</strong> billed - '
|
|
. escapeHtml(billingMoney($e['amount'], $currency)) . ' on ' . escapeHtml(billingMoney($e['cost'], $currency)) . ' of cost';
|
|
}
|
|
if (!empty($result['trips'])) {
|
|
$t = $result['trips'];
|
|
$parts[] = ($result['time'] || $result['expenses'] ? '' : '<strong>' . escapeHtml($t['number']) . '</strong>: ') . '<strong>' . $t['trips'] . ' trip' . ($t['trips'] === 1 ? '' : 's') . '</strong> billed - '
|
|
. escapeHtml(billingMoney($t['amount'], $currency)) . ' for ' . escapeHtml(tripDistanceText($t['miles']));
|
|
}
|
|
$text = implode('. ', $parts);
|
|
if ($result['error'] !== '') {
|
|
$text .= '. Not billed: ' . escapeHtml($result['error']);
|
|
}
|
|
return $text;
|
|
}
|
|
|
|
/** The ticked expenses and their prices from Unbilled Time - expense id => price; empty unless the agent can bill them. */
|
|
function unbilledPostPrices() {
|
|
$prices = [];
|
|
foreach (array_unique(array_filter(array_map('intval', (array) ($_POST['expense_ids'] ?? [])))) as $expense_id) {
|
|
$prices[$expense_id] = $_POST['expense_price'][$expense_id] ?? null;
|
|
}
|
|
if ($prices) {
|
|
enforceUserPermission('module_financial', 2);
|
|
}
|
|
return $prices;
|
|
}
|
|
|
|
/** The ticked trips and their prices from Unbilled Time - trip id => price; empty unless the agent can bill them. */
|
|
function unbilledPostTripPrices() {
|
|
$prices = [];
|
|
foreach (array_unique(array_filter(array_map('intval', (array) ($_POST['trip_ids'] ?? [])))) as $trip_id) {
|
|
$prices[$trip_id] = $_POST['trip_price'][$trip_id] ?? null;
|
|
}
|
|
if ($prices) {
|
|
enforceUserPermission('module_financial', 2);
|
|
}
|
|
return $prices;
|
|
}
|
|
|
|
/** The audit log's name for each kind. */
|
|
function billingAuditType($type) {
|
|
return ['invoice' => 'Invoice', 'quote' => 'Quote', 'recurring' => 'Recurring Invoice'][$type] ?? 'Invoice';
|
|
}
|
|
|
|
if (isset($_POST['billing_item_save'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$type = (string) ($_POST['type'] ?? '');
|
|
$doc_id = intval($_POST['doc_id'] ?? 0);
|
|
$item_id = intval($_POST['item_id'] ?? 0);
|
|
$row = billingPostDocument($type, $doc_id);
|
|
$number = billingNumber($type, $row);
|
|
|
|
$saved = billingItemSave($type, $doc_id, $_POST, $item_id, $error);
|
|
if (!$saved) {
|
|
flashAlert(escapeHtml($error), 'error');
|
|
billingPostBack($type, $doc_id, $item_id ? 'items' : 'add-line');
|
|
}
|
|
|
|
$name = trim((string) $_POST['name']);
|
|
logAudit(billingAuditType($type), 'Edit', escapeSql("$session_name " . ($item_id ? 'edited' : 'added') . " line $name " . ($item_id ? 'on' : 'to') . " $number"), $client_id, $doc_id);
|
|
|
|
flashAlert('<strong>' . escapeHtml($name) . '</strong> ' . ($item_id ? 'updated' : 'added'));
|
|
|
|
billingPostBack($type, $doc_id, $item_id ? "line-$saved" : 'add-line');
|
|
|
|
}
|
|
|
|
/*
|
|
* Lines written with AI (modals/billing/billing_ai_writer.php), on a quote or
|
|
* an invoice - the ticked ones added in order, each taxed as a line of its
|
|
* kind is (a product at its own rate, labor as time, the rest as typed-in
|
|
* lines); then the scope and the note, when the agent kept them - the note
|
|
* goes after any already there. A line that can't go on is said, and the rest
|
|
* still do.
|
|
*/
|
|
if (isset($_POST['billing_ai_lines'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$type = (string) ($_POST['type'] ?? '');
|
|
$doc_id = intval($_POST['doc_id'] ?? 0);
|
|
if (!in_array($type, ['quote', 'invoice'], true)) {
|
|
flashAlert('That document no longer exists, or is not one you can open.', 'error');
|
|
redirect();
|
|
}
|
|
$row = billingPostDocument($type, $doc_id);
|
|
$t = billingType($type);
|
|
$c = $t['col'];
|
|
$number = billingNumber($type, $row);
|
|
|
|
$added = [];
|
|
$failed = [];
|
|
foreach (array_slice((array) ($_POST['lines'] ?? []), 0, 25) as $line) {
|
|
if (!is_array($line) || empty($line['include'])) {
|
|
continue;
|
|
}
|
|
$name = trim((string) ($line['name'] ?? ''));
|
|
$kind = in_array($line['kind'] ?? '', ['catalog', 'labor', 'other'], true) ? $line['kind'] : 'other';
|
|
$product_id = $kind === 'catalog' ? intval($line['product_id'] ?? 0) : 0;
|
|
$product_tax_id = $product_id ? getFieldById('products', $product_id, 'product_tax_id') : null;
|
|
if ($product_tax_id === null) {
|
|
$product_id = 0;
|
|
}
|
|
if (trim((string) ($line['price'] ?? '')) === '') {
|
|
$failed[] = ($name !== '' ? $name : 'A line') . ' - it needs a price';
|
|
continue;
|
|
}
|
|
$in = [
|
|
'name' => $name,
|
|
'description' => str_replace("\r", '', (string) ($line['description'] ?? '')),
|
|
'qty' => $line['qty'] ?? '1',
|
|
'price' => $line['price'],
|
|
'tax_id' => $kind === 'labor' ? taxDefaultFor($client_id, 'time') : ($product_id ? taxDefaultFor($client_id, 'product', intval($product_tax_id)) : taxDefaultFor($client_id)),
|
|
'product_id' => $product_id,
|
|
];
|
|
if ($type === 'quote') {
|
|
$in['frequency'] = (string) ($line['frequency'] ?? '');
|
|
}
|
|
if (billingItemSave($type, $doc_id, $in, 0, $error)) {
|
|
$added[] = $name;
|
|
} else {
|
|
$failed[] = ($name !== '' ? $name : 'A line') . ' - ' . rtrim($error, '.');
|
|
}
|
|
}
|
|
|
|
// The scope and the note, when kept
|
|
$kept = [];
|
|
$scope = trim((string) ($_POST['scope'] ?? ''));
|
|
if (!empty($_POST['use_scope']) && $scope !== '') {
|
|
mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_scope = '" . escapeSql(mb_substr($scope, 0, 255)) . "' WHERE {$c}_id = $doc_id");
|
|
$kept[] = 'the scope';
|
|
}
|
|
$note = trim(str_replace("\r", '', (string) ($_POST['note'] ?? '')));
|
|
if (!empty($_POST['use_note']) && $note !== '') {
|
|
$old_note = trim((string) $row["{$c}_note"]);
|
|
$note = $old_note === '' ? $note : "$old_note\n\n$note";
|
|
mysqli_query($mysqli, "UPDATE {$t['table']} SET {$c}_note = '" . escapeSql($note) . "' WHERE {$c}_id = $doc_id");
|
|
$kept[] = 'the note';
|
|
}
|
|
|
|
$not_added = $failed ? '<br>Not added: ' . escapeHtml(implode('; ', $failed)) . '.' : '';
|
|
if (!$added && !$kept) {
|
|
flashAlert('Nothing was added to <strong>' . escapeHtml($number) . '</strong> - ' . ($failed ? 'none of the lines could go on.' . $not_added : 'tick the lines to add.'), 'error');
|
|
billingPostBack($type, $doc_id);
|
|
}
|
|
|
|
$what = $added ? count($added) . ' line' . (count($added) === 1 ? '' : 's') : '';
|
|
$what = trim($what . ($what !== '' && $kept ? ', with ' : '') . implode(' and ', $kept));
|
|
logAudit(billingAuditType($type), 'Edit', "$session_name added " . escapeSql($what) . ' written with AI to ' . escapeSql($number), $client_id, $doc_id);
|
|
|
|
flashAlert('<strong>' . escapeHtml(ucfirst($what)) . '</strong> added to <strong>' . escapeHtml($number) . '</strong>' . $not_added, $failed ? 'warning' : 'success');
|
|
|
|
billingPostBack($type, $doc_id);
|
|
|
|
}
|
|
|
|
if (isset($_GET['billing_item_delete'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$type = (string) ($_GET['type'] ?? '');
|
|
$doc_id = intval($_GET['doc_id'] ?? 0);
|
|
$item_id = intval($_GET['billing_item_delete']);
|
|
$row = billingPostDocument($type, $doc_id);
|
|
|
|
$item = billingItemDelete($type, $doc_id, $item_id);
|
|
if (!$item) {
|
|
flashAlert('That line was already gone.', 'error');
|
|
billingPostBack($type, $doc_id);
|
|
}
|
|
|
|
logAudit(billingAuditType($type), 'Edit', escapeSql("$session_name removed line {$item['item_name']} from " . billingNumber($type, $row)), $client_id, $doc_id);
|
|
|
|
flashAlert('<strong>' . escapeHtml($item['item_name']) . '</strong> removed', 'error');
|
|
|
|
billingPostBack($type, $doc_id);
|
|
|
|
}
|
|
|
|
/* Take the tax off every line - an exempt client's document (taxDocWarning()) */
|
|
if (isset($_GET['billing_remove_tax'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$type = (string) ($_GET['type'] ?? '');
|
|
$doc_id = intval($_GET['billing_remove_tax']);
|
|
$row = billingPostDocument($type, $doc_id);
|
|
$number = billingNumber($type, $row);
|
|
|
|
$lines = billingRemoveTax($type, $doc_id);
|
|
|
|
logAudit(billingAuditType($type), 'Edit', escapeSql("$session_name took the tax off $lines line" . ($lines === 1 ? '' : 's') . " on $number - the client is tax exempt"), $client_id, $doc_id);
|
|
|
|
flashAlert("Tax taken off $lines line" . ($lines === 1 ? '' : 's') . " on <strong>" . escapeHtml($number) . "</strong>");
|
|
|
|
billingPostBack($type, $doc_id);
|
|
|
|
}
|
|
|
|
/*
|
|
* Pause or resume a recurring invoice. Resuming one whose next date has
|
|
* passed says so - it only runs on its next date, so that has to move first.
|
|
*/
|
|
if (isset($_GET['recurring_invoice_set_status'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$recurring_invoice_id = intval($_GET['recurring_invoice_set_status']);
|
|
$status = intval($_GET['status'] ?? 0) ? 1 : 0;
|
|
$row = billingRow('recurring', $recurring_invoice_id);
|
|
if (!$row) {
|
|
flashAlert('That recurring invoice no longer exists.', 'error');
|
|
redirect();
|
|
}
|
|
$client_id = intval($row['client_id']);
|
|
enforceClientAccess();
|
|
|
|
$number = billingNumber('recurring', $row);
|
|
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_status = $status WHERE recurring_invoice_id = $recurring_invoice_id");
|
|
billingLog('recurring', $recurring_invoice_id, $status ? 'Active' : 'Paused', ($status ? 'Resumed' : 'Paused') . " by $session_name");
|
|
logAudit('Recurring Invoice', 'Edit', escapeSql("$session_name " . ($status ? 'resumed' : 'paused') . " recurring invoice $number"), $client_id, $recurring_invoice_id);
|
|
|
|
if ($status && $row['recurring_invoice_next_date'] < date('Y-m-d')) {
|
|
flashAlert("<strong>" . escapeHtml($number) . "</strong> resumed - its next date has passed, so move it on before it will run again.", 'warning');
|
|
} else {
|
|
flashAlert("<strong>" . escapeHtml($number) . "</strong> " . ($status ? 'resumed' : 'paused'));
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
/*
|
|
* Move a recurring invoice's next date on to the first one that hasn't
|
|
* passed, skipping the runs it missed (nothing is billed for them).
|
|
*/
|
|
if (isset($_GET['recurring_invoice_catch_up'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$recurring_invoice_id = intval($_GET['recurring_invoice_catch_up']);
|
|
$row = billingRow('recurring', $recurring_invoice_id);
|
|
if (!$row) {
|
|
flashAlert('That recurring invoice no longer exists.', 'error');
|
|
redirect();
|
|
}
|
|
$client_id = intval($row['client_id']);
|
|
enforceClientAccess();
|
|
|
|
$next = recurringInvoiceCatchUp($row);
|
|
$number = billingNumber('recurring', $row);
|
|
if ($next === $row['recurring_invoice_next_date']) {
|
|
flashAlert("<strong>" . escapeHtml($number) . "</strong> is not behind.");
|
|
redirect();
|
|
}
|
|
mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_next_date = '" . escapeSql($next) . "' WHERE recurring_invoice_id = $recurring_invoice_id");
|
|
billingLog('recurring', $recurring_invoice_id, 'Rescheduled', "Next date moved from {$row['recurring_invoice_next_date']} to $next by $session_name");
|
|
logAudit('Recurring Invoice', 'Edit', escapeSql("$session_name moved recurring invoice $number on to $next"), $client_id, $recurring_invoice_id);
|
|
|
|
flashAlert("<strong>" . escapeHtml($number) . "</strong> next runs on " . escapeHtml($next));
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
/*
|
|
* Count a recurring invoice's seat lines again now, and take its taxes at
|
|
* today's rates - what the nightly run does before it makes an invoice.
|
|
*/
|
|
if (isset($_GET['recurring_invoice_sync'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$recurring_invoice_id = intval($_GET['recurring_invoice_sync']);
|
|
$row = billingRow('recurring', $recurring_invoice_id);
|
|
if (!$row) {
|
|
flashAlert('That recurring invoice no longer exists.', 'error');
|
|
redirect();
|
|
}
|
|
$client_id = intval($row['client_id']);
|
|
enforceClientAccess();
|
|
|
|
$changes = recurringInvoiceRefresh($recurring_invoice_id, stripslashes($session_name));
|
|
$number = billingNumber('recurring', $row);
|
|
if ($changes) {
|
|
logAudit('Recurring Invoice', 'Edit', escapeSql("$session_name updated the seats on recurring invoice $number: " . implode('; ', $changes)), $client_id, $recurring_invoice_id);
|
|
flashAlert('<strong>' . escapeHtml($number) . '</strong> seats updated - ' . escapeHtml(implode('; ', $changes)));
|
|
} else {
|
|
flashAlert('<strong>' . escapeHtml($number) . '</strong> already bills what the client has.');
|
|
}
|
|
|
|
redirect();
|
|
|
|
}
|
|
|
|
/*
|
|
* Unbilled Time, one client: bill the ticked tickets at the hours typed, and
|
|
* the ticked expenses and trips at the prices typed, on a new draft or one
|
|
* of the client's drafts.
|
|
*/
|
|
if (isset($_POST['unbilled_invoice'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$client_id = intval($_POST['client_id'] ?? 0);
|
|
enforceClientAccess();
|
|
|
|
$hours = [];
|
|
foreach (array_unique(array_filter(array_map('intval', (array) ($_POST['ticket_ids'] ?? [])))) as $ticket_id) {
|
|
$hours[$ticket_id] = $_POST['hours'][$ticket_id] ?? null;
|
|
}
|
|
$prices = unbilledPostPrices();
|
|
$trip_prices = unbilledPostTripPrices();
|
|
$result = expenseBillWithTime($client_id, $hours, $prices, [
|
|
'invoice_id' => intval($_POST['invoice_id'] ?? 0),
|
|
'date' => (string) ($_POST['date'] ?? ''),
|
|
'category_id' => intval($_POST['category'] ?? 0),
|
|
'scope' => (string) ($_POST['scope'] ?? ''),
|
|
'item_name' => (string) ($_POST['item_name'] ?? ''),
|
|
'price' => (string) ($_POST['price'] ?? ''),
|
|
'tax_id' => intval($_POST['tax_id'] ?? 0),
|
|
'combine' => ($_POST['lines'] ?? '') === 'one',
|
|
'by' => stripslashes($session_name),
|
|
], $error, $trip_prices);
|
|
if (!$result) {
|
|
flashAlert(escapeHtml($error), 'error');
|
|
redirect();
|
|
}
|
|
|
|
flashAlert(unbilledBothText($result, $session_company_currency), $result['error'] !== '' ? 'warning' : 'success');
|
|
redirect("invoice.php?invoice_id={$result['invoice_id']}");
|
|
|
|
}
|
|
|
|
/*
|
|
* Unbilled Time, every client: bill each ticked client's unbilled tickets at
|
|
* the hours their time comes to, their expenses to bill at cost with the
|
|
* markup and their trips to bill at the trip charge and rate - on a new
|
|
* draft each, or each one's newest draft when it has one. Expenses and
|
|
* trips only for agents who can bill them.
|
|
*/
|
|
if (isset($_POST['unbilled_invoice_clients'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$use_draft = ($_POST['target'] ?? '') === 'draft';
|
|
$with_expenses = lookupUserPermission('module_financial') >= 2;
|
|
$by_client = intval($config_module_enable_ticketing) === 1 ? billingUnbilledByClient() : [];
|
|
$by_client = $with_expenses ? tripUnbilledMerge(expenseUnbilledMerge($by_client)) : $by_client;
|
|
$made = [];
|
|
$failed = [];
|
|
foreach (array_unique(array_map('intval', (array) ($_POST['client_ids'] ?? []))) as $bill_client_id) {
|
|
if (!isset($by_client[$bill_client_id]) || !hasClientAccess($bill_client_id)) {
|
|
continue;
|
|
}
|
|
$c = $by_client[$bill_client_id];
|
|
$result = expenseBillWithTime($bill_client_id, array_fill_keys(array_keys($c['tickets']), null), array_fill_keys(array_keys($c['expenses'] ?? []), null), [
|
|
'invoice_id' => $use_draft && $c['drafts'] ? intval(array_key_first($c['drafts'])) : 0,
|
|
'date' => (string) ($_POST['date'] ?? ''),
|
|
'category_id' => intval($_POST['category'] ?? 0),
|
|
'scope' => (string) ($_POST['scope'] ?? ''),
|
|
'item_name' => (string) ($_POST['item_name'] ?? ''),
|
|
'tax_id' => intval($_POST['tax_id'] ?? 0),
|
|
'combine' => ($_POST['lines'] ?? '') === 'one',
|
|
'by' => stripslashes($session_name),
|
|
], $error, array_fill_keys(array_keys($c['trips'] ?? []), null));
|
|
if ($result) {
|
|
$made[] = $result;
|
|
if ($result['error'] !== '') {
|
|
$failed[] = $c['client_name'] . ': ' . $result['error'];
|
|
}
|
|
} else {
|
|
$failed[] = $c['client_name'] . ': ' . $error;
|
|
}
|
|
}
|
|
|
|
if (!$made) {
|
|
flashAlert($failed ? escapeHtml(implode(' ', $failed)) : 'Nothing billed - tick at least one client.', 'error');
|
|
redirect('unbilled_time.php');
|
|
}
|
|
|
|
$tickets = array_sum(array_map(function ($r) { return $r['time'] ? count($r['time']['tickets']) : 0; }, $made));
|
|
$hours = array_sum(array_map(function ($r) { return $r['time'] ? $r['time']['hours'] : 0; }, $made));
|
|
$expenses = array_sum(array_map(function ($r) { return $r['expenses'] ? $r['expenses']['expenses'] : 0; }, $made));
|
|
$trips = array_sum(array_map(function ($r) { return !empty($r['trips']) ? $r['trips']['trips'] : 0; }, $made));
|
|
$numbers = array_column($made, 'number');
|
|
$what = [];
|
|
if ($tickets) {
|
|
$what[] = $tickets . ' ticket' . ($tickets === 1 ? '' : 's') . ', ' . billingQuantity($hours) . ' h';
|
|
}
|
|
if ($expenses) {
|
|
$what[] = $expenses . ' expense' . ($expenses === 1 ? '' : 's');
|
|
}
|
|
if ($trips) {
|
|
$what[] = $trips . ' trip' . ($trips === 1 ? '' : 's');
|
|
}
|
|
flashAlert('<strong>' . count($made) . ' invoice' . (count($made) === 1 ? '' : 's') . '</strong> ' . (array_filter(array_column($made, 'new')) ? 'made or added to' : 'added to') . ' - '
|
|
. implode(' and ', $what) . ': ' . escapeHtml(implode(', ', array_slice($numbers, 0, 12)) . (count($numbers) > 12 ? ' and more' : ''))
|
|
. ($failed ? '. Not billed: ' . escapeHtml(implode(' ', $failed)) : ''), $failed ? 'warning' : 'success');
|
|
redirect('invoices.php?status=Draft');
|
|
|
|
}
|
|
|
|
/* Unbilled Time: the ticked tickets, expenses and trips aren't billable after all - written off. */
|
|
if (isset($_POST['unbilled_write_off'])) {
|
|
|
|
validateCSRFToken();
|
|
|
|
enforceUserPermission('module_sales', 2);
|
|
|
|
$client_id = intval($_POST['client_id'] ?? 0);
|
|
enforceClientAccess();
|
|
|
|
$done = billingTimeWriteOff($client_id, array_filter(array_map('intval', (array) ($_POST['ticket_ids'] ?? []))), stripslashes($session_name));
|
|
$expense_ids = array_keys(unbilledPostPrices());
|
|
$done_expenses = $expense_ids ? expenseWriteOff($client_id, $expense_ids, stripslashes($session_name)) : [];
|
|
$trip_ids = array_keys(unbilledPostTripPrices());
|
|
$done_trips = $trip_ids ? tripWriteOff($client_id, $trip_ids, stripslashes($session_name)) : [];
|
|
if (!$done && !$done_expenses && !$done_trips) {
|
|
flashAlert('Nothing changed - tick the tickets, expenses or trips that are not billable.', 'error');
|
|
redirect();
|
|
}
|
|
if ($done) {
|
|
logAudit('Ticket', 'Edit', escapeSql("$session_name marked " . (count($done) === 1 ? 'ticket ' : count($done) . ' tickets ') . implode(', ', array_slice($done, 0, 20)) . ' not billable'), $client_id);
|
|
}
|
|
|
|
$what = [];
|
|
if ($done) {
|
|
$what[] = count($done) === 1 ? '<strong>' . escapeHtml($done[0]) . '</strong>' : '<strong>' . count($done) . ' tickets</strong>';
|
|
}
|
|
if ($done_expenses) {
|
|
$what[] = count($done_expenses) === 1 ? 'the expense <strong>' . escapeHtml($done_expenses[0]) . '</strong>' : '<strong>' . count($done_expenses) . ' expenses</strong>';
|
|
}
|
|
if ($done_trips) {
|
|
$what[] = count($done_trips) === 1 ? 'the trip <strong>' . escapeHtml($done_trips[0]) . '</strong>' : '<strong>' . count($done_trips) . ' trips</strong>';
|
|
}
|
|
flashAlert(ucfirst(implode(' and ', $what)) . (count($done) + count($done_expenses) + count($done_trips) === 1 ? ' is' : ' are') . ' not billable now');
|
|
redirect();
|
|
|
|
}
|