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

365 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 and the add-a-line row
* notes_edit the notes save in place (agent pages)
* taxes billingTaxes(), for the add-a-line row
* 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
*
* 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]];
$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 ?>">
<?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">
<?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) { ?>
<form id="billingAddLine" action="post.php" method="post" autocomplete="off" class="d-none">
<input type="hidden" name="csrf_token" value="<?= escapeHtml($paper['csrf'] ?? '') ?>">
<input type="hidden" name="billing_item_save" value="1">
<input type="hidden" name="type" value="<?= $paper_type ?>">
<input type="hidden" name="doc_id" value="<?= $paper_id ?>">
<input type="hidden" name="product_id" value="0" data-line-product>
</form>
<?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']) . '%' : ''))
: ''; ?>
<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"><?= escapeHtml($item['item_name']) ?></div>
<?php if (trim((string) $item['item_description']) !== '') { ?>
<div class="small text-body-secondary"><?= nl2br(escapeHtml($item['item_description'])) ?></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>
<td class="text-end text-nowrap"><?= billingQuantity($item['item_quantity']) ?></td>
<td class="text-end text-nowrap"><?= $paper_money($item['item_price']) ?></td>
<td class="text-end text-nowrap">
<?php if ($item_tax_label !== '') { ?>
<?= $paper_money($item['item_tax']) ?>
<div class="small text-body-secondary itflow-paper-tax-label"><?= escapeHtml($item_tax_label) ?></div>
<?php } else { ?>
<span class="text-body-secondary">-</span>
<?php } ?>
</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>
<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">
<div class="itflow-paper-add-grid<?= in_array($paper_type, ['recurring', 'quote'], true) ? ' itflow-paper-add-grid-seats' : '' ?>" data-billing-add>
<div class="itflow-paper-add-item">
<input type="text" class="form-control form-control-sm mb-1" name="name" form="billingAddLine" placeholder="<?= $paper_items ? 'Add a line - type a product, or anything' : 'First line - type a product, or anything' ?>" maxlength="200" required data-line-name aria-label="Item">
<textarea class="form-control form-control-sm" name="description" form="billingAddLine" rows="1" placeholder="Description (optional)" data-line-description aria-label="Description"></textarea>
</div>
<div class="itflow-paper-add-qty">
<label class="form-label small text-body-secondary mb-0">Qty</label>
<input type="text" class="form-control form-control-sm text-end" name="qty" form="billingAddLine" inputmode="decimal" placeholder="1" value="1" data-line-qty>
</div>
<?php if ($paper_type === 'recurring') { ?>
<div class="itflow-paper-add-seat">
<label class="form-label small text-body-secondary mb-0">Counted</label>
<select class="form-select form-select-sm" name="seat_source" form="billingAddLine" data-add-seat title="Bill per user or per device - counted again for each invoice">
<option value="">As typed</option>
<option value="contacts" data-count="<?= intval(recurringSeatCount($paper_row['client_id'], 'contacts', [])) ?>">Per user</option>
<option value="assets" data-count="<?= intval(recurringSeatCount($paper_row['client_id'], 'assets', [])) ?>">Per device</option>
</select>
</div>
<?php } elseif ($paper_type === 'quote') { ?>
<div class="itflow-paper-add-seat">
<label class="form-label small text-body-secondary mb-0">Billed</label>
<select class="form-select form-select-sm" name="frequency" form="billingAddLine" title="Billed once, or each month, quarter or year - those go on a recurring invoice when the quote is invoiced">
<?php foreach (quoteFrequencies() as $frequency_value => $frequency_label) { ?>
<option value="<?= $frequency_value ?>"><?= $frequency_label ?></option>
<?php } ?>
</select>
</div>
<?php } ?>
<div class="itflow-paper-add-price">
<label class="form-label small text-body-secondary mb-0">Price</label>
<input type="text" class="form-control form-control-sm text-end" name="price" form="billingAddLine" inputmode="decimal" placeholder="0.00" data-line-price>
</div>
<div class="itflow-paper-add-tax">
<label class="form-label small text-body-secondary mb-0">Tax</label>
<select class="form-select form-select-sm" name="tax_id" form="billingAddLine" data-line-tax>
<?= taxOptionsHtml($paper['taxes'] ?? [], taxDefaultFor($paper_row['client_id'] ?? 0)) ?>
</select>
</div>
<div class="itflow-paper-add-go">
<span class="small text-body-secondary text-nowrap" data-line-preview></span>
<button type="submit" form="billingAddLine" class="btn btn-sm btn-primary text-nowrap" title="Add the line"><i class="fas fa-plus me-1"></i>Add</button>
</div>
</div>
</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">
<?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">
<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>