= CURDATE() LIMIT 1"); return mysqli_num_rows($sql) > 0; } /* * ############################################################################################################### * STARTUP ACTIONS * ############################################################################################################### */ //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"); // Shared links: wipe the secrets of the ones that ended, and forget them // after SHARE_KEEP_DAYS - until then they stay listed, with who opened them sharePrune(); // Forget the portal password links that have run out - an invite works for a // week, Forgot password for an hour (functions/portal.php), and one with no // expiry is from before links had one mysqli_query($mysqli, "UPDATE users SET user_password_reset_token = NULL, user_password_reset_token_expire = NULL WHERE user_password_reset_token IS NOT NULL AND (user_password_reset_token_expire IS NULL OR user_password_reset_token_expire < NOW())"); // 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 " . MAIL_QUEUE_KEEP_DAYS . " DAY"); // Clean-up the PDFs made to go with billing emails - sent or given up on by now billingEmailPdfCleanup(7); // 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 backups, and reconcile rows whose file is gone against files with no row. // Retention lives here rather than in cron/backup.php so a failed backup run can never // delete the archive it was supposed to replace. backupRunRetention($mysqli); // Cleanup old logs - each kept for its own number of days, 0 for keep forever (Admin > Settings > Logs, functions/app_log.php) $log_retention = appLogRetention(); // The audit log, and API key and AI use with it if ($log_retention['audit'] > 0) { mysqli_query($mysqli, "DELETE FROM logs WHERE log_created_at < CURDATE() - INTERVAL {$log_retention['audit']} DAY"); mysqli_query($mysqli, "DELETE FROM api_key_usage WHERE api_key_usage_date < CURDATE() - INTERVAL {$log_retention['audit']} DAY"); if (aiUsageTableExists()) { mysqli_query($mysqli, "DELETE FROM ai_usage WHERE ai_usage_created_at < CURDATE() - INTERVAL {$log_retention['audit']} DAY"); } } // The app logs if ($log_retention['app'] > 0) { mysqli_query($mysqli, "DELETE FROM app_logs WHERE app_log_created_at < CURDATE() - INTERVAL {$log_retention['app']} 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"); } // CONTRACTS - renew the ones that renew themselves, end the ones that don't, and remind owners before the notice date $contracts_done = contractsNightly(); if (array_sum($contracts_done)) { logApp("Cron", "info", "Contracts: {$contracts_done['renewed']} renewed, {$contracts_done['ended']} ended, {$contracts_done['reminded']} owner(s) reminded, {$contracts_done['drawn']} ticket(s) drawn from prepaid hours, {$contracts_done['paused']} recurring invoice(s) paused"); } // CLIENT PORTAL - links to join that nobody used $portal_invites_cleared = portalInvitesNightly(); if ($portal_invites_cleared) { logApp("Cron", "info", "Client portal: cleared $portal_invites_cleared sign-up or cancelled invite link(s) nobody used"); } // AGENT INVITES - cancelled ones, and ones that ran out, after 90 days $user_invites_cleared = userInvitesNightly(); if ($user_invites_cleared) { logApp("Cron", "info", "Users: cleared $user_invites_cleared cancelled or run-out agent invite(s)"); } // 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 client_id, client_name, domain_expire, domain_auto_renew, domain_id, domain_name FROM domains LEFT JOIN clients ON domain_client_id = client_id WHERE domain_expire IS NOT NULL AND domain_expire = CURDATE() + INTERVAL $day DAY AND domain_archived_at IS NULL AND client_archived_at IS NULL" ); 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']); // Auto-renew still fails on an expired card, so it is said rather than skipped $domain_renewal = intval($row['domain_auto_renew']) ? ' - set to auto-renew, check it goes through' : ''; appNotify("Domain Expiring", "Domain $domain_name for $client_name will expire in $day Days on $domain_expire$domain_renewal", "/agent/domains.php?client_id=$client_id&open=$domain_id", $client_id); } } // Logging // logAudit("Cron", "Task", "Cron created notifications for domains expiring"); } // CERTIFICATES EXPIRING // A month and a half out, then a week, then the day before. A short-lived // certificate (Let's Encrypt and friends) renews itself with about 30 days // left, so its 45-day mark is routine - only the week and the day are said, // and by then it should already have renewed. $certificateAlertArray = [1,7,45]; foreach ($certificateAlertArray as $day) { //Get Certs Expiring $sql = mysqli_query( $mysqli, "SELECT certificate_domain, certificate_expire, certificate_issued_at, certificate_id, certificate_name, client_id, client_name FROM certificates LEFT JOIN clients ON certificate_client_id = client_id WHERE certificate_expire = CURDATE() + INTERVAL $day DAY AND certificate_archived_at IS NULL AND client_archived_at IS NULL" ); while ($row = mysqli_fetch_assoc($sql)) { $certificate_id = intval($row['certificate_id']); $certificate_name = escapeSql($row['certificate_name']); $certificate_expire = escapeSql($row['certificate_expire']); $client_id = intval($row['client_id']); $client_name = escapeSql($row['client_name']); $short_lived = certificateShortLived($row); if ($day == 45 && $short_lived) { echo "Not raising a notification for short-lived certificate $certificate_name expiring in 45 days\n"; continue; } $renewal = $short_lived ? ' - it renews itself, so check why it has not' : ''; appNotify("Certificate Expiring", "Certificate $certificate_name for $client_name will expire in $day day(s) on $certificate_expire$renewal", "/agent/certificates.php?client_id=$client_id&open=$certificate_id", $client_id); } } // Logging // logAudit("Cron", "Task", "Cron created notifications for certificates expiring"); // API KEYS EXPIRING $sql = mysqli_query($mysqli, "SELECT api_key_id, api_key_name, api_key_expire FROM api_keys WHERE api_key_expire = CURDATE() + INTERVAL 7 DAY"); while ($row = mysqli_fetch_assoc($sql)) { $api_key_id = intval($row['api_key_id']); $api_key_name = escapeSql($row['api_key_name']); $api_key_expire = escapeSql($row['api_key_expire']); appNotify("API Key Expiring", "API key $api_key_name will expire in 7 days on $api_key_expire", "/admin/api_keys.php", 0, $api_key_id); } // Asset Warranties Expiring $warranty_alert_array = [1,7,45]; foreach ($warranty_alert_array as $day) { //Get Asset Warranty Expiring $sql = mysqli_query( $mysqli, "SELECT asset_id, asset_name, asset_warranty_expire, client_id, client_name 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"); // UPS batteries due for replacing - the same days ahead as warranties (asset_specs, functions/asset_type.php) foreach (assetSpecsReady() ? $warranty_alert_array : [] as $day) { $sql = mysqli_query($mysqli, "SELECT asset_id, asset_name, asset_spec_value, client_id, client_name FROM asset_specs JOIN assets ON asset_id = asset_spec_asset_id JOIN clients ON asset_client_id = client_id WHERE asset_spec_key = 'battery_replace' AND asset_spec_value = CURDATE() + INTERVAL $day DAY AND asset_type = 'UPS' AND asset_archived_at IS NULL AND client_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql)) { $asset_id = intval($row['asset_id']); $client_id = intval($row['client_id']); appNotify("UPS Battery Due", "The battery in " . escapeSql($row['asset_name']) . " for " . escapeSql($row['client_name']) . " is due for replacing in $day day" . ($day === 1 ? '' : 's') . ", on " . escapeSql($row['asset_spec_value']), "/agent/asset.php?client_id=$client_id&asset_id=$asset_id", $client_id); } } // Services due for review - said once, the day it falls due $sql = mysqli_query($mysqli, "SELECT service_id, service_name, service_importance, client_id, client_name FROM services JOIN clients ON service_client_id = client_id WHERE service_review_due = CURDATE() AND service_archived_at IS NULL AND client_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql)) { $service_id = intval($row['service_id']); $service_name = escapeSql($row['service_name']); $client_id = intval($row['client_id']); $client_name = escapeSql($row['client_name']); appNotify("Service Review", "Service $service_name for $client_name (" . escapeSql($row['service_importance']) . " importance) is due for review today", "/agent/" . serviceUrl($service_id, $client_id), $client_id); } // 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 - each one due today, or missed (a night the cron didn't // run), raises one ticket and moves on to its next run after today. Paused ones // raise nothing. recurringTicketRunNow() (functions/ticket.php) is the same code // the Raise now button runs. $sql_recurring_tickets = mysqli_query($mysqli, "SELECT recurring_ticket_id, recurring_ticket_next_run, recurring_ticket_frequency, recurring_ticket_start_date FROM recurring_tickets WHERE recurring_ticket_next_run <= CURDATE() AND recurring_ticket_paused_at IS NULL"); $recurring_tickets_raised = 0; while ($row = mysqli_fetch_assoc($sql_recurring_tickets)) { if (recurringTicketRunNow($row, 'Cron')) { $recurring_tickets_raised++; } } if ($recurring_tickets_raised) { logApp("Cron", "info", "Cron raised $recurring_tickets_raised recurring ticket(s)"); } // 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 ticket_assigned_to, ticket_client_id, ticket_id, ticket_number, ticket_prefix, ticket_status, ticket_subject 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 logTicketHistory($ticket_id, "Automatically closed after $config_ticket_autoclose_hours hours with no activity"); logAudit("Ticket", "Close", "$ticket_prefix$ticket_number auto closed", $client_id, $ticket_id); triggerCustomAction('ticket_close', $ticket_id); // The client is told, as when an agent closes it if (intval($config_ticket_client_general_notifications) === 1) { ticketMailClosed($ticket_id); } } /* * Payment reminders, on the schedule in the invoice settings (functions/billing_email.php, * billingReminderRules()): each open invoice whose day it is - before, on or after its due * date - gets one, to the client's primary and billing contacts, unless the client is left * out of reminders. One a recurring autopay will charge on its due date isn't reminded * before then. Agents hear about each one late, on its reminder days, and the late fee * still comes every 30 days late. At most one of each a day, however often this runs. */ $reminder_rules = billingReminderRules(true); if ($reminder_rules['enabled']) { $earliest = min(0, $reminder_rules['days'] ? min($reminder_rules['days']) : 0); $sql_reminders = mysqli_query($mysqli, "SELECT invoice_id, DATEDIFF(CURDATE(), invoice_due) AS days_late, client_reminders, invoice_recurring_invoice_id > 0 AND EXISTS (SELECT 1 FROM recurring_payments WHERE recurring_payment_recurring_invoice_id = invoice_recurring_invoice_id) AS autopay FROM invoices JOIN clients ON client_id = invoice_client_id WHERE invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_due IS NOT NULL AND DATEDIFF(CURDATE(), invoice_due) >= $earliest ORDER BY invoice_due ASC, invoice_id ASC"); while ($row = mysqli_fetch_assoc($sql_reminders)) { $invoice_id = intval($row['invoice_id']); $days = intval($row['days_late']); $invoice = billingRow('invoice', $invoice_id, false); if (!$invoice) { continue; } $balance = invoiceState($invoice, invoicePaid($invoice_id))['balance']; $number = billingNumber('invoice', $invoice); // Nothing actually owed (paid in full but status lagging) - skip if ($balance <= 0) { continue; } // Late fee - every 30 days late, as a line of its own, at most once a day if ($config_invoice_late_fee_enable == 1 && $days > 0 && $days % 30 === 0 && !cronInvoiceHistoryToday($invoice_id, 'Cron applied a late fee')) { $late_fee = round($balance * $config_invoice_late_fee_percent / 100, 2); if ($late_fee > 0) { billingItemSave('invoice', $invoice_id, ['name' => 'Late Fee', 'description' => floatval($config_invoice_late_fee_percent) . '% late fee applied on ' . date('Y-m-d'), 'qty' => 1, 'price' => $late_fee, 'tax_id' => 0]); billingLog('invoice', $invoice_id, $invoice['invoice_status'], 'Cron applied a late fee of ' . billingMoney($late_fee, $invoice['invoice_currency_code'])); appNotify("Invoice Late Charge", escapeSql("Invoice $number for {$invoice['client_name']} with a balance of " . billingMoney($balance, $invoice['invoice_currency_code']) . " was charged a late fee of " . billingMoney($late_fee, $invoice['invoice_currency_code'])), "/agent/invoice.php?invoice_id=$invoice_id", intval($invoice['client_id'])); $invoice = billingRow('invoice', $invoice_id, false); $balance = invoiceState($invoice, invoicePaid($invoice_id))['balance']; } } if (!billingReminderOnDay($days, $reminder_rules)) { continue; } if ($days > 0) { appNotify("Invoice Overdue", escapeSql("Invoice $number for {$invoice['client_name']} with a balance of " . billingMoney($balance, $invoice['invoice_currency_code']) . " is overdue by $days days"), "/agent/invoice.php?invoice_id=$invoice_id", intval($invoice['client_id'])); } // Left out, charged by autopay on the day, already reminded today, or no mail if (!intval($row['client_reminders']) || ($days <= 0 && intval($row['autopay'])) || empty($config_smtp_provider) || cronInvoiceHistoryToday($invoice_id, 'Reminder emailed') || cronInvoiceHistoryToday($invoice_id, 'Reminder not sent') || cronInvoiceHistoryToday($invoice_id, 'Cron Emailed Overdue Invoice')) { continue; } $recipients = billingEmailRecipients($invoice['client_id']); if (!$recipients) { billingLog('invoice', $invoice_id, $invoice['invoice_status'], 'Reminder not sent - no primary or billing contact with an email address'); continue; } [$subject, $email] = billingInvoiceEmail($invoice, ['kind' => 'reminder']); $attach = []; if ($reminder_rules['attach_pdf'] && ($file = billingEmailPdf(billingPdf('invoice', $invoice)))) { $attach[] = $file; } $sent = billingEmailQueue($recipients, $subject, $email, ['attach' => $attach]); // Texted too, in the morning, to those of them who agreed to texts (functions/sms.php) $texted = smsInvoiceNotify($invoice, 'reminder', $recipients); billingLog('invoice', $invoice_id, $invoice['invoice_status'], 'Reminder emailed to ' . implode(', ', $sent) . ' - ' . billingReminderWhen($days) . ($texted ? ' - and texted to ' . implode(', ', $texted) : '')); } // Statements each month, on the day in the invoice settings, to every client with a balance if ($reminder_rules['statement_monthly'] && intval(date('j')) === $reminder_rules['statement_day'] && !empty($config_smtp_provider)) { $sql_statements = mysqli_query($mysqli, "SELECT DISTINCT client_id FROM invoices JOIN clients ON client_id = invoice_client_id WHERE invoice_status IN ('Sent', 'Viewed', 'Partial') AND client_reminders = 1 AND client_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql_statements)) { $client_id = intval($row['client_id']); $already = mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM logs WHERE log_type = 'Client' AND log_action = 'Statement' AND log_client_id = $client_id AND log_created_at >= CURDATE() LIMIT 1")); $statement = billingStatement($client_id); if ($already || $statement['total'] <= 0) { continue; } $sent = billingStatementSend($statement, null, ['attach' => $reminder_rules['attach_pdf']]); if ($sent) { logAudit("Client", "Statement", escapeSql("Cron emailed the monthly statement (" . count($statement['invoices']) . " invoices, " . billingMoney($statement['total'], $statement['currency']) . " due) to " . implode(', ', $sent)), $client_id); } } } } // Recurring invoices - each one due today, or missed by up to // RECURRING_INVOICE_GRACE_DAYS (a night or a few the cron didn't run), makes // its invoice and moves on a period from the run it billed. One further // behind than that is left for someone to look at - the page says it is late // and offers Invoice Now, or moving it on without billing the missed runs. // recurringInvoiceGenerate() (functions/billing.php) is the same code the // Invoice Now button runs. $sql_recurring_invoices = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_next_date FROM recurring_invoices WHERE recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL AND recurring_invoice_next_date <= CURDATE() AND recurring_invoice_next_date >= CURDATE() - INTERVAL " . RECURRING_INVOICE_GRACE_DAYS . " DAY ORDER BY recurring_invoice_next_date ASC, recurring_invoice_id ASC"); $recurring_invoices_made = 0; while ($row = mysqli_fetch_assoc($sql_recurring_invoices)) { if (recurringInvoiceGenerate($row['recurring_invoice_id'], 'Cron', false, $row['recurring_invoice_next_date'])) { $recurring_invoices_made++; } } if ($recurring_invoices_made) { logApp("Cron", "info", "Cron made $recurring_invoices_made invoice(s) from recurring invoices"); } // Start Flag any active recurring "next run" dates that are in the past - too far behind for the run above to bill $sql_invalid_recurring_invoices = mysqli_query($mysqli, "SELECT recurring_invoice_id, recurring_invoice_number, recurring_invoice_prefix, recurring_invoice_client_id FROM recurring_invoices WHERE recurring_invoice_next_date < CURDATE() AND recurring_invoice_status = 1 AND recurring_invoice_archived_at IS NULL"); while ($row = mysqli_fetch_assoc($sql_invalid_recurring_invoices)) { $invoice_prefix = $row['recurring_invoice_prefix']; $invoice_number = intval($row['recurring_invoice_number']); appNotify("Invoice", escapeSql("Recurring invoice $invoice_prefix$invoice_number is behind - its next date has passed and it won't bill until it is invoiced or moved on"), "/agent/recurring_invoice.php?recurring_invoice_id=" . intval($row['recurring_invoice_id']), intval($row['recurring_invoice_client_id'])); } // End Flag any active recurring "next run" dates that are in the past // Start Notify draft invoices needing review $sql_invoices_pending_send = mysqli_query($mysqli,"SELECT invoice_id FROM invoices WHERE invoice_status = 'Draft'"); $invoices_pending_send = mysqli_num_rows($sql_invoices_pending_send); if ($invoices_pending_send > 0) { appNotify("Draft Invoices", "There are $invoices_pending_send draft invoices pending review", "/agent/invoices.php?&status=Draft"); } // End Notify draft invoices needing review // Start Recurring Payments $sql_recurring_payments = mysqli_query($mysqli, " SELECT client_id, client_name, contact_email, contact_name, invoice_amount, invoice_currency_code, invoice_date, invoice_due, invoice_id, invoice_number, invoice_prefix, invoice_scope, invoice_url_key, recurring_payment_account_id, recurring_payment_currency_code, recurring_payment_method, recurring_payment_saved_payment_id 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']); // A card that already declined today is not retried - the day-matched selection above // would otherwise re-attempt the same charge on every extra run of this script if (cronInvoiceHistoryToday($invoice_id, 'Stripe autopay failed')) { continue; } // 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 payment_provider_account, payment_provider_id, payment_provider_name, payment_provider_private_key, saved_payment_description, saved_payment_provider_method 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); // The card fee, when the client pays it (functions/billing_pay.php) - it goes on the invoice once the charge goes through $balance_cents = (int) round($balance_to_pay * 100); $fee_cents = billingCardFeeCents($balance_cents, billingCardFeeFor($client_id)); $pi_metadata = [ 'itflow_client_id' => $client_id, 'itflow_client_name' => $client_name, 'itflow_invoice_number' => $invoice_prefix . $invoice_number, 'itflow_invoice_id' => $invoice_id, ]; if ($fee_cents > 0) { $pi_metadata['itflow_fees'] = "$invoice_id:$fee_cents"; } $pi_description = "ITFlow: $client_name payment of $recurring_payment_currency_code " . number_format(($balance_cents + $fee_cents) / 100, 2, '.', '') . ($fee_cents > 0 ? ' (with a ' . number_format($fee_cents / 100, 2, '.', '') . ' card fee)' : '') . " for $invoice_prefix$invoice_number"; try { $payment_intent = $stripe->paymentIntents->create([ 'amount' => $balance_cents + $fee_cents, 'currency' => $recurring_payment_currency_code, 'customer' => $stripe_customer_id, 'payment_method' => $stripe_payment_method_id, 'off_session' => true, 'confirm' => true, 'description' => $pi_description, 'metadata' => $pi_metadata, ]); $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($payment_intent->amount_received) === $balance_cents + $fee_cents) { // Update Invoice Status mysqli_query($mysqli, "UPDATE invoices SET invoice_status = 'Paid' WHERE invoice_id = $invoice_id"); // The card fee goes on the invoice, so the payment - the invoice and its fee - pays it to the cent billingCardFeeAdd($invoice_id, $fee_cents / 100, $pi_date); // 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)" . ($fee_cents > 0 ? ', with a ' . escapeSql(billingMoney($fee_cents / 100, $pi_currency)) . ' card fee' : '') . "', history_invoice_id = $invoice_id"); // The client's receipt, and the paid notification address - functions/billing_email.php if (!empty($config_smtp_provider)) { $receipt_to = billingPaymentSend($invoice_id, $pi_amount_paid, ['date' => $pi_date, 'method' => 'Card', 'reference' => "Stripe - $pi_id", 'how' => 'Automatic card'], true); if ($receipt_to) { logAudit("Invoice", "Payment", "Payment receipt for invoice $invoice_prefix$invoice_number queued to " . escapeSql(implode(', ', $receipt_to)), $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, $pi_amount_paid, $recurring_payment_currency_code) . ($fee_cents > 0 ? ' (with a ' . numfmt_format_currency($currency_format, $fee_cents / 100, $recurring_payment_currency_code) . ' card fee)' : '') . " 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 fees are recorded by their own job every few minutes - cron/payment_provider_fees.php /* * Recurring expenses - each running one due today, or missed by up to * RECURRING_EXPENSE_GRACE_DAYS (nights the cron didn't run), is recorded as * an expense dated the day it was due, and moves on a period * (recurringExpenseRecord() in functions/expenses.php - the same code Record * Now runs). One with an end date stops after its last run. One that falls * further behind than that is left for someone to look at, and they are told * once, the day it does. */ $sql_recurring_expenses = mysqli_query($mysqli, "SELECT recurring_expense_id, recurring_expense_next_date FROM recurring_expenses WHERE " . recurringExpenseRunningSql() . " AND recurring_expense_next_date <= CURDATE() AND recurring_expense_next_date >= CURDATE() - INTERVAL " . RECURRING_EXPENSE_GRACE_DAYS . " DAY ORDER BY recurring_expense_next_date ASC, recurring_expense_id ASC"); while ($row = mysqli_fetch_assoc($sql_recurring_expenses)) { $expense_id = recurringExpenseRecord(intval($row['recurring_expense_id']), 0, $row['recurring_expense_next_date']); if ($expense_id) { $expense = expenseRow($expense_id, false); appNotify("Expense Created", escapeSql("Recorded " . expenseLabel($expense) . " from the recurring expense"), "/agent/expenses.php?open=$expense_id", intval($expense['expense_client_id']), $expense_id); } } $sql_recurring_expenses = mysqli_query($mysqli, "SELECT recurring_expense_id FROM recurring_expenses WHERE " . recurringExpenseRunningSql() . " AND recurring_expense_next_date = CURDATE() - INTERVAL " . (RECURRING_EXPENSE_GRACE_DAYS + 1) . " DAY"); while ($row = mysqli_fetch_assoc($sql_recurring_expenses)) { $recurring = recurringExpenseRow(intval($row['recurring_expense_id']), false); appNotify("Expense", escapeSql("The recurring expense " . recurringExpenseLabel($recurring) . " is " . (RECURRING_EXPENSE_GRACE_DAYS + 1) . " days behind and won't be recorded by itself - record the missed runs or move it on"), "/agent/recurring_expenses.php?status=late", intval($recurring['recurring_expense_client_id'])); } // TELEMETRY if ($config_telemetry > 0 || $config_telemetry == 2) { $current_version = gitCurrentCommit(); // 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 /* * The check itself is cron/update_check.php now - read what it stored rather than running a * second git fetch here. Nothing to say until that job has run once, which is the same * position this was in when a fetch failed. */ if (settingsColumnExists($mysqli, 'config_update_latest_commit')) { $update_check_row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_update_latest_commit FROM settings WHERE company_id = 1")); $latest_version = (string) ($update_check_row['config_update_latest_commit'] ?? ''); $current_version = gitCurrentCommit(); if ($latest_version !== '' && $current_version !== '' && $latest_version !== $current_version) { // Send Alert to inform Updates Available appNotify("Update", "New Updates are Available [$latest_version]", "/admin/update.php"); } } /* * ############################################################################################################### * FINISH UP * ############################################################################################################### */ // Logging logApp("Cron", "info", "Cron executed successfully");