diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d66670a..ba62c07a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,15 +41,22 @@ This file documents all notable changes made to ITFlow.
>
> **Back up your database before upgrading.**
-- **A new cron job is required if you intend to use ticket SLAs.** `cron/ticket_sla.php` moves
- tickets through their SLA warning and breach stages and sends the notifications. Without it,
- SLA targets are still calculated and displayed but warnings and breaches will never fire. Add
- it alongside the existing every-minute jobs:
+- **The crontab collapses to a single entry.** `cron/cron.php` is now a dispatcher: it runs every
+ minute and decides which of the scripts in `cron/` are due. Everything the old `cron.php` did
+ nightly has moved to `cron/nightly_tasks.php`, which the dispatcher runs at 03:00. Replace every
+ ITFlow line in your crontab with this one:
```
- * * * * * php /path/to/itflow/cron/ticket_sla.php
+ * * * * * php /path/to/itflow/cron/cron.php >/dev/null
```
-
- If you do not use SLAs the job is a no-op and can be skipped.
+
+ An existing crontab keeps working as it is — the per-minute scripts still run and still lock
+ correctly, and `cron.php` still runs the nightly work at whatever time you call it — but jobs
+ added in this and future releases only run if the dispatcher is scheduled.
+- **Ticket SLAs need no cron entry of their own.** `cron/ticket_sla.php` moves tickets through
+ their SLA warning and breach stages and sends the notifications. It is in the dispatcher's job
+ list and runs every minute once the entry above is in place. Without it, SLA targets are still
+ calculated and displayed but warnings and breaches will never fire. If you do not use SLAs the
+ job is a no-op.
- **All existing API keys are deleted by this update and must be recreated.** API keys are now
owned by a user and inherit that user's role, module, and client permissions rather than
carrying their own client scope. Existing keys predate this and cannot be safely mapped to a
@@ -67,7 +74,14 @@ This file documents all notable changes made to ITFlow.
- Several pages were renamed to drop the `_details` suffix and to use consistent singular and
plural filenames. Bookmarks or external links pointing at the old filenames will 404.
### Major Changes
-
+
+- **One cron entry instead of five.** `cron/cron.php` is now a dispatcher that runs every minute
+ and decides which jobs are due, so scheduling lives in ITFlow rather than in the crontab and new
+ jobs arrive with an update instead of an install note. Jobs are tracked in a new `cron_jobs`
+ table, which means a job whose slot was missed runs at the next opportunity rather than waiting
+ a day, and each job is locked for its own run so a slow mailbox or a long nightly run no longer
+ delays anything else. The nightly work itself moved to `cron/nightly_tasks.php`.
+
- **Ticket SLAs (optional).** SLAs define a response target and an optional resolution target,
and are assigned per client and priority, with a global default and an explicit "no SLA"
override available for any combination. Targets are measured against your configured business
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d7fff645..95a95aff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,7 +24,7 @@ There is no `composer install` or `npm install` step. All third-party libraries
| `client/` | The logged-in client portal (contacts of a client). |
| `guest/` | Unauthenticated flows via URL keys (view/pay invoice, view quote/ticket, view shared credentials/files/documents). |
| `api/v1/` | Key-authenticated JSON CRUD API, one directory per module. |
-| `cron/` | Scheduled jobs: `cron.php`, mail queue, ticket email parser, domain/cert refreshers. |
+| `cron/` | Scheduled jobs. `cron.php` is the dispatcher and the only entry in the crontab; everything else in the directory is a job it runs. See [Cron](#cron). |
| `functions.php` + `functions/` | Shared helper functions, split into topical files (`sanitize.php`, `auth.php`, `logging.php`, …) loaded by `functions.php`. New helpers go in the topical file that matches their concern. |
| `includes/` (root) | **Shared** across portals: session/auth bootstrap, DB, layout partials. |
| `post/` (root) | **Shared** POST handlers (logout, misc). |
@@ -77,6 +77,28 @@ Files named `agent/post/*_model.php` hold shared field collection/sanitization l
---
+## Cron
+
+One crontab entry runs everything:
+
+```
+* * * * * php /path/to/itflow/cron/cron.php >/dev/null
+```
+
+`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 a line in the job table at the top of the dispatcher — `'every' => n` for interval jobs, `'daily_at' => 'HH:MM'` for daily ones. The crontab never changes again.
+
+Due-ness is recorded in the `cron_jobs` table rather than matched against the clock, so a job whose minute was missed — machine down, previous run still going — runs at the next opportunity instead of being skipped for the day. A job is claimed *before* it runs, not after: a run that dies half way through is not repeated, which matters because `nightly_tasks.php` generates invoices and charges cards. Each job is also locked individually for the length of its own run (`includes/cron_lock.php`), so a long or hung job holds up only itself — the next minute's dispatch picks up everything else in a second process.
+
+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. **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.
+
+Every script in `cron/` still runs standalone (`php cron/mail_queue.php`) and still takes its own lock when it does, so anything can be run by hand for testing.
+
+---
+
## Security rules (non-negotiable)
ITFlow does not use prepared statements or an ORM; queries are built as strings. That works **only** if every value is neutralized before interpolation. The rules:
diff --git a/admin/database_updates/2.6.0.php b/admin/database_updates/2.6.0.php
new file mode 100644
index 00000000..46678af5
--- /dev/null
+++ b/admin/database_updates/2.6.0.php
@@ -0,0 +1,26 @@
+/dev/null
+ *
+ * It wakes once a minute, works out which jobs are due, and runs them. Adding a job is a
+ * new script in cron/ and a line in the table below - the crontab never has to change again.
+ *
+ * WHAT THE JOBS INHERIT
+ *
+ * Jobs are require'd into this process, so config.php, the timezone and functions.php are
+ * already loaded by the time a job's own require_once lines run and those lines become
+ * no-ops. That is fine for the bootstrap, which is what every job needs anyway, but it
+ * means two things for job code:
+ *
+ * - A job must end itself with cronJobStop(), never exit(). exit() ends the whole cycle
+ * and every job after it in the list.
+ * - Jobs share one global scope. A job must set the variables it reads rather than
+ * assuming the state a fresh process would have given it.
+ *
+ * SCHEDULING
+ *
+ * Due-ness is tracked in the cron_jobs table rather than by matching the clock, so a job
+ * whose minute was missed - machine down, previous run still going, dispatch running a
+ * second or two late - runs at the next opportunity instead of being skipped for the day.
+ * A job is claimed before it runs, not after, so a run that dies half way through is not
+ * repeated: nightly_tasks generates invoices and charges cards.
+ *
+ * Each job is also locked individually for the length of its own run, so a long job (the
+ * nightly run, a slow mailbox) never holds up the every-minute jobs - the next minute's
+ * dispatch picks those up in a second process while the long one is still going.
+ */
+
// Set working directory to the directory this cron script lives at.
chdir(dirname(__FILE__));
@@ -8,1332 +43,148 @@ if (php_sapi_name() !== 'cli') {
die("This script must be run from the command line.\n");
}
-// Prevent overlapping runs of this script
-$cron_lock_script = __FILE__;
-require_once "../includes/cron_lock.php";
+// Tells includes/cron_lock.php and the jobs themselves that they are running under the
+// dispatcher rather than being executed directly. Must be defined before anything else
+// is loaded.
+define('ITFLOW_CRON_DISPATCHER', true);
+require_once "../includes/cron_lock.php";
require_once "../config.php";
// Set Timezone
require_once "../includes/inc_set_timezone.php";
require_once "../functions.php";
-$sql_companies = mysqli_query($mysqli, "SELECT * FROM companies, settings WHERE companies.company_id = settings.company_id AND companies.company_id = 1");
-
-$row = mysqli_fetch_assoc($sql_companies);
-
-// Company Details
-$company_name = escapeSql($row['company_name']);
-$company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']));
-$company_email = escapeSql($row['company_email']);
-$company_website = escapeSql($row['company_website']);
-$company_city = escapeSql($row['company_city']);
-$company_state = escapeSql($row['company_state']);
-$company_country = escapeSql($row['company_country']);
-$company_locale = escapeSql($row['company_locale']);
-$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']);
-$config_invoice_late_fee_enable = intval($row['config_invoice_late_fee_enable']);
-$config_invoice_late_fee_percent = floatval($row['config_invoice_late_fee_percent']);
-
-// Mail Settings
-$config_smtp_provider = escapeSql($row['config_smtp_provider']);
-$config_smtp_host = $row['config_smtp_host'];
-$config_smtp_username = $row['config_smtp_username'];
-$config_smtp_password = $row['config_smtp_password'];
-$config_smtp_port = intval($row['config_smtp_port']);
-$config_smtp_encryption = $row['config_smtp_encryption'];
-$config_mail_from_email = escapeSql($row['config_mail_from_email']);
-$config_mail_from_name = escapeSql($row['config_mail_from_name']);
-$config_recurring_auto_send_invoice = intval($row['config_recurring_auto_send_invoice']);
-
-// Tickets
-$config_ticket_prefix = escapeSql($row['config_ticket_prefix']);
-$config_ticket_from_name = escapeSql($row['config_ticket_from_name']);
-$config_ticket_from_email = escapeSql($row['config_ticket_from_email']);
-$config_ticket_client_general_notifications = intval($row['config_ticket_client_general_notifications']);
-$config_ticket_autoclose_hours = intval($row['config_ticket_autoclose_hours']);
-$config_ticket_new_ticket_notification_email = escapeSql($row['config_ticket_new_ticket_notification_email']);
-
-// Get Config for Telemetry
-$config_theme = $row['config_theme'];
-$config_ticket_email_parse = intval($row['config_ticket_email_parse']);
-$config_module_enable_itdoc = intval($row['config_module_enable_itdoc']);
-$config_module_enable_ticketing = intval($row['config_module_enable_ticketing']);
-$config_module_enable_accounting = intval($row['config_module_enable_accounting']);
-$config_telemetry = intval($row['config_telemetry']);
-
-// Alerts
-$config_enable_alert_domain_expire = intval($row['config_enable_alert_domain_expire']);
-$config_send_invoice_reminders = intval($row['config_send_invoice_reminders']);
-
-// Remember-me Token Expiry
-$config_login_remember_me_expire = intval($row['config_login_remember_me_expire']);
-
-// Log retention
-$config_log_retention = intval($row['config_log_retention']);
-
-// Set Currency Format
-$currency_format = numfmt_create($company_locale, NumberFormatter::CURRENCY);
-
-// White label
-$config_whitelabel_enabled = intval($row['config_whitelabel_enabled']);
-$config_whitelabel_key = $row['config_whitelabel_key'];
-
-// Check cron is enabled
-if ($config_enable_cron == 0) {
- exit("Cron: is not enabled -- Quitting..");
-}
+/*
+ * The jobs, in the order they run.
+ *
+ * 'every' => n run every n minutes
+ * 'daily_at' => 'HH:MM' run once a day, at or after this time
+ *
+ * Order matters: the every-minute jobs come first so a nightly run cannot delay them, and
+ * mail_queue comes before the jobs that queue mail so nothing sits in the queue for a
+ * minute longer than it has to.
+ *
+ * Each job still checks its own settings (cron enabled, email parsing enabled, and so on)
+ * and stops itself when it has nothing to do, so a disabled feature costs a require and a
+ * settings read.
+ */
+$cron_dispatch_jobs = [
+ ['name' => 'mail_queue', 'script' => 'mail_queue.php', 'every' => 1],
+ ['name' => 'ticket_email_parser', 'script' => 'ticket_email_parser.php', 'every' => 1],
+ ['name' => 'ticket_sla', 'script' => 'ticket_sla.php', 'every' => 1],
+ ['name' => 'domain_refresher', 'script' => 'domain_refresher.php', 'every' => 5],
+ ['name' => 'nightly_tasks', 'script' => 'nightly_tasks.php', 'daily_at' => '03:00'],
+ ['name' => 'certificate_refresher', 'script' => 'certificate_refresher.php', 'daily_at' => '03:30'],
+];
/*
- * ###############################################################################################################
- * STARTUP ACTIONS
- * ###############################################################################################################
+ * Claim a job if it is due. The UPDATE is the claim: two dispatchers racing for the same
+ * job both run it against the same row and the loser matches nothing, which also holds
+ * across two web servers sharing one database, where the file lock would not.
+ *
+ * Every comparison is made against PHP's clock, not the database's, so the schedule follows
+ * the timezone ITFlow is configured for however the database server is set up.
*/
+function cronJobClaim($mysqli, array $job): bool
+{
+ $name = escapeSql($job['name']);
+ $now = date('Y-m-d H:i:s');
-//Logging
-logApp("Cron", "info", "Cron Started");
-
-/*
- * ###############################################################################################################
- * CLEAN UP (OLD) DATA
- * ###############################################################################################################
- */
-
-// Clean-up ticket views table used for collision detection
-mysqli_query($mysqli, "TRUNCATE TABLE ticket_views");
-
-// Clean-up shared items that have been used
-mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_view_limit > 0 AND item_views >= item_view_limit");
-
-// Clean-up shared items that have expired
-mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_expire_at < NOW()");
-
-// Invalidate any password reset links
-mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL WHERE user_archived_at IS NULL");
-mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL"); // TODO: Make this 'expired' tokens only when we actually use expiry
-
-// Clean-up old dismissed notifications
-mysqli_query($mysqli, "DELETE FROM notifications WHERE notification_dismissed_at < CURDATE() - INTERVAL 90 DAY");
-
-// Clean-up mail queue
-mysqli_query($mysqli, "DELETE FROM email_queue WHERE email_queued_at < CURDATE() - INTERVAL 90 DAY");
-
-// Clean-up old remember me tokens
-mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_created_at < CURDATE() - INTERVAL $config_login_remember_me_expire DAY");
-
-// Cleanup old audit logs
-mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
-
-// Cleanup old app/debug logs
-mysqli_query($mysqli, "DELETE FROM app_logs WHERE app_log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
-
-// Cleanup old auth logs
-mysqli_query($mysqli, "DELETE FROM auth_logs WHERE auth_log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
-
-// CLeanup old domain history
-$sql = mysqli_query($mysqli, "SELECT domain_id FROM domains");
-while ($row = mysqli_fetch_assoc($sql)) {
- $domain_id = intval($row['domain_id']);
- mysqli_query($mysqli, "
- DELETE FROM domain_history
- WHERE domain_history_id NOT IN (
- SELECT domain_history_id FROM (
- SELECT domain_history_id FROM domain_history
- WHERE domain_history_domain_id = $domain_id
- ORDER BY domain_history_modified_at DESC
- LIMIT 25
- ) AS recent_entries
- ) AND domain_history_domain_id = $domain_id
- ");
-}
-
-// Logging
-// logAudit("Cron", "Task", "Cron cleaned up old data");
-
-/*
- * ###############################################################################################################
- * ACTION DATA
- * ###############################################################################################################
- */
-
-// Whitelabel - Disable if expired/invalid
-if ($config_whitelabel_enabled && !validateWhitelabelKey($config_whitelabel_key)) {
- mysqli_query($mysqli, "UPDATE settings SET config_whitelabel_enabled = 0, config_whitelabel_key = '' WHERE company_id = 1");
- appNotify("Settings", "White-labelling was disabled due to expired/invalid key", "/admin/settings_modules.php");
-}
-
-
-// GET NOTIFICATIONS
-
-// DOMAINS EXPIRING
-
-if ($config_enable_alert_domain_expire == 1) {
-
- $domainAlertArray = [1,7,45];
-
- foreach ($domainAlertArray as $day) {
-
- //Get Domains Expiring
- $sql = mysqli_query(
- $mysqli,
- "SELECT * FROM domains
- LEFT JOIN clients ON domain_client_id = client_id
- WHERE domain_expire IS NOT NULL AND domain_expire = CURDATE() + INTERVAL $day DAY"
- );
-
- while ($row = mysqli_fetch_assoc($sql)) {
- $domain_id = intval($row['domain_id']);
- $domain_name = escapeSql($row['domain_name']);
- $domain_expire = escapeSql($row['domain_expire']);
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
-
- appNotify("Domain Expiring", "Domain $domain_name for $client_name will expire in $day Days on $domain_expire", "/agent/domains.php?client_id=$client_id", $client_id);
+ // Register the job the first time it is seen - adding a job needs no migration
+ mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = '$name'");
+ if (isset($job['daily_at'])) {
+ // Due once today's scheduled time has passed, unless we have already run since it
+ $threshold = date('Y-m-d') . ' ' . $job['daily_at'] . ':00';
+ if ($now < $threshold) {
+ return false;
}
-
- }
- // Logging
- // logAudit("Cron", "Task", "Cron created notifications for domains expiring");
-}
-
-// CERTIFICATES EXPIRING
-
-$certificateAlertArray = [1,7,45];
-
-foreach ($certificateAlertArray as $day) {
-
- //Get Certs Expiring
- $sql = mysqli_query(
- $mysqli,
- "SELECT * FROM certificates
- LEFT JOIN clients ON certificate_client_id = client_id
- WHERE certificate_expire = CURDATE() + INTERVAL $day DAY"
- );
-
- while ($row = mysqli_fetch_assoc($sql)) {
- $certificate_id = intval($row['certificate_id']);
- $certificate_name = escapeSql($row['certificate_name']);
- $certificate_domain = escapeSql($row['certificate_domain']);
- $certificate_expire = escapeSql($row['certificate_expire']);
- $certificate_public_key = $row['certificate_public_key']; // Sanitize input breaks parsing
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
-
- // Calculate the validity period
- if (!empty($certificate_public_key)) {
- $cert_public_key_obj = openssl_x509_parse($certificate_public_key);
- $validity_days = intval(round(($cert_public_key_obj['validTo_time_t'] - $cert_public_key_obj['validFrom_time_t']) / (60 * 60 * 24)));
-
- // Only raise a notification at 45 days if the certificate is valid for more than 90 days (i.e. not a LE)
-
- if ($day == 45 && $validity_days < 91) {
- // LE certificate - Do nothing here
- echo "Not raising notification for LE certificate $certificate_name expiring in 45 days";
-
- } else {
- // This certificate is either expiring in 1 or 7 days or is a non-LE certificate expiring in 45 days
- appNotify("Certificate Expiring", "Certificate $certificate_name for $client_name will expire in $day day(s) on $certificate_expire", "/agent/certificates.php?client_id=$client_id", $client_id);
- }
-
- } else {
- // No public key - notify anyway as we can't check the validity period
- appNotify("Certificate Expiring", "Certificate $certificate_name for $client_name will expire in $day day(s) on $certificate_expire", "/agent/certificates.php?client_id=$client_id", $client_id);
- }
-
- }
-
-}
-// Logging
-// logAudit("Cron", "Task", "Cron created notifications for certificates expiring");
-
-// Asset Warranties Expiring
-
-$warranty_alert_array = [1,7,45];
-
-foreach ($warranty_alert_array as $day) {
-
- //Get Asset Warranty Expiring
- $sql = mysqli_query(
- $mysqli,
- "SELECT * FROM assets
- LEFT JOIN clients ON asset_client_id = client_id
- WHERE asset_warranty_expire = CURDATE() + INTERVAL $day DAY"
- );
-
- while ($row = mysqli_fetch_assoc($sql)) {
- $asset_id = intval($row['asset_id']);
- $asset_name = escapeSql($row['asset_name']);
- $asset_warranty_expire = escapeSql($row['asset_warranty_expire']);
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
-
- appNotify("Asset Warranty Expiring", "Asset $asset_name warranty for $client_name will expire in $day Days on $asset_warranty_expire", "/agent/assets.php?client_id=$client_id", $client_id);
-
- }
-
-}
-// Logging
-// logAudit("Cron", "Task", "Cron created notifications for asset warranties expiring");
-
-// Notify of New Tickets
-// Get Ticket Pending Assignment
-$sql_tickets_pending_assignment = mysqli_query($mysqli,"SELECT ticket_id FROM tickets WHERE ticket_status = 1");
-
-$tickets_pending_assignment = mysqli_num_rows($sql_tickets_pending_assignment);
-
-if ($tickets_pending_assignment > 0) {
-
- appNotify("Pending Tickets", "There are $tickets_pending_assignment new tickets pending assignment", "/agent/tickets.php?status=New");
-
- // Logging
- logApp("Cron", "info", "Cron created notifications for new tickets that are pending assignment");
-}
-
-// Recurring tickets
-
-// Get recurring tickets for today
-$sql_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets WHERE recurring_ticket_next_run = CURDATE()");
-
-if (mysqli_num_rows($sql_recurring_tickets) > 0) {
- while ($row = mysqli_fetch_assoc($sql_recurring_tickets)) {
-
- $recurring_ticket_id = intval($row['recurring_ticket_id']);
- $subject = escapeSql($row['recurring_ticket_subject']);
- $details = mysqli_real_escape_string($mysqli, $row['recurring_ticket_details']);
- $priority = escapeSql($row['recurring_ticket_priority']);
- $frequency = escapeSql(strtolower($row['recurring_ticket_frequency']));
- $billable = intval($row['recurring_ticket_billable']);
- $created_id = intval($row['recurring_ticket_created_by']);
- $assigned_id = intval($row['recurring_ticket_assigned_to']);
- $client_id = intval($row['recurring_ticket_client_id']);
- $contact_id = intval($row['recurring_ticket_contact_id']);
- $asset_id = intval($row['recurring_ticket_asset_id']);
- $category = intval($row['recurring_ticket_category']);
- $url_key = randomString(32);
-
- $ticket_status = 1; // Default
- if ($assigned_id > 0) {
- $ticket_status = 2; // Set to open if we've auto-assigned an agent
- }
-
- if ($client_id) {
- $client_uri = "&client_id=$client_id";
- } else {
- $client_uri = '';
- }
-
- // Atomically increment and get the new ticket number
- mysqli_query($mysqli, "
- UPDATE settings
- SET
- config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number),
- config_ticket_next_number = config_ticket_next_number + 1
- WHERE company_id = 1
- ");
-
- $ticket_number = mysqli_insert_id($mysqli);
-
- // Raise the ticket
- mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_url_key = '$url_key', ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
- $id = mysqli_insert_id($mysqli);
- applyTicketSla($id);
-
- // Copy Additional Assets from Recurring ticket to new ticket
- mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
- SELECT $id, asset_id
- FROM recurring_ticket_assets
- WHERE recurring_ticket_id = $recurring_ticket_id");
-
- // Copy Tasks from the schedule's own task list
- addTasksFromRecurringTicket($id, $recurring_ticket_id);
-
- // Logging
- logAudit("Ticket", "Create", "Cron created recurring scheduled $frequency ticket - $subject", $client_id, $id);
-
- triggerCustomAction('ticket_create', $id);
-
- // Notifications
-
- // Get client/contact/ticket details
- $sql = mysqli_query(
- $mysqli,
- "SELECT client_name, contact_name, contact_email, ticket_prefix, ticket_number, ticket_priority, ticket_subject, ticket_details FROM tickets
- LEFT JOIN clients ON ticket_client_id = client_id
- LEFT JOIN contacts ON ticket_contact_id = contact_id
- WHERE ticket_id = $id"
- );
- $row = mysqli_fetch_assoc($sql);
-
- $contact_name = escapeSql($row['contact_name']);
- $contact_email = escapeSql($row['contact_email']);
- $client_name = escapeSql($row['client_name']);
- $contact_name = escapeSql($row['contact_name']);
- $contact_email = escapeSql($row['contact_email']);
- $ticket_prefix = escapeSql($row['ticket_prefix']);
- $ticket_number = intval($row['ticket_number']);
- $ticket_priority = escapeSql($row['ticket_priority']);
- $ticket_subject = escapeSql($row['ticket_subject']);
- $ticket_details = mysqli_real_escape_string($mysqli, $row['ticket_details']);
-
- $data = [];
-
- // Notify client by email their ticket has been raised, if general notifications are turned on & there is a valid contact email
- if (!empty($config_smtp_provider) && $config_ticket_client_general_notifications == 1 && filter_var($contact_email, FILTER_VALIDATE_EMAIL)) {
-
- $email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)";
- $email_body = "##- Please type your reply above this line -##
Hello $contact_name,
A ticket regarding \"$ticket_subject\" has been automatically created for you.
--------------------------------
$ticket_details--------------------------------
Ticket: $ticket_prefix$ticket_number
Subject: $ticket_subject
Status: Open
Portal: https://$config_base_url/client/ticket.php?id=$id
--
$company_name - Support
$config_ticket_from_email
$company_phone";
-
- $email = [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $contact_email,
- 'recipient_name' => $contact_name,
- 'subject' => $email_subject,
- 'body' => $email_body
- ];
-
- $data[] = $email;
-
- }
-
- // Notify agent's via the DL address of the new ticket, if it's populated with a valid email
- if (filter_var($config_ticket_new_ticket_notification_email, FILTER_VALIDATE_EMAIL)) {
-
- $email_subject = "ITFlow - New Recurring Ticket - $client_name: $ticket_subject";
- $email_body = "Hello,
This is a notification that a recurring (scheduled) ticket has been raised in ITFlow.
Ticket: $ticket_prefix$ticket_number
Client: $client_name
Priority: $priority
Link: https://$config_base_url/agent/ticket.php?ticket_id=$id$client_uri
--------------------------------
$ticket_subject
$ticket_details";
-
- $email = [
- 'from' => $config_ticket_from_email,
- 'from_name' => $config_ticket_from_name,
- 'recipient' => $config_ticket_new_ticket_notification_email,
- 'recipient_name' => $config_ticket_from_name,
- 'subject' => $email_subject,
- 'body' => $email_body
- ];
-
- $data[] = $email;
- }
-
- // Add to the mail queue
- addToMailQueue($data);
-
- // Set the next run date
- if ($frequency == "three days") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('3 days'));
- } elseif ($frequency == "weekly") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('1 week'));
- } elseif ($frequency == "biweekly") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('2 weeks'));
- } elseif ($frequency == "monthly") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('1 month'));
- } elseif ($frequency == "quarterly") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('3 months'));
- } elseif ($frequency == "biannually") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('6 months'));
- } elseif ($frequency == "annually") {
- $now = new DateTime();
- $next_run = date_add($now, date_interval_create_from_date_string('12 months'));
- }
-
- // Update the run date
- $next_run = $next_run->format('Y-m-d');
- $a = mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_next_run = '$next_run' WHERE recurring_ticket_id = $recurring_ticket_id");
-
- }
-}
-
-// Flag any active recurring "next run" dates that are in the past
-$sql_invalid_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets WHERE recurring_ticket_next_run < CURDATE()");
-while ($row = mysqli_fetch_assoc($sql_invalid_recurring_tickets)) {
- $subject = escapeSql($row['recurring_ticket_subject']);
- appNotify("Ticket", "Recurring ticket $subject next run date is in the past!", "/agent/recurring_tickets.php");
-}
-
-// Logging
-// logAudit("Cron", "Task", "Cron created sent out recurring tickets");
-
-
-// TICKET RESOLUTION/CLOSURE PROCESS
-// Changes tickets status from 'Resolved' >> 'Closed' after a defined interval
-
-$sql_resolved_tickets_to_close = mysqli_query(
- $mysqli,
- "SELECT * FROM tickets
- WHERE ticket_status = 4
- AND ticket_updated_at < NOW() - INTERVAL $config_ticket_autoclose_hours HOUR"
-);
-
-while ($row = mysqli_fetch_assoc($sql_resolved_tickets_to_close)) {
-
- $ticket_id = $row['ticket_id'];
- $ticket_prefix = escapeSql($row['ticket_prefix']);
- $ticket_number = intval($row['ticket_number']);
- $ticket_subject = escapeSql($row['ticket_subject']);
- $ticket_status = escapeSql($row['ticket_status']);
- $ticket_assigned_to = escapeSql($row['ticket_assigned_to']);
- $client_id = intval($row['ticket_client_id']);
-
- mysqli_query($mysqli,"UPDATE tickets SET ticket_status = 5, ticket_closed_at = NOW(), ticket_closed_by = $ticket_assigned_to WHERE ticket_id = $ticket_id");
- syncTicketSlaClock($ticket_id);
-
- //Logging
- logAudit("Ticket", "Closed", "$ticket_prefix$ticket_number auto closed", $client_id, $ticket_id);
-
- triggerCustomAction('ticket_close', $ticket_id);
-
- //TODO: Add client notifs if $config_ticket_client_general_notifications is on
-}
-
-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) {
-
- $sql = mysqli_query(
- $mysqli,
- "SELECT * FROM invoices
- LEFT JOIN clients ON invoice_client_id = client_id
- LEFT JOIN contacts ON clients.client_id = contacts.contact_client_id AND contact_primary = 1
- WHERE invoice_status != 'Draft'
- AND invoice_status != 'Paid'
- AND invoice_status != 'Cancelled'
- AND invoice_status != 'Non-Billable'
- AND DATE_ADD(invoice_due, INTERVAL $day DAY) = CURDATE()
- ORDER BY invoice_number DESC"
- );
-
- while ($row = mysqli_fetch_assoc($sql)) {
- $invoice_id = intval($row['invoice_id']);
- $invoice_prefix = escapeSql($row['invoice_prefix']);
- $invoice_number = intval($row['invoice_number']);
- $invoice_status = escapeSql($row['invoice_status']);
- $invoice_date = escapeSql($row['invoice_date']);
- $invoice_due = escapeSql($row['invoice_due']);
- $invoice_url_key = escapeSql($row['invoice_url_key']);
- $invoice_amount = floatval($row['invoice_amount']);
- $invoice_currency_code = escapeSql($row['invoice_currency_code']);
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
- $contact_name = escapeSql($row['contact_name']);
- $contact_email = escapeSql($row['contact_email']);
-
- // Sum payments already applied, derive the real balance owed
- $sql_paid = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS amount_paid FROM payments WHERE payment_invoice_id = $invoice_id");
- $paid_row = mysqli_fetch_assoc($sql_paid);
- $amount_paid = floatval($paid_row['amount_paid']);
-
- $invoice_balance = $invoice_amount - $amount_paid;
-
- // Nothing actually owed (e.g. paid in full but status lagging) - skip
- if ($invoice_balance <= 0) {
- continue;
- }
-
- // Late Charges
- if ($config_invoice_late_fee_enable == 1 && $day > 1) {
-
- $todays_date = date('Y-m-d');
- $late_fee_amount = ($invoice_balance * $config_invoice_late_fee_percent) / 100;
- $new_invoice_amount = $invoice_amount + $late_fee_amount;
-
- mysqli_query($mysqli, "UPDATE invoices SET invoice_amount = $new_invoice_amount WHERE invoice_id = $invoice_id");
-
- //Insert Items into New Invoice
- mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = 'Late Fee', item_description = '$config_invoice_late_fee_percent% late fee applied on $todays_date', item_quantity = 1, item_price = $late_fee_amount, item_total = $late_fee_amount, item_order = 998, item_invoice_id = $invoice_id");
-
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron applied a late fee of $late_fee_amount', history_invoice_id = $invoice_id");
-
- appNotify("Invoice Late Charge", "Invoice $invoice_prefix$invoice_number for $client_name in the amount of $invoice_amount was charged a late fee of $late_fee_amount", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
-
- // Roll the fee into the balance and total we report below
- $invoice_amount = $new_invoice_amount;
- $invoice_balance = $invoice_balance + $late_fee_amount;
-
- }
-
- appNotify("Invoice Overdue", "Invoice $invoice_prefix$invoice_number for $client_name with a balance of " . numfmt_format_currency($currency_format, $invoice_balance, $invoice_currency_code) . " is overdue by $day days", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
-
- $subject = "Overdue Invoice $invoice_prefix$invoice_number";
-
- // Only show the paid line if a payment has actually been applied
- $paid_line = $amount_paid > 0 ? "Amount Paid: " . numfmt_format_currency($currency_format, $amount_paid, $invoice_currency_code) . "
" : "";
-
- $body = "Hello $contact_name,
Our records indicate that we have not yet received payment in full for the invoice $invoice_prefix$invoice_number. We kindly request that you submit your payment as soon as possible. If you have any questions or concerns, please do not hesitate to contact us at $company_email or $company_phone.
-
- Kindly review the invoice details mentioned below.
Invoice: $invoice_prefix$invoice_number
Issue Date: $invoice_date
Invoice Total: " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . "
$paid_line" . "Balance Due: " . numfmt_format_currency($currency_format, $invoice_balance, $invoice_currency_code) . "
Due Date: $invoice_due
Over Due By: $day Days
To view your invoice, please click here.
--
$company_name - Billing
$config_invoice_from_email
$company_phone";
-
- $mail = addToMailQueue([
- [
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $contact_email,
- 'recipient_name' => $contact_name,
- 'subject' => $subject,
- 'body' => $body
- ]
- ]);
-
- if ($mail === true) {
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Emailed Overdue Invoice', history_invoice_id = $invoice_id");
- } else {
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Failed to send Overdue Invoice', history_invoice_id = $invoice_id");
-
- appNotify("Mail", "Failed to send email to $contact_email");
-
- // Logging
- logApp("Mail", "error", "Failed to send email to $contact_email regarding $subject. $mail");
- }
-
- }
-
- }
-}
-// Logging
-// logAudit("Cron", "Task", "Cron created notifications for past due invoices and sent out notifications to the primary and billing contacts email");
-
-// Send Recurring Invoices that match todays date and are active
-
-//Loop through all recurring that match today's date and is active
-$sql_recurring_invoices = mysqli_query($mysqli, "SELECT * FROM recurring_invoices
- LEFT JOIN recurring_payments ON recurring_invoice_id = recurring_payment_recurring_invoice_id
- LEFT JOIN clients ON client_id = recurring_invoice_client_id
- WHERE recurring_invoice_next_date = CURDATE()
- AND recurring_invoice_status = 1
-");
-
-while ($row = mysqli_fetch_assoc($sql_recurring_invoices)) {
- $recurring_invoice_id = intval($row['recurring_invoice_id']);
- $recurring_invoice_scope = escapeSql($row['recurring_invoice_scope']);
- $recurring_invoice_frequency = validateRecurringFrequency($row['recurring_invoice_frequency']);
- $recurring_invoice_status = escapeSql($row['recurring_invoice_status']);
- $recurring_invoice_last_sent = escapeSql($row['recurring_invoice_last_sent']);
- $recurring_invoice_next_date = escapeSql($row['recurring_invoice_next_date']);
- $recurring_invoice_discount_amount = floatval($row['recurring_invoice_discount_amount']);
- $recurring_invoice_amount = floatval($row['recurring_invoice_amount']);
- $recurring_invoice_currency_code = escapeSql($row['recurring_invoice_currency_code']);
- $recurring_invoice_note = escapeSql($row['recurring_invoice_note']);
- $recurring_invoice_email_notify = intval($row['recurring_invoice_email_notify']);
- $category_id = intval($row['recurring_invoice_category_id']);
- $client_id = intval($row['recurring_invoice_client_id']);
- $client_name = escapeSql($row['client_name']);
- $client_net_terms = intval($row['client_net_terms']);
-
- $recurring_payment_recurring_invoice_id = intval($row['recurring_payment_recurring_invoice_id']);
- $recurring_payment_currency_code = escapeSql($row['recurring_payment_currency_code']);
- $recurring_payment_method = escapeSql($row['recurring_payment_method']);
- $recurring_payment_account_id = intval($row['recurring_payment_account_id']);
-
- // Atomically increment and get the new invoice number
- mysqli_query($mysqli, "
- UPDATE settings
- SET
- config_invoice_next_number = LAST_INSERT_ID(config_invoice_next_number),
- config_invoice_next_number = config_invoice_next_number + 1
- WHERE company_id = 1
- ");
-
- $new_invoice_number = mysqli_insert_id($mysqli);
-
- //Generate a unique URL key for clients to access
- $url_key = randomString(32);
-
- mysqli_query($mysqli, "INSERT INTO invoices SET invoice_prefix = '$config_invoice_prefix', invoice_number = $new_invoice_number, invoice_scope = '$recurring_invoice_scope', invoice_date = CURDATE(), invoice_due = DATE_ADD(CURDATE(), INTERVAL $client_net_terms day), invoice_discount_amount = $recurring_invoice_discount_amount, invoice_amount = $recurring_invoice_amount, invoice_currency_code = '$recurring_invoice_currency_code', invoice_note = '$recurring_invoice_note', invoice_category_id = $category_id, invoice_status = 'Sent', invoice_url_key = '$url_key', invoice_recurring_invoice_id = $recurring_invoice_id, invoice_client_id = $client_id");
-
- $new_invoice_id = mysqli_insert_id($mysqli);
-
- //Copy Items from original recurring invoice to new invoice
- $sql_invoice_items = mysqli_query($mysqli, "SELECT * FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id ORDER BY item_id ASC");
-
- while ($row = mysqli_fetch_assoc($sql_invoice_items)) {
- $item_id = intval($row['item_id']);
- $item_name = escapeSql($row['item_name']); //SQL Escape incase of ,
- $item_description = escapeSql($row['item_description']); //SQL Escape incase of ,
- $item_quantity = floatval($row['item_quantity']);
- $item_price = floatval($row['item_price']);
- $item_subtotal = floatval($row['item_subtotal']);
- $item_tax = floatval($row['item_tax']);
- $item_total = floatval($row['item_total']);
- $item_order = intval($row['item_order']);
- $tax_id = intval($row['item_tax_id']);
-
- //Insert Items into New Invoice
- mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = '$item_name', item_description = '$item_description', item_quantity = $item_quantity, item_price = $item_price, item_subtotal = $item_subtotal, item_tax = $item_tax, item_total = $item_total, item_order = $item_order, item_tax_id = $tax_id, item_invoice_id = $new_invoice_id");
-
- }
-
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Invoice Generated from Recurring!', history_invoice_id = $new_invoice_id");
-
- appNotify("Recurring Sent", "Recurring Invoice $config_invoice_prefix$new_invoice_number for $client_name Sent", "/agent/invoice.php?invoice_id=$new_invoice_id", $client_id);
-
- triggerCustomAction('invoice_create', $new_invoice_id);
-
- //Update recurring dates
-
- mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_last_sent = CURDATE(), recurring_invoice_next_date = DATE_ADD(CURDATE(), INTERVAL 1 $recurring_invoice_frequency) WHERE recurring_invoice_id = $recurring_invoice_id");
-
- // Get details of the newly generated invoice
- $sql = mysqli_query(
- $mysqli,
- "SELECT * FROM invoices
- LEFT JOIN clients ON invoice_client_id = client_id
- LEFT JOIN contacts ON clients.client_id = contacts.contact_client_id AND contact_primary = 1
- WHERE invoice_id = $new_invoice_id"
- );
- $row = mysqli_fetch_assoc($sql);
- $invoice_prefix = escapeSql($row['invoice_prefix']);
- $invoice_number = intval($row['invoice_number']);
- $invoice_scope = escapeSql($row['invoice_scope']);
- $invoice_date = escapeSql($row['invoice_date']);
- $invoice_due = escapeSql($row['invoice_due']);
- $invoice_amount = floatval($row['invoice_amount']);
- $invoice_url_key = escapeSql($row['invoice_url_key']);
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
- $contact_name = escapeSql($row['contact_name']);
- $contact_email = escapeSql($row['contact_email']);
-
- if ($config_recurring_auto_send_invoice == 1 && $recurring_invoice_email_notify == 1) {
-
- $subject = "Invoice $invoice_prefix$invoice_number";
- $body = "Hello $contact_name,
An invoice regarding \"$invoice_scope\" has been generated. Please view the details below.
Invoice: $invoice_prefix$invoice_number
Issue Date: $invoice_date
Total: " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_invoice_currency_code) . "
Due Date: $invoice_due
To view your invoice, please click here.
--
$company_name - Billing
$config_invoice_from_email
$company_phone";
-
- $mail = addToMailQueue([
- [
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $contact_email,
- 'recipient_name' => $contact_name,
- 'subject' => $subject,
- 'body' => $body
- ]
- ]);
-
- if ($mail === true) {
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Emailed Invoice!', history_invoice_id = $new_invoice_id");
- mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Sent', invoice_client_id = $client_id WHERE invoice_id = $new_invoice_id");
-
- } else {
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Draft', history_description = 'Cron Failed to send Invoice!', history_invoice_id = $new_invoice_id");
-
- appNotify("Mail", "Failed to send email to $contact_email");
-
- // Logging
- logApp("Mail", "error", "Failed to send email to $contact_email regarding $subject. $mail");
-
- }
-
- // Send copies of the invoice to any additional billing contacts
- $sql_billing_contacts = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts
- WHERE contact_billing = 1
- AND contact_email != '$contact_email'
- AND contact_client_id = $client_id"
- );
-
- while ($billing_contact = mysqli_fetch_assoc($sql_billing_contacts)) {
- $billing_contact_name = escapeSql($billing_contact['contact_name']);
- $billing_contact_email = escapeSql($billing_contact['contact_email']);
-
- $data = [
- [
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $billing_contact_email,
- 'recipient_name' => $billing_contact_name,
- 'subject' => $subject,
- 'body' => $body
- ]
- ];
-
- addToMailQueue($data);
- }
-
- } //End if Autosend is on
-
-} //End Recurring Invoices Loop
-
-// Start Flag any active recurring "next run" dates that are in the past
-$sql_invalid_recurring_invoices = mysqli_query($mysqli, "SELECT * FROM recurring_invoices WHERE recurring_invoice_next_date < CURDATE() AND recurring_invoice_status = 1");
-while ($row = mysqli_fetch_assoc($sql_invalid_recurring_invoices)) {
- $invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
- $invoice_number = intval($row['recurring_invoice_number']);
- appNotify("Invoice", "Recurring invoice $invoice_prefix$invoice_number next run date is in the past!", "/agent/recurring_invoices.php");
-}
-// End Flag any active recurring "next run" dates that are in the past
-
-
-// Start Recurring Payments
-$sql_recurring_payments = mysqli_query($mysqli, "
- SELECT * FROM recurring_payments
- LEFT JOIN invoices ON invoice_recurring_invoice_id = recurring_payment_recurring_invoice_id
- LEFT JOIN clients ON client_id = invoice_client_id
- LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1
- WHERE invoice_due = CURDATE()
- AND (invoice_status = 'Sent' OR invoice_status = 'Viewed')
-");
-
-while ($row = mysqli_fetch_assoc($sql_recurring_payments)) {
- $invoice_id = intval($row['invoice_id']);
- $invoice_prefix = escapeSql($row['invoice_prefix']);
- $invoice_number = intval($row['invoice_number']);
- $invoice_scope = escapeSql($row['invoice_scope']);
- $invoice_date = escapeSql($row['invoice_date']);
- $invoice_due = escapeSql($row['invoice_due']);
- $invoice_amount = floatval($row['invoice_amount']);
- $invoice_url_key = escapeSql($row['invoice_url_key']);
- $invoice_currency_code = escapeSql($row['invoice_currency_code']);
- $recurring_payment_account_id = intval($row['recurring_payment_account_id']);
- $recurring_payment_method = escapeSql($row['recurring_payment_method']);
- $recurring_payment_currency_code = escapeSql($row['recurring_payment_currency_code']);
- $recurring_payment_saved_payment_id = intval($row['recurring_payment_saved_payment_id']);
- $client_id = intval($row['client_id']);
- $client_name = escapeSql($row['client_name']);
- $contact_name = escapeSql($row['contact_name']);
- $contact_email = escapeSql($row['contact_email']);
-
- // Only attempt autopay if a saved payment method is set
- if ($recurring_payment_saved_payment_id) {
- // Get the saved payment method and provider details
- $saved_payment = mysqli_fetch_assoc(mysqli_query($mysqli, "
- SELECT * FROM client_saved_payment_methods
- LEFT JOIN payment_providers ON saved_payment_provider_id = payment_provider_id
- WHERE saved_payment_id = $recurring_payment_saved_payment_id
- AND saved_payment_client_id = $client_id
- AND payment_provider_active = 1
- LIMIT 1
- "));
-
- if (!$saved_payment) {
- logAudit("Invoice", "Payment", "Failed auto Payment for invoice $invoice_prefix$invoice_number: Saved payment method not found or provider inactive", $client_id, $invoice_id);
- continue;
- }
-
- $provider_id = intval($saved_payment['payment_provider_id']);
- $provider_name = escapeSql($saved_payment['payment_provider_name']);
- $provider_private_key = $saved_payment['payment_provider_private_key'];
- $account_id = intval($saved_payment['payment_provider_account']);
- $saved_payment_description = escapeSql($saved_payment['saved_payment_description']);
- $stripe_payment_method_id = $saved_payment['saved_payment_provider_method'];
-
- // NEW: Get the payment_provider_client (Stripe Customer ID) from client_payment_provider
- $cpp_query = mysqli_query($mysqli, "
- SELECT payment_provider_client FROM client_payment_provider
- WHERE client_id = $client_id
- AND payment_provider_id = $provider_id
- LIMIT 1
- ");
- $cpp_row = mysqli_fetch_assoc($cpp_query);
- $stripe_customer_id = $cpp_row ? escapeSql($cpp_row['payment_provider_client']) : '';
-
- // Stripe
- if ($provider_name === "Stripe") {
- if ($provider_private_key && $stripe_customer_id && $stripe_payment_method_id) {
- require_once __DIR__ . '/../includes/stripe_init.php';
- $stripe = new \Stripe\StripeClient($provider_private_key);
-
- $balance_to_pay = round($invoice_amount, 2);
- $pi_description = "ITFlow: $client_name payment of $recurring_payment_currency_code $balance_to_pay for $invoice_prefix$invoice_number";
-
- try {
- $payment_intent = $stripe->paymentIntents->create([
- 'amount' => intval($balance_to_pay * 100),
- 'currency' => $recurring_payment_currency_code,
- 'customer' => $stripe_customer_id,
- 'payment_method' => $stripe_payment_method_id,
- 'off_session' => true,
- 'confirm' => true,
- 'description' => $pi_description,
- 'metadata' => [
- 'itflow_client_id' => $client_id,
- 'itflow_client_name' => $client_name,
- 'itflow_invoice_number' => $invoice_prefix . $invoice_number,
- 'itflow_invoice_id' => $invoice_id,
- ]
- ]);
-
- $pi_id = escapeSql($payment_intent->id);
- $pi_date = date('Y-m-d', $payment_intent->created);
- $pi_amount_paid = floatval($payment_intent->amount_received / 100);
- $pi_currency = strtoupper(escapeSql($payment_intent->currency));
- $pi_livemode = $payment_intent->livemode;
-
- } catch (Exception $e) {
- $error = $e->getMessage();
- error_log("Stripe payment error - encountered exception during payment intent for invoice ID $invoice_id / $invoice_prefix$invoice_number: $error");
- logApp("Stripe", "error", "Exception during PI for invoice ID $invoice_id: $error");
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Payment failed', history_description = 'Stripe autopay failed due to payment error', history_invoice_id = $invoice_id");
- logAudit("Invoice", "Payment", "Failed auto Payment amount of invoice $invoice_prefix$invoice_number due to Stripe payment error: $error", $client_id, $invoice_id);
- continue;
- }
-
- if ($payment_intent->status == "succeeded" && intval($balance_to_pay) == intval($pi_amount_paid)) {
-
- // Update Invoice Status
- mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
-
- // Add Payment to History
- mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$pi_date', payment_amount = $pi_amount_paid, payment_currency_code = '$pi_currency', payment_account_id = $account_id, payment_method = 'Stripe', payment_reference = 'Stripe - $pi_id', payment_invoice_id = $invoice_id");
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Paid', history_description = 'Online Payment added (autopay)', history_invoice_id = $invoice_id");
-
- // RECEIPT EMAIL
- if (!empty($config_smtp_provider)) {
- $subject = "Payment Received - Invoice $invoice_prefix$invoice_number";
- $body = "Hello $contact_name
We have received online payment for the amount of " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . " for invoice $invoice_prefix$invoice_number. Please keep this email as a receipt for your records.
Amount Paid: " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . "
Thank you for your business!
--
$company_name - Billing Department
$config_invoice_from_email
$company_phone";
-
- $data = [[
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $contact_email,
- 'recipient_name' => $contact_name,
- 'subject' => $subject,
- 'body' => $body,
- ]];
-
- // Internal notification
- if (!empty($config_invoice_paid_notification_email)) {
- $subject_int = "Payment Received - $client_name - Invoice $invoice_prefix$invoice_number";
- $body_int = "This is a notification that an invoice has been paid in ITFlow. Below is a copy of the receipt sent to the client:-
--------
$body";
- $data[] = [
- 'from' => $config_invoice_from_email,
- 'from_name' => $config_invoice_from_name,
- 'recipient' => $config_invoice_paid_notification_email,
- 'recipient_name' => $contact_name,
- 'subject' => $subject_int,
- 'body' => $body_int,
- ];
- }
- $mail = addToMailQueue($data);
- $email_id = mysqli_insert_id($mysqli);
- mysqli_query($mysqli,"INSERT INTO history SET history_status = 'Sent', history_description = 'Payment Receipt sent to mail queue ID: $email_id!', history_invoice_id = $invoice_id");
- logAudit("Invoice", "Payment", "Payment receipt for invoice $invoice_prefix$invoice_number queued to $contact_email Email ID: $email_id", $client_id, $invoice_id);
- }
-
- // LOGGING
- $extended_log_desc = !$pi_livemode ? '(DEV MODE)' : '';
- appNotify("Invoice Paid", "Invoice $invoice_prefix$invoice_number automatically paid", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
- logAudit("Invoice", "Payment", "Auto Stripe payment amount of " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . " added to invoice $invoice_prefix$invoice_number - $pi_id $extended_log_desc", $client_id, $invoice_id);
- triggerCustomAction('invoice_pay', $invoice_id);
-
- } else {
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Payment failed', history_description = 'Stripe autopay failed: Status {$payment_intent->status}', history_invoice_id = $invoice_id");
- logAudit("Invoice", "Payment", "Failed auto Payment for invoice $invoice_prefix$invoice_number. Stripe PI status: {$payment_intent->status}", $client_id, $invoice_id);
- }
- } // End if Stripe creds and IDs
- } // End if Stripe provider
- // Add other provider logic here as needed
} else {
- // Handle Non-payment-provider autopay
- mysqli_query($mysqli, "INSERT INTO payments SET payment_date = CURDATE(), payment_amount = $invoice_amount, payment_currency_code = '$recurring_payment_currency_code', payment_account_id = $recurring_payment_account_id, payment_method = '$recurring_payment_method', payment_reference = 'Paid via AutoPay', payment_invoice_id = $invoice_id");
- $payment_id = mysqli_insert_id($mysqli);
-
- mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
- mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Paid', history_description = 'Payment added via Auto Pay', history_invoice_id = $invoice_id");
- logAudit("Invoice", "Payment", "Auto Payment amount of $recurring_payment_currency_code $invoice_amount added to invoice $invoice_prefix$invoice_number", $client_id, $invoice_id);
+ // Interval jobs get 30 seconds of slack. cron fires on the minute but a run can
+ // start a second or two late, and an exact n-minute comparison would then find the
+ // job 'not due yet' and skip every other cycle.
+ $every = max(1, intval($job['every'] ?? 1));
+ $threshold = date('Y-m-d H:i:s', time() - (($every * 60) - 30));
}
+
+ mysqli_query($mysqli, "UPDATE cron_jobs SET
+ cron_job_last_run_at = '$now',
+ cron_job_last_status = 'Running'
+ WHERE cron_job_name = '$name'
+ AND (cron_job_last_run_at IS NULL OR cron_job_last_run_at < '$threshold')");
+
+ return mysqli_affected_rows($mysqli) === 1;
}
/*
- * Stripe fee reconciliation
- * A payment can complete before Stripe attaches the balance transaction,
- * in which case the fee expense is skipped at payment time. Find recent
- * Stripe payments with no matching fee expense and record the actual fee
- * now that the balance transaction exists.
+ * Record how a job ended. Only ever cosmetic - nothing schedules off the result - but it is
+ * the difference between "cron is broken" and knowing which job broke and when.
*/
-$stripe_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
+function cronJobFinished($mysqli, string $job_name, string $status): void
+{
+ $name = escapeSql($job_name);
+ $status = escapeSql(substr($status, 0, 200));
+ $finished_at = date('Y-m-d H:i:s');
-if ($stripe_provider) {
-
- $provider_private_key = $stripe_provider['payment_provider_private_key'];
- $expense_vendor_id = intval($stripe_provider['payment_provider_expense_vendor']);
- $expense_category_id = intval($stripe_provider['payment_provider_expense_category']);
- $expense_account_id = intval($stripe_provider['payment_provider_account']);
-
- if ($provider_private_key && $expense_vendor_id > 0 && $expense_category_id > 0) {
-
- $sql_missing_fee = mysqli_query($mysqli, "
- SELECT payment_reference, payment_date, payment_amount, invoice_prefix, invoice_number, invoice_client_id
- FROM payments
- LEFT JOIN invoices ON payment_invoice_id = invoice_id
- WHERE payment_reference LIKE 'Stripe - pi\_%'
- AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
- AND NOT EXISTS (
- SELECT 1 FROM expenses WHERE LOCATE(payments.payment_reference, expenses.expense_reference) = 1
- )
- LIMIT 50
- ");
-
- if ($sql_missing_fee && mysqli_num_rows($sql_missing_fee) > 0) {
-
- require_once __DIR__ . '/../includes/stripe_init.php';
- $stripe = new \Stripe\StripeClient($provider_private_key);
-
- while ($missing = mysqli_fetch_assoc($sql_missing_fee)) {
-
- $payment_reference = escapeSql($missing['payment_reference']);
- $payment_date = escapeSql($missing['payment_date']);
- $payment_amount = floatval($missing['payment_amount']);
- $invoice_prefix = escapeSql($missing['invoice_prefix']);
- $invoice_number = intval($missing['invoice_number']);
- $client_id = intval($missing['invoice_client_id']);
-
- $pi_id = str_replace('Stripe - ', '', $missing['payment_reference']);
-
- try {
- $payment_intent = $stripe->paymentIntents->retrieve($pi_id, ['expand' => ['latest_charge.balance_transaction']]);
- } catch (Exception $e) {
- logApp("Stripe", "warning", "Fee reconciliation - could not retrieve $pi_id: " . $e->getMessage());
- continue;
- }
-
- // Actual fee from the balance transaction (null until Stripe attaches it - retried next run)
- $balance_transaction = $payment_intent->latest_charge->balance_transaction ?? null;
- if ($balance_transaction && !is_string($balance_transaction)) {
- $gateway_fee = round($balance_transaction->fee / 100, 2);
- $gateway_fee_currency = escapeSql(strtoupper($balance_transaction->currency));
- mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$payment_date', expense_amount = $gateway_fee, expense_currency_code = '$gateway_fee_currency', expense_account_id = $expense_account_id, expense_vendor_id = $expense_vendor_id, expense_client_id = $client_id, expense_category_id = $expense_category_id, expense_description = 'Stripe fee for Invoice $invoice_prefix$invoice_number payment of $payment_amount', expense_reference = '$payment_reference'");
- logApp("Stripe", "info", "Fee reconciliation - recorded Stripe fee of $gateway_fee for $pi_id");
- }
- // Still-missing balance transactions get picked up on the next run
- }
- }
- }
+ mysqli_query($mysqli, "UPDATE cron_jobs SET
+ cron_job_last_finished_at = '$finished_at',
+ cron_job_last_status = '$status'
+ WHERE cron_job_name = '$name'");
}
-// Recurring Expenses
-// Loop through all recurring expenses that match today's date and is active
-$sql_recurring_expenses = mysqli_query($mysqli, "SELECT * FROM recurring_expenses WHERE recurring_expense_next_date = CURDATE() AND recurring_expense_status = 1");
-
-while ($row = mysqli_fetch_assoc($sql_recurring_expenses)) {
- $recurring_expense_id = intval($row['recurring_expense_id']);
- $recurring_expense_frequency = intval($row['recurring_expense_frequency']);
- $recurring_expense_month = intval($row['recurring_expense_month']);
- $recurring_expense_day = intval($row['recurring_expense_day']);
- $recurring_expense_description = escapeSql($row['recurring_expense_description']);
- $recurring_expense_amount = floatval($row['recurring_expense_amount']);
- $recurring_expense_payment_method = escapeSql($row['recurring_expense_payment_method']);
- $recurring_expense_reference = escapeSql($row['recurring_expense_reference']);
- $recurring_expense_currency_code = escapeSql($row['recurring_expense_currency_code']);
- $recurring_expense_vendor_id = intval($row['recurring_expense_vendor_id']);
- $recurring_expense_category_id = intval($row['recurring_expense_category_id']);
- $recurring_expense_account_id = intval($row['recurring_expense_account_id']);
- $recurring_expense_client_id = intval($row['recurring_expense_client_id']);
-
- // Calculate next billing date based on frequency
- if ($recurring_expense_frequency == 1) { // Monthly
- $next_date_query = "DATE_ADD(CURDATE(), INTERVAL 1 MONTH)";
- } elseif ($recurring_expense_frequency == 2) { // Yearly
- $next_date_query = "DATE(CONCAT(YEAR(CURDATE()) + 1, '-', $recurring_expense_month, '-', $recurring_expense_day))";
- } else {
- // Handle unexpected frequency values. For now, just use current date.
- $next_date_query = "CURDATE()";
+// A fatal error inside a job cannot be caught, and it takes the rest of the cycle with it.
+// Recording which job was running at the time is the only trace of that left behind.
+$cron_dispatch_running = null;
+register_shutdown_function(function () use (&$cron_dispatch_running, $mysqli) {
+ if ($cron_dispatch_running === null) {
+ return;
}
- mysqli_query($mysqli,"INSERT INTO expenses SET expense_date = CURDATE(), expense_amount = $recurring_expense_amount, expense_currency_code = '$recurring_expense_currency_code', expense_account_id = $recurring_expense_account_id, expense_vendor_id = $recurring_expense_vendor_id, expense_client_id = $recurring_expense_client_id, expense_category_id = $recurring_expense_category_id, expense_description = '$recurring_expense_description', expense_reference = '$recurring_expense_reference'");
+ $error = error_get_last();
+ $reason = $error['message'] ?? 'ended unexpectedly';
- $expense_id = mysqli_insert_id($mysqli);
+ cronJobFinished($mysqli, $cron_dispatch_running, "Failed: $reason");
+});
- appNotify("Expense Created", "Expense $recurring_expense_description created from recurring expenses", "/agent/expenses.php", $recurring_expense_client_id);
+foreach ($cron_dispatch_jobs as $cron_dispatch_job) {
- // Update recurring dates using calculated next billing date
+ $cron_dispatch_path = realpath(__DIR__ . '/' . $cron_dispatch_job['script']);
- mysqli_query($mysqli, "UPDATE recurring_expenses SET recurring_expense_last_sent = CURDATE(), recurring_expense_next_date = $next_date_query WHERE recurring_expense_id = $recurring_expense_id");
+ if ($cron_dispatch_path === false) {
+ // A job listed above with no script behind it is a mistake worth hearing about
+ echo "Cron: job '{$cron_dispatch_job['name']}' points at {$cron_dispatch_job['script']}, which does not exist.\n";
+ continue;
+ }
+ // Locked before the schedule is consulted: if the previous run of this job is still
+ // going there is nothing to decide, and its claim already stands.
+ $cron_dispatch_lock = cronLockAcquire($cron_dispatch_path);
+ if ($cron_dispatch_lock === false) {
+ continue;
+ }
-} //End Recurring expenses loop
+ if (!cronJobClaim($mysqli, $cron_dispatch_job)) {
+ cronLockRelease($cron_dispatch_lock);
+ continue;
+ }
-// Flag any active recurring "next run" dates that are in the past
-$sql_invalid_recurring_expenses = mysqli_query($mysqli, "SELECT * FROM recurring_expenses WHERE recurring_expense_next_date < CURDATE() AND recurring_expense_status = 1");
-while ($row = mysqli_fetch_assoc($sql_invalid_recurring_expenses)) {
- $recurring_expense_description = escapeSql($row['recurring_expense_description']);
- appNotify("Expense", "Recurring expense $recurring_expense_description next run date is in the past!", "/agent/recurring_expenses.php");
+ $cron_dispatch_running = $cron_dispatch_job['name'];
+
+ try {
+ require_once $cron_dispatch_path;
+ cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Completed');
+ } catch (CronJobStopped $e) {
+ // The job ended itself early - disabled in settings, nothing configured, nothing to do
+ $reason = $e->getMessage();
+ cronJobFinished($mysqli, $cron_dispatch_job['name'], $reason === '' ? 'Stopped' : "Stopped: $reason");
+ } catch (Throwable $e) {
+ // One job throwing is not a reason to skip the rest of the cycle
+ logApp("Cron", "error", "Cron job {$cron_dispatch_job['name']} failed: " . $e->getMessage());
+ cronJobFinished($mysqli, $cron_dispatch_job['name'], 'Failed: ' . $e->getMessage());
+ }
+
+ $cron_dispatch_running = null;
+
+ cronLockRelease($cron_dispatch_lock);
}
-
-// Logging
-//logApp("Cron", "info", "Cron created expenses from recurring expenses");
-
-// TELEMETRY
-
-if ($config_telemetry > 0 || $config_telemetry == 2) {
-
- $current_version = exec("git rev-parse HEAD");
-
- // Client Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('client_id') AS num FROM clients"));
- $client_count = $row['num'];
-
- // Ticket Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_id') AS num FROM tickets"));
- $ticket_count = $row['num'];
-
- // Recurring Ticket Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_ticket_id') AS num FROM recurring_tickets"));
- $recurring_ticket_count = $row['num'];
-
- // Calendar Event Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('event_id') AS num FROM calendar_events"));
- $calendar_event_count = $row['num'];
-
- // Quote Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('quote_id') AS num FROM quotes"));
- $quote_count = $row['num'];
-
- // Invoice Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('invoice_id') AS num FROM invoices"));
- $invoice_count = $row['num'];
-
- // Revenue Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('revenue_id') AS num FROM revenues"));
- $revenue_count = $row['num'];
-
- // Recurring Invoice Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_invoice_id') AS num FROM recurring_invoices"));
- $recurring_invoice_count = $row['num'];
-
- // Account Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('account_id') AS num FROM accounts"));
- $account_count = $row['num'];
-
- // Tax Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('tax_id') AS num FROM taxes"));
- $tax_count = $row['num'];
-
- // Product Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('product_id') AS num FROM products"));
- $product_count = $row['num'];
-
- // Payment Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('payment_id') AS num FROM payments WHERE payment_invoice_id > 0"));
- $payment_count = $row['num'];
-
- // Company Vendor Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_id') AS num FROM vendors WHERE vendor_client_id = 0"));
- $company_vendor_count = $row['num'];
-
- // Expense Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('expense_id') AS num FROM expenses WHERE expense_vendor_id > 0"));
- $expense_count = $row['num'];
-
- // Trip Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('trip_id') AS num FROM trips"));
- $trip_count = $row['num'];
-
- // Transfer Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('transfer_id') AS num FROM transfers"));
- $transfer_count = $row['num'];
-
- // Contact Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('contact_id') AS num FROM contacts"));
- $contact_count = $row['num'];
-
- // Location Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('location_id') AS num FROM locations"));
- $location_count = $row['num'];
-
- // Asset Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('asset_id') AS num FROM assets"));
- $asset_count = $row['num'];
-
- // Software Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('software_id') AS num FROM software"));
- $software_count = $row['num'];
-
- // Software Template Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('software_template_id') AS num FROM software_templates"));
- $software_template_count = $row['num'];
-
- // Credential Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('credential_id') AS num FROM credentials"));
- $credential_count = $row['num'];
-
- // Network Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('network_id') AS num FROM networks"));
- $network_count = $row['num'];
-
- // Certificate Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('certificate_id') AS num FROM certificates"));
- $certificate_count = $row['num'];
-
- // Domain Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('domain_id') AS num FROM domains"));
- $domain_count = $row['num'];
-
- // Service Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('service_id') AS num FROM services"));
- $service_count = $row['num'];
-
- // Client Vendor Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_id') AS num FROM vendors WHERE vendor_client_id > 0"));
- $client_vendor_count = $row['num'];
-
- // Vendor Template Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_template_id') AS num FROM vendor_templates"));
- $vendor_template_count = $row['num'];
-
- // File Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('file_id') AS num FROM files"));
- $file_count = $row['num'];
-
- // Document Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('document_id') AS num FROM documents"));
- $document_count = $row['num'];
-
- // Document Template Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('document_template_id') AS num FROM document_templates"));
- $document_template_count = $row['num'];
-
- // Shared Item Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('item_id') AS num FROM shared_items"));
- $shared_item_count = $row['num'];
-
- // Company Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('company_id') AS num FROM companies"));
- $company_count = $row['num'];
-
- // User Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('user_id') AS num FROM users"));
- $user_count = $row['num'];
-
- // Category Expense Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Expense'"));
- $category_expense_count = $row['num'];
-
- // Category Income Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Income'"));
- $category_income_count = $row['num'];
-
- // Category Referral Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Referral'"));
- $category_referral_count = $row['num'];
-
- // Category Payment Method Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Payment Method'"));
- $category_payment_method_count = $row['num'];
-
- // Tag Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('tag_id') AS num FROM tags"));
- $tag_count = $row['num'];
-
- // API Key Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('api_key_id') AS num FROM api_keys"));
- $api_key_count = $row['num'];
-
- // Log Count
- $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('log_id') AS num FROM logs"));
- $log_count = $row['num'];
-
- $postdata = http_build_query(
- array(
- 'installation_id' => "$installation_id",
- 'version' => "$current_version",
- 'company_name' => "$company_name",
- 'website' => "$company_website",
- 'city' => "$company_city",
- 'state' => "$company_state",
- 'country' => "$company_country",
- 'currency' => "$company_currency",
- 'client_count' => $client_count,
- 'ticket_count' => $ticket_count,
- 'recurring_ticket_count' => $recurring_ticket_count,
- 'calendar_event_count' => $calendar_event_count,
- 'quote_count' => $quote_count,
- 'invoice_count' => $invoice_count,
- 'revenue_count' => $revenue_count,
- 'recurring_invoice_count' => $recurring_invoice_count,
- 'account_count' => $account_count,
- 'tax_count' => $tax_count,
- 'product_count' => $product_count,
- 'payment_count' => $payment_count,
- 'company_vendor_count' => $company_vendor_count,
- 'expense_count' => $expense_count,
- 'trip_count' => $trip_count,
- 'transfer_count' => $transfer_count,
- 'contact_count' => $contact_count,
- 'location_count' => $location_count,
- 'asset_count' => $asset_count,
- 'software_count' => $software_count,
- 'software_template_count' => $software_template_count,
- 'credential_count' => $credential_count,
- 'network_count' => $network_count,
- 'certificate_count' => $certificate_count,
- 'domain_count' => $domain_count,
- 'service_count' => $service_count,
- 'client_vendor_count' => $client_vendor_count,
- 'vendor_template_count' => $vendor_template_count,
- 'file_count' => $file_count,
- 'document_count' => $document_count,
- 'document_template_count' => $document_template_count,
- 'shared_item_count' => $shared_item_count,
- 'company_count' => $company_count,
- 'user_count' => $user_count,
- 'category_expense_count' => $category_expense_count,
- 'category_income_count' => $category_income_count,
- 'category_referral_count' => $category_referral_count,
- 'category_payment_method_count' => $category_payment_method_count,
- 'tag_count' => $tag_count,
- 'api_key_count' => $api_key_count,
- 'log_count' => $log_count,
- 'config_theme' => "$config_theme",
- 'config_enable_cron' => $config_enable_cron,
- 'config_ticket_email_parse' => $config_ticket_email_parse,
- 'config_module_enable_itdoc' => $config_module_enable_itdoc,
- 'config_module_enable_ticketing' => $config_module_enable_ticketing,
- 'config_module_enable_accounting' => $config_module_enable_accounting,
- 'config_telemetry' => $config_telemetry,
- 'collection_method' => 3
- )
- );
-
- $opts = array('http' =>
- array(
- 'method' => 'POST',
- 'header' => 'Content-type: application/x-www-form-urlencoded',
- 'content' => $postdata
- )
- );
-
- $context = stream_context_create($opts);
-
- $result = file_get_contents('https://telemetry.itflow.org', false, $context);
-
- // Logging
- // logAudit("Cron", "Task", "Cron sent telemetry results to ITFlow Developers");
-
-}
-
-
-// Fetch Updates
-$updates = checkForUpdates();
-
-$update_message = $updates->update_message;
-
-if ($updates->current_version !== $updates->latest_version) {
- // Send Alert to inform Updates Available
- appNotify("Update", "$update_message", "/admin/update.php");
-}
-
-
-
-/*
- * ###############################################################################################################
- * FINISH UP
- * ###############################################################################################################
- */
-
-// Logging
-logApp("Cron", "info", "Cron executed successfully");
diff --git a/cron/domain_refresher.php b/cron/domain_refresher.php
index abaa7c0e..5b1d8ece 100644
--- a/cron/domain_refresher.php
+++ b/cron/domain_refresher.php
@@ -28,7 +28,7 @@ $config_enable_cron = intval($row['config_enable_cron']);
// Check cron is enabled
if ($config_enable_cron == 0) {
logApp("Cron-Domain-Refresher", "error", "Cron Domain Refresh unable to run - cron not enabled in admin settings.");
- exit("Cron: is not enabled -- Quitting..");
+ cronJobStop("Cron: is not enabled -- Quitting..");
}
/*
diff --git a/cron/mail_queue.php b/cron/mail_queue.php
index 39790ef2..bae5385f 100644
--- a/cron/mail_queue.php
+++ b/cron/mail_queue.php
@@ -77,12 +77,12 @@ $config_mail_oauth_access_token_expires_at = $row['config_mail_oauth_access_toke
if ($config_enable_cron == 0) {
logApp("Cron-Mail-Queue", "error", "Cron Mail Queue unable to run - cron not enabled in admin settings.");
- exit("Cron: is not enabled -- Quitting..");
+ cronJobStop("Cron: is not enabled -- Quitting..");
}
if (empty($config_smtp_provider)) {
logApp("Cron-Mail-Queue", "info", "SMTP sending skipped: provider not configured.");
- exit(0);
+ cronJobStop();
}
/** =======================================================================
@@ -102,27 +102,6 @@ function tokenIsExpired(?string $expires_at): bool {
return ($ts - 60) <= time();
}
-function httpFormPost(string $url, array $fields): array {
- $ch = curl_init($url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
- curl_setopt($ch, CURLOPT_TIMEOUT, 20);
-
- $raw = curl_exec($ch);
- $err = curl_error($ch);
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-
- curl_close($ch);
-
- return [
- 'ok' => ($raw !== false && $code >= 200 && $code < 300),
- 'body' => $raw,
- 'code' => $code,
- 'err' => $err,
- ];
-}
-
function persistMailOauthTokens(string $access_token, string $expires_at, ?string $refresh_token = null): void {
global $mysqli;
diff --git a/cron/nightly_tasks.php b/cron/nightly_tasks.php
new file mode 100644
index 00000000..127ae7aa
--- /dev/null
+++ b/cron/nightly_tasks.php
@@ -0,0 +1,1347 @@
+ 0 AND item_views >= item_view_limit");
+
+// Clean-up shared items that have expired
+mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_expire_at < NOW()");
+
+// Invalidate any password reset links
+mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL WHERE user_archived_at IS NULL");
+mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL"); // TODO: Make this 'expired' tokens only when we actually use expiry
+
+// Clean-up old dismissed notifications
+mysqli_query($mysqli, "DELETE FROM notifications WHERE notification_dismissed_at < CURDATE() - INTERVAL 90 DAY");
+
+// Clean-up mail queue
+mysqli_query($mysqli, "DELETE FROM email_queue WHERE email_queued_at < CURDATE() - INTERVAL 90 DAY");
+
+// Clean-up old remember me tokens
+mysqli_query($mysqli, "DELETE FROM remember_tokens WHERE remember_token_created_at < CURDATE() - INTERVAL $config_login_remember_me_expire DAY");
+
+// Cleanup old audit logs
+mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
+
+// Cleanup old app/debug logs
+mysqli_query($mysqli, "DELETE FROM app_logs WHERE app_log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
+
+// Cleanup old auth logs
+mysqli_query($mysqli, "DELETE FROM auth_logs WHERE auth_log_created_at < CURDATE() - INTERVAL $config_log_retention DAY");
+
+// CLeanup old domain history
+$sql = mysqli_query($mysqli, "SELECT domain_id FROM domains");
+while ($row = mysqli_fetch_assoc($sql)) {
+ $domain_id = intval($row['domain_id']);
+ mysqli_query($mysqli, "
+ DELETE FROM domain_history
+ WHERE domain_history_id NOT IN (
+ SELECT domain_history_id FROM (
+ SELECT domain_history_id FROM domain_history
+ WHERE domain_history_domain_id = $domain_id
+ ORDER BY domain_history_modified_at DESC
+ LIMIT 25
+ ) AS recent_entries
+ ) AND domain_history_domain_id = $domain_id
+ ");
+}
+
+// Logging
+// logAudit("Cron", "Task", "Cron cleaned up old data");
+
+/*
+ * ###############################################################################################################
+ * ACTION DATA
+ * ###############################################################################################################
+ */
+
+// Whitelabel - Disable if expired/invalid
+if ($config_whitelabel_enabled && !validateWhitelabelKey($config_whitelabel_key)) {
+ mysqli_query($mysqli, "UPDATE settings SET config_whitelabel_enabled = 0, config_whitelabel_key = '' WHERE company_id = 1");
+ appNotify("Settings", "White-labelling was disabled due to expired/invalid key", "/admin/settings_modules.php");
+}
+
+
+// GET NOTIFICATIONS
+
+// DOMAINS EXPIRING
+
+if ($config_enable_alert_domain_expire == 1) {
+
+ $domainAlertArray = [1,7,45];
+
+ foreach ($domainAlertArray as $day) {
+
+ //Get Domains Expiring
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM domains
+ LEFT JOIN clients ON domain_client_id = client_id
+ WHERE domain_expire IS NOT NULL AND domain_expire = CURDATE() + INTERVAL $day DAY"
+ );
+
+ while ($row = mysqli_fetch_assoc($sql)) {
+ $domain_id = intval($row['domain_id']);
+ $domain_name = escapeSql($row['domain_name']);
+ $domain_expire = escapeSql($row['domain_expire']);
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+
+ appNotify("Domain Expiring", "Domain $domain_name for $client_name will expire in $day Days on $domain_expire", "/agent/domains.php?client_id=$client_id", $client_id);
+
+ }
+
+ }
+ // Logging
+ // logAudit("Cron", "Task", "Cron created notifications for domains expiring");
+}
+
+// CERTIFICATES EXPIRING
+
+$certificateAlertArray = [1,7,45];
+
+foreach ($certificateAlertArray as $day) {
+
+ //Get Certs Expiring
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM certificates
+ LEFT JOIN clients ON certificate_client_id = client_id
+ WHERE certificate_expire = CURDATE() + INTERVAL $day DAY"
+ );
+
+ while ($row = mysqli_fetch_assoc($sql)) {
+ $certificate_id = intval($row['certificate_id']);
+ $certificate_name = escapeSql($row['certificate_name']);
+ $certificate_domain = escapeSql($row['certificate_domain']);
+ $certificate_expire = escapeSql($row['certificate_expire']);
+ $certificate_public_key = $row['certificate_public_key']; // Sanitize input breaks parsing
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+
+ // Calculate the validity period
+ if (!empty($certificate_public_key)) {
+ $cert_public_key_obj = openssl_x509_parse($certificate_public_key);
+ $validity_days = intval(round(($cert_public_key_obj['validTo_time_t'] - $cert_public_key_obj['validFrom_time_t']) / (60 * 60 * 24)));
+
+ // Only raise a notification at 45 days if the certificate is valid for more than 90 days (i.e. not a LE)
+
+ if ($day == 45 && $validity_days < 91) {
+ // LE certificate - Do nothing here
+ echo "Not raising notification for LE certificate $certificate_name expiring in 45 days";
+
+ } else {
+ // This certificate is either expiring in 1 or 7 days or is a non-LE certificate expiring in 45 days
+ appNotify("Certificate Expiring", "Certificate $certificate_name for $client_name will expire in $day day(s) on $certificate_expire", "/agent/certificates.php?client_id=$client_id", $client_id);
+ }
+
+ } else {
+ // No public key - notify anyway as we can't check the validity period
+ appNotify("Certificate Expiring", "Certificate $certificate_name for $client_name will expire in $day day(s) on $certificate_expire", "/agent/certificates.php?client_id=$client_id", $client_id);
+ }
+
+ }
+
+}
+// Logging
+// logAudit("Cron", "Task", "Cron created notifications for certificates expiring");
+
+// Asset Warranties Expiring
+
+$warranty_alert_array = [1,7,45];
+
+foreach ($warranty_alert_array as $day) {
+
+ //Get Asset Warranty Expiring
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM assets
+ LEFT JOIN clients ON asset_client_id = client_id
+ WHERE asset_warranty_expire = CURDATE() + INTERVAL $day DAY"
+ );
+
+ while ($row = mysqli_fetch_assoc($sql)) {
+ $asset_id = intval($row['asset_id']);
+ $asset_name = escapeSql($row['asset_name']);
+ $asset_warranty_expire = escapeSql($row['asset_warranty_expire']);
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+
+ appNotify("Asset Warranty Expiring", "Asset $asset_name warranty for $client_name will expire in $day Days on $asset_warranty_expire", "/agent/assets.php?client_id=$client_id", $client_id);
+
+ }
+
+}
+// Logging
+// logAudit("Cron", "Task", "Cron created notifications for asset warranties expiring");
+
+// Notify of New Tickets
+// Get Ticket Pending Assignment
+$sql_tickets_pending_assignment = mysqli_query($mysqli,"SELECT ticket_id FROM tickets WHERE ticket_status = 1");
+
+$tickets_pending_assignment = mysqli_num_rows($sql_tickets_pending_assignment);
+
+if ($tickets_pending_assignment > 0) {
+
+ appNotify("Pending Tickets", "There are $tickets_pending_assignment new tickets pending assignment", "/agent/tickets.php?status=New");
+
+ // Logging
+ logApp("Cron", "info", "Cron created notifications for new tickets that are pending assignment");
+}
+
+// Recurring tickets
+
+// Get recurring tickets for today
+$sql_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets WHERE recurring_ticket_next_run = CURDATE()");
+
+if (mysqli_num_rows($sql_recurring_tickets) > 0) {
+ while ($row = mysqli_fetch_assoc($sql_recurring_tickets)) {
+
+ $recurring_ticket_id = intval($row['recurring_ticket_id']);
+ $subject = escapeSql($row['recurring_ticket_subject']);
+ $details = mysqli_real_escape_string($mysqli, $row['recurring_ticket_details']);
+ $priority = escapeSql($row['recurring_ticket_priority']);
+ $frequency = escapeSql(strtolower($row['recurring_ticket_frequency']));
+ $billable = intval($row['recurring_ticket_billable']);
+ $created_id = intval($row['recurring_ticket_created_by']);
+ $assigned_id = intval($row['recurring_ticket_assigned_to']);
+ $client_id = intval($row['recurring_ticket_client_id']);
+ $contact_id = intval($row['recurring_ticket_contact_id']);
+ $asset_id = intval($row['recurring_ticket_asset_id']);
+ $category = intval($row['recurring_ticket_category']);
+ $url_key = randomString(32);
+
+ $ticket_status = 1; // Default
+ if ($assigned_id > 0) {
+ $ticket_status = 2; // Set to open if we've auto-assigned an agent
+ }
+
+ if ($client_id) {
+ $client_uri = "&client_id=$client_id";
+ } else {
+ $client_uri = '';
+ }
+
+ // Atomically increment and get the new ticket number
+ mysqli_query($mysqli, "
+ UPDATE settings
+ SET
+ config_ticket_next_number = LAST_INSERT_ID(config_ticket_next_number),
+ config_ticket_next_number = config_ticket_next_number + 1
+ WHERE company_id = 1
+ ");
+
+ $ticket_number = mysqli_insert_id($mysqli);
+
+ // Raise the ticket
+ mysqli_query($mysqli, "INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'Recurring', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = '$ticket_status', ticket_billable = $billable, ticket_url_key = '$url_key', ticket_created_by = $created_id, ticket_assigned_to = $assigned_id, ticket_contact_id = $contact_id, ticket_client_id = $client_id, ticket_asset_id = $asset_id, ticket_category = $category, ticket_recurring_ticket_id = $recurring_ticket_id");
+ $id = mysqli_insert_id($mysqli);
+ applyTicketSla($id);
+
+ // Copy Additional Assets from Recurring ticket to new ticket
+ mysqli_query($mysqli, "INSERT INTO ticket_assets (ticket_id, asset_id)
+ SELECT $id, asset_id
+ FROM recurring_ticket_assets
+ WHERE recurring_ticket_id = $recurring_ticket_id");
+
+ // Copy Tasks from the schedule's own task list
+ addTasksFromRecurringTicket($id, $recurring_ticket_id);
+
+ // Logging
+ logAudit("Ticket", "Create", "Cron created recurring scheduled $frequency ticket - $subject", $client_id, $id);
+
+ triggerCustomAction('ticket_create', $id);
+
+ // Notifications
+
+ // Get client/contact/ticket details
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT client_name, contact_name, contact_email, ticket_prefix, ticket_number, ticket_priority, ticket_subject, ticket_details FROM tickets
+ LEFT JOIN clients ON ticket_client_id = client_id
+ LEFT JOIN contacts ON ticket_contact_id = contact_id
+ WHERE ticket_id = $id"
+ );
+ $row = mysqli_fetch_assoc($sql);
+
+ $contact_name = escapeSql($row['contact_name']);
+ $contact_email = escapeSql($row['contact_email']);
+ $client_name = escapeSql($row['client_name']);
+ $contact_name = escapeSql($row['contact_name']);
+ $contact_email = escapeSql($row['contact_email']);
+ $ticket_prefix = escapeSql($row['ticket_prefix']);
+ $ticket_number = intval($row['ticket_number']);
+ $ticket_priority = escapeSql($row['ticket_priority']);
+ $ticket_subject = escapeSql($row['ticket_subject']);
+ $ticket_details = mysqli_real_escape_string($mysqli, $row['ticket_details']);
+
+ $data = [];
+
+ // Notify client by email their ticket has been raised, if general notifications are turned on & there is a valid contact email
+ if (!empty($config_smtp_provider) && $config_ticket_client_general_notifications == 1 && filter_var($contact_email, FILTER_VALIDATE_EMAIL)) {
+
+ $email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)";
+ $email_body = "##- Please type your reply above this line -##
Hello $contact_name,
A ticket regarding \"$ticket_subject\" has been automatically created for you.
--------------------------------
$ticket_details--------------------------------
Ticket: $ticket_prefix$ticket_number
Subject: $ticket_subject
Status: Open
Portal: https://$config_base_url/client/ticket.php?id=$id
--
$company_name - Support
$config_ticket_from_email
$company_phone";
+
+ $email = [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $contact_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $email_subject,
+ 'body' => $email_body
+ ];
+
+ $data[] = $email;
+
+ }
+
+ // Notify agent's via the DL address of the new ticket, if it's populated with a valid email
+ if (filter_var($config_ticket_new_ticket_notification_email, FILTER_VALIDATE_EMAIL)) {
+
+ $email_subject = "ITFlow - New Recurring Ticket - $client_name: $ticket_subject";
+ $email_body = "Hello,
This is a notification that a recurring (scheduled) ticket has been raised in ITFlow.
Ticket: $ticket_prefix$ticket_number
Client: $client_name
Priority: $priority
Link: https://$config_base_url/agent/ticket.php?ticket_id=$id$client_uri
--------------------------------
$ticket_subject
$ticket_details";
+
+ $email = [
+ 'from' => $config_ticket_from_email,
+ 'from_name' => $config_ticket_from_name,
+ 'recipient' => $config_ticket_new_ticket_notification_email,
+ 'recipient_name' => $config_ticket_from_name,
+ 'subject' => $email_subject,
+ 'body' => $email_body
+ ];
+
+ $data[] = $email;
+ }
+
+ // Add to the mail queue
+ addToMailQueue($data);
+
+ // Set the next run date
+ if ($frequency == "three days") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('3 days'));
+ } elseif ($frequency == "weekly") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('1 week'));
+ } elseif ($frequency == "biweekly") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('2 weeks'));
+ } elseif ($frequency == "monthly") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('1 month'));
+ } elseif ($frequency == "quarterly") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('3 months'));
+ } elseif ($frequency == "biannually") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('6 months'));
+ } elseif ($frequency == "annually") {
+ $now = new DateTime();
+ $next_run = date_add($now, date_interval_create_from_date_string('12 months'));
+ }
+
+ // Update the run date
+ $next_run = $next_run->format('Y-m-d');
+ $a = mysqli_query($mysqli, "UPDATE recurring_tickets SET recurring_ticket_next_run = '$next_run' WHERE recurring_ticket_id = $recurring_ticket_id");
+
+ }
+}
+
+// Flag any active recurring "next run" dates that are in the past
+$sql_invalid_recurring_tickets = mysqli_query($mysqli, "SELECT * FROM recurring_tickets WHERE recurring_ticket_next_run < CURDATE()");
+while ($row = mysqli_fetch_assoc($sql_invalid_recurring_tickets)) {
+ $subject = escapeSql($row['recurring_ticket_subject']);
+ appNotify("Ticket", "Recurring ticket $subject next run date is in the past!", "/agent/recurring_tickets.php");
+}
+
+// Logging
+// logAudit("Cron", "Task", "Cron created sent out recurring tickets");
+
+
+// TICKET RESOLUTION/CLOSURE PROCESS
+// Changes tickets status from 'Resolved' >> 'Closed' after a defined interval
+
+$sql_resolved_tickets_to_close = mysqli_query(
+ $mysqli,
+ "SELECT * FROM tickets
+ WHERE ticket_status = 4
+ AND ticket_updated_at < NOW() - INTERVAL $config_ticket_autoclose_hours HOUR"
+);
+
+while ($row = mysqli_fetch_assoc($sql_resolved_tickets_to_close)) {
+
+ $ticket_id = $row['ticket_id'];
+ $ticket_prefix = escapeSql($row['ticket_prefix']);
+ $ticket_number = intval($row['ticket_number']);
+ $ticket_subject = escapeSql($row['ticket_subject']);
+ $ticket_status = escapeSql($row['ticket_status']);
+ $ticket_assigned_to = escapeSql($row['ticket_assigned_to']);
+ $client_id = intval($row['ticket_client_id']);
+
+ mysqli_query($mysqli,"UPDATE tickets SET ticket_status = 5, ticket_closed_at = NOW(), ticket_closed_by = $ticket_assigned_to WHERE ticket_id = $ticket_id");
+ syncTicketSlaClock($ticket_id);
+
+ //Logging
+ logAudit("Ticket", "Closed", "$ticket_prefix$ticket_number auto closed", $client_id, $ticket_id);
+
+ triggerCustomAction('ticket_close', $ticket_id);
+
+ //TODO: Add client notifs if $config_ticket_client_general_notifications is on
+}
+
+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) {
+
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM invoices
+ LEFT JOIN clients ON invoice_client_id = client_id
+ LEFT JOIN contacts ON clients.client_id = contacts.contact_client_id AND contact_primary = 1
+ WHERE invoice_status != 'Draft'
+ AND invoice_status != 'Paid'
+ AND invoice_status != 'Cancelled'
+ AND invoice_status != 'Non-Billable'
+ AND DATE_ADD(invoice_due, INTERVAL $day DAY) = CURDATE()
+ ORDER BY invoice_number DESC"
+ );
+
+ while ($row = mysqli_fetch_assoc($sql)) {
+ $invoice_id = intval($row['invoice_id']);
+ $invoice_prefix = escapeSql($row['invoice_prefix']);
+ $invoice_number = intval($row['invoice_number']);
+ $invoice_status = escapeSql($row['invoice_status']);
+ $invoice_date = escapeSql($row['invoice_date']);
+ $invoice_due = escapeSql($row['invoice_due']);
+ $invoice_url_key = escapeSql($row['invoice_url_key']);
+ $invoice_amount = floatval($row['invoice_amount']);
+ $invoice_currency_code = escapeSql($row['invoice_currency_code']);
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+ $contact_name = escapeSql($row['contact_name']);
+ $contact_email = escapeSql($row['contact_email']);
+
+ // Sum payments already applied, derive the real balance owed
+ $sql_paid = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS amount_paid FROM payments WHERE payment_invoice_id = $invoice_id");
+ $paid_row = mysqli_fetch_assoc($sql_paid);
+ $amount_paid = floatval($paid_row['amount_paid']);
+
+ $invoice_balance = $invoice_amount - $amount_paid;
+
+ // Nothing actually owed (e.g. paid in full but status lagging) - skip
+ if ($invoice_balance <= 0) {
+ continue;
+ }
+
+ // Late Charges
+ if ($config_invoice_late_fee_enable == 1 && $day > 1) {
+
+ $todays_date = date('Y-m-d');
+ $late_fee_amount = ($invoice_balance * $config_invoice_late_fee_percent) / 100;
+ $new_invoice_amount = $invoice_amount + $late_fee_amount;
+
+ mysqli_query($mysqli, "UPDATE invoices SET invoice_amount = $new_invoice_amount WHERE invoice_id = $invoice_id");
+
+ //Insert Items into New Invoice
+ mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = 'Late Fee', item_description = '$config_invoice_late_fee_percent% late fee applied on $todays_date', item_quantity = 1, item_price = $late_fee_amount, item_total = $late_fee_amount, item_order = 998, item_invoice_id = $invoice_id");
+
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron applied a late fee of $late_fee_amount', history_invoice_id = $invoice_id");
+
+ appNotify("Invoice Late Charge", "Invoice $invoice_prefix$invoice_number for $client_name in the amount of $invoice_amount was charged a late fee of $late_fee_amount", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
+
+ // Roll the fee into the balance and total we report below
+ $invoice_amount = $new_invoice_amount;
+ $invoice_balance = $invoice_balance + $late_fee_amount;
+
+ }
+
+ appNotify("Invoice Overdue", "Invoice $invoice_prefix$invoice_number for $client_name with a balance of " . numfmt_format_currency($currency_format, $invoice_balance, $invoice_currency_code) . " is overdue by $day days", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
+
+ $subject = "Overdue Invoice $invoice_prefix$invoice_number";
+
+ // Only show the paid line if a payment has actually been applied
+ $paid_line = $amount_paid > 0 ? "Amount Paid: " . numfmt_format_currency($currency_format, $amount_paid, $invoice_currency_code) . "
" : "";
+
+ $body = "Hello $contact_name,
Our records indicate that we have not yet received payment in full for the invoice $invoice_prefix$invoice_number. We kindly request that you submit your payment as soon as possible. If you have any questions or concerns, please do not hesitate to contact us at $company_email or $company_phone.
+
+ Kindly review the invoice details mentioned below.
Invoice: $invoice_prefix$invoice_number
Issue Date: $invoice_date
Invoice Total: " . numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) . "
$paid_line" . "Balance Due: " . numfmt_format_currency($currency_format, $invoice_balance, $invoice_currency_code) . "
Due Date: $invoice_due
Over Due By: $day Days
To view your invoice, please click here.
--
$company_name - Billing
$config_invoice_from_email
$company_phone";
+
+ $mail = addToMailQueue([
+ [
+ 'from' => $config_invoice_from_email,
+ 'from_name' => $config_invoice_from_name,
+ 'recipient' => $contact_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $subject,
+ 'body' => $body
+ ]
+ ]);
+
+ if ($mail === true) {
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Emailed Overdue Invoice', history_invoice_id = $invoice_id");
+ } else {
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Failed to send Overdue Invoice', history_invoice_id = $invoice_id");
+
+ appNotify("Mail", "Failed to send email to $contact_email");
+
+ // Logging
+ logApp("Mail", "error", "Failed to send email to $contact_email regarding $subject. $mail");
+ }
+
+ }
+
+ }
+}
+// Logging
+// logAudit("Cron", "Task", "Cron created notifications for past due invoices and sent out notifications to the primary and billing contacts email");
+
+// Send Recurring Invoices that match todays date and are active
+
+//Loop through all recurring that match today's date and is active
+$sql_recurring_invoices = mysqli_query($mysqli, "SELECT * FROM recurring_invoices
+ LEFT JOIN recurring_payments ON recurring_invoice_id = recurring_payment_recurring_invoice_id
+ LEFT JOIN clients ON client_id = recurring_invoice_client_id
+ WHERE recurring_invoice_next_date = CURDATE()
+ AND recurring_invoice_status = 1
+");
+
+while ($row = mysqli_fetch_assoc($sql_recurring_invoices)) {
+ $recurring_invoice_id = intval($row['recurring_invoice_id']);
+ $recurring_invoice_scope = escapeSql($row['recurring_invoice_scope']);
+ $recurring_invoice_frequency = validateRecurringFrequency($row['recurring_invoice_frequency']);
+ $recurring_invoice_status = escapeSql($row['recurring_invoice_status']);
+ $recurring_invoice_last_sent = escapeSql($row['recurring_invoice_last_sent']);
+ $recurring_invoice_next_date = escapeSql($row['recurring_invoice_next_date']);
+ $recurring_invoice_discount_amount = floatval($row['recurring_invoice_discount_amount']);
+ $recurring_invoice_amount = floatval($row['recurring_invoice_amount']);
+ $recurring_invoice_currency_code = escapeSql($row['recurring_invoice_currency_code']);
+ $recurring_invoice_note = escapeSql($row['recurring_invoice_note']);
+ $recurring_invoice_email_notify = intval($row['recurring_invoice_email_notify']);
+ $category_id = intval($row['recurring_invoice_category_id']);
+ $client_id = intval($row['recurring_invoice_client_id']);
+ $client_name = escapeSql($row['client_name']);
+ $client_net_terms = intval($row['client_net_terms']);
+
+ $recurring_payment_recurring_invoice_id = intval($row['recurring_payment_recurring_invoice_id']);
+ $recurring_payment_currency_code = escapeSql($row['recurring_payment_currency_code']);
+ $recurring_payment_method = escapeSql($row['recurring_payment_method']);
+ $recurring_payment_account_id = intval($row['recurring_payment_account_id']);
+
+ // Atomically increment and get the new invoice number
+ mysqli_query($mysqli, "
+ UPDATE settings
+ SET
+ config_invoice_next_number = LAST_INSERT_ID(config_invoice_next_number),
+ config_invoice_next_number = config_invoice_next_number + 1
+ WHERE company_id = 1
+ ");
+
+ $new_invoice_number = mysqli_insert_id($mysqli);
+
+ //Generate a unique URL key for clients to access
+ $url_key = randomString(32);
+
+ mysqli_query($mysqli, "INSERT INTO invoices SET invoice_prefix = '$config_invoice_prefix', invoice_number = $new_invoice_number, invoice_scope = '$recurring_invoice_scope', invoice_date = CURDATE(), invoice_due = DATE_ADD(CURDATE(), INTERVAL $client_net_terms day), invoice_discount_amount = $recurring_invoice_discount_amount, invoice_amount = $recurring_invoice_amount, invoice_currency_code = '$recurring_invoice_currency_code', invoice_note = '$recurring_invoice_note', invoice_category_id = $category_id, invoice_status = 'Sent', invoice_url_key = '$url_key', invoice_recurring_invoice_id = $recurring_invoice_id, invoice_client_id = $client_id");
+
+ $new_invoice_id = mysqli_insert_id($mysqli);
+
+ //Copy Items from original recurring invoice to new invoice
+ $sql_invoice_items = mysqli_query($mysqli, "SELECT * FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id ORDER BY item_id ASC");
+
+ while ($row = mysqli_fetch_assoc($sql_invoice_items)) {
+ $item_id = intval($row['item_id']);
+ $item_name = escapeSql($row['item_name']); //SQL Escape incase of ,
+ $item_description = escapeSql($row['item_description']); //SQL Escape incase of ,
+ $item_quantity = floatval($row['item_quantity']);
+ $item_price = floatval($row['item_price']);
+ $item_subtotal = floatval($row['item_subtotal']);
+ $item_tax = floatval($row['item_tax']);
+ $item_total = floatval($row['item_total']);
+ $item_order = intval($row['item_order']);
+ $tax_id = intval($row['item_tax_id']);
+
+ //Insert Items into New Invoice
+ mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = '$item_name', item_description = '$item_description', item_quantity = $item_quantity, item_price = $item_price, item_subtotal = $item_subtotal, item_tax = $item_tax, item_total = $item_total, item_order = $item_order, item_tax_id = $tax_id, item_invoice_id = $new_invoice_id");
+
+ }
+
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Invoice Generated from Recurring!', history_invoice_id = $new_invoice_id");
+
+ appNotify("Recurring Sent", "Recurring Invoice $config_invoice_prefix$new_invoice_number for $client_name Sent", "/agent/invoice.php?invoice_id=$new_invoice_id", $client_id);
+
+ triggerCustomAction('invoice_create', $new_invoice_id);
+
+ //Update recurring dates
+
+ mysqli_query($mysqli, "UPDATE recurring_invoices SET recurring_invoice_last_sent = CURDATE(), recurring_invoice_next_date = DATE_ADD(CURDATE(), INTERVAL 1 $recurring_invoice_frequency) WHERE recurring_invoice_id = $recurring_invoice_id");
+
+ // Get details of the newly generated invoice
+ $sql = mysqli_query(
+ $mysqli,
+ "SELECT * FROM invoices
+ LEFT JOIN clients ON invoice_client_id = client_id
+ LEFT JOIN contacts ON clients.client_id = contacts.contact_client_id AND contact_primary = 1
+ WHERE invoice_id = $new_invoice_id"
+ );
+ $row = mysqli_fetch_assoc($sql);
+ $invoice_prefix = escapeSql($row['invoice_prefix']);
+ $invoice_number = intval($row['invoice_number']);
+ $invoice_scope = escapeSql($row['invoice_scope']);
+ $invoice_date = escapeSql($row['invoice_date']);
+ $invoice_due = escapeSql($row['invoice_due']);
+ $invoice_amount = floatval($row['invoice_amount']);
+ $invoice_url_key = escapeSql($row['invoice_url_key']);
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+ $contact_name = escapeSql($row['contact_name']);
+ $contact_email = escapeSql($row['contact_email']);
+
+ if ($config_recurring_auto_send_invoice == 1 && $recurring_invoice_email_notify == 1) {
+
+ $subject = "Invoice $invoice_prefix$invoice_number";
+ $body = "Hello $contact_name,
An invoice regarding \"$invoice_scope\" has been generated. Please view the details below.
Invoice: $invoice_prefix$invoice_number
Issue Date: $invoice_date
Total: " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_invoice_currency_code) . "
Due Date: $invoice_due
To view your invoice, please click here.
--
$company_name - Billing
$config_invoice_from_email
$company_phone";
+
+ $mail = addToMailQueue([
+ [
+ 'from' => $config_invoice_from_email,
+ 'from_name' => $config_invoice_from_name,
+ 'recipient' => $contact_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $subject,
+ 'body' => $body
+ ]
+ ]);
+
+ if ($mail === true) {
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Sent', history_description = 'Cron Emailed Invoice!', history_invoice_id = $new_invoice_id");
+ mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Sent', invoice_client_id = $client_id WHERE invoice_id = $new_invoice_id");
+
+ } else {
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Draft', history_description = 'Cron Failed to send Invoice!', history_invoice_id = $new_invoice_id");
+
+ appNotify("Mail", "Failed to send email to $contact_email");
+
+ // Logging
+ logApp("Mail", "error", "Failed to send email to $contact_email regarding $subject. $mail");
+
+ }
+
+ // Send copies of the invoice to any additional billing contacts
+ $sql_billing_contacts = mysqli_query($mysqli, "SELECT contact_name, contact_email FROM contacts
+ WHERE contact_billing = 1
+ AND contact_email != '$contact_email'
+ AND contact_client_id = $client_id"
+ );
+
+ while ($billing_contact = mysqli_fetch_assoc($sql_billing_contacts)) {
+ $billing_contact_name = escapeSql($billing_contact['contact_name']);
+ $billing_contact_email = escapeSql($billing_contact['contact_email']);
+
+ $data = [
+ [
+ 'from' => $config_invoice_from_email,
+ 'from_name' => $config_invoice_from_name,
+ 'recipient' => $billing_contact_email,
+ 'recipient_name' => $billing_contact_name,
+ 'subject' => $subject,
+ 'body' => $body
+ ]
+ ];
+
+ addToMailQueue($data);
+ }
+
+ } //End if Autosend is on
+
+} //End Recurring Invoices Loop
+
+// Start Flag any active recurring "next run" dates that are in the past
+$sql_invalid_recurring_invoices = mysqli_query($mysqli, "SELECT * FROM recurring_invoices WHERE recurring_invoice_next_date < CURDATE() AND recurring_invoice_status = 1");
+while ($row = mysqli_fetch_assoc($sql_invalid_recurring_invoices)) {
+ $invoice_prefix = escapeSql($row['recurring_invoice_prefix']);
+ $invoice_number = intval($row['recurring_invoice_number']);
+ appNotify("Invoice", "Recurring invoice $invoice_prefix$invoice_number next run date is in the past!", "/agent/recurring_invoices.php");
+}
+// End Flag any active recurring "next run" dates that are in the past
+
+
+// Start Recurring Payments
+$sql_recurring_payments = mysqli_query($mysqli, "
+ SELECT * FROM recurring_payments
+ LEFT JOIN invoices ON invoice_recurring_invoice_id = recurring_payment_recurring_invoice_id
+ LEFT JOIN clients ON client_id = invoice_client_id
+ LEFT JOIN contacts ON client_id = contact_client_id AND contact_primary = 1
+ WHERE invoice_due = CURDATE()
+ AND (invoice_status = 'Sent' OR invoice_status = 'Viewed')
+");
+
+while ($row = mysqli_fetch_assoc($sql_recurring_payments)) {
+ $invoice_id = intval($row['invoice_id']);
+ $invoice_prefix = escapeSql($row['invoice_prefix']);
+ $invoice_number = intval($row['invoice_number']);
+ $invoice_scope = escapeSql($row['invoice_scope']);
+ $invoice_date = escapeSql($row['invoice_date']);
+ $invoice_due = escapeSql($row['invoice_due']);
+ $invoice_amount = floatval($row['invoice_amount']);
+ $invoice_url_key = escapeSql($row['invoice_url_key']);
+ $invoice_currency_code = escapeSql($row['invoice_currency_code']);
+ $recurring_payment_account_id = intval($row['recurring_payment_account_id']);
+ $recurring_payment_method = escapeSql($row['recurring_payment_method']);
+ $recurring_payment_currency_code = escapeSql($row['recurring_payment_currency_code']);
+ $recurring_payment_saved_payment_id = intval($row['recurring_payment_saved_payment_id']);
+ $client_id = intval($row['client_id']);
+ $client_name = escapeSql($row['client_name']);
+ $contact_name = escapeSql($row['contact_name']);
+ $contact_email = escapeSql($row['contact_email']);
+
+ // Only attempt autopay if a saved payment method is set
+ if ($recurring_payment_saved_payment_id) {
+ // Get the saved payment method and provider details
+ $saved_payment = mysqli_fetch_assoc(mysqli_query($mysqli, "
+ SELECT * FROM client_saved_payment_methods
+ LEFT JOIN payment_providers ON saved_payment_provider_id = payment_provider_id
+ WHERE saved_payment_id = $recurring_payment_saved_payment_id
+ AND saved_payment_client_id = $client_id
+ AND payment_provider_active = 1
+ LIMIT 1
+ "));
+
+ if (!$saved_payment) {
+ logAudit("Invoice", "Payment", "Failed auto Payment for invoice $invoice_prefix$invoice_number: Saved payment method not found or provider inactive", $client_id, $invoice_id);
+ continue;
+ }
+
+ $provider_id = intval($saved_payment['payment_provider_id']);
+ $provider_name = escapeSql($saved_payment['payment_provider_name']);
+ $provider_private_key = $saved_payment['payment_provider_private_key'];
+ $account_id = intval($saved_payment['payment_provider_account']);
+ $saved_payment_description = escapeSql($saved_payment['saved_payment_description']);
+ $stripe_payment_method_id = $saved_payment['saved_payment_provider_method'];
+
+ // NEW: Get the payment_provider_client (Stripe Customer ID) from client_payment_provider
+ $cpp_query = mysqli_query($mysqli, "
+ SELECT payment_provider_client FROM client_payment_provider
+ WHERE client_id = $client_id
+ AND payment_provider_id = $provider_id
+ LIMIT 1
+ ");
+ $cpp_row = mysqli_fetch_assoc($cpp_query);
+ $stripe_customer_id = $cpp_row ? escapeSql($cpp_row['payment_provider_client']) : '';
+
+ // Stripe
+ if ($provider_name === "Stripe") {
+ if ($provider_private_key && $stripe_customer_id && $stripe_payment_method_id) {
+ require_once __DIR__ . '/../includes/stripe_init.php';
+ $stripe = new \Stripe\StripeClient($provider_private_key);
+
+ $balance_to_pay = round($invoice_amount, 2);
+ $pi_description = "ITFlow: $client_name payment of $recurring_payment_currency_code $balance_to_pay for $invoice_prefix$invoice_number";
+
+ try {
+ $payment_intent = $stripe->paymentIntents->create([
+ 'amount' => intval($balance_to_pay * 100),
+ 'currency' => $recurring_payment_currency_code,
+ 'customer' => $stripe_customer_id,
+ 'payment_method' => $stripe_payment_method_id,
+ 'off_session' => true,
+ 'confirm' => true,
+ 'description' => $pi_description,
+ 'metadata' => [
+ 'itflow_client_id' => $client_id,
+ 'itflow_client_name' => $client_name,
+ 'itflow_invoice_number' => $invoice_prefix . $invoice_number,
+ 'itflow_invoice_id' => $invoice_id,
+ ]
+ ]);
+
+ $pi_id = escapeSql($payment_intent->id);
+ $pi_date = date('Y-m-d', $payment_intent->created);
+ $pi_amount_paid = floatval($payment_intent->amount_received / 100);
+ $pi_currency = strtoupper(escapeSql($payment_intent->currency));
+ $pi_livemode = $payment_intent->livemode;
+
+ } catch (Exception $e) {
+ $error = $e->getMessage();
+ error_log("Stripe payment error - encountered exception during payment intent for invoice ID $invoice_id / $invoice_prefix$invoice_number: $error");
+ logApp("Stripe", "error", "Exception during PI for invoice ID $invoice_id: $error");
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Payment failed', history_description = 'Stripe autopay failed due to payment error', history_invoice_id = $invoice_id");
+ logAudit("Invoice", "Payment", "Failed auto Payment amount of invoice $invoice_prefix$invoice_number due to Stripe payment error: $error", $client_id, $invoice_id);
+ continue;
+ }
+
+ if ($payment_intent->status == "succeeded" && intval($balance_to_pay) == intval($pi_amount_paid)) {
+
+ // Update Invoice Status
+ mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
+
+ // Add Payment to History
+ mysqli_query($mysqli, "INSERT INTO payments SET payment_date = '$pi_date', payment_amount = $pi_amount_paid, payment_currency_code = '$pi_currency', payment_account_id = $account_id, payment_method = 'Stripe', payment_reference = 'Stripe - $pi_id', payment_invoice_id = $invoice_id");
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Paid', history_description = 'Online Payment added (autopay)', history_invoice_id = $invoice_id");
+
+ // RECEIPT EMAIL
+ if (!empty($config_smtp_provider)) {
+ $subject = "Payment Received - Invoice $invoice_prefix$invoice_number";
+ $body = "Hello $contact_name
We have received online payment for the amount of " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . " for invoice $invoice_prefix$invoice_number. Please keep this email as a receipt for your records.
Amount Paid: " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . "
Thank you for your business!
--
$company_name - Billing Department
$config_invoice_from_email
$company_phone";
+
+ $data = [[
+ 'from' => $config_invoice_from_email,
+ 'from_name' => $config_invoice_from_name,
+ 'recipient' => $contact_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $subject,
+ 'body' => $body,
+ ]];
+
+ // Internal notification
+ if (!empty($config_invoice_paid_notification_email)) {
+ $subject_int = "Payment Received - $client_name - Invoice $invoice_prefix$invoice_number";
+ $body_int = "This is a notification that an invoice has been paid in ITFlow. Below is a copy of the receipt sent to the client:-
--------
$body";
+ $data[] = [
+ 'from' => $config_invoice_from_email,
+ 'from_name' => $config_invoice_from_name,
+ 'recipient' => $config_invoice_paid_notification_email,
+ 'recipient_name' => $contact_name,
+ 'subject' => $subject_int,
+ 'body' => $body_int,
+ ];
+ }
+ $mail = addToMailQueue($data);
+ $email_id = mysqli_insert_id($mysqli);
+ mysqli_query($mysqli,"INSERT INTO history SET history_status = 'Sent', history_description = 'Payment Receipt sent to mail queue ID: $email_id!', history_invoice_id = $invoice_id");
+ logAudit("Invoice", "Payment", "Payment receipt for invoice $invoice_prefix$invoice_number queued to $contact_email Email ID: $email_id", $client_id, $invoice_id);
+ }
+
+ // LOGGING
+ $extended_log_desc = !$pi_livemode ? '(DEV MODE)' : '';
+ appNotify("Invoice Paid", "Invoice $invoice_prefix$invoice_number automatically paid", "/agent/invoice.php?invoice_id=$invoice_id", $client_id);
+ logAudit("Invoice", "Payment", "Auto Stripe payment amount of " . numfmt_format_currency($currency_format, $invoice_amount, $recurring_payment_currency_code) . " added to invoice $invoice_prefix$invoice_number - $pi_id $extended_log_desc", $client_id, $invoice_id);
+ triggerCustomAction('invoice_pay', $invoice_id);
+
+ } else {
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Payment failed', history_description = 'Stripe autopay failed: Status {$payment_intent->status}', history_invoice_id = $invoice_id");
+ logAudit("Invoice", "Payment", "Failed auto Payment for invoice $invoice_prefix$invoice_number. Stripe PI status: {$payment_intent->status}", $client_id, $invoice_id);
+ }
+ } // End if Stripe creds and IDs
+ } // End if Stripe provider
+ // Add other provider logic here as needed
+ } else {
+ // Handle Non-payment-provider autopay
+ mysqli_query($mysqli, "INSERT INTO payments SET payment_date = CURDATE(), payment_amount = $invoice_amount, payment_currency_code = '$recurring_payment_currency_code', payment_account_id = $recurring_payment_account_id, payment_method = '$recurring_payment_method', payment_reference = 'Paid via AutoPay', payment_invoice_id = $invoice_id");
+ $payment_id = mysqli_insert_id($mysqli);
+
+ mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id");
+ mysqli_query($mysqli, "INSERT INTO history SET history_status = 'Paid', history_description = 'Payment added via Auto Pay', history_invoice_id = $invoice_id");
+ logAudit("Invoice", "Payment", "Auto Payment amount of $recurring_payment_currency_code $invoice_amount added to invoice $invoice_prefix$invoice_number", $client_id, $invoice_id);
+ }
+}
+
+/*
+ * Stripe fee reconciliation
+ * A payment can complete before Stripe attaches the balance transaction,
+ * in which case the fee expense is skipped at payment time. Find recent
+ * Stripe payments with no matching fee expense and record the actual fee
+ * now that the balance transaction exists.
+ */
+$stripe_provider = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM payment_providers WHERE payment_provider_name = 'Stripe' LIMIT 1"));
+
+if ($stripe_provider) {
+
+ $provider_private_key = $stripe_provider['payment_provider_private_key'];
+ $expense_vendor_id = intval($stripe_provider['payment_provider_expense_vendor']);
+ $expense_category_id = intval($stripe_provider['payment_provider_expense_category']);
+ $expense_account_id = intval($stripe_provider['payment_provider_account']);
+
+ if ($provider_private_key && $expense_vendor_id > 0 && $expense_category_id > 0) {
+
+ $sql_missing_fee = mysqli_query($mysqli, "
+ SELECT payment_reference, payment_date, payment_amount, invoice_prefix, invoice_number, invoice_client_id
+ FROM payments
+ LEFT JOIN invoices ON payment_invoice_id = invoice_id
+ WHERE payment_reference LIKE 'Stripe - pi\_%'
+ AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
+ AND NOT EXISTS (
+ SELECT 1 FROM expenses WHERE LOCATE(payments.payment_reference, expenses.expense_reference) = 1
+ )
+ LIMIT 50
+ ");
+
+ if ($sql_missing_fee && mysqli_num_rows($sql_missing_fee) > 0) {
+
+ require_once __DIR__ . '/../includes/stripe_init.php';
+ $stripe = new \Stripe\StripeClient($provider_private_key);
+
+ while ($missing = mysqli_fetch_assoc($sql_missing_fee)) {
+
+ $payment_reference = escapeSql($missing['payment_reference']);
+ $payment_date = escapeSql($missing['payment_date']);
+ $payment_amount = floatval($missing['payment_amount']);
+ $invoice_prefix = escapeSql($missing['invoice_prefix']);
+ $invoice_number = intval($missing['invoice_number']);
+ $client_id = intval($missing['invoice_client_id']);
+
+ $pi_id = str_replace('Stripe - ', '', $missing['payment_reference']);
+
+ try {
+ $payment_intent = $stripe->paymentIntents->retrieve($pi_id, ['expand' => ['latest_charge.balance_transaction']]);
+ } catch (Exception $e) {
+ logApp("Stripe", "warning", "Fee reconciliation - could not retrieve $pi_id: " . $e->getMessage());
+ continue;
+ }
+
+ // Actual fee from the balance transaction (null until Stripe attaches it - retried next run)
+ $balance_transaction = $payment_intent->latest_charge->balance_transaction ?? null;
+ if ($balance_transaction && !is_string($balance_transaction)) {
+ $gateway_fee = round($balance_transaction->fee / 100, 2);
+ $gateway_fee_currency = escapeSql(strtoupper($balance_transaction->currency));
+ mysqli_query($mysqli, "INSERT INTO expenses SET expense_date = '$payment_date', expense_amount = $gateway_fee, expense_currency_code = '$gateway_fee_currency', expense_account_id = $expense_account_id, expense_vendor_id = $expense_vendor_id, expense_client_id = $client_id, expense_category_id = $expense_category_id, expense_description = 'Stripe fee for Invoice $invoice_prefix$invoice_number payment of $payment_amount', expense_reference = '$payment_reference'");
+ logApp("Stripe", "info", "Fee reconciliation - recorded Stripe fee of $gateway_fee for $pi_id");
+ }
+ // Still-missing balance transactions get picked up on the next run
+ }
+ }
+ }
+}
+
+// Recurring Expenses
+// Loop through all recurring expenses that match today's date and is active
+$sql_recurring_expenses = mysqli_query($mysqli, "SELECT * FROM recurring_expenses WHERE recurring_expense_next_date = CURDATE() AND recurring_expense_status = 1");
+
+while ($row = mysqli_fetch_assoc($sql_recurring_expenses)) {
+ $recurring_expense_id = intval($row['recurring_expense_id']);
+ $recurring_expense_frequency = intval($row['recurring_expense_frequency']);
+ $recurring_expense_month = intval($row['recurring_expense_month']);
+ $recurring_expense_day = intval($row['recurring_expense_day']);
+ $recurring_expense_description = escapeSql($row['recurring_expense_description']);
+ $recurring_expense_amount = floatval($row['recurring_expense_amount']);
+ $recurring_expense_payment_method = escapeSql($row['recurring_expense_payment_method']);
+ $recurring_expense_reference = escapeSql($row['recurring_expense_reference']);
+ $recurring_expense_currency_code = escapeSql($row['recurring_expense_currency_code']);
+ $recurring_expense_vendor_id = intval($row['recurring_expense_vendor_id']);
+ $recurring_expense_category_id = intval($row['recurring_expense_category_id']);
+ $recurring_expense_account_id = intval($row['recurring_expense_account_id']);
+ $recurring_expense_client_id = intval($row['recurring_expense_client_id']);
+
+ // Calculate next billing date based on frequency
+ if ($recurring_expense_frequency == 1) { // Monthly
+ $next_date_query = "DATE_ADD(CURDATE(), INTERVAL 1 MONTH)";
+ } elseif ($recurring_expense_frequency == 2) { // Yearly
+ $next_date_query = "DATE(CONCAT(YEAR(CURDATE()) + 1, '-', $recurring_expense_month, '-', $recurring_expense_day))";
+ } else {
+ // Handle unexpected frequency values. For now, just use current date.
+ $next_date_query = "CURDATE()";
+ }
+
+ mysqli_query($mysqli,"INSERT INTO expenses SET expense_date = CURDATE(), expense_amount = $recurring_expense_amount, expense_currency_code = '$recurring_expense_currency_code', expense_account_id = $recurring_expense_account_id, expense_vendor_id = $recurring_expense_vendor_id, expense_client_id = $recurring_expense_client_id, expense_category_id = $recurring_expense_category_id, expense_description = '$recurring_expense_description', expense_reference = '$recurring_expense_reference'");
+
+ $expense_id = mysqli_insert_id($mysqli);
+
+ appNotify("Expense Created", "Expense $recurring_expense_description created from recurring expenses", "/agent/expenses.php", $recurring_expense_client_id);
+
+ // Update recurring dates using calculated next billing date
+
+ mysqli_query($mysqli, "UPDATE recurring_expenses SET recurring_expense_last_sent = CURDATE(), recurring_expense_next_date = $next_date_query WHERE recurring_expense_id = $recurring_expense_id");
+
+
+} //End Recurring expenses loop
+
+// Flag any active recurring "next run" dates that are in the past
+$sql_invalid_recurring_expenses = mysqli_query($mysqli, "SELECT * FROM recurring_expenses WHERE recurring_expense_next_date < CURDATE() AND recurring_expense_status = 1");
+while ($row = mysqli_fetch_assoc($sql_invalid_recurring_expenses)) {
+ $recurring_expense_description = escapeSql($row['recurring_expense_description']);
+ appNotify("Expense", "Recurring expense $recurring_expense_description next run date is in the past!", "/agent/recurring_expenses.php");
+}
+
+// Logging
+//logApp("Cron", "info", "Cron created expenses from recurring expenses");
+
+// TELEMETRY
+
+if ($config_telemetry > 0 || $config_telemetry == 2) {
+
+ $current_version = exec("git rev-parse HEAD");
+
+ // Client Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('client_id') AS num FROM clients"));
+ $client_count = $row['num'];
+
+ // Ticket Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_id') AS num FROM tickets"));
+ $ticket_count = $row['num'];
+
+ // Recurring Ticket Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_ticket_id') AS num FROM recurring_tickets"));
+ $recurring_ticket_count = $row['num'];
+
+ // Calendar Event Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('event_id') AS num FROM calendar_events"));
+ $calendar_event_count = $row['num'];
+
+ // Quote Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('quote_id') AS num FROM quotes"));
+ $quote_count = $row['num'];
+
+ // Invoice Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('invoice_id') AS num FROM invoices"));
+ $invoice_count = $row['num'];
+
+ // Revenue Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('revenue_id') AS num FROM revenues"));
+ $revenue_count = $row['num'];
+
+ // Recurring Invoice Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('recurring_invoice_id') AS num FROM recurring_invoices"));
+ $recurring_invoice_count = $row['num'];
+
+ // Account Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('account_id') AS num FROM accounts"));
+ $account_count = $row['num'];
+
+ // Tax Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('tax_id') AS num FROM taxes"));
+ $tax_count = $row['num'];
+
+ // Product Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('product_id') AS num FROM products"));
+ $product_count = $row['num'];
+
+ // Payment Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('payment_id') AS num FROM payments WHERE payment_invoice_id > 0"));
+ $payment_count = $row['num'];
+
+ // Company Vendor Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_id') AS num FROM vendors WHERE vendor_client_id = 0"));
+ $company_vendor_count = $row['num'];
+
+ // Expense Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('expense_id') AS num FROM expenses WHERE expense_vendor_id > 0"));
+ $expense_count = $row['num'];
+
+ // Trip Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('trip_id') AS num FROM trips"));
+ $trip_count = $row['num'];
+
+ // Transfer Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('transfer_id') AS num FROM transfers"));
+ $transfer_count = $row['num'];
+
+ // Contact Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('contact_id') AS num FROM contacts"));
+ $contact_count = $row['num'];
+
+ // Location Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('location_id') AS num FROM locations"));
+ $location_count = $row['num'];
+
+ // Asset Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('asset_id') AS num FROM assets"));
+ $asset_count = $row['num'];
+
+ // Software Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('software_id') AS num FROM software"));
+ $software_count = $row['num'];
+
+ // Software Template Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('software_template_id') AS num FROM software_templates"));
+ $software_template_count = $row['num'];
+
+ // Credential Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('credential_id') AS num FROM credentials"));
+ $credential_count = $row['num'];
+
+ // Network Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('network_id') AS num FROM networks"));
+ $network_count = $row['num'];
+
+ // Certificate Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('certificate_id') AS num FROM certificates"));
+ $certificate_count = $row['num'];
+
+ // Domain Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('domain_id') AS num FROM domains"));
+ $domain_count = $row['num'];
+
+ // Service Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('service_id') AS num FROM services"));
+ $service_count = $row['num'];
+
+ // Client Vendor Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_id') AS num FROM vendors WHERE vendor_client_id > 0"));
+ $client_vendor_count = $row['num'];
+
+ // Vendor Template Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('vendor_template_id') AS num FROM vendor_templates"));
+ $vendor_template_count = $row['num'];
+
+ // File Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('file_id') AS num FROM files"));
+ $file_count = $row['num'];
+
+ // Document Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('document_id') AS num FROM documents"));
+ $document_count = $row['num'];
+
+ // Document Template Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('document_template_id') AS num FROM document_templates"));
+ $document_template_count = $row['num'];
+
+ // Shared Item Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('item_id') AS num FROM shared_items"));
+ $shared_item_count = $row['num'];
+
+ // Company Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('company_id') AS num FROM companies"));
+ $company_count = $row['num'];
+
+ // User Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('user_id') AS num FROM users"));
+ $user_count = $row['num'];
+
+ // Category Expense Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Expense'"));
+ $category_expense_count = $row['num'];
+
+ // Category Income Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Income'"));
+ $category_income_count = $row['num'];
+
+ // Category Referral Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Referral'"));
+ $category_referral_count = $row['num'];
+
+ // Category Payment Method Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('category_id') AS num FROM categories WHERE category_type = 'Payment Method'"));
+ $category_payment_method_count = $row['num'];
+
+ // Tag Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('tag_id') AS num FROM tags"));
+ $tag_count = $row['num'];
+
+ // API Key Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('api_key_id') AS num FROM api_keys"));
+ $api_key_count = $row['num'];
+
+ // Log Count
+ $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('log_id') AS num FROM logs"));
+ $log_count = $row['num'];
+
+ $postdata = http_build_query(
+ array(
+ 'installation_id' => "$installation_id",
+ 'version' => "$current_version",
+ 'company_name' => "$company_name",
+ 'website' => "$company_website",
+ 'city' => "$company_city",
+ 'state' => "$company_state",
+ 'country' => "$company_country",
+ 'currency' => "$company_currency",
+ 'client_count' => $client_count,
+ 'ticket_count' => $ticket_count,
+ 'recurring_ticket_count' => $recurring_ticket_count,
+ 'calendar_event_count' => $calendar_event_count,
+ 'quote_count' => $quote_count,
+ 'invoice_count' => $invoice_count,
+ 'revenue_count' => $revenue_count,
+ 'recurring_invoice_count' => $recurring_invoice_count,
+ 'account_count' => $account_count,
+ 'tax_count' => $tax_count,
+ 'product_count' => $product_count,
+ 'payment_count' => $payment_count,
+ 'company_vendor_count' => $company_vendor_count,
+ 'expense_count' => $expense_count,
+ 'trip_count' => $trip_count,
+ 'transfer_count' => $transfer_count,
+ 'contact_count' => $contact_count,
+ 'location_count' => $location_count,
+ 'asset_count' => $asset_count,
+ 'software_count' => $software_count,
+ 'software_template_count' => $software_template_count,
+ 'credential_count' => $credential_count,
+ 'network_count' => $network_count,
+ 'certificate_count' => $certificate_count,
+ 'domain_count' => $domain_count,
+ 'service_count' => $service_count,
+ 'client_vendor_count' => $client_vendor_count,
+ 'vendor_template_count' => $vendor_template_count,
+ 'file_count' => $file_count,
+ 'document_count' => $document_count,
+ 'document_template_count' => $document_template_count,
+ 'shared_item_count' => $shared_item_count,
+ 'company_count' => $company_count,
+ 'user_count' => $user_count,
+ 'category_expense_count' => $category_expense_count,
+ 'category_income_count' => $category_income_count,
+ 'category_referral_count' => $category_referral_count,
+ 'category_payment_method_count' => $category_payment_method_count,
+ 'tag_count' => $tag_count,
+ 'api_key_count' => $api_key_count,
+ 'log_count' => $log_count,
+ 'config_theme' => "$config_theme",
+ 'config_enable_cron' => $config_enable_cron,
+ 'config_ticket_email_parse' => $config_ticket_email_parse,
+ 'config_module_enable_itdoc' => $config_module_enable_itdoc,
+ 'config_module_enable_ticketing' => $config_module_enable_ticketing,
+ 'config_module_enable_accounting' => $config_module_enable_accounting,
+ 'config_telemetry' => $config_telemetry,
+ 'collection_method' => 3
+ )
+ );
+
+ $opts = array('http' =>
+ array(
+ 'method' => 'POST',
+ 'header' => 'Content-type: application/x-www-form-urlencoded',
+ 'content' => $postdata
+ )
+ );
+
+ $context = stream_context_create($opts);
+
+ $result = file_get_contents('https://telemetry.itflow.org', false, $context);
+
+ // Logging
+ // logAudit("Cron", "Task", "Cron sent telemetry results to ITFlow Developers");
+
+}
+
+
+// Fetch Updates
+$updates = checkForUpdates();
+
+$update_message = $updates->update_message;
+
+if ($updates->current_version !== $updates->latest_version) {
+ // Send Alert to inform Updates Available
+ appNotify("Update", "$update_message", "/admin/update.php");
+}
+
+
+
+/*
+ * ###############################################################################################################
+ * FINISH UP
+ * ###############################################################################################################
+ */
+
+// Logging
+logApp("Cron", "info", "Cron executed successfully");
diff --git a/cron/ticket_email_parser.php b/cron/ticket_email_parser.php
index c3f48195..fc77bf7a 100644
--- a/cron/ticket_email_parser.php
+++ b/cron/ticket_email_parser.php
@@ -45,35 +45,13 @@ $company_phone = escapeSql(formatPhoneNumber($row['company_phone'], $row['compan
// 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.");
- exit("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
+ cronJobStop("Email Parser: Feature is not enabled - check Settings > Ticketing > Email-to-ticket parsing. See https://docs.itflow.org/ticket_email_parse -- Quitting..");
}
-// System temp directory & lock
-$temp_dir = sys_get_temp_dir();
-$lock_file_path = "{$temp_dir}/itflow_email_parser_{$installation_id}.lock";
-
-if (file_exists($lock_file_path)) {
- $file_age = time() - filemtime($lock_file_path);
- if ($file_age > 300) {
- unlink($lock_file_path);
- logApp("Cron-Email-Parser", "warning", "Cron Email Parser detected a lock file was present but was over 5 minutes old so it removed it.");
- } else {
- logApp("Cron-Email-Parser", "warning", "Lock file present. Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
- exit("Script is already running. Exiting.");
- }
-}
-// Atomically create the lock ('x' fails if another process beat us to it)
-if (@fopen($lock_file_path, 'x') === false) {
- logApp("Cron-Email-Parser", "warning", "Lock file present (race). Cron Email Parser attempted to execute but was already executing, so instead it terminated.");
- exit("Script is already running. Exiting.");
-}
-
-// Ensure lock gets removed even on fatal error
-register_shutdown_function(function() use ($lock_file_path) {
- if (file_exists($lock_file_path)) {
- @unlink($lock_file_path);
- }
-});
+// Overlapping runs are prevented by includes/cron_lock.php. This script used to keep a
+// lock file of its own alongside that one, which needed a five minute age heuristic to
+// recover from a killed run and could only end itself with exit() - fatal to a dispatched
+// job. flock covers the same ground and the kernel drops it however the process ends.
// Allowed attachment extensions
$allowed_extensions = array('jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'txt', 'md', 'doc', 'docx', 'csv', 'xls', 'xlsx', 'xlsm', 'zip', 'tar', 'gz');
@@ -394,19 +372,6 @@ function tokenExpired(?string $expires_at): bool {
}
// very small form-encoded POST helper using curl
-function httpFormPost(string $url, array $fields): array {
- $ch = curl_init($url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields, '', '&'));
- curl_setopt($ch, CURLOPT_TIMEOUT, 20);
- $raw = curl_exec($ch);
- $err = curl_error($ch);
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
- return ['ok' => ($raw !== false && $code >= 200 && $code < 300), 'body' => $raw, 'code' => $code, 'err' => $err];
-}
-
/**
* Get a valid access token for Google Workspace IMAP via refresh token if needed.
* Uses settings: config_mail_oauth_client_id / _client_secret / _refresh_token / _access_token / _access_token_expires_at
@@ -524,8 +489,7 @@ if ($imap_provider === null) $imap_provider = '';
if ($imap_provider === '') {
// IMAP disabled by admin: exit cleanly
logApp("Cron-Email-Parser", "info", "IMAP polling skipped: provider not configured.");
- @unlink($lock_file_path);
- exit(0);
+ cronJobStop();
}
/** ------------------------------------------------------------------
@@ -551,8 +515,7 @@ if ($imap_provider === 'google_oauth') {
$pass = getGoogleAccessToken($user);
if (empty($pass)) {
logApp("Cron-Email-Parser", "error", "Google OAuth: no usable access token (check refresh token/client credentials).");
- @unlink($lock_file_path);
- exit(1);
+ cronJobStop('', 1);
}
} elseif ($imap_provider === 'microsoft_oauth') {
$host = 'outlook.office365.com';
@@ -562,15 +525,13 @@ if ($imap_provider === 'google_oauth') {
$pass = getMicrosoftAccessToken($user);
if (empty($pass)) {
logApp("Cron-Email-Parser", "error", "Microsoft OAuth: no usable access token (check refresh token/client credentials/tenant).");
- @unlink($lock_file_path);
- exit(1);
+ cronJobStop('', 1);
}
} else {
// standard_imap (username/password)
if (empty($host) || empty($port) || empty($user)) {
logApp("Cron-Email-Parser", "error", "Standard IMAP: missing host/port/username.");
- @unlink($lock_file_path);
- exit(1);
+ cronJobStop('', 1);
}
}
@@ -597,8 +558,7 @@ try {
$mailbox->connect();
} catch (\Throwable $e) {
echo "Error connecting to IMAP server: " . $e->getMessage();
- @unlink($lock_file_path);
- exit(1);
+ cronJobStop('', 1);
}
$inbox = $mailbox->inbox();
@@ -664,8 +624,7 @@ try {
}
} catch (\Throwable $e) {
logApp("Cron-Email-Parser", "error", "Unable to find/create target folder [$targetFolderName]: " . $e->getMessage());
- @unlink($lock_file_path);
- exit(1);
+ cronJobStop('', 1);
}
// Fetch unseen messages (headers, body & flags; BODY.PEEK so they stay unread)
@@ -1037,13 +996,6 @@ if ($processed_count || $unprocessed_count) {
logApp("Cron-Email-Parser", "info", "Cron Email Parser executed in $execution_time_formatted seconds. $processed_info");
}
-// Remove the lock file
-unlink($lock_file_path);
-
// DEBUG
-echo "\nLock File Path: $lock_file_path\n";
-if (file_exists($lock_file_path)) {
- echo "\nLock is present\n\n";
-}
echo "Processed Emails: $processed_count\n";
echo "Unprocessed Emails: $unprocessed_count\n";
diff --git a/db.sql b/db.sql
index b5d344bf..02272031 100644
--- a/db.sql
+++ b/db.sql
@@ -950,6 +950,24 @@ CREATE TABLE `credits` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
+--
+-- Table structure for table `cron_jobs`
+--
+
+DROP TABLE IF EXISTS `cron_jobs`;
+/*!40101 SET @saved_cs_client = @@character_set_client */;
+/*!40101 SET character_set_client = utf8mb4 */;
+CREATE TABLE `cron_jobs` (
+ `cron_job_id` int(11) NOT NULL AUTO_INCREMENT,
+ `cron_job_name` varchar(200) NOT NULL,
+ `cron_job_last_run_at` datetime DEFAULT NULL,
+ `cron_job_last_finished_at` datetime DEFAULT NULL,
+ `cron_job_last_status` varchar(200) DEFAULT NULL,
+ PRIMARY KEY (`cron_job_id`),
+ UNIQUE KEY `cron_job_name` (`cron_job_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
--
-- Table structure for table `custom_fields`
--
diff --git a/functions/request.php b/functions/request.php
index a870c73e..6725c581 100644
--- a/functions/request.php
+++ b/functions/request.php
@@ -1,9 +1,35 @@
($raw !== false && $code >= 200 && $code < 300),
+ 'body' => $raw,
+ 'code' => $code,
+ 'err' => $err,
+ ];
+}
+
function getUserAgent() {
return $_SERVER['HTTP_USER_AGENT'];
}
diff --git a/includes/cron_lock.php b/includes/cron_lock.php
index e77c1f3e..dcf586a2 100644
--- a/includes/cron_lock.php
+++ b/includes/cron_lock.php
@@ -1,7 +1,7 @@