Files
itflow/includes/billing_paper.php
johnnyq 755e488d6c Add and change lines on invoices, quotes and recurring invoices on the page itself, without a reload: the row of boxes under the lines is now one field - type a product and Enter takes the one picked out, adding it as a line with its description, price and tax, billed as the product usually is on a quote and counted per user or per device on a recurring invoice, or type anything and pick the Add row for a line of its own - and the new line opens with its quantity ready to type over, Enter moving on to a price it still needs and then back to the field; a line name, description, quantity, price and tax are changed where they sit, by a click or by Tab and Enter, saved on Enter or on leaving, put back by Escape, with the tax picked from the select picker and a counted quantity left to the Edit Line window; a quote line menu gains Billed, one time to yearly; agent/ajax.php gains billing_item_add and billing_item_set with the checks post.php billing_item_save makes, answering JSON, and functions/billing.php gains billingItemSetField, which replaces one value and hands the line to billingItemSave so stock, tax and totals follow as before, saves nothing when the value is the same, and gives the audit log what changed; js/billing_items.js is rewritten to send saves one at a time in order and then fetch the page again, taking from it the lines by id and every element marked data-billing-live - the totals and amount due in includes/billing_paper.php, the header card, tax warning and side column on the three pages - so nothing is drawn twice, a field in use is left alone, and a document no longer open for changes reloads; the paper carries the products as JSON and the tax options, and itflowBillingItems no longer takes products; js/autocomplete.js gains pickFirst and an extra row kept in view under the list, and opens above the field when the list does not fit below; getProductsForAutocomplete encodes with JSON_HEX_TAG and JSON_HEX_AMP; the guest and client portal pages are unchanged; add a 26.11.8 changelog section
2026-11-12 16:14:30 -05:00

359 lines
24 KiB
PHP

