diff --git a/CHANGELOG.md b/CHANGELOG.md index 26bbfb2bc..ecef5c9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.4.3) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.4.4) in the same pass. Nothing else is required. This is a big release, with sixty database steps in one go, so take a backup before you update. @@ -39,6 +39,8 @@ The first nightly run after the update deals with active contracts whose end dat No contract is out for signature after the update - signing starts when you send one (see New Features, Contracts). A signature already stored on a contract, put there by hand or by a script, is kept as a copy recorded before signing came to ITFlow, under the contract's Signing. +No contract has prepaid hours after the update - none included each month and none bought - so nothing is drawn from them, and Unbilled Time is as it was, until you add some (see New Features, Contracts). + Every account is given a kind - checking, savings, credit card, cash or payment processor - guessed from its name: Visa, Amex or card is a credit card, savings or reserve is savings, cash or petty cash is cash, and Stripe, PayPal, Square or merchant is a payment processor. An account given a liability type back when accounts had types (2023) is a credit card, so what is on it reads as owed. Anything else is checking. Change any that came out wrong with Edit on the Accounts page. An account's balance now leaves out archived payments, income and expenses everywhere, so the Accounts page can show less or more than before (see Bug Fixes). No account is reconciled after the update. The first statement you reconcile on each can start from the bank's balance on the day it starts, bringing everything before it forward as reconciled, or from the account's opening balance (see New Features, Accounts and transactions). Transfers already made out of a cash account count as having taken everything in it up to then to the bank, so the first deposit you make after the update only offers the checks and cash taken in since the last one. Taxes keep their names and rates, now kept to four decimal places. No client has a rate of its own or is tax exempt, and there is no default rate, so new lines start untaxed as before until you pick one in Admin > Taxes. Billed time, trips and expenses stay untaxed until you switch them on there. Discounts now come off before tax on invoices and quotes that are still open, the next time they are edited (see Breaking Changes, Taxes). No expense has tax recorded on it until you add it, so Tax Collected shows no tax paid on purchases for the months before. @@ -1154,10 +1156,16 @@ If you are coming from 26.08 or earlier, the database update still has to be run - When they have signed, whoever looks after the contract is emailed and agents are told in ITFlow. Countersign signs it for your side, and then the client is emailed the PDF signed by both. A draft becomes active from its start date, or from that day when it has none. - The contract's Signing card says where it stands - sent, opened, signed or countersigned - with when and from where. It has the link to copy, Send the link again, Withdraw, the copy sent or signed to view or download, and the requests before it. The signed PDF ends with how it was signed: when it was sent and opened, who signed it, when and from which IP address, and the document's fingerprint. - Changing a contract while it is out for signature withdraws the request when what it says has changed, so no one signs an old version. Send it again for the new one. A signed contract that no longer matches the copy signed says so. - - The contract emails - the reminder before the notice date, Please sign, Signed by the client and the signed copy - are in Admin > Mail > Previews with the rest. + - Prepaid hours. A contract can include hours each month - set under Cover in the contract and template windows - and the hours a client buys are added on its page with Add hours: how many, the price, the day they were bought and the day they run out, and a note, with a draft invoice for them if you like. Hours included each month are for that month only. + - Every night the time on the client's billable tickets done since is drawn from those hours, ticket by ticket, rounded the way Billing Time in Admin > Settings > Invoice says: the oldest ticket first, from the hours that run out first, and only from hours that cover the day the ticket was done. A ticket they cover leaves Unbilled Time. One they run out on part way stays there with the rest, billed at the usual rate. Draw now does it straight away, from the contract or the client's Unbilled Time. Each ticket's history says what it drew, and a ticket worked on again after it was covered goes back to Unbilled Time for the extra time. + - The contract's Prepaid hours card shows what is left and of how much, what runs out when, each block of hours with its days, what was used of it and its invoice, and the tickets drawn from them. Put back returns a ticket's hours and bills it as usual, and the nightly draw leaves it alone until Draw it on the ticket. Hours nothing was drawn from can be deleted. Once a ticket is invoiced, its hours stay drawn. + - Set Tell the owner under, and when the hours left fall below it, whoever looks after the contract is emailed and agents are told in ITFlow - once, until more hours are added. Agents are also told when the hours run out part way through a ticket. + - The contracts list shows the hours left on each, the client overview the hours left across them, a ticket what it drew, and Unbilled Time what is left, with Draw now and how much a ticket's hours were cut by. In the client portal, a contract shows the hours left, what runs out when and the tickets they went on (a ticket's subject only to a contact who can open it), and the list shows the hours left. + - The hours included each month are in the contract's document, and [Monthly Hours] fills them in. A contract without them reads as it did, so nothing already signed goes out of date. + - The contract emails - the reminder before the notice date, Please sign, Signed by the client, the signed copy and Prepaid hours running low - are in Admin > Mail > Previews with the rest. - **Contract Templates** (rebuilt) - Admin > Contract Templates shows each template with its type, its term and renewal, the notice, its cover (support hours, rate and net terms), the SLA for each priority and how many contracts were started from it. Tabs for the ones in use and the archived ones; search; sort by name, term or contracts. - - New and Edit are one window with Template, Cover and Terms tabs and an icon in front of every field. A template has the same terms as a contract, without the client and dates, and its terms can use the same placeholders. + - New and Edit are one window with Template, Cover and Terms tabs and an icon in front of every field. A template has the same terms as a contract, the hours included each month among them, without the client and dates, and its terms can use the same placeholders. - Copy makes "Name (copy)". Archive takes a template off New Contract and keeps it, and Restore brings it back. Delete leaves the contracts started from it as they are. ### Bug Fixes @@ -1960,6 +1968,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Payment methods: database 3.4.1 adds `payment_methods.payment_method_use` tinyint(1) (0 money in and out - every method so far, 1 money in, 2 money out) and `payment_method_archived_at`. New `functions/payment_method.php`: `PAYMENT_METHOD_USES`, `PAYMENT_METHOD_ONLINE` ('Stripe', what online card payments are recorded as - its name can't be changed), `paymentMethodUsageColumns()` (every column a method is kept in by name: `payment_method`, `revenue_payment_method`, `expense_payment_method`, `recurring_expense_payment_method`, `reimbursement_payment_method`, `transfer_method`, `recurring_payment_method`), `paymentMethodRow()`, `paymentMethodsAll()`, `paymentMethodUsage()` (one UNION ALL, counts and the last date per name), `paymentMethodUsageText()`, `paymentMethodsNotSetUp()`, `paymentMethodDefaults()`, `paymentMethodDefaultFor()`, `paymentMethodSetDefault()`, `paymentMethodFromInput()`, `paymentMethodProblem()` (a name, not taken), `paymentMethodCreate()`, `paymentMethodRename()` (every column above and both defaults, case-insensitively), `paymentMethodUpdate()` (returns the from-to changes and how many records were renamed; drops a default the new use rules out), `paymentMethodSetArchived()`, `paymentMethodDelete()` (refuses a used method without another to merge into) and `paymentMethodAddNames()`. `paymentMethods($way = '', $with_archived = false)` moves here from `functions/billing.php` - `'in'` for payments and income, `'out'` for expenses, `''` for transfers; archived ones only for filters - and every picker and check passes its way: the payment, income, expense, recurring expense, reimbursement, mileage claim, bulk edit and import windows, `incomeImportMethod()`, the expense and income save functions, and Settings > Defaults. `paymentMethodColumnsReady()` keeps it working before 3.4.1 has run. The payment window keeps a payment's own method among the choices. `admin/post/payment_method.php` adds `default_payment_method` (GET, `way` in or out), `archive_payment_method`, `restore_payment_method`, `delete_payment_method` as POST with `move_to` (the GET delete is gone) and `add_payment_method_names`. The add and edit windows share `admin/modals/payment_method/payment_method_form.php`; `payment_method_delete.php` is the delete and merge window. - Contracts: database 3.4.2 adds to `contract_templates` and `contracts` `{prefix}_term_months` int (12), `_auto_renew` tinyint(1) (1), `_notice_days` int (30) and `_sla_low_id`, `_sla_medium_id`, `_sla_high_id`, `_sla_urgent_id` int (0, the client's own), and to `contracts` `contract_template_id`, `contract_contact_id`, `contract_owner_id`, `contract_created_by` int, `contract_notes` text, `contract_renewed_at`, `contract_reminded_at`, `contract_cancelled_at` datetime and `contract_cancel_reason` varchar(255). It turns the `_sla_{low,medium,high}_{response,resolution}_time` hours into SLA ids - an unarchived `slas` row with the same minutes, or a new one - and `_renewal_frequency` into the term and auto-renew, then drops those seven columns from both tables. It makes `_net_terms` int (the digits of the old text, or NULL), sets `_type` to a key of `CONTRACT_TYPES` and `contract_status` to Draft, Active, Ended or Cancelled, and re-adds `contracts_ibfk_1` with ON DELETE CASCADE. New `functions/contract.php`: `CONTRACT_TYPES` (key => label, icon, about), `CONTRACT_TERMS`, `CONTRACT_REMINDER_DAYS` (30), `CONTRACT_SLA_PRIORITIES`, `contractsReady()` (keeps every page and the nightly job working before 3.4.2 has run), `contractRow()` (with the client, contact, owner and template names), `contractTemplateRow()`, `contractTemplates()`, `contractTypeLabel()`, `contractTermLabel()`, `contractTermEnd($start, $months)` (a term less a day - `termEnd()` in `js/contract_form.js` matches it), `contractNextEnd()`, `contractNoticeDate()`, `contractReminderDate()`, `contractState()` and `contractStateHtml()`, `contractViews()`, `contractSlaChoices($keep)`, `contractSlaTargets()`, `contractFromInput($in, 'contract'|'template')` (returns the fields and an error; checks the client in scope, the contact at that client, an active agent as owner and listed SLAs, and purifies the terms), `contractTermsSetSql($f, $prefix)` (shared by contracts and templates), `contractSetSql()`, `contractCreate()`, `contractUpdate()` (returns the changes; a new end date clears `contract_reminded_at`), `contractActivate()`, `contractRenew()`, `contractCancel()`, `contractsNightly($today)` (returns renewed, ended and reminded counts; `cron/nightly_tasks.php` runs it and logs them), `contractRemind()`, `contractClientCount()` and `contractClientActive()`. New `agent/contracts.php`, `agent/contract.php`, `agent/post/contract.php` (`add_contract`, `edit_contract`, `activate_contract`, `renew_contract`, `cancel_contract` POST with Sales Modify, and `delete_contract` GET with Sales Delete), and `agent/modals/contract/` (the add, copy and edit windows share `contract_form.php` with `js/contract_form.js`, plus `contract_activate.php`, `contract_renew.php` and `contract_cancel.php`). `admin/post/contract_template.php` is rewritten on `contractFromInput()` and adds `copy_contract_template`; deleting a template sets its contracts' `contract_template_id` to 0. The template add and edit windows share `admin/modals/contract_template/contract_template_form.php`. `auditLogKinds()` has a Contract kind, the client overview has Contracts ending and Contracts renewing attention items and a contract in the Financial row, and `agent/post/client.php` deletes a client's contracts with it. - Contract signing: database 3.4.3 adds `contract_signatures`, a row for each time a contract is sent to sign - `contract_signature_contract_id` (ON DELETE CASCADE), `_url_key` (48 hex characters, '' for one recorded), `_document` (mediumtext, the document as sent) and `_document_hash` (SHA-256), `_contact_id`, `_sent_to_name`, `_sent_to_email`, `_sent_at`, `_sent_by`, `_viewed_at`, the client's `_client_name`, `_client_title`, `_client_image` (a PNG data URI, NULL when typed), `_client_signed_at`, `_client_via` (link, portal or recorded), `_client_contact_id`, `_client_ip`, `_client_user_agent`, the countersignature's `_agent_user_id`, `_agent_name`, `_agent_title`, `_agent_image`, `_agent_signed_at`, `_agent_ip`, and `_withdrawn_at` with `_withdrawn_reason`. A signature found in `contracts.contract_contact_signature` or `contract_agent_signature` is moved to a recorded row first; then those and `contract_client_name`, `_client_address`, `_client_email`, `_client_phone`, `contract_contact_name`, `_contact_signature_date`, `contract_agent_name` and `_agent_signature_date` are dropped. New `functions/contract_signature.php`: `CONTRACT_PLACEHOLDERS`, `contractSignaturesReady()`, `contractPlaceholderValues($row, $as_of)`, `contractFillTerms()` (through `ticketPlaceholderFill()`, with `[SLA]` on its own line as `contractSlaTableHtml()`), `contractUnfilled()`, `contractSlaRows()` (the contract's SLA or the client's, by `getTicketSlaId()`), `contractDocumentHtml($row, $as_of)` (classes only, no styles - the portal's CSP allows none), `contractDocumentHash()`, `contractSignatureRow()`, `contractSignatureByKey()` (hash_equals), `contractSignatureRounds()`, `contractSignatureSigned()`, `contractSignatureState()` (sent, viewed, countersign, signed, withdrawn, incomplete), `contractSignaturePending()`, `contractSignaturePendingFor()`, `contractSignatureUrl()`, `contractSignatureImageFromInput()` (a PNG data URI of at most 400 KB, redrawn with GD and cut to the ink - blank pads and other formats refused), `contractSignatureFromInput()`, `contractSignatureSend()` (withdraws a request still out), `contractSignatureRequestEmail()`, `contractEmailFrom()` (the quotes mailbox, else ITFlow's), `contractSignatureWithdraw()`, `contractSignatureViewed()`, `contractSignatureClientSign()` and `contractSignatureAgentSign()` (each one UPDATE guarded by the state it needs, so a second press or a race does nothing), `contractSignatureSignedEmail()`, `contractSignatureBlockHtml()`, `contractSignatureTrail()`, `contractSignatureCheckChanged()` (the edit handler withdraws a request whose document, made as of the day it was sent, now hashes differently), `contractSignatureOutdated()` and `contractPdf()`, with `functions/contract_pdf.php` (`ContractPdf`, its `Error()` throwing so terms TCPDF can't draw fall back to text). `agent/post/contract.php` adds `send_contract_signature` and `countersign_contract` (POST), `resend_contract_signature` and `withdraw_contract_signature` (GET with the token), all with Sales Modify, and `export_contract_pdf` (Sales Read, `&round=` for a round's copy). New `agent/contract_document.php`, `agent/modals/contract/contract_send.php` and `contract_countersign.php`, `guest/guest_view_contract.php` with `sign_contract` (POST, the id and key, and the document hash the page showed) and `export_contract_pdf` in `guest/guest_post.php`, and `client/contracts.php` and `client/contract.php` (its `?signature=` serves a signature's picture, as the portal's CSP allows no data: images) with `portal_sign_contract` in `client/post.php`, `?contract=` in `client/pdf.php`, `portalContractsGate()`, `portalContractScopeSql()`, `portalContractWaitingSql()`, `portalContractRound()` and `portalContractState()` in `client/functions.php`, a Contracts entry in `portalNav()` and the home page's list. Shared: `includes/contract_paper.php` (the document on a page), `includes/contract_signature_pad.php` with `js/signature_pad.js` (pointer events, the strokes redrawn on resize, the PNG put in the form as it is sent), and `includes/contract_placeholders.php` with `js/contract_placeholders.js`. `contractViews()` adds Signing; `contractRow()` adds `client_currency_code` and `contact_title`. Audit log actions Sign and Withdraw. `emailPreviewCatalog()` adds the Contracts group (`emailPreviewContractCatalog()`, `emailPreviewContract()`). New CSS: `.itflow-contract-doc-*`, `.itflow-contract-signature*`, `.itflow-signature-*`, `.itflow-contract-trail`. +- Contract prepaid hours: database 3.4.4 adds to `contract_templates` and `contracts` `{prefix}_monthly_hours` and `{prefix}_hours_alert` decimal(8,2) (0.00), and `contracts.contract_hours_alerted_at` datetime, plus two tables. `contract_blocks`: `contract_block_contract_id` (ON DELETE CASCADE), `_hours`, `_price` (NULL for none), `_bought`, `_expires` (NULL for never), `_month` (char(7), for a month's included hours - UNIQUE with the contract, so a month is made once), `_invoice_id`, `_note`, `_created_by`, `_created_at`. `contract_block_uses`: `contract_block_use_block_id` (ON DELETE CASCADE), `_ticket_id`, `_hours`, `_full` (on the use that covered the rest of a ticket), `_created_by`, `_created_at`; a use of 0 hours keeps a ticket that was put back out of the draw. New `functions/contract_hours.php`: `contractHoursReady()`, `contractHoursText()`, `contractHoursWords()`, `contractBlocks()`, `contractHoursSummary()`, `contractHoursLeftFor()`, `contractHasHours()`, `contractClientHours()`, `contractHoursEnsureMonthly()`, `contractHoursSync()` (after a save, Make active, Renew or a countersignature that makes a draft active), `contractHoursTicketUses()`, `contractHoursUses()`, `contractHoursDraw()` (one at a time per client, under `GET_LOCK`), `contractHoursDoneOn()`, `contractHoursRecheck()`, `contractHoursPutBack()`, `contractHoursAlertReset()`, `contractHoursAlertCheck()`, `contractHoursAlertSend()`, `contractHoursFromInput()`, `contractHoursAdd()` (the invoice through `billingInvoiceCreate()` and `billingItemSave()`), `contractHoursBlockDelete()`, `contractBlockRow()` and `contractHoursNightly()`. `billingUnbilledSql()` leaves out a ticket with a full use; `billingUnbilledTickets()` adds `worked` (the hours the time logged makes) and `prepaid`, and `hours` is now what is left after them; `billingUnbilledByClient()` adds `prepaid`. `contractFromInput()` takes `monthly_hours` and `hours_alert`, `contractTermsSetSql()` writes them once 3.4.4 has run, and `contractUpdate()` logs them and clears `contract_hours_alerted_at` when the level changes. `contractsNightly()` returns `drawn` too, logged by `cron/nightly_tasks.php`. `ticketDeleteForGood()` deletes the ticket's uses. `CONTRACT_PLACEHOLDERS` adds Monthly Hours, and `contractDocumentHtml()` adds an Included hours row only when there are some, so documents without them hash as before. `agent/post/contract.php` adds `add_contract_hours` and `draw_contract_hours` (POST, with a `contract_id`, a `client_id` or a `ticket_id`) and `delete_contract_block` and `put_back_contract_hours` (GET with the token), all with Sales Modify. New `agent/modals/contract/contract_hours_add.php`. `emailPreviewContractCatalog()` adds `contract_hours_low`. New CSS: `.itflow-contract-hours-left` and `.itflow-hours-bar` (a ``, as the portal's CSP allows no inline styles). ### Library Updates diff --git a/admin/database_updates/3.4.4.php b/admin/database_updates/3.4.4.php new file mode 100644 index 000000000..f589225ed --- /dev/null +++ b/admin/database_updates/3.4.4.php @@ -0,0 +1,65 @@ + 0; + }; + + foreach (['contract_templates' => 'contract_template', 'contracts' => 'contract'] as $itflow_table => $itflow_prefix) { + if (!$itflow_column($itflow_table, "{$itflow_prefix}_monthly_hours")) { + mysqli_query($mysqli, "ALTER TABLE `$itflow_table` ADD `{$itflow_prefix}_monthly_hours` decimal(8,2) NOT NULL DEFAULT 0.00"); + } + if (!$itflow_column($itflow_table, "{$itflow_prefix}_hours_alert")) { + mysqli_query($mysqli, "ALTER TABLE `$itflow_table` ADD `{$itflow_prefix}_hours_alert` decimal(8,2) NOT NULL DEFAULT 0.00"); + } + } + if (!$itflow_column('contracts', 'contract_hours_alerted_at')) { + mysqli_query($mysqli, "ALTER TABLE `contracts` ADD `contract_hours_alerted_at` datetime DEFAULT NULL"); + } + + mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `contract_blocks` ( + `contract_block_id` int(11) NOT NULL AUTO_INCREMENT, + `contract_block_contract_id` int(11) NOT NULL, + `contract_block_hours` decimal(8,2) NOT NULL, + `contract_block_price` decimal(15,2) DEFAULT NULL, + `contract_block_bought` date NOT NULL, + `contract_block_expires` date DEFAULT NULL, + `contract_block_month` char(7) DEFAULT NULL, + `contract_block_invoice_id` int(11) NOT NULL DEFAULT 0, + `contract_block_note` varchar(255) DEFAULT NULL, + `contract_block_created_by` int(11) NOT NULL DEFAULT 0, + `contract_block_created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`contract_block_id`), + UNIQUE KEY `contract_block_month` (`contract_block_contract_id`, `contract_block_month`), + CONSTRAINT `contract_blocks_ibfk_1` FOREIGN KEY (`contract_block_contract_id`) REFERENCES `contracts` (`contract_id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); + + mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `contract_block_uses` ( + `contract_block_use_id` int(11) NOT NULL AUTO_INCREMENT, + `contract_block_use_block_id` int(11) NOT NULL, + `contract_block_use_ticket_id` int(11) NOT NULL, + `contract_block_use_hours` decimal(8,2) NOT NULL, + `contract_block_use_full` tinyint(1) NOT NULL DEFAULT 0, + `contract_block_use_created_by` int(11) NOT NULL DEFAULT 0, + `contract_block_use_created_at` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`contract_block_use_id`), + KEY `contract_block_use_block_id` (`contract_block_use_block_id`), + KEY `contract_block_use_ticket_id` (`contract_block_use_ticket_id`), + CONSTRAINT `contract_block_uses_ibfk_1` FOREIGN KEY (`contract_block_use_block_id`) REFERENCES `contract_blocks` (`contract_block_id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"); + + unset($itflow_column, $itflow_table, $itflow_prefix); diff --git a/admin/modals/contract_template/contract_template_form.php b/admin/modals/contract_template/contract_template_form.php index 8efe5ddf6..a7704ab7a 100644 --- a/admin/modals/contract_template/contract_template_form.php +++ b/admin/modals/contract_template/contract_template_form.php @@ -16,6 +16,9 @@ $t_value = function ($key, $default = '') use ($t) { $t_money = function ($value) { return $value === '' || $value === null ? '' : number_format((float) $value, 2, '.', ''); }; +$t_hours = function ($value) { + return floatval($value) > 0 ? rtrim(rtrim(number_format(floatval($value), 2, '.', ''), '0'), '.') : ''; +}; $t_term = intval($t_value('term_months', 12)); $t_slas = contractSlaChoices($t ? array_map(function ($part) use ($t) { return intval($t["contract_template_sla_{$part}_id"]); }, CONTRACT_SLA_PRIORITIES) : []); $tab = 'templateTab' . bin2hex(random_bytes(3)); @@ -96,6 +99,27 @@ $tab = 'templateTab' . bin2hex(random_bytes(3)); + +
+
+ +
+ + + hours +
+
+
+ +
+ + + hours left +
+
+
+ Tickets' time is drawn from the hours included each month - for that month only - and from any hours bought on the contract, before it is billed. +
diff --git a/admin/post/contract_template.php b/admin/post/contract_template.php index 80bb89b6b..6ec051b62 100644 --- a/admin/post/contract_template.php +++ b/admin/post/contract_template.php @@ -64,6 +64,9 @@ if (isset($_GET['copy_contract_template'])) { $columns = ['description', 'type', 'rate_standard', 'rate_after_hours', 'net_terms', 'support_hours', 'details', 'term_months', 'auto_renew', 'notice_days', 'sla_low_id', 'sla_medium_id', 'sla_high_id', 'sla_urgent_id']; + if (contractHoursReady()) { + array_push($columns, 'monthly_hours', 'hours_alert'); + } $columns = implode(', ', array_map(function ($column) { return "contract_template_$column"; }, $columns)); mysqli_query($mysqli, "INSERT INTO contract_templates (contract_template_name, $columns) SELECT '" . escapeSql($new_name) . "', $columns FROM contract_templates WHERE contract_template_id = $contract_template_id"); diff --git a/agent/client_overview.php b/agent/client_overview.php index a47c4ea67..b9c7e57c4 100644 --- a/agent/client_overview.php +++ b/agent/client_overview.php @@ -523,13 +523,19 @@ $money_format = function ($amount) use ($currency_format, $client_currency_code, + $overview_contract = $overview_contracts[0] ?? null; + // Prepaid hours left across them + $overview_hours = contractHoursLeftFor(array_column($overview_contracts, 'contract_id')); + $overview_hours_left = round(array_sum(array_column($overview_hours, 'left')), 2); ?> Contract 1 ? ' · ' . (count($overview_contracts) - 1) . ' more' : '' ?> + + prepaid left + None No contract in force diff --git a/agent/contract.php b/agent/contract.php index ef74a775b..e720af351 100644 --- a/agent/contract.php +++ b/agent/contract.php @@ -8,8 +8,11 @@ * the audit log. Make active, Renew, Cancel and Edit from the top. Signing: * send it to the client to sign, where each request stands - sent, opened, * signed, countersigned - the link to copy, the copies signed before, and - * the document as the client sees it (agent/contract_document.php). The work - * is in functions/contract.php and functions/contract_signature.php; the + * the document as the client sees it (agent/contract_document.php). + * Prepaid hours: what is left of the hours bought and included each month, + * each block of them, and the tickets drawn from them - Add hours, Draw now, + * and a ticket put back. The work is in functions/contract.php, + * functions/contract_signature.php and functions/contract_hours.php; the * handlers are agent/post/contract.php. */ @@ -58,6 +61,15 @@ if (!$contract || ($client_url && intval($contract['contract_client_id']) !== in $signed_round = contractSignatureSigned($rounds); $outdated = $signed_round && (!$latest_state || in_array($latest_state['key'], ['signed', 'withdrawn'], true)) && contractSignatureOutdated($contract, $signed_round); $can_send = $signing && $can_edit && !in_array($status, ['Ended', 'Cancelled'], true); + // Prepaid hours - the blocks, where they stand, and the tickets drawn from them + $hours_on = contractHoursReady(); + $blocks = $hours_on ? contractBlocks($contract_id) : []; + $hours = contractHoursSummary($blocks); + $show_hours = $hours_on && contractHasHours($contract, $blocks); + $hours_uses = $show_hours ? contractHoursUses($contract_id, 20) : []; + $can_hours = $hours_on && $can_edit && !in_array($status, ['Ended', 'Cancelled'], true); + $hours_alert = $hours_on ? floatval($contract['contract_hours_alert']) : 0; + $hours_low = $show_hours && $status === 'Active' && $hours['bought'] > 0 && ($hours['left'] <= 0 || ($hours_alert > 0 && $hours['left'] < $hours_alert)); $currency = $session_company_currency ?? ''; $money = function ($amount) use ($currency) { return escapeHtml(function_exists('billingMoney') ? billingMoney($amount, $currency) : number_format((float) $amount, 2)); @@ -128,6 +140,9 @@ if (!$contract || ($client_url && intval($contract['contract_client_id']) !== in + + Add prepaid hours + Copy Cancel @@ -337,6 +352,143 @@ if (!$contract || ($client_url && intval($contract['contract_client_id']) !== in
+ 0 ? max(0, min(100, round($hours['used'] / $hours['bought'] * 100))) : 0; + $monthly = floatval($contract['contract_monthly_hours']); ?> +
+
+

Prepaid hours

+ + 0) { ?> +
+ + + +
+ + + +
+
+
+
+
Left
+
+
+ 0) { ?> + Of · used + + No hours to draw from 0 && $status !== 'Active' ? ' until it is active' : '' ?> + +
+
+
+ 0) { ?> +
+
+
+ +
    + 0) { ?> +
  • included each month, for that month only
  • + + +
  • run out on
  • + +
  • 0 ? 'The owner is told under ' . escapeHtml(contractHoursText($hours_alert)) . ' left' : 'No one is told when they run low' ?>
  • + 0) { ?> +
  • ran out unused
  • + +
+
+
+ +
Time is drawn from them only while the contract is active.
+ +
0 ? 'Running low.' : 'All used.' ?> Time beyond them goes to Unbilled Time to bill as usual.
+ + + +
+ + + + + + + + + + + + + $block) { + $what = $block['contract_block_note'] ?: ($block['contract_block_month'] ? 'Included hours' : 'Bought'); ?> + + + + + + + + + + + + +
HoursGood forUsedLeftPaid
+ + + Ran out + + – + + + + + + + + + + + + + +
+
+ +
0 ? "This month's included hours are added once it is active." : 'No hours yet.' . ($can_hours ? ' Add the hours the client bought, and invoice them if you like.' : '') ?>
+ + + +
Drawn by tickets
+
    + +
  • +
    + +
    + Drawn + part - the rest is billed' ?> + +
    +
    + + + + +
  • + +
+ +
+
+ +

Terms

diff --git a/agent/contracts.php b/agent/contracts.php index febe94570..94ed74046 100644 --- a/agent/contracts.php +++ b/agent/contracts.php @@ -6,7 +6,8 @@ * many renew soon (a chance to review them with the client) and the drafts. * Tabs: Active (the default), Due soon (within a month of their notice * date), Signing (out for signature, or to countersign), Drafts, Ended - * (ended or cancelled) and All. Each row says where its signing stands. + * (ended or cancelled) and All. Each row says where its signing stands, + * and how many prepaid hours are left on it. * The work is in functions/contract.php; the handlers are * agent/post/contract.php. */ @@ -86,6 +87,9 @@ if (contractsReady()) { } } + // Prepaid hours left on each + $hours_left = contractHoursLeftFor(array_column($contracts, 'contract_id')); + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', array_map(function ($v) { return "COALESCE(SUM({$v['where']}), 0)"; }, $views)) . " FROM contracts $joins WHERE 1 $filters")); @@ -242,6 +246,11 @@ $csrf = $_SESSION['csrf_token']; · Signed + + · left +
diff --git a/agent/modals/contract/contract_form.php b/agent/modals/contract/contract_form.php index f7a01b4f3..1ad0a87ea 100644 --- a/agent/modals/contract/contract_form.php +++ b/agent/modals/contract/contract_form.php @@ -6,7 +6,8 @@ * contract being copied, or null) and $form_client_id (0 to pick the client * here). Agreement: the template it starts from, the client, name, type, * contact, owner, and its term - start, length, end, renewal and notice. - * Cover: support hours, rates, net terms and an SLA for each priority. + * Cover: support hours, hours included each month and when to tell the + * owner they run low, rates, net terms and an SLA for each priority. * Terms: the agreement's words. Notes: internal. Picking a template fills * the fields from it, and the end date follows the start and term until it * is changed (js/contract_form.js). The server checks it all again @@ -29,6 +30,9 @@ $c_end = $c_new ? contractTermEnd($c_start, $c_term) : (string) $c_value('end_da $c_money = function ($value) { return $value === '' || $value === null ? '' : number_format((float) $value, 2, '.', ''); }; +$c_hours = function ($value) { + return floatval($value) > 0 ? rtrim(rtrim(number_format(floatval($value), 2, '.', ''), '0'), '.') : ''; +}; // The clients to pick from, and their contacts - shown for the client picked $c_clients = []; @@ -71,6 +75,7 @@ foreach ($c_templates as $template_id => $template) { 'rate_standard' => $c_money($template['contract_template_rate_standard']), 'rate_after_hours' => $c_money($template['contract_template_rate_after_hours']), 'net_terms' => $template['contract_template_net_terms'] === null ? '' : intval($template['contract_template_net_terms']), 'support_hours' => (string) $template['contract_template_support_hours'], 'details' => documentHtmlPurify((string) $template['contract_template_details']), + 'monthly_hours' => $c_hours($template['contract_template_monthly_hours'] ?? 0), 'hours_alert' => $c_hours($template['contract_template_hours_alert'] ?? 0), ]; foreach (CONTRACT_SLA_PRIORITIES as $part) { $c_template_data[$template_id]["sla_$part"] = intval($template["contract_template_sla_{$part}_id"]); @@ -249,6 +254,27 @@ $tab = 'contractTab' . bin2hex(random_bytes(3)); + +
+
+ +
+ + + hours +
+
+
+ +
+ + + hours left +
+
+
+ Tickets' time is drawn from the hours included each month - for that month only - and from any hours bought on the contract, before it is billed. +
diff --git a/agent/modals/contract/contract_hours_add.php b/agent/modals/contract/contract_hours_add.php new file mode 100644 index 000000000..2d9718852 --- /dev/null +++ b/agent/modals/contract/contract_hours_add.php @@ -0,0 +1,101 @@ + + + + + +
+ + + + +
+ + No time logged' ?> · done + 0 ? '· less ' . escapeHtml(contractHoursText($ticket['prepaid'])) . ' prepaid' : '' ?>
diff --git a/agent/modals/ticket/ticket_invoice_add.php b/agent/modals/ticket/ticket_invoice_add.php index d77014000..a33492d2f 100644 --- a/agent/modals/ticket/ticket_invoice_add.php +++ b/agent/modals/ticket/ticket_invoice_add.php @@ -30,6 +30,11 @@ if (!intval($ticket['ticket_billable'])) { $unbilled = billingUnbilledTickets($client_id, [$ticket_id], true); $row = $unbilled[$ticket_id] ?? null; +// All covered by a contract's prepaid hours - there is nothing to bill unless it is put back +$prepaid_uses = !$row ? contractHoursTicketUses($ticket_id) : []; +if ($prepaid_uses) { + exit(json_encode(['error' => "$reference is covered by prepaid hours on " . $prepaid_uses[0]['contract_name'] . " - put it back on the contract to bill it instead."])); +} if (!$row) { exit(json_encode(['error' => "$reference can't be billed - it is archived."])); } @@ -98,6 +103,9 @@ ob_start();
To bill
h
+ 0) { ?> +
less prepaid
+
diff --git a/agent/post/contract.php b/agent/post/contract.php index 2de56da46..a79ea644e 100644 --- a/agent/post/contract.php +++ b/agent/post/contract.php @@ -3,9 +3,11 @@ /* * ITFlow - contracts: add, change, copy (through add), make a draft active, * renew, cancel and delete; send one for signature, send the request again, - * withdraw it, countersign, and the PDF. Needs Sales - to change, or to - * delete. The work is in functions/contract.php and, for signing, - * functions/contract_signature.php. + * withdraw it, countersign, and the PDF; prepaid hours - add a block (and + * invoice it), delete one nothing was drawn from, draw tickets' time now, + * put a ticket's hours back. Needs Sales - to change, or to delete. The + * work is in functions/contract.php and, for signing and prepaid hours, + * functions/contract_signature.php and functions/contract_hours.php. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); @@ -59,6 +61,7 @@ if (isset($_POST['add_contract']) || isset($_POST['edit_contract'])) { if ($row) { $contract_id = intval($row['contract_id']); $changes = contractUpdate($row, $fields); + contractHoursSync($contract_id); if ($changes) { logAudit("Contract", "Edit", "$session_name changed contract " . escapeSql($row['contract_name']) . ": " . escapeSql(implode('; ', $changes)), $client_id, $contract_id); } @@ -70,6 +73,7 @@ if (isset($_POST['add_contract']) || isset($_POST['edit_contract'])) { flashAlert("Contract " . escapeHtml($fields['name']) . " saved" . ($withdrawn ? " - it was out for signature, so that request was withdrawn. Send it again for the new version." : ''), $withdrawn ? 'warning' : 'success'); } else { $contract_id = contractCreate($fields); + contractHoursSync($contract_id); logAudit("Contract", "Create", "$session_name added " . strtolower($fields['status']) . " contract " . escapeSql($fields['name']) . " (" . escapeSql(contractTypeLabel($fields['type'])) . ")" . ($fields['start_date'] !== '' ? " from " . escapeSql($fields['start_date']) : '') . ($fields['end_date'] !== '' ? " to " . escapeSql($fields['end_date']) : ''), $client_id, $contract_id); flashAlert("Contract " . escapeHtml($fields['name']) . " added" . ($fields['status'] === 'Draft' ? ' as a draft' : '')); @@ -97,6 +101,7 @@ if (isset($_POST['activate_contract'])) { } $end = contractActivate($row, $start); + contractHoursSync(intval($row['contract_id'])); logAudit("Contract", "Edit", "$session_name made contract " . escapeSql($row['contract_name']) . " active from " . escapeSql($start) . ($end !== '' ? " to " . escapeSql($end) : ''), $client_id, intval($row['contract_id'])); flashAlert("Contract " . escapeHtml($row['contract_name']) . " is active"); @@ -117,6 +122,7 @@ if (isset($_POST['renew_contract'])) { } contractRenew($row, $end); + contractHoursSync(intval($row['contract_id'])); logAudit("Contract", "Edit", "$session_name renewed contract " . escapeSql($row['contract_name']) . " to " . escapeSql($end), $client_id, intval($row['contract_id'])); flashAlert("Contract " . escapeHtml($row['contract_name']) . " renewed to " . date('M j, Y', strtotime($end))); @@ -266,6 +272,9 @@ if (isset($_POST['countersign_contract'])) { flashAlert("That contract isn't waiting for a countersignature.", 'error'); redirect(); } + if ($was_draft) { + contractHoursSync(intval($round['contract_id'])); + } logAudit("Contract", "Sign", "$session_name countersigned contract " . escapeSql($round['contract_name']) . " as " . escapeSql($f['name']) . ($was_draft ? ", which is now active" : ''), $client_id, intval($round['contract_id'])); flashAlert("Contract " . escapeHtml($round['contract_name']) . " is signed by both" . ($was_draft ? " and active" : '') . " - a copy went to " . escapeHtml($round['contract_signature_sent_to_name'])); @@ -289,3 +298,124 @@ if (isset($_GET['export_contract_pdf'])) { billingPdfSend(contractPdf($row, $round), true); } + +/* ---------------------------------------------------------------- prepaid hours */ + +if (isset($_POST['add_contract_hours'])) { + + validateCSRFToken(); + enforceUserPermission('module_sales', 2); + + $row = contractPostRow($_POST['contract_id'] ?? 0); + if (!contractHoursReady()) { + flashAlert("Prepaid hours need the database update - run it from Maintenance > Update first.", 'error'); + redirect(); + } + if (in_array($row['contract_status'], ['Ended', 'Cancelled'], true)) { + flashAlert("Hours are added to a draft or active contract - renew it first.", 'error'); + redirect(); + } + [$f, $error] = contractHoursFromInput($_POST); + if ($error === '' && $f['invoice'] && intval($config_module_enable_accounting) !== 1) { + $error = 'Invoicing needs accounting turned on, in Admin > Settings > Modules.'; + } + if ($error !== '') { + flashAlert(escapeHtml($error) . ' No hours were added.', 'error'); + redirect(); + } + [$block_id, $invoice_id, $number] = contractHoursAdd($row, $f, $session_user_id, $session_name); + $hours = contractHoursWords($f['hours']); + logAudit("Contract", "Edit", "$session_name added $hours to contract " . escapeSql($row['contract_name']) + . ($f['price'] !== null ? " for " . escapeSql(billingMoney($f['price'], $session_company_currency)) : '') + . ($f['expires'] !== '' ? ", good until " . escapeSql($f['expires']) : '') . ($invoice_id ? ", on invoice " . escapeSql($number) : ''), $client_id, intval($row['contract_id'])); + flashAlert("" . escapeHtml($hours) . " added to " . escapeHtml($row['contract_name']) . "" + . ($invoice_id ? " - draft invoice " . escapeHtml($number) . " made for them" : '')); + + redirect(); + +} + +if (isset($_GET['delete_contract_block'])) { + + validateCSRFToken(); + enforceUserPermission('module_sales', 2); + + $block = contractBlockRow($_GET['delete_contract_block']); + if (!$block) { + flashAlert("Those hours are gone - they may have been deleted.", 'error'); + redirect(); + } + $row = contractPostRow($block['contract_id']); + if (intval($block['uses']) || !contractHoursBlockDelete($block['contract_block_id'])) { + flashAlert("Time was drawn from those hours, so they stay - put the tickets back first.", 'error'); + redirect(); + } + $hours = contractHoursWords($block['contract_block_hours']); + logAudit("Contract", "Edit", "$session_name deleted $hours from contract " . escapeSql($row['contract_name']) . " (" . escapeSql($block['contract_block_note'] ?: 'bought ' . $block['contract_block_bought']) . ")", $client_id, intval($row['contract_id'])); + flashAlert("" . escapeHtml($hours) . " deleted from " . escapeHtml($row['contract_name']) . "" . (intval($block['contract_block_invoice_id']) ? " - the invoice for them stays" : ''), 'error'); + + redirect(); + +} + +if (isset($_POST['draw_contract_hours'])) { + + validateCSRFToken(); + enforceUserPermission('module_sales', 2); + + // From a contract, Unbilled Time, or one ticket + $ticket_ids = []; + if (!empty($_POST['ticket_id'])) { + $client_id = intval(getFieldById('tickets', intval($_POST['ticket_id']), 'ticket_client_id')); + $ticket_ids = [intval($_POST['ticket_id'])]; + } elseif (!empty($_POST['contract_id'])) { + $client_id = intval(contractPostRow($_POST['contract_id'])['contract_client_id']); + } else { + $client_id = intval($_POST['client_id'] ?? 0); + } + if (!$client_id || !contractHoursReady()) { + flashAlert("Pick a client with prepaid hours.", 'error'); + redirect(); + } + enforceClientAccess(); + + $result = contractHoursDraw($client_id, $ticket_ids, $session_name, $session_user_id); + $count = count($result['drawn']); + if (!$count) { + flashAlert($result['contracts'] ? 'Nothing to draw - no done tickets waiting to bill fall within prepaid hours with time left.' : 'Nothing to draw - the client has no active contract with prepaid hours.', 'warning'); + redirect(); + } + logAudit("Contract", "Edit", "$session_name drew " . escapeSql(contractHoursWords($result['hours'])) . " of prepaid hours for $count ticket" . ($count === 1 ? '' : 's') . ": " . escapeSql(implode(', ', array_keys($result['drawn']))), $client_id); + $over = array_sum($result['overage']); + flashAlert("" . escapeHtml(contractHoursWords($result['hours'])) . " drawn from prepaid hours for $count ticket" . ($count === 1 ? '' : 's') + . " - " . escapeHtml(contractHoursText($result['left'])) . " left" . ($over > 0 ? ". " . escapeHtml(contractHoursText($over)) . " went over and is left to bill" : ''), $over > 0 ? 'warning' : 'success'); + + redirect(); + +} + +if (isset($_GET['put_back_contract_hours'])) { + + validateCSRFToken(); + enforceUserPermission('module_sales', 2); + + $ticket_id = intval($_GET['put_back_contract_hours']); + $ticket = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_client_id, ticket_invoice_id FROM tickets WHERE ticket_id = $ticket_id")); + if (!$ticket || !contractHoursReady()) { + flashAlert("That ticket is gone.", 'error'); + redirect(); + } + $client_id = intval($ticket['ticket_client_id']); + enforceClientAccess(); + $hours = contractHoursPutBack($ticket_id, $session_name, $session_user_id); + if ($hours === null) { + flashAlert(intval($ticket['ticket_invoice_id']) ? "That ticket is on an invoice now, so its hours stay drawn." : "That ticket drew no prepaid hours.", 'error'); + redirect(); + } + $reference = $ticket['ticket_prefix'] . $ticket['ticket_number']; + logAudit("Contract", "Edit", "$session_name put " . escapeSql(contractHoursWords($hours)) . " back on the prepaid hours from ticket " . escapeSql($reference), $client_id, 0); + flashAlert("" . escapeHtml(contractHoursWords($hours)) . " put back from " . escapeHtml($reference) . " - it is in Unbilled Time to bill as usual"); + + redirect(); + +} diff --git a/agent/ticket.php b/agent/ticket.php index c9de91834..31f1128bb 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -463,7 +463,12 @@ if (isset($_GET['ticket_id'])) { // Billing actions, decided once - the header shows Invoice once the work is done $show_quote_button = $can_edit_ticket && $config_module_enable_accounting && $ticket_billable == 1 && empty($quote_id) && empty($invoice_id) && lookupUserPermission("module_sales") >= 2; - $show_invoice_button = $can_edit_ticket && $config_module_enable_accounting && $ticket_billable == 1 && empty($invoice_id) && lookupUserPermission("module_sales") >= 2; + // Prepaid hours on a contract it drew from - all of it covered leaves nothing to invoice + $ticket_prepaid_kept_out = false; + $ticket_prepaid_uses = $config_module_enable_accounting && lookupUserPermission("module_sales") >= 1 ? contractHoursTicketUses($ticket_id, $ticket_prepaid_kept_out) : []; + $ticket_prepaid_hours = round(array_sum(array_column($ticket_prepaid_uses, 'contract_block_use_hours')), 2); + $ticket_prepaid_full = in_array('1', array_map('strval', array_column($ticket_prepaid_uses, 'contract_block_use_full')), true); + $show_invoice_button = $can_edit_ticket && $config_module_enable_accounting && $ticket_billable == 1 && empty($invoice_id) && !$ticket_prepaid_full && lookupUserPermission("module_sales") >= 2; // Trips - the driving done for it: anyone working it can log theirs (functions/trips.php) $show_trips = $config_module_enable_accounting && $client_id > 0; @@ -1185,6 +1190,30 @@ if (isset($_GET['ticket_id'])) {
+ +
+
Prepaid
+
+ + + from + + + + Put back + = 2) { ?> +
+ + + +
+ +
+ +
+
+
diff --git a/agent/unbilled_time.php b/agent/unbilled_time.php index b97e6c2dc..688731e9b 100644 --- a/agent/unbilled_time.php +++ b/agent/unbilled_time.php @@ -16,7 +16,10 @@ * from the time logged and the rounding in Admin > Settings > Invoice, ready * to change before it goes on, and every expense with its price. Tick the * ones to bill and make an invoice, or add them to one of the client's - * drafts - or mark them not billable. + * drafts - or mark them not billable. A client with prepaid hours on a + * contract has them drawn from each night (functions/contract_hours.php): + * what is left shows here, with Draw now, and a ticket they covered part of + * is here with the rest. * * functions/billing.php (Billing time) counts and bills the time, * functions/expenses.php (Billing expenses on) the expenses and @@ -48,12 +51,13 @@ $money = function ($amount) use ($currency) { return escapeHtml(billingMoney($amount, $currency)); }; -$total = ['tickets' => 0, 'seconds' => 0, 'hours' => 0.0, 'value' => 0.0, 'oldest' => 0, 'no_time' => 0, 'no_rate' => 0, +$total = ['tickets' => 0, 'seconds' => 0, 'hours' => 0.0, 'prepaid' => 0.0, 'value' => 0.0, 'oldest' => 0, 'no_time' => 0, 'no_rate' => 0, 'expenses' => 0, 'expense_value' => 0.0, 'expense_cost' => 0.0, 'trips' => 0, 'trip_value' => 0.0]; foreach ($clients as $c) { $total['tickets'] += count($c['tickets']); $total['seconds'] += $c['seconds']; $total['hours'] += $c['hours']; + $total['prepaid'] += $c['prepaid'] ?? 0; $total['value'] += $c['value']; $total['oldest'] = max($total['oldest'], $c['oldest']); $total['no_time'] += $c['no_time']; @@ -78,6 +82,9 @@ $extras_text = function ($expenses, $trips) { return implode(', ', $parts); }; $this_client = $client_view ? ($clients[$client_id] ?? null) : null; +// The client's prepaid hours - drawn from each night, or now +$prepaid = $client_view && $with_time && $this_client && $this_client['tickets'] ? contractClientHours($client_id) : []; +$prepaid_left = round(array_sum(array_map(function ($c) { return $c['hours']['left']; }, $prepaid)), 2); $show_time = $with_time && (!$client_view || ($this_client && $this_client['tickets'])); $markup_text = function ($markup) { return rtrim(rtrim(number_format(floatval($markup), 2, '.', ''), '0'), '.') . '%'; @@ -116,7 +123,7 @@ $hours_text = function ($hours) {
Hours
-
logged, rounded
+
logged, rounded 0 ? ' · less ' . escapeHtml(contractHoursText($total['prepaid'])) . ' prepaid' : '' ?>
@@ -300,6 +307,22 @@ $hours_text = function ($hours) {
+ + +
+ +
+ 0 ? escapeHtml(contractHoursText($prepaid_left)) . ' of prepaid hours left' : 'No prepaid hours left' ?> + on 1 ? ' and ' . (count($prepaid) - 1) . ' more' : '' ?>. + Done tickets are drawn from them each night - the tickets here are beyond them, done outside the days they cover, or put back. +
+ 0) { ?> + + +
+ +

Unbilled Time

@@ -371,7 +394,12 @@ $hours_text = function ($hours) {
- > + > + + 0) { ?> +
less prepaid
+ + diff --git a/client/contract.php b/client/contract.php index c7eb19df2..f470d962b 100644 --- a/client/contract.php +++ b/client/contract.php @@ -4,7 +4,9 @@ * A contract - the copy sent to sign (or the one signed by both, or the * contract as it is today when it was never sent), with where it stands * alongside: the signing form while it waits for their signature, then - * signed, then signed by both. Its term in a few lines, and the PDF. + * signed, then signed by both. Its term in a few lines, its prepaid hours + * (what is left, and the tickets they went on - a ticket's subject only + * when the contact can open it), and the PDF. * * ?signature=client|agent&round= is a signature's picture for the page - * the portal's Content-Security-Policy allows no data: pictures. @@ -80,6 +82,11 @@ $signature_pad = [ 'title' => (string) ($contact['contact_title'] ?? ''), 'agree' => 'I have read this contract and agree to it for ' . $session_client_name . ', and to signing it electronically', ]; +// Prepaid hours - shown once there are some, or some each month +$blocks = contractHoursReady() ? contractBlocks($contract_id) : []; +$hours = contractHoursSummary($blocks); +$show_hours = contractHoursReady() && ($blocks || floatval($contract['contract_monthly_hours']) > 0); +$hours_uses = $show_hours ? contractHoursUses($contract_id, 8) : []; $terms = [ 'Type' => contractTypeLabel($contract['contract_type']), 'Term' => contractTermLabel($contract['contract_term_months']) . ($contract['contract_start_date'] ? ' from ' . date('M j, Y', strtotime($contract['contract_start_date'])) : ''), @@ -166,6 +173,47 @@ $terms = [
+ +
+
+

Prepaid hours

+
+
+
+ left + 0) { ?> of used +
+ 0) { ?> + used + +
    + 0) { ?>
  • included each month
  • +
  • to use by
  • +
  • Time on your tickets comes out of them; time beyond them is invoiced.
  • +
+ +
Used on
+
    + +
  • + + + + + Ticket + + + +
  • + +
+ +
+
+ +

Questions about this contract? Ask us or call .

diff --git a/client/contracts.php b/client/contracts.php index 4e8ee0132..5aef18df1 100644 --- a/client/contracts.php +++ b/client/contracts.php @@ -90,7 +90,8 @@ function contractsUrl(array $changes) {