Remove unused overdue invoice setting move master cron switch out of notificaiton and into cron

This commit is contained in:
johnnyq
2026-08-02 17:22:28 -04:00
parent ad15fabbb3
commit a27019511e
23 changed files with 154 additions and 114 deletions

View File

@@ -9,9 +9,9 @@ This file documents all notable changes made to ITFlow.
Backups are now encrypted, catalogued, schedulable, and restorable from the command line.
- **Three types** — Full (database + uploads), Database Only, and Master Key. Every archive is an AES-256 encrypted zip.
- **One encryption key per install**, generated on first use and stored in `config.php` — never in the database and never in the file name. It is shown in Settings > Backup. **Write it down: without it a backup cannot be restored.** Open archives with 7-Zip, WinZip, PeaZip or Keka — `unzip`, Windows Explorer and the macOS Archive Utility do not support AES.
- **One encryption key per install**, generated on first use and stored in `config.php` — never in the database and never in the file name. It is shown in Maintenance > Backup. **Write it down: without it a backup cannot be restored.** Open archives with 7-Zip, WinZip, PeaZip or Keka — `unzip`, Windows Explorer and the macOS Archive Utility do not support AES.
- **Backups are built by cron, not by your browser.** The button queues the work and the dispatcher picks it up within the minute, then notifies you. A dump of a real install takes longer than a web request is allowed to live, which is why the old Download Backup button timed out on large instances.
- **Scheduled backups** are a new `backup` cron job, off by default. Turn it on in Settings > Cron. Retention (by age and by count) runs in the nightly job and never deletes the newest backup.
- **Scheduled backups** are a new `backup` cron job, off by default. Turn it on in Maintenance > Cron. Retention (by age and by count) runs in the nightly job and never deletes the newest backup.
- **Archives are stored outside the web-served path** under `uploads/backups/` with a deny-all rule, and downloaded through an admin-only handler. Set `$config_backup_path` in `config.php` to keep them off the web root entirely.
- **Restore from the command line** with `php scripts/restore_cli.php --file=/path/to/backup.zip`. This is the only restore path with no size limit — the setup wizard's restore is capped by PHP's upload limits, and a full backup is usually larger. Use `--inspect` to check an archive without changing anything.
- **Restores validate before they destroy.** The key is checked and the archive unpacked before any table is dropped, and the current database is dumped first and put back automatically if the import fails.
@@ -38,14 +38,14 @@ sudo -u www-data php /path/to/itflow/scripts/update_cli.php --update_db
```
It applies every pending version in order and reports each one as it goes. On an install with a lot of ticket history it can take a minute or more, so let it finish. If a step fails it stops there without advancing the recorded version, so you can fix the problem and run it again. The 500s stop as soon as it completes.
5. **Add the new cron entry.** One line runs everything now, and the schedules are managed in ITFlow under Settings > Cron:
5. **Add the new cron entry.** One line runs everything now, and the schedules are managed in ITFlow under Maintenance > Cron:
```
* * * * * www-data php /path/to/itflow/cron/cron.php >/dev/null
```
Drop the `www-data` column if this goes in a user crontab rather than `/etc/cron.d`.
6. **Recreate your API keys.** Every existing key is deleted by this update. Issue new ones and update anything that talks to the ITFlow API.
7. **Check it took.** Open Settings > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule.
7. **Check it took, and check the master switch.** Open Maintenance > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule. The **Enable Cron** switch has moved to this page from Settings > Notifications, and it now stops *every* job rather than most of them. If it is off, turn it on here: on earlier releases the ticket email parser and the SLA monitor ran regardless of it, so an install with the switch off may have been parsing mail all along without anyone realising the switch mattered.
Only this release needs the command line for the database update. Normal updates go back to running from Settings > Update as usual.
### Breaking Changes and Notes
@@ -62,7 +62,13 @@ Only this release needs the command line for the database update. Normal updates
### New Features & Updates
- Cron: one entry instead of five, and a page to manage it. Jobs are tracked in a new `cron_jobs` table, so a job whose slot was missed runs at the next opportunity rather than waiting a day, and each job locks for its own run so a slow mailbox or a long nightly pass no longer holds anything else up.
- Cron: new Settings > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing.
- Cron: new Maintenance > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing.
- Cron: the master **Enable Cron** switch has moved from Settings > Notifications to Maintenance > Cron, alongside everything else about cron. It can be turned on and off from that page, and the page now explains what it is for — stopping a restored backup or a staging clone from emailing clients and charging cards, which per-job toggles cannot do on their own. Saving Notification settings no longer touches it.
- Cron: the master switch now stops **every** job. The Ticket Email Parser and Ticket SLA Monitor previously ignored it and kept running while cron was switched off, which was neither documented nor visible anywhere. **If your Enable Cron switch is off but email-to-ticket has been working, turn cron on in Maintenance > Cron after upgrading** — see the upgrade steps above. Both jobs report "Cron: is not enabled" on the Cron page when the switch is off, so the state is now legible instead of silent.
- Settings > Notifications: removed five rows that had no controls in them and no setting behind them — certificate expiry, asset warranty expiry, shared item views, cron execution and ITFlow updates. The cron execution row is now covered properly by the last run, duration and outcome columns on Maintenance > Cron.
- Settings > Ticketing: the email-to-ticket hint no longer names a cron script that has not existed for several releases, and points at Maintenance > Cron instead.
- Setup: the command line installer now prints the crontab entry and the reminder to turn cron on, the same two steps the web installer shows on its finish page.
- Removed the unused `config_invoice_overdue_reminders` setting. It was seeded on install and read in two places but never actually used by anything; the overdue reminder schedule is fixed in the nightly job.
- Cron: the nightly run is safe to repeat. Late fees, overdue invoice reminders and autopay retries now apply at most once per invoice per day, so a Run Now after the scheduled pass no longer stacks fees or re-emails clients. Nightly Tasks only accepts the daily schedule.
- Ticket SLAs, optional throughout. An SLA sets a response target and an optional resolution target, assigned per client and priority with a global default and an explicit "no SLA" override. Targets are measured against your business hours. Tickets show time remaining and turn yellow at a configurable warning threshold and red on breach, on both the ticket list and the kanban board, and can be filtered by SLA state. Nominated statuses pause the resolution clock for "waiting on customer", preserving the remaining budget. Two new reports, SLA Summary and SLA by Client. With no assignments defined nothing behaves any differently.
- Ticket: added an Urgent priority.

View File

@@ -87,7 +87,7 @@ One crontab entry runs everything:
`cron/cron.php` is a dispatcher. It wakes every minute, works out which scripts in `cron/` are due, and requires them into its own process. Adding a job is a new script in `cron/` plus an entry in `includes/cron_jobs.php`. The crontab never changes again.
That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Settings > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way.
That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Maintenance > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way.
Run Now in the admin UI does not execute anything in the web request: these scripts are CLI-only and some take minutes, so the button sets `cron_job_run_now` and the next dispatch picks it up, through the same lock and claim as a scheduled run.
@@ -97,14 +97,24 @@ Because the jobs share one PHP process, job code has three rules:
1. **Never `exit()` or `die()`.** It ends the whole cycle and every job after it. Use `cronJobStop($message, $exit_code)` instead: it exits when the script was run directly and unwinds back to the dispatcher when it wasn't, so both paths behave as they always have.
2. **Never declare a function or class another job might declare.** Two jobs each declaring the same helper is a fatal `Cannot redeclare` the moment they share a process. Shared helpers belong in `functions/`.
3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Settings > Cron and in the dispatcher.
3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Maintenance > Cron and in the dispatcher.
4. **Set what you read.** One global scope and one set of `require_once` includes are shared across the cycle — a job's own `require_once "../config.php"` is a no-op if an earlier job already loaded it, and any variable an earlier job left behind is still there. Do not rely on the state a fresh process would have given you.
A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Settings > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night.
A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Maintenance > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night.
### The master switch
`config_enable_cron` is a second, coarser switch that sits above the per-job ones. It is **not** enforced by the dispatcher — every job checks it in its own header and stops itself with `cronJobStop()`. A new job has to make that check too; one that skips it keeps running on an install that believes cron is off, which is exactly the trap `ticket_email_parser` and `ticket_sla` sat in until 26.08.
Be precise about what it does, because it is easy to oversell. It is **not** a guard on restored data: a full backup dumps the `settings` table, so a restored copy comes back with `config_enable_cron = 1` alongside every enabled `cron_jobs` row, exactly as production had them. What it gives you is one reversible bit — the fastest way to stop an install acting on live data once you have noticed, and the only way to stop everything without editing seven rows.
That last part is the reason it is not redundant with `cron_job_enabled`. Turning the switch off and back on returns you to exactly the configuration you had. Sweeping all seven rows off and back on does not: `backup` ships `'enabled' => 0`, so the sweep quietly turns on nightly backups nobody asked for, along with anything else that was deliberately disabled.
It defaults to `0`. That is a weaker guard than it looks — an install with no crontab entry runs nothing whatever the switch says, so the entry is the real gate — but it does mean adding the crontab line to a half-configured install is not enough on its own to start emailing. Both setup paths name the step on the way out.
## Backups
`functions/backup.php` is the whole engine, and all three entry points go through it: Settings > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database.
`functions/backup.php` is the whole engine, and all three entry points go through it: Maintenance > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database.
Archives are AES-256 encrypted zips. The key is one value per install, generated on first use and appended to `config.php`**never** the database and **never** the file name. That is the point: a backup that leaks cannot be opened with anything the backup itself contains, and a URL or an access log never carries the key. The 32 random characters in the file name are an unguessable path component, nothing more. Note that `unzip`, Windows Explorer and the macOS Archive Utility cannot read AES zips; 7-Zip, WinZip, PeaZip and Keka can.

View File

@@ -13,7 +13,7 @@ $config_backup_retention_days = intval($row['config_backup_retention_days']);
$config_backup_retention_count = intval($row['config_backup_retention_count']);
$config_backup_cron_type = $row['config_backup_cron_type'];
// Same heartbeat rule as Settings > Cron - archives are built by the dispatcher, so a dead
// Same heartbeat rule as Maintenance > Cron - archives are built by the dispatcher, so a dead
// crontab means the buttons below queue work that never runs
$cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300;
@@ -51,12 +51,12 @@ if (!empty($_SESSION['backup_master_key_reveal'])) {
<div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Cron is not running</h5>
Backups are built by the cron dispatcher, not by your browser. Until cron is running, anything you
start here will sit in the queue. See <a href="cron.php">Settings &gt; Cron</a>.
start here will sit in the queue. See <a href="cron.php">Maintenance &gt; Cron</a>.
</div>
<?php } elseif ($config_enable_cron == 0) { ?>
<div class="alert alert-warning">
<i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off in
<a href="settings_notification.php">Settings &gt; Notifications</a>.
<a href="cron.php">Maintenance &gt; Cron</a>.
</div>
<?php } ?>
@@ -241,7 +241,7 @@ if (!empty($_SESSION['backup_master_key_reveal'])) {
<?php } else { ?>
<i class="fas fa-fw fa-times text-danger mr-2"></i>Scheduled backups are switched off.
<?php } ?>
Turn them on or change the time in <a href="cron.php">Settings &gt; Cron</a>.
Turn them on or change the time in <a href="cron.php">Maintenance &gt; Cron</a>.
</p>
<p class="text-muted small mt-2 mb-0">
Old backups are removed by the nightly job, never by the backup itself, so a failed nightly

View File

@@ -54,10 +54,25 @@ while ($job_row = mysqli_fetch_assoc($sql)) {
<?php if ($config_enable_cron == 0) { ?>
<div class="alert alert-warning">
<i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off in
<a href="settings_notification.php">Settings &gt; Notifications</a>. The dispatcher is running, but most jobs
stop themselves immediately while this is off.
<div class="float-right">
<a class="btn btn-sm btn-warning" href="post.php?enable_cron=1&amp;csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-power-off mr-2"></i>Turn cron on
</a>
</div>
<h5><i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off</h5>
The dispatcher is running, but every job below stops itself immediately while this is off -
no mail is sent, no email becomes a ticket, and nothing is invoiced.
</div>
<?php } else { ?>
<p class="text-muted">
<small>
<i class="fas fa-fw fa-power-off mr-1"></i>The master switch is <strong>on</strong>. Turning it off
stops every job at once without touching their schedules, which is what you want on a restored
backup or a staging clone - those come up with every job enabled and will otherwise email clients
and charge cards. Switching back on returns you to exactly this configuration.
<a href="post.php?disable_cron=1&amp;csrf_token=<?= $_SESSION['csrf_token'] ?>">Turn cron off</a>.
</small>
</p>
<?php } ?>
<div class="table-responsive-sm">

View File

@@ -8,7 +8,7 @@
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// The cron dispatcher's schedule moves out of code and into the database so it can be
// managed from Settings > Cron. The registry in includes/cron_jobs.php still decides
// managed from Maintenance > Cron. The registry in includes/cron_jobs.php still decides
// which scripts exist and seeds these columns the first time it meets a job; from then
// on the row is what runs. Nothing here can name a script - a row whose job is not in
// the registry is ignored.

View File

@@ -0,0 +1,19 @@
<?php
/*
* ITFlow - Database update to version 2.6.6 (from 2.6.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// config_invoice_overdue_reminders never did anything. Both setup paths seeded it with
// '1,3,7' and two files read it, but the only line that would have used it was commented
// out in the nightly job - the reminder schedule is a hardcoded array. The read in
// includes/load_global_settings.php also ran intval() over a comma-separated string, so
// the global was 1 whatever the column said.
//
// Nothing in the app has ever written to it either, so no install has a value worth
// keeping and there is nothing to migrate anywhere.
mysqli_query($mysqli, "ALTER TABLE `settings` DROP COLUMN IF EXISTS `config_invoice_overdue_reminders`");

View File

@@ -26,7 +26,7 @@ if (isset($_GET['queue_backup'])) {
$cron_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1"));
if (intval($cron_row['config_enable_cron']) === 0) {
flashAlert(backupTypeLabel($type) . " queued, but cron is switched off in Settings > Notifications, so it will not start until that is enabled.", 'error');
flashAlert(backupTypeLabel($type) . " queued, but cron is switched off in Maintenance > Cron, so it will not start until that is enabled.", 'error');
} else {
flashAlert(backupTypeLabel($type) . " queued - it will start within a minute and you will be notified when it is ready.");
}

View File

@@ -3,11 +3,32 @@
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/*
* Settings > Cron. Everything here identifies a job by its row, and every row is checked
* against the registry in includes/cron_jobs.php before anything is written - the database
* decides when and whether a job runs, never which file the dispatcher executes.
* Maintenance > Cron. Apart from the master switch, everything here identifies a job by its
* row, and every row is checked against the registry in includes/cron_jobs.php before
* anything is written - the database decides when and whether a job runs, never which file
* the dispatcher executes.
*/
if (isset($_GET['enable_cron']) || isset($_GET['disable_cron'])) {
validateCSRFToken();
// The master switch, config_enable_cron. Most jobs check it themselves and stop; it is
// not a dispatcher-level gate, so the two jobs that do not check it keep running. It
// lived in Settings > Notifications until 26.08, which is nowhere near anything else
// about cron.
$enabled = isset($_GET['enable_cron']) ? 1 : 0;
mysqli_query($mysqli, "UPDATE settings SET config_enable_cron = $enabled WHERE company_id = 1");
logAudit("Cron", "Edit", "$session_name " . ($enabled ? 'enabled' : 'disabled') . " the master cron switch");
flashAlert("Cron " . ($enabled ? 'enabled' : 'disabled') . ".", $enabled ? 'success' : 'error');
redirect();
}
if (isset($_POST['edit_cron_job'])) {
validateCSRFToken();

View File

@@ -6,13 +6,15 @@ if (isset($_POST['edit_notification_settings'])) {
validateCSRFToken();
$config_enable_cron = intval($_POST['config_enable_cron'] ?? 0);
// config_enable_cron is NOT set here - the master cron switch moved to Maintenance > Cron
// in 26.08. Leaving it in this UPDATE would switch cron off every time somebody saved
// this form, because the checkbox that fed it is gone from the page.
$config_enable_alert_domain_expire = intval($_POST['config_enable_alert_domain_expire'] ?? 0);
$config_send_invoice_reminders = intval($_POST['config_send_invoice_reminders'] ?? 0);
$config_recurring_auto_send_invoice = intval($_POST['config_recurring_auto_send_invoice'] ?? 0);
$config_ticket_client_general_notifications = intval($_POST['config_ticket_client_general_notifications'] ?? 0);
mysqli_query($mysqli,"UPDATE settings SET config_send_invoice_reminders = $config_send_invoice_reminders, config_recurring_auto_send_invoice = $config_recurring_auto_send_invoice, config_enable_cron = $config_enable_cron, config_enable_alert_domain_expire = $config_enable_alert_domain_expire, config_ticket_client_general_notifications = $config_ticket_client_general_notifications WHERE company_id = 1");
mysqli_query($mysqli,"UPDATE settings SET config_send_invoice_reminders = $config_send_invoice_reminders, config_recurring_auto_send_invoice = $config_recurring_auto_send_invoice, config_enable_alert_domain_expire = $config_enable_alert_domain_expire, config_ticket_client_general_notifications = $config_ticket_client_general_notifications WHERE company_id = 1");
logAudit("Settings", "Edit", "$session_name edited notification settings");

View File

@@ -12,12 +12,6 @@ require_once "includes/inc_all_admin.php";
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="config_enable_cron" <?php if ($config_enable_cron == 1) { echo "checked"; } ?> value="1" id="enableCronSwitch">
<label class="custom-control-label" for="enableCronSwitch">Enable Cron (recommended) <small>(several cron scripts must also be added to cron with correct schedules, <a href="https://docs.itflow.org/cron">docs</a>)</small></label>
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead class="thead-dark">
@@ -50,32 +44,6 @@ require_once "includes/inc_all_admin.php";
<td></td>
<td></td>
</tr>
<tr>
<th>
<div><i class="fas fa-fw fa-lock mr-2"></i>Certificate Expiration Notice</div>
<small class="text-muted">
(This setting triggers a notification when a certificate is approaching its expiration date, specifically at 1, 7 and 45 days prior to expiry.)
</small>
</th>
<td>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<th>
<div><i class="fas fa-fw fa-desktop mr-2"></i>Asset Warranty Expiration Notice</div>
<small class="text-muted">
(This setting triggers a notification when an asset is approaching its expiration date, specifically at 1, 7 and 45 days prior to expiry.)
</small>
</th>
<td>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr>
<th colspan=5>Billing</th>
</tr>
@@ -135,39 +103,6 @@ require_once "includes/inc_all_admin.php";
</td>
<td></td>
</tr>
<tr>
<th>
<div><i class="fas fa-fw fa-link mr-2"></i>Shared Item View</div>
<small class="text-secondary">(Notify when Shared items are viewed)</small>
</th>
<td></td>
<td></td>
<td>
</td>
<td></td>
</tr>
<tr>
<th>
<div><i class="fas fa-fw fa-clock mr-2"></i>Cron Execution</div>
<small class="text-secondary">(Notify when the nightly cron job ran)</small>
</th>
<td></td>
<td></td>
<td>
</td>
<td></td>
</tr>
<tr>
<th>
<div><i class="fas fa-fw fa-download mr-2"></i>ITFlow Updates</div>
<small class="text-secondary">(Notify when ITFlow has an update)</small>
</th>
<td></td>
<td></td>
<td>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>

View File

@@ -33,7 +33,7 @@ require_once "includes/inc_all_admin.php";
<div class="form-group">
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" name="config_ticket_email_parse" <?php if($config_ticket_email_parse == 1){ echo "checked"; } ?> value="1" id="emailToTicketParseSwitch">
<label class="custom-control-label" for="emailToTicketParseSwitch">Email-to-ticket parsing <small class="text-secondary">(cron_ticket_email_parser.php must also be added to cron and run every few mins)</small></label>
<label class="custom-control-label" for="emailToTicketParseSwitch">Email-to-ticket parsing <small class="text-secondary">(the Ticket Email Parser cron job must also be enabled - see <a href="cron.php">Maintenance &gt; Cron</a>)</small></label>
</div>
</div>

View File

@@ -29,7 +29,7 @@ if ($config_enable_cron == 0) {
}
/*
* Anything an administrator started from Settings > Backup is built first. Those are
* Anything an administrator started from Maintenance > Backup is built first. Those are
* explicit requests and somebody is waiting on the notification.
*/
$queued = backupRunQueued($mysqli);

View File

@@ -9,7 +9,7 @@
*
* It wakes once a minute, works out which jobs are due, and runs them. The jobs themselves
* are listed in includes/cron_jobs.php; when and whether each one runs is held in the
* cron_jobs table and edited from Settings > Cron.
* cron_jobs table and edited from Maintenance > Cron.
*
* WHAT THE JOBS INHERIT
*
@@ -72,7 +72,7 @@ function cronJobClaim($mysqli, array $job): bool
$now = date('Y-m-d H:i:s');
// Register the job the first time it is seen, seeded with the schedule it ships with.
// From here on the row is what runs - Settings > Cron writes to it.
// From here on the row is what runs - Maintenance > Cron writes to it.
$default_schedule = escapeSql($job['schedule']);
$default_interval = intval($job['interval_minutes'] ?? 1);
$default_daily_at = isset($job['daily_at']) ? "'" . escapeSql($job['daily_at']) . ":00'" : 'NULL';
@@ -144,7 +144,7 @@ function cronJobClaim($mysqli, array $job): bool
/*
* Record how a job ended. The status is the outcome of the run that just happened; the error
* is sticky and survives later successes, because the run that failed is usually long gone by
* the time anyone goes looking. Settings > Cron clears it.
* the time anyone goes looking. Maintenance > Cron clears it.
*/
function cronJobFinished($mysqli, string $job_name, string $status, ?float $duration = null, ?string $error = null): void
{
@@ -179,7 +179,7 @@ function cronJobFinished($mysqli, string $job_name, string $status, ?float $dura
}
}
// Proof the crontab is firing, recorded before any job runs. Settings > Cron reads it to tell
// Proof the crontab is firing, recorded before any job runs. Maintenance > Cron reads it to tell
// "no job was due" apart from "nothing has run this since the server was rebuilt".
mysqli_query($mysqli, "UPDATE settings SET config_cron_last_dispatch_at = '" . date('Y-m-d H:i:s') . "' WHERE company_id = 1");

View File

@@ -43,7 +43,6 @@ $company_currency = escapeSql($row['company_currency']);
// Company Settings
$config_enable_cron = intval($row['config_enable_cron']);
$config_invoice_overdue_reminders = $row['config_invoice_overdue_reminders'];
$config_invoice_prefix = escapeSql($row['config_invoice_prefix']);
$config_invoice_from_email = escapeSql($row['config_invoice_from_email']);
$config_invoice_from_name = escapeSql($row['config_invoice_from_name']);
@@ -539,7 +538,6 @@ while ($row = mysqli_fetch_assoc($sql_resolved_tickets_to_close)) {
if ($config_send_invoice_reminders == 1) {
// PAST DUE INVOICE Notifications
//$invoiceAlertArray = [$config_invoice_overdue_reminders];
$invoiceAlertArray = [1,30,60,90,120,150,180,210,240,270,300,330,360,390,420,450,480,510,540,570,590,620,650,680,710,740];
foreach ($invoiceAlertArray as $day) {

View File

@@ -42,6 +42,12 @@ $row = mysqli_fetch_assoc($sql);
$company_name = escapeSql($row['company_name']);
$company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
// Check cron is enabled
if ($config_enable_cron == 0) {
logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - cron not enabled in admin settings.");
cronJobStop("Cron: is not enabled -- Quitting..");
}
// Check setting enabled
if ($config_ticket_email_parse == 0) {
logApp("Cron-Email-Parser", "error", "Cron Email Parser unable to run - not enabled in admin settings.");

View File

@@ -34,6 +34,19 @@ require_once "../functions.php";
* breach until someone moves them back to a running status.
*/
// Read the master switch here rather than trusting a global. Every job shares one process
// and one global scope, so a value an earlier job happened to leave behind is not this
// script's to rely on - see the cron rules in CONTRIBUTING.md.
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron FROM settings WHERE company_id = 1"));
$config_enable_cron = intval($row['config_enable_cron']);
// Check cron is enabled
if ($config_enable_cron == 0) {
logApp("Cron-Ticket-SLA", "error", "Cron Ticket SLA monitor unable to run - cron not enabled in admin settings.");
cronJobStop("Cron: is not enabled -- Quitting..");
}
$sla_settings = getSlaSettings();
$warning_percent = intval($sla_settings['warning_percent']);

3
db.sql
View File

@@ -2276,7 +2276,6 @@ CREATE TABLE `settings` (
`config_recurring_auto_send_invoice` tinyint(1) NOT NULL DEFAULT 1,
`config_enable_alert_domain_expire` tinyint(1) NOT NULL DEFAULT 1,
`config_send_invoice_reminders` tinyint(1) NOT NULL DEFAULT 1,
`config_invoice_overdue_reminders` varchar(200) DEFAULT NULL,
`config_azure_client_id` varchar(200) DEFAULT NULL,
`config_azure_client_secret` varchar(200) DEFAULT NULL,
`config_module_enable_itdoc` tinyint(1) NOT NULL DEFAULT 1,
@@ -3150,4 +3149,4 @@ CREATE TABLE `vendors` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-08-02 16:18:05
-- Dump completed on 2026-08-02 17:22:01

View File

@@ -58,7 +58,7 @@ function backupAppRoot(): string
* The encryption key for this install.
*
* Lives in config.php only. Generated and appended on first use so nobody has to think
* about it, then displayed in Settings > Backup so it can be written down - without it a
* about it, then displayed in Maintenance > Backup so it can be written down - without it a
* backup cannot be restored, on this server or any other.
*
* Returns an empty string if config.php could not be written, which every caller treats
@@ -513,7 +513,7 @@ function backupSealArchive(array $entries, string $zip_path, string $key, ?strin
* than a web request is allowed to live on most hosts - PHP-FPM's request_terminate_timeout
* and the front end's read timeout both cut it off, and neither is affected by
* set_time_limit() - so the button records the intent and cron/backup.php does the work
* within the minute. Same shape as the Run Now button on Settings > Cron.
* within the minute. Same shape as the Run Now button on Maintenance > Cron.
*/
function backupQueue(mysqli $mysqli, string $type, string $created_by, ?string &$error = null): int
{

View File

@@ -7,7 +7,7 @@
* Adding a job is a script in cron/ and an entry here - the crontab never changes.
*
* Schedules here are DEFAULTS. They seed the job's row in the cron_jobs table the first
* time the dispatcher sees it, and from then on the row is what runs: Settings > Cron
* time the dispatcher sees it, and from then on the row is what runs: Maintenance > Cron
* writes to it. Changing a default in this file therefore only affects installs that have
* not met the job yet.
*
@@ -16,7 +16,7 @@
* so nothing that reaches the database can point the dispatcher at an arbitrary file.
*
* Loaded from both sides: cron/cron.php requires it on the command line under system cron,
* and Settings > Cron requires it in a web request. Nothing in here may touch $_SERVER,
* and Maintenance > Cron requires it in a web request. Nothing in here may touch $_SERVER,
* $_SESSION or any other superglobal - there is no DOCUMENT_ROOT, no session and no request
* when cron runs it.
*
@@ -24,13 +24,17 @@
* only cron loads: the admin pages would otherwise be reaching into the cron directory.
*
* 'enabled' => 0 ships a job switched off - the row is seeded disabled and stays that way
* until somebody turns it on in Settings > Cron. Used for work an install should opt into
* until somebody turns it on in Maintenance > Cron. Used for work an install should opt into
* rather than inherit from an upgrade, like the backup job filling a disk overnight.
*
* 'interval_safe' => false marks a job whose work repeats if the day repeats - nightly's
* late fees and overdue reminders fire again on a second run of the same day. Settings >
* late fees and overdue reminders fire again on a second run of the same day. Maintenance >
* Cron only offers the daily schedule for such a job, and the dispatcher refuses to run
* one on an interval whatever its row says.
*
* Every job here checks config_enable_cron in its own header and stops itself when that
* switch is off. It is not a dispatcher-level gate - a new job has to make the check
* itself, and a job that skips it will keep running on an install that thinks cron is off.
*/
function cronJobRegistry(): array
@@ -81,7 +85,7 @@ function cronJobRegistry(): array
'name' => 'backup',
'label' => 'Backup',
'script' => 'backup.php',
'description' => 'Builds the scheduled backup and anything queued from Settings > Backup. Off by default.',
'description' => 'Builds the scheduled backup and anything queued from Maintenance > Backup. Off by default.',
'schedule' => 'Daily',
'daily_at' => '02:00',
'enabled' => 0,

View File

@@ -99,7 +99,6 @@ $config_enable_cron = intval($row['config_enable_cron']);
$config_recurring_auto_send_invoice = intval($row['config_recurring_auto_send_invoice']);
$config_enable_alert_domain_expire = intval($row['config_enable_alert_domain_expire']);
$config_send_invoice_reminders = intval($row['config_send_invoice_reminders']);
$config_invoice_overdue_reminders = intval($row['config_invoice_overdue_reminders']);
// Modules
$config_module_enable_itdoc = intval($row['config_module_enable_itdoc']);

View File

@@ -62,7 +62,7 @@ $key = $options['key'] ?? ($config_backup_key ?? '');
if ($key === '') {
fwrite(STDERR, "No encryption key.\n\n");
fwrite(STDERR, "config.php has no \$config_backup_key, so pass the key from the install that made\n");
fwrite(STDERR, "this backup with --key=... It is shown in Settings > Backup on that install.\n");
fwrite(STDERR, "this backup with --key=... It is shown in Maintenance > Backup on that install.\n");
exit(1);
}
@@ -131,6 +131,6 @@ echo "\nNext steps:\n";
echo " 1. Log in with the credentials that were in use when the backup was taken.\n";
echo " 2. If the backup is older than the code in this directory, finish the database update:\n";
echo " php " . realpath(__DIR__) . "/update_cli.php --update_db\n";
echo " 3. Check Settings > Cron - the schedule came back with the database.\n";
echo " 3. Check Maintenance > Cron - the schedule came back with the database.\n";
exit(0);

View File

@@ -282,7 +282,7 @@ mysqli_query($mysqli,"INSERT INTO companies SET company_name = '$company_name',
// Insert default settings and categories
$latest_database_version = LATEST_DATABASE_VERSION;
mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_invoice_overdue_reminders = '1,3,7', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'");
mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'");
// Categories
mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Office Supplies', category_type = 'Expense', category_color = 'blue'");
@@ -447,4 +447,15 @@ fclose($myfile);
echo "\nSetup complete!\n";
echo "You can now log in with the user you created at: https://$base_url/login.php\n";
// Whoever ran this is already at a shell on the right host, which makes this the one moment
// they can paste the crontab line without going and finding it. The web setup shows the same
// two steps on its finish page.
$itflow_path = realpath(__DIR__ . '/..');
echo "\nTwo things left before ITFlow can send mail, parse tickets or bill anyone:\n\n";
echo " 1. Add this single entry to the crontab of the user that owns the ITFlow files:\n\n";
echo " * * * * * php $itflow_path/cron/cron.php >/dev/null\n\n";
echo " 2. Turn cron on in Maintenance > Cron. It ships off, and every job stops\n";
echo " itself until it is enabled. Per-job schedules live on the same page.\n\n";
exit(0);

View File

@@ -204,7 +204,7 @@ if (isset($_POST['restore'])) {
}
if ($restore_key === '') {
$_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Settings > Backup on the install that made this archive.";
$_SESSION['alert_message'] = "Enter the backup encryption key. It is shown in Maintenance > Backup on the install that made this archive.";
header("Location: ?restore");
exit;
}
@@ -376,7 +376,7 @@ if (isset($_POST['add_company_settings'])) {
}
$latest_database_version = LATEST_DATABASE_VERSION;
mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_invoice_overdue_reminders = '1,3,7', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'");
mysqli_query($mysqli,"INSERT INTO settings SET company_id = 1, config_current_database_version = '$latest_database_version', config_invoice_prefix = 'INV-', config_invoice_next_number = 1, config_recurring_invoice_prefix = 'REC-', config_quote_prefix = 'QUO-', config_quote_next_number = 1, config_default_net_terms = 30, config_ticket_next_number = 1, config_ticket_prefix = 'TCK-'");
// Create Categories
// Expense Categories Examples
@@ -1154,7 +1154,7 @@ if (isset($_POST['add_telemetry'])) {
<div class="form-group">
<label>Backup encryption key</label>
<input type="text" name="backup_key" class="form-control" placeholder="The key from the install that made this backup" autocomplete="off" required>
<small class="text-muted">Shown in Settings &gt; Backup on that install. The archive cannot be opened without it.</small>
<small class="text-muted">Shown in Maintenance &gt; Backup on that install. The archive cannot be opened without it.</small>
</div>
<div class="alert alert-warning mb-0">
@@ -1454,10 +1454,12 @@ if (isset($_POST['add_telemetry'])) {
<li><a href="https://docs.itflow.org/backups">Setup backups</a></li>
<li>
<a href="https://docs.itflow.org/cron">Setup cron</a> - ITFlow needs one entry, which runs
every job on the schedule set in Settings &gt; Cron. Add it to the crontab of the user that
every job on the schedule set in Maintenance &gt; Cron. Add it to the crontab of the user that
owns the ITFlow files:
<pre class="bg-dark text-white p-2 mt-2"><?= escapeHtml("* * * * * php " . dirname(__DIR__) . "/cron/cron.php >/dev/null") ?></pre>
*If installing via the script this is set up for you.
Then <strong>turn cron on</strong> in Maintenance &gt; Cron - it ships off, and every job
stops itself until it is enabled.
*If installing via the script the crontab entry is set up for you.
</li>
<li>Star ITFlow on <a href="https://github.com/itflow-org/itflow">Github</a> :)</li>
</ul>