<?php
/*
* The document itself - an invoice, quote or recurring invoice as the
* client sees it, on the agent pages and the guest pages alike, and as it
* prints. Set $paper before including:
*
* type invoice | quote | recurring
* row billingRow()
* items billingItems()
* totals billingTotals()
* paid what has been paid (invoices)
* balance what is still owed (invoices)
* edit the lines can be changed here (agent pages): drag handles,
* a menu on each line, the add field, and a line's name,
* description, quantity, price and tax changed where they sit
* (js/billing_items.js)
* notes_edit the notes save in place (agent pages)
* taxes billingTaxes(), the rates a line's tax is picked from
* logo_url the company logo, relative to the page
* client shown to the client (the client portal) - no agent-only notes
* csrf the agent's token, for the edit controls
* ai Write it with AI under the add field (quotes and
* invoices, when the agent may use AI for Billing)
*
* Everything in it is escaped here.
*/
isset($paper) || die("Direct file access is not allowed");
$paper_type = $paper['type'];
$paper_t = billingType($paper_type);
$paper_c = $paper_t['col'];
$paper_row = $paper['row'];
$paper_items = $paper['items'];
$paper_totals = $paper['totals'];
$paper_edit = !empty($paper['edit']);
$paper_currency = (string) ($paper_row["{$paper_c}_currency_code"] ?? '');
$paper_id = intval($paper_row["{$paper_c}_id"]);
$paper_company = billingCompany();
$paper_bill_to = billingBillTo($paper_row['client_id']);
$paper_status = $paper_type === 'recurring' ? (intval($paper_row['recurring_invoice_status']) ? 'Active' : 'Paused') : (string) $paper_row["{$paper_c}_status"];
$paper_money = function ($amount) use ($paper_currency) {
return escapeHtml(billingMoney($amount, $paper_currency));
};
$paper_title = ['invoice' => 'Invoice', 'quote' => 'Quote', 'recurring' => 'Recurring Invoice'][$paper_type];
$paper_footer = $paper_type === 'quote' ? ($GLOBALS['config_quote_footer'] ?? '') : ($GLOBALS['config_invoice_footer'] ?? '');
// A stamp across a document that is done with
$paper_stamp = '';
if ($paper_type === 'invoice' && $paper_status === 'Paid') {
$paper_stamp = ['Paid', 'success'];
} elseif ($paper_type === 'invoice' && $paper_status === 'Cancelled') {
$paper_stamp = ['Cancelled', 'danger'];
} elseif ($paper_type === 'quote' && in_array($paper_status, ['Accepted', 'Invoiced'], true)) {
$paper_stamp = ['Accepted', 'success'];
} elseif ($paper_type === 'quote' && $paper_status === 'Declined') {
$paper_stamp = ['Declined', 'danger'];
}
$paper_dates = [];
if ($paper_type === 'invoice') {
$paper_dates = ['Invoice date' => $paper_row['invoice_date'], 'Due' => $paper_row['invoice_due']];
} elseif ($paper_type === 'quote') {
$paper_dates = ['Quote date' => $paper_row['quote_date'], 'Valid until' => $paper_row['quote_expire']];
} else {
$paper_dates = ['Billed' => recurringInvoiceFrequencies()[$paper_row['recurring_invoice_frequency']] ?? ucfirst((string) $paper_row['recurring_invoice_frequency']),
'Next invoice' => $paper_row['recurring_invoice_next_date']];
}
// A recurring invoice's seat lines - what each counts now, and whether the line is off
$paper_seats = $paper_type === 'recurring' ? recurringSeatLines($paper_id, intval($paper_row['client_id'])) : [];
$paper_seat_sources = recurringSeatSources();
// A quote with lines billed each month, quarter or year shows them apart from
// what is billed once - the lines under a heading each, and a total each
$paper_groups = $paper_type === 'quote' && !empty($paper_totals['recurring']) ? $paper_totals['groups'] : null;
$paper_line_groups = $paper_groups ?: ['' => ['frequency' => '', 'label' => '', 'items' => $paper_items]];
// Where the lines can be changed, what can be changed where it sits is wrapped
// as a field: which one, its value as it is typed, and what a click on it
// does. And the parts of the paper that follow the lines are marked, to be
// drawn again after a change.
$paper_field = function ($field, $value, $what, $html, $class = '') use ($paper_edit) {
return $paper_edit
? '<span class="itflow-line-field' . ($class !== '' ? " $class" : '') . '" data-line-field="' . $field . '" data-value="' . escapeHtml($value) . '" tabindex="0" role="button" title="Change the ' . $what . '">' . $html . '</span>'
: $html;
};
$paper_live = function ($part) use ($paper_edit) {
return $paper_edit ? ' data-billing-live="' . $part . '"' : '';
};
$paper_post = function ($params) use ($paper_type, $paper_id, $paper) {
return 'post.php?' . http_build_query(array_merge($params, ['type' => $paper_type, 'doc_id' => $paper_id, 'csrf_token' => $paper['csrf'] ?? '']));
};
$paper_company_lines = array_merge(array_filter([
formatAddress($paper_company['company_address'] ?? '', $paper_company['company_city'] ?? '', $paper_company['company_state'] ?? '', $paper_company['company_zip'] ?? '', $paper_company['company_country'] ?? '', "\n"),
implode(' · ', array_filter([$paper_company['company_email'] ?? '', $paper_company['phone_display'] ?? ''])),
$paper_company['company_website'] ?? '',
!empty($GLOBALS['config_invoice_show_tax_id']) && !empty($paper_company['company_tax_id']) ? 'Tax ID: ' . $paper_company['company_tax_id'] : '',
]), billingTaxRegistrationLines($paper_totals));
$paper_bill_lines = array_filter([
formatAddress($paper_bill_to['location_address'] ?? '', $paper_bill_to['location_city'] ?? '', $paper_bill_to['location_state'] ?? '', $paper_bill_to['location_zip'] ?? '', $paper_bill_to['location_country'] ?? '', "\n"),
$paper_bill_to['contact_name'] ?? '',
implode(' · ', array_filter([$paper_bill_to['contact_email'] ?? '', $paper_bill_to['contact_phone'] ?? ''])),
!empty($paper_bill_to['client_tax_id_number']) ? 'Tax ID: ' . $paper_bill_to['client_tax_id_number'] : '',
// Exempt - said only when nothing here is taxed, so an older taxed invoice doesn't contradict itself
!empty($paper_bill_to['tax_exempt_text']) && $paper_totals['tax'] == 0 ? $paper_bill_to['tax_exempt_text'] : '',
]);
?>
<div class="itflow-paper" id="items" data-billing-type="<?= $paper_type ?>" data-billing-id="<?= $paper_id ?>"<?= $paper_edit ? ' data-billing-edit' : '' ?>>
<?php if ($paper_stamp) { ?>
<div class="itflow-paper-stamp text-<?= $paper_stamp[1] ?>" aria-hidden="true"><?= $paper_stamp[0] ?></div>
<?php } ?>
<!-- Who it is from, and what it is -->
<div class="itflow-paper-head">
<div class="itflow-paper-from">
<?php if (!empty($paper['logo_url']) && !empty($paper_company['logo_url'])) { ?>
<img class="itflow-paper-logo" src="<?= escapeHtml($paper['logo_url']) ?>" alt="<?= escapeHtml($paper_company['company_name'] ?? '') ?>">
<?php } ?>
<div class="fw-semibold fs-5"><?= escapeHtml($paper_company['company_name'] ?? '') ?></div>
<div class="small text-body-secondary"><?= nl2br(escapeHtml(implode("\n", $paper_company_lines))) ?></div>
</div>
<div class="itflow-paper-what text-md-end">
<div class="itflow-paper-title"><?= $paper_title ?></div>
<div class="fs-5 fw-semibold font-monospace"><?= escapeHtml(billingNumber($paper_type, $paper_row)) ?></div>
<dl class="itflow-paper-dates">
<?php foreach ($paper_dates as $label => $value) { ?>
<dt><?= $label ?></dt><dd><?= escapeHtml($value) ?></dd>
<?php } ?>
</dl>
</div>
</div>
<!-- Who it is for, and what it comes to -->
<div class="itflow-paper-parties">
<div>
<div class="itflow-paper-label"><?= $paper_type === 'quote' ? 'Prepared for' : 'Bill to' ?></div>
<div class="fw-semibold"><?= escapeHtml($paper_bill_to['client_name'] ?? $paper_row['client_name']) ?></div>
<div class="small text-body-secondary"><?= nl2br(escapeHtml(implode("\n", $paper_bill_lines))) ?></div>
</div>
<div class="itflow-paper-due text-md-end"<?= $paper_live('due') ?>>
<?php if ($paper_type === 'invoice') { ?>
<div class="itflow-paper-label"><?= in_array($paper_status, ['Paid', 'Cancelled', 'Non-Billable'], true) ? 'Balance' : 'Amount due' ?></div>
<div class="itflow-paper-amount"><?= $paper_money($paper['balance'] ?? $paper_totals['total']) ?></div>
<?php if (!in_array($paper_status, ['Paid', 'Cancelled', 'Non-Billable', 'Draft'], true) && ($paper['balance'] ?? 0) > 0) { ?>
<div class="small text-body-secondary">by <?= escapeHtml($paper_row['invoice_due']) ?></div>
<?php } ?>
<?php } elseif ($paper_type === 'quote' && $paper_groups) {
$paper_first = true;
foreach ($paper_groups as $paper_frequency => $paper_group) {
if ($paper_frequency === '' && !$paper_group['items'] && $paper_group['discount'] == 0) {
continue;
} ?>
<div class="itflow-paper-label <?= $paper_first ? '' : 'mt-2' ?>"><?= $paper_frequency === '' ? 'Up front' : escapeHtml($paper_group['label']) ?></div>
<div class="<?= $paper_first ? 'itflow-paper-amount' : 'fs-5 fw-semibold' ?>"><?= $paper_money($paper_group['total']) ?></div>
<?php $paper_first = false;
}
} elseif ($paper_type === 'quote') { ?>
<div class="itflow-paper-label">Quote total</div>
<div class="itflow-paper-amount"><?= $paper_money($paper_totals['total']) ?></div>
<?php } else { ?>
<div class="itflow-paper-label">Each <?= recurringInvoicePeriodWord($paper_row['recurring_invoice_frequency']) ?></div>
<div class="itflow-paper-amount"><?= $paper_money($paper_totals['total']) ?></div>
<?php } ?>
</div>
</div>
<?php if (trim((string) $paper_row["{$paper_c}_scope"]) !== '') { ?>
<div class="itflow-paper-scope"><span class="itflow-paper-label me-2">For</span><?= escapeHtml($paper_row["{$paper_c}_scope"]) ?></div>
<?php } ?>
<!-- The lines -->
<?php if ($paper_edit) { ?>
<!-- What the add field offers, and the rates a line's tax is picked from -->
<script type="application/json" data-billing-products><?= getProductsForAutocomplete($GLOBALS['mysqli'], intval($paper_row['client_id'])) ?></script>
<template data-billing-taxes><?= taxOptionsHtml($paper['taxes'] ?? [], 0) ?></template>
<?php } ?>
<div class="table-responsive">
<table class="table itflow-paper-items align-top mb-0" data-billing-items>
<thead>
<tr>
<?php if ($paper_edit) { ?><th class="itflow-paper-handle d-print-none"></th><?php } ?>
<th>Item</th>
<th class="text-end">Qty</th>
<th class="text-end">Price</th>
<th class="text-end">Tax</th>
<th class="text-end">Amount</th>
<?php if ($paper_edit) { ?><th class="d-print-none"></th><?php } ?>
</tr>
</thead>
<tbody>
<?php foreach ($paper_line_groups as $paper_frequency => $paper_line_group) {
if ($paper_groups) { ?>
<tr class="itflow-paper-group" data-group="<?= $paper_frequency ?>">
<td colspan="7"><span class="itflow-paper-label"><?= escapeHtml($paper_line_group['label']) ?></span></td>
</tr>
<?php }
foreach ($paper_line_group['items'] as $item) {
$item_id = intval($item['item_id']);
$item_tax_label = floatval($item['item_tax']) != 0 || intval($item['item_tax_id'])
? trim(($item['tax_name'] ?? '') . ' ' . ($item['tax_percent'] !== null ? taxPercentText($item['tax_percent']) . '%' : ''))
: '';
$item_tax_html = $item_tax_label !== ''
? $paper_money($item['item_tax']) . '<div class="small text-body-secondary itflow-paper-tax-label">' . escapeHtml($item_tax_label) . '</div>'
: '<span class="text-body-secondary">-</span>';
$item_quantity = billingQuantity($item['item_quantity']); ?>
<tr id="line-<?= $item_id ?>" data-item-id="<?= $item_id ?>" data-group="<?= $paper_frequency ?>">
<?php if ($paper_edit) { ?>
<td class="itflow-paper-handle d-print-none"><span class="drag-handle" title="Drag to reorder"><i class="fas fa-grip-vertical"></i></span></td>
<?php } ?>
<td class="itflow-paper-item">
<div class="fw-semibold"><?= $paper_field('name', $item['item_name'], 'name', escapeHtml($item['item_name']), 'd-block') ?></div>
<?php if (trim((string) $item['item_description']) !== '') { ?>
<div class="small text-body-secondary"><?= $paper_field('description', $item['item_description'], 'description', nl2br(escapeHtml($item['item_description'])), 'd-block') ?></div>
<?php } elseif ($paper_edit) { // shown while the line is being worked on ?>
<div class="small text-body-secondary itflow-line-blank d-print-none"><?= $paper_field('description', '', 'description', 'Add a description', 'd-block') ?></div>
<?php } ?>
<?php if (isset($paper_seats[$item_id])) {
$seat = $paper_seats[$item_id];
$seat_source = $paper_seat_sources[$seat['item_seat_source']] ?? ['unit' => 'seat', 'icon' => 'fa-users']; ?>
<div class="small mt-1 d-print-none">
<span class="badge rounded-pill bg-primary-subtle text-primary-emphasis fw-normal text-wrap text-start" title="The quantity is counted again for each invoice">
<i class="fas <?= $seat_source['icon'] ?> me-1"></i>Per <?= $seat_source['unit'] ?> &middot; <?= escapeHtml($seat['seat_label']) ?><?= floatval($seat['item_seat_minimum']) > 0 ? ' &middot; at least ' . billingQuantity($seat['item_seat_minimum']) : '' ?>
</span>
<?php if ($seat['seat_count'] === null) { ?>
<?php if (empty($paper['client'])) { ?><span class="badge rounded-pill text-bg-danger fw-normal">The license is gone - it stays at <?= billingQuantity($item['item_quantity']) ?></span><?php } ?>
<?php } elseif ($seat['seat_drift']) { ?>
<span class="badge rounded-pill text-bg-warning fw-normal" title="The next invoice bills the new count">Now <?= billingQuantity($seat['seat_quantity']) ?></span>
<?php } ?>
</div>
<?php } ?>
</td>
<?php // A counted quantity isn't typed - the Edit Line window has what it counts ?>
<td class="text-end text-nowrap"><?= isset($paper_seats[$item_id]) ? $item_quantity : $paper_field('qty', $item_quantity, 'quantity', $item_quantity) ?></td>
<td class="text-end text-nowrap"><?= $paper_field('price', number_format(floatval($item['item_price']), 2, '.', ''), 'price', $paper_money($item['item_price'])) ?></td>
<td class="text-end text-nowrap"><?= $paper_field('tax_id', intval($item['item_tax_id']), 'tax', $item_tax_html) ?></td>
<td class="text-end text-nowrap fw-semibold"><?= $paper_money($item['item_total']) ?></td>
<?php if ($paper_edit) { ?>
<td class="text-end d-print-none itflow-paper-actions">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-link text-body-secondary" title="Line actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/billing/billing_item.php?type=<?= $paper_type ?>&doc_id=<?= $paper_id ?>&item_id=<?= $item_id ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<?php if ($paper_type === 'quote') {
$item_frequency = quoteFrequency($item['item_frequency'] ?? ''); ?>
<div class="dropdown-divider"></div>
<h6 class="dropdown-header">Billed</h6>
<?php foreach (quoteFrequencies() as $frequency_value => $frequency_label) { ?>
<button type="button" class="dropdown-item" data-line-set="frequency" data-value="<?= $frequency_value ?>"<?= $item_frequency === $frequency_value ? ' aria-current="true"' : '' ?>>
<i class="fas fa-fw <?= $item_frequency === $frequency_value ? 'fa-check' : '' ?> text-secondary me-2"></i><?= $frequency_label ?>
</button>
<?php } ?>
<?php } ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="<?= escapeHtml($paper_post(['billing_item_delete' => $item_id])) ?>"
data-confirm-text="<?= escapeHtml($item['item_name']) ?> comes off <?= escapeHtml(billingNumber($paper_type, $paper_row)) ?><?= $paper_type === 'invoice' && intval($item['item_product_id']) ? ' - anything taken from stock goes back' : '' ?>.">
<i class="fas fa-fw fa-trash me-2"></i>Remove
</a>
</div>
</div>
</td>
<?php } ?>
</tr>
<?php }
} ?>
<?php if (!$paper_items && !$paper_edit) { ?>
<tr><td colspan="5" class="text-center text-body-secondary py-4">Nothing on it yet.</td></tr>
<?php } ?>
</tbody>
<?php if ($paper_edit) { ?>
<tfoot class="d-print-none">
<tr class="itflow-paper-add" id="add-line">
<td colspan="7">
<!-- One field: pick a product and it goes on as a line, or type anything and press Enter -->
<div class="itflow-paper-add-field">
<i class="fas fa-plus" aria-hidden="true"></i>
<input type="text" class="form-control" maxlength="200" autocomplete="off" data-billing-add aria-label="Add a line"
placeholder="<?= $paper_items ? 'Add a line' : 'First line' ?> - type a product, or anything">
</div>
<?php if (!empty($paper['ai'])) { ?>
<div class="d-flex flex-wrap align-items-center column-gap-2 row-gap-1 mt-2 pt-2 border-top" data-billing-ai>
<button type="button" class="btn btn-sm btn-outline-secondary ajax-modal" data-modal-size="lg" data-modal-url="modals/billing/billing_ai_writer.php?type=<?= $paper_type ?>&doc_id=<?= $paper_id ?>">
<i class="fas fa-magic me-1"></i>Write it with AI
</button>
<span class="small text-body-secondary">Say what it's for - lines from your catalog, labor at the client's rate.</span>
</div>
<?php } ?>
</td>
</tr>
</tfoot>
<?php } ?>
</table>
</div>
<!-- Notes, and what it all comes to -->
<div class="itflow-paper-bottom">
<div class="itflow-paper-notes">
<?php if (!empty($paper['notes_edit'])) { ?>
<div class="itflow-paper-label d-flex align-items-center">Notes <span class="ms-2 d-print-none fw-normal text-transform-none" data-note-status-for="billingNotes"></span></div>
<textarea class="form-control form-control-sm itflow-inline-note d-print-none" rows="3" id="billingNotes"
placeholder="Payment details, terms, a thank you - shown on the <?= strtolower($paper_title) ?>"
data-endpoint="<?= $paper_c ?>_set_notes" data-id-field="<?= $paper_t['param'] ?>" data-id="<?= $paper_id ?>"
data-csrf="<?= escapeHtml($paper['csrf'] ?? '') ?>"><?= escapeHtml($paper_row["{$paper_c}_note"]) ?></textarea>
<div class="d-none d-print-block small"><?= nl2br(escapeHtml($paper_row["{$paper_c}_note"])) ?></div>
<?php } elseif (trim((string) $paper_row["{$paper_c}_note"]) !== '') { ?>
<div class="itflow-paper-label">Notes</div>
<div class="small"><?= nl2br(escapeHtml($paper_row["{$paper_c}_note"])) ?></div>
<?php } ?>
</div>
<?php if ($paper_groups) { ?>
<table class="itflow-paper-totals"<?= $paper_live('totals') ?>>
<?php foreach ($paper_groups as $paper_frequency => $paper_group) {
if ($paper_frequency === '' && !$paper_group['items'] && $paper_group['discount'] == 0) {
continue;
} ?>
<tr class="itflow-paper-totals-head"><th colspan="2"><?= escapeHtml($paper_group['label']) ?></th></tr>
<tr><th>Subtotal</th><td><?= $paper_money($paper_group['subtotal']) ?></td></tr>
<?php if ($paper_group['discount'] != 0) { ?>
<tr><th>Discount</th><td>-<?= $paper_money($paper_group['discount']) ?></td></tr>
<?php } ?>
<?php foreach ($paper_group['taxes'] as $tax_label => $tax_amount) { ?>
<tr><th><?= escapeHtml($tax_label) ?></th><td><?= $paper_money($tax_amount) ?></td></tr>
<?php } ?>
<tr class="itflow-paper-total"><th><?= $paper_frequency === '' ? 'Total up front' : 'Total ' . strtolower(escapeHtml($paper_group['label'])) ?></th><td><?= $paper_money($paper_group['total']) ?></td></tr>
<?php } ?>
</table>
<?php } else { ?>
<table class="itflow-paper-totals"<?= $paper_live('totals') ?>>
<tr><th>Subtotal</th><td><?= $paper_money($paper_totals['subtotal']) ?></td></tr>
<?php if ($paper_totals['discount'] != 0) { ?>
<tr><th>Discount</th><td>-<?= $paper_money($paper_totals['discount']) ?></td></tr>
<?php } ?>
<?php foreach ($paper_totals['taxes'] as $tax_label => $tax_amount) { ?>
<tr><th><?= escapeHtml($tax_label) ?></th><td><?= $paper_money($tax_amount) ?></td></tr>
<?php } ?>
<?php if ($paper_totals['credit'] > 0) { ?>
<tr><th>Credit</th><td>-<?= $paper_money($paper_totals['credit']) ?></td></tr>
<?php } ?>
<tr class="itflow-paper-total"><th>Total</th><td><?= $paper_money($paper_totals['total']) ?></td></tr>
<?php if ($paper_type === 'invoice' && ($paper['paid'] ?? 0) > 0) { ?>
<tr class="text-success"><th>Paid</th><td>-<?= $paper_money($paper['paid']) ?></td></tr>
<tr class="itflow-paper-total"><th>Balance due</th><td><?= $paper_money($paper['balance']) ?></td></tr>
<?php } ?>
</table>
<?php } ?>
</div>
<?php if (trim((string) $paper_footer) !== '') { ?>
<div class="itflow-paper-footer"><?= nl2br(escapeHtml($paper_footer)) ?></div>
<?php } ?>
</div>