Files
itflow/agent/modals/billing/billing_form.php
johnnyq 1e5bffff3c Rates, net terms and SLAs come from the client's contract, else the instance defaults: database 3.5.0 adds a default after-hours rate (Settings > Defaults, now Rates and terms), gives each client whose own rate, net terms or SLAs differ from the defaults an active Standard terms contract setting just those (12-month term renewing itself, starting the day the client was added or the day before its earliest contract, archived with an archived client), deletes per-client SLA assignments and drops clients.client_rate and client_net_terms; time bills at the contract's rate, its after-hours rate or the default after-hours rate, else the default rate, new invoices are due on the contract's net terms else the default, and new tickets get the contract's SLA else the default; a client's own terms still apply until the update runs; the client add and edit windows lose the rate, terms and SLA fields for a note saying what the client is billed on and where it comes from, the Clients list loses Set Hourly Rate and Set Net Terms and shows the contract's rate, the overview's Terms shows the after-hours rate and whether terms are a contract's or the defaults, a contract's page and document show what it leaves empty from another contract in force or the default so signed documents stay unchanged, contract windows show the defaults as placeholders, contract names take a proper possessive; the client import and API create make a Standard terms contract for a rate or terms that aren't the defaults (returning contract_id), API update reports them in not_kept; the client export gives the rates, terms and SLAs as billed; demo data puts each demo client's terms on a contract; changelog updated
2026-10-27 22:22:34 -04:00

260 lines
14 KiB
PHP

