Files
itflow/includes/billing_paper.php
johnnyq 422088fb60 Invoices, quotes and recurring invoices makeover stage 2: per user, per device and per license recurring lines that take their quantity from the client's contacts (all or tagged), devices (ticked types or tagged, lost/stolen/retired left out) or a license's seats (in use or bought), counted again for every invoice with an optional contract minimum, set from a Counted choice on the add row or the full Quantity from section in Edit Line with a live count, marked on each line and listed in a new Seats card with Update now when the client has changed, quarterly frequency alongside monthly and yearly counted as a third in MRR on the dashboard, client pages, client portal and reports, period words {month} {quarter} {year} {period} filled into scope, notes and lines, schedule card showing the period the next invoice covers, recurring_invoice_anchor_day so month-end schedules return to the 31st after a short month, nightly run and Invoice Now consolidated into recurringInvoiceGenerate() which claims the run so it is billed once, refreshes seat counts and takes taxes at current rates, nightly run now bills runs missed by up to 7 days, Invoice Now bills the next run early and moves the schedule on from that run instead of restarting from today, recurring email goes to non-archived primary and billing contacts once per address and stays a draft when outgoing mail is not set up. Fixes portal recurring card ignoring yearly-only clients and counting archived ones, edit recurring invoice resetting the billing day. Adds DB 2.9.1, recurring_invoice_sync and recurring_seat_count handlers, itflowBillingSeatForm(), and updates the 26.11 changelog
2026-10-16 13:09:59 -04:00

315 lines
20 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
* 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();
$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_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'] : '',
]);
$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'] : '',
]);
?>
<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') { ?>
<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_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 ? billingQuantity($item['tax_percent']) . '%' : ''))
: ''; ?>
<tr id="line-<?= $item_id ?>" data-item-id="<?= $item_id ?>">
<?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) { ?>
<span class="badge rounded-pill text-bg-danger fw-normal">The license is gone - it stays at <?= billingQuantity($item['item_quantity']) ?></span>
<?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"><?= 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<?= $paper_type === 'recurring' ? ' 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 } ?>
<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>
<option value="0" data-percent="0">No tax</option>
<?php foreach ($paper['taxes'] ?? [] as $tax_id => $tax) { ?>
<option value="<?= intval($tax_id) ?>" data-percent="<?= floatval($tax['tax_percent']) ?>"><?= escapeHtml($tax['tax_name'] . ' ' . billingQuantity($tax['tax_percent']) . '%') ?></option>
<?php } ?>
</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>
<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>
</div>
<?php if (trim((string) $paper_footer) !== '') { ?>
<div class="itflow-paper-footer"><?= nl2br(escapeHtml($paper_footer)) ?></div>
<?php } ?>
</div>