<?php
/*
* The details of an invoice, quote or recurring invoice - shared by the New
* and Edit windows for all three. Set before including:
*
* $form_type invoice | quote | recurring
* $form_row billingRow() when editing, null for a new one
* $form_client_id the client a new one is for (0 = pick it here)
*
* Posts the fields the existing handlers read: client_id, scope, category,
* and then by kind - invoice: date, due (edit), invoice_discount; quote:
* date, expire, quote_discount; recurring: frequency, start_date (new) or
* next_date (edit), recurring_invoice_discount, auto_send, status (edit).
*
* Should not be accessed directly.
*/
isset($mysqli) || die("Direct file access is not allowed");
$form_t = billingType($form_type);
$form_c = $form_t['col'];
$form_edit = !empty($form_row);
$form_id = 'billingForm' . ucfirst($form_type) . ($form_edit ? intval($form_row["{$form_c}_id"]) : 'New');
$form_value = function ($column, $default = '') use ($form_row, $form_c) {
return $form_row ? (string) ($form_row["{$form_c}_$column"] ?? $default) : $default;
};
$form_currency = $form_edit ? (string) $form_row["{$form_c}_currency_code"] : (string) $session_company_currency;
$form_category_id = intval($form_value('category_id', '0'));
$form_discount = floatval($form_value('discount_amount', '0'));
// Net terms for a new invoice's due date - a contract in force's, else the
// default (0 is On Receipt), as the invoice is saved with
$form_default_terms = billingDefaults()['net_terms'];
[$form_terms, $form_terms_from] = $form_client_id ? contractNetTermsWhose($form_client_id, date('Y-m-d')) : [$form_default_terms, 'the default'];
$form_contract_terms = contractNetTermsToday();
// Before the 3.5.0 update a client still has net terms of its own, which apply without a contract's
$form_legacy_terms = !billingAfterHoursReady();
$form_clients = [];
if (!$form_edit && !$form_client_id) {
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, client_abbreviation" . ($form_legacy_terms ? ', client_net_terms' : '') . " FROM clients WHERE client_archived_at IS NULL " . clientScopeSql('clients.client_id') . " ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$form_clients[] = $row;
}
}
$form_scope_placeholder = [
'invoice' => 'Managed services - October, onsite visit 12 March, new laptop for Sam',
'quote' => 'Firewall replacement, Microsoft 365 migration, new starter kit',
'recurring' => 'Monthly managed services, backup and hosting',
][$form_type];
?>
<div class="row g-3" id="<?= $form_id ?>" data-billing-form="<?= $form_type ?>" data-terms="<?= $form_terms ?>" data-terms-from="<?= escapeHtml($form_terms_from) ?>">
<?php if (!$form_edit && !$form_client_id) { ?>
<div class="col-12">
<label class="form-label">Client <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-building"></i></span>
<select class="form-select select2" name="client_id" required data-billing-client>
<option value="">- Select a client -</option>
<?php foreach ($form_clients as $form_client) { ?>
<?php $form_option_terms = isset($form_contract_terms[intval($form_client['client_id'])]) ? [$form_contract_terms[intval($form_client['client_id'])][0], contractPossessive($form_contract_terms[intval($form_client['client_id'])][1])]
: ($form_legacy_terms && $form_client['client_net_terms'] !== null ? [intval($form_client['client_net_terms']), "the client's"] : [$form_default_terms, 'the default']); ?>
<option value="<?= intval($form_client['client_id']) ?>" data-terms="<?= $form_option_terms[0] ?>" data-terms-from="<?= escapeHtml($form_option_terms[1]) ?>"<?= selectOptionClient($form_client['client_id'], $form_client['client_name'], $form_client['client_abbreviation'] ?? '') ?>><?= escapeHtml($form_client['client_name']) ?></option>
<?php } ?>
</select>
</div>
</div>
<?php } elseif (!$form_edit) { ?>
<input type="hidden" name="client_id" value="<?= intval($form_client_id) ?>">
<?php } ?>
<div class="col-12">
<label class="form-label">Scope</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
<input type="text" class="form-control" name="scope" maxlength="255" placeholder="<?= escapeHtml($form_scope_placeholder) ?>" value="<?= escapeHtml($form_value('scope')) ?>" <?= $form_edit ? '' : 'autofocus' ?>>
</div>
<div class="form-text">
What it is for, in a few words - shown on it and in lists.
<?php if ($form_type === 'recurring') { ?>
Put <code>{month}</code>, <code>{quarter}</code>, <code>{year}</code> or <code>{period}</code> in it - here, in the notes or on a line - and each invoice says the period it covers: "Managed services - {month}" reads "Managed services - November 2026".
<?php } ?>
</div>
</div>
<div class="col-12">
<label class="form-label">Category <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
<select class="form-select select2" name="category" required>
<option value="">- Category -</option>
<?= categoryOptionsHtml('Income', $form_category_id, ['since' => $form_edit ? $form_value('created_at') : null]) ?>
</select>
<?= categoryAddButtonHtml('Income', 'btn btn-secondary') ?>
</div>
</div>
<?php if ($form_type === 'invoice') { ?>
<div class="col-sm-6">
<label class="form-label">Invoice date <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
<input type="date" class="form-control" name="date" max="2999-12-31" value="<?= escapeHtml($form_value('date', date('Y-m-d'))) ?>" required data-billing-date>
</div>
</div>
<div class="col-sm-6">
<label class="form-label">Due <?= $form_edit ? '<strong class="text-danger">*</strong>' : '' ?></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-check"></i></span>
<?php if ($form_edit) { ?>
<input type="date" class="form-control" name="due" max="2999-12-31" value="<?= escapeHtml($form_value('due')) ?>" required data-billing-due>
<?php } else { ?>
<input type="date" class="form-control" value="<?= date('Y-m-d', strtotime("+$form_terms days")) ?>" disabled data-billing-due>
<?php } ?>
</div>
<?php if (!$form_edit) { ?>
<div class="form-text" data-billing-terms-note><?= $form_terms ? "$form_terms days from the invoice date" : 'Due on receipt' ?> - <?= escapeHtml($form_terms_from) ?> terms.</div>
<?php } ?>
</div>
<?php } elseif ($form_type === 'quote') { ?>
<div class="col-sm-6">
<label class="form-label">Quote date <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar"></i></span>
<input type="date" class="form-control" name="date" max="2999-12-31" value="<?= escapeHtml($form_value('date', date('Y-m-d'))) ?>" required data-billing-date>
</div>
</div>
<div class="col-sm-6">
<label class="form-label">Valid until <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-hourglass-half"></i></span>
<input type="date" class="form-control" name="expire" max="2999-12-31" value="<?= escapeHtml($form_value('expire', date('Y-m-d', strtotime('+30 days')))) ?>" required data-billing-expire>
</div>
<div class="form-text">
<?php foreach ([15, 30, 60, 90] as $days) { ?>
<a href="#" class="me-2" data-billing-expire-days="<?= $days ?>"><?= $days ?> days</a>
<?php } ?>
</div>
</div>
<?php } else { ?>
<div class="col-sm-6">
<label class="form-label">Billed <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-sync-alt"></i></span>
<select class="form-select" name="frequency" required>
<?php foreach (recurringInvoiceFrequencies() as $frequency => $label) { ?>
<option value="<?= $frequency ?>" <?= $form_value('frequency', 'month') === $frequency ? 'selected' : '' ?>><?= $label ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="col-sm-6">
<label class="form-label"><?= $form_edit ? 'Next invoice' : 'First invoice' ?> <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-calendar-day"></i></span>
<input type="date" class="form-control" name="<?= $form_edit ? 'next_date' : 'start_date' ?>" max="2999-12-31"
value="<?= escapeHtml($form_edit ? $form_value('next_date') : date('Y-m-01', strtotime('first day of next month'))) ?>" required>
</div>
<div class="form-text">Made on this date, then every period after.</div>
</div>
<?php } ?>
<div class="col-sm-6">
<label class="form-label">Discount</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-tags"></i></span>
<input type="text" class="form-control text-end" inputmode="decimal" pattern="-?[0-9]*\.?[0-9]{0,2}"
name="<?= ['invoice' => 'invoice_discount', 'quote' => 'quote_discount', 'recurring' => 'recurring_invoice_discount'][$form_type] ?>"
placeholder="0.00" value="<?= $form_discount != 0 ? number_format($form_discount, 2, '.', '') : '' ?>">
<span class="input-group-text"><?= escapeHtml($form_currency) ?></span>
</div>
<div class="form-text">Taken off the total, after the lines.</div>
</div>
<?php if ($form_type === 'recurring') { ?>
<div class="col-sm-6">
<label class="form-label">When it is made</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-paper-plane"></i></span>
<select class="form-select" name="auto_send">
<option value="1" <?= $form_value('auto_send', '1') === '1' ? 'selected' : '' ?>>Send it automatically</option>
<option value="0" <?= $form_value('auto_send', '1') === '0' ? 'selected' : '' ?>>Leave a draft to check</option>
</select>
</div>
<?php if (empty($config_recurring_auto_send_invoice)) { ?>
<div class="form-text text-warning-emphasis">Emailing the invoices recurring invoices make is off in Settings &gt; Notifications - they are made as drafts.</div>
<?php } ?>
</div>
<?php if ($form_edit) { ?>
<div class="col-12">
<div class="form-check form-switch">
<input type="hidden" name="status" value="0">
<input class="form-check-input" type="checkbox" role="switch" name="status" value="1" id="<?= $form_id ?>Active" <?= intval($form_value('status', '1')) ? 'checked' : '' ?>>
<label class="form-check-label" for="<?= $form_id ?>Active">Active - invoices are made on schedule</label>
</div>
</div>
<?php } ?>
<?php } ?>
</div>
<script>
(function () {
var root = document.getElementById(<?= json_encode($form_id) ?>);
if (!root || root.hasAttribute('data-wired')) {
return;
}
root.setAttribute('data-wired', '1');
function ymd(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function plus(value, days) {
var parts = String(value || '').split('-');
var d = parts.length === 3 ? new Date(+parts[0], +parts[1] - 1, +parts[2]) : new Date();
d.setDate(d.getDate() + days);
return ymd(d);
}
var date = root.querySelector('[data-billing-date]');
// A new invoice: the due date follows the date and the client's terms
var due = root.querySelector('[data-billing-due][disabled]');
var client = root.querySelector('[data-billing-client]');
var note = root.querySelector('[data-billing-terms-note]');
function terms() {
var option = client && client.options[client.selectedIndex];
return option && option.getAttribute('data-terms') ? Number(option.getAttribute('data-terms')) : Number(root.getAttribute('data-terms') || 0);
}
function follow() {
if (due && date) {
due.value = plus(date.value, terms());
if (note) {
var picked = client && client.options[client.selectedIndex];
var from = picked && picked.value ? picked.getAttribute('data-terms-from') : root.getAttribute('data-terms-from');
note.textContent = (terms() ? terms() + ' days from the invoice date' : 'Due on receipt') + ' - ' + (from || 'the default') + ' terms.';
}
}
}
if (due) {
date.addEventListener('change', follow);
if (client) {
client.addEventListener('change', follow);
}
}
// A quote: valid for so many days from its date
root.querySelectorAll('[data-billing-expire-days]').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
var expire = root.querySelector('[data-billing-expire]');
expire.value = plus(date ? date.value : '', Number(link.getAttribute('data-billing-expire-days')));
});
});
})();
</script>