Starter Content. * * This is NOT starter content. Starter content is configuration you keep; * this is disposable sample data that has no business sitting in a live * install, so everything it creates hangs off a demo client and every demo * client carries the 'Demo Data' client tag. Removal works off that tag and * nothing else - untag a client and it stops being demo data. * * Dates are generated relative to today so the dashboard, aging, SLA clocks * and reports have something live to show rather than a flat wall of rows. * * Named _model so admin/post.php does not glob it in on every admin request. */ require_once __DIR__ . '/starter_content_model.php'; // The one marker. Everything else keys off which clients carry it. const DEMO_DATA_TAG = 'Demo Data'; // ------------------------------ // demoDataTagId // The 'Demo Data' client tag (tag_type 1), created on demand. // ------------------------------ function demoDataTagId($mysqli, $create = false) { $name = escapeSql(DEMO_DATA_TAG); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_id FROM tags WHERE tag_name = '$name' AND tag_type = 1 LIMIT 1")); $tag_id = intval($row['tag_id'] ?? 0); if (!$tag_id && $create) { $tag_id = starterInsert($mysqli, 'tags', [ 'tag_name' => DEMO_DATA_TAG, 'tag_type' => 1, 'tag_color' => '#e83e8c', 'tag_icon' => 'flask', ]); } return $tag_id; } // ------------------------------ // demoDataClients // Client IDs currently carrying the demo tag, in name order. // ------------------------------ function demoDataClients($mysqli) { $clients = []; $tag_id = demoDataTagId($mysqli); if (!$tag_id) { return $clients; } $sql = mysqli_query($mysqli, "SELECT clients.client_id, client_name FROM clients LEFT JOIN client_tags ON client_tags.client_id = clients.client_id WHERE client_tags.tag_id = $tag_id ORDER BY client_name ASC"); while ($row = mysqli_fetch_assoc($sql)) { $clients[intval($row['client_id'])] = $row['client_name']; } return $clients; } // ------------------------------ // demoDataStatus // What the page needs: how many demo clients the library holds, how many are // already here, and whether a real book of business is already in the install. // ------------------------------ function demoDataStatus($mysqli) { $loaded = demoDataClients($mysqli); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(client_id) AS total FROM clients WHERE client_archived_at IS NULL")); $all_clients = intval($row['total'] ?? 0); return [ 'total' => count(demoDataProfiles()), 'loaded' => $loaded, 'other_clients' => $all_clients - count($loaded), ]; } // ------------------------------ // demoNextNumber // The same atomic increment the real handlers use, so demo rows do not leave // ticket/invoice/quote numbering sitting on a number that is already taken. // ------------------------------ function demoNextNumber($mysqli, $setting) { mysqli_query($mysqli, " UPDATE settings SET $setting = LAST_INSERT_ID($setting), $setting = $setting + 1 WHERE company_id = 1 "); return intval(mysqli_insert_id($mysqli)); } // ------------------------------ // demoTicketStatusId // Built-in statuses are IDs 1-5 on an install seeded by setup, but look them // up by name anyway - an install that renamed or reordered them still works. // ------------------------------ function demoTicketStatusId($mysqli, $name, $fallback) { $name = escapeSql($name); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_status_id FROM ticket_statuses WHERE ticket_status_name = '$name' LIMIT 1")); return intval($row['ticket_status_id'] ?? 0) ?: $fallback; } // ------------------------------ // demoFirstId // First live row in a table, or 0. Used for the account payments post to and // the calendar events land on - demo data never creates either of those. // ------------------------------ function demoFirstId($mysqli, $table, $id_column, $archived_column = '') { $where = $archived_column ? "WHERE $archived_column IS NULL" : ''; $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT $id_column FROM $table $where ORDER BY $id_column ASC LIMIT 1")); return intval($row[$id_column] ?? 0); } // ------------------------------ // demoDateTime / demoDate // Everything is generated relative to now. // ------------------------------ function demoDateTime($days_ago, $hour = 9, $minute = 0) { return date('Y-m-d H:i:s', mktime($hour, $minute, 0, date('n'), date('j') - $days_ago, date('Y'))); } function demoDate($days_ago) { return date('Y-m-d', mktime(12, 0, 0, date('n'), date('j') - $days_ago, date('Y'))); } // ------------------------------ // demoSerial // Deterministic stand-in for a serial number - same input, same serial, so a // reload after a purge does not produce a whole new set of numbers. // ------------------------------ function demoSerial($seed, $length = 10) { return strtoupper(substr(md5('itflow-demo-' . $seed), 0, $length)); } // ------------------------------ // demoDataLoad // Builds the whole book of business. Clients already present by name are // skipped, so a second run adds nothing and a run after a partial purge only // fills the gaps. Returns a per-area count for the flash message. // ------------------------------ function demoDataLoad($mysqli) { global $session_user_id, $session_company_currency; $user_id = intval($session_user_id ?? 0); $currency = $session_company_currency ?? ''; $counts = [ 'clients' => 0, 'contacts' => 0, 'assets' => 0, 'documentation' => 0, 'tickets' => 0, 'projects' => 0, 'billing' => 0, 'company' => 0, 'skipped_credentials' => 0, ]; $existing = starterExistingNames($mysqli, 'clients', 'client_name'); $tag_id = demoDataTagId($mysqli, true); // Shared lookups - one query each rather than one per client $context = [ 'user_id' => $user_id, 'currency' => $currency, 'tag_id' => $tag_id, 'account_id' => demoFirstId($mysqli, 'accounts', 'account_id', 'account_archived_at'), 'accounts' => demoEnsureAccounts($mysqli, $currency), 'calendar_id' => demoFirstId($mysqli, 'calendars', 'calendar_id'), 'status_new' => demoTicketStatusId($mysqli, 'New', 1), 'status_open' => demoTicketStatusId($mysqli, 'Open', 2), 'status_hold' => demoTicketStatusId($mysqli, 'On Hold', 3), 'status_resolved' => demoTicketStatusId($mysqli, 'Resolved', 4), 'status_closed' => demoTicketStatusId($mysqli, 'Closed', 5), 'vault_open' => encryptCredentialEntry('itflow-demo-probe') !== false, ]; // One transaction for the whole load - several thousand rows go in a great // deal faster, and a failure part way through does not leave half a demo mysqli_begin_transaction($mysqli); foreach (demoDataProfiles() as $index => $profile) { if (isset($existing[mb_strtolower($profile['name'])])) { continue; } demoBuildClient($mysqli, $profile, $index, $context, $counts); $counts['clients']++; } // The MSP's own accounts, operating costs and transfers if ($counts['clients']) { demoBuildCompanyFinancials($mysqli, $context, $counts); } mysqli_commit($mysqli); if (!$context['vault_open'] && $counts['clients']) { $counts['skipped_credentials'] = 1; } return $counts; } // ------------------------------ // demoDataRemove // Purges every client carrying the demo tag and everything hanging off it. // This deliberately goes further than the client delete handler, which leaves // tags, notes, tasks, ticket history, projects and expenses behind. // ------------------------------ function demoDataRemove($mysqli) { $clients = demoDataClients($mysqli); if (!$clients) { return 0; } foreach (array_keys($clients) as $client_id) { $client_id = intval($client_id); // Tickets and everything under them $ticket_ids = []; $sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $ticket_ids[] = intval($row['ticket_id']); } if ($ticket_ids) { $ticket_ids = implode(',', $ticket_ids); mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id IN ($ticket_ids)"); mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id IN ($ticket_ids)"); } mysqli_query($mysqli, "DELETE FROM tickets WHERE ticket_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT recurring_ticket_id FROM recurring_tickets WHERE recurring_ticket_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $recurring_ticket_id = intval($row['recurring_ticket_id']); mysqli_query($mysqli, "DELETE FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id"); mysqli_query($mysqli, "DELETE FROM recurring_ticket_assets WHERE recurring_ticket_id = $recurring_ticket_id"); } mysqli_query($mysqli, "DELETE FROM recurring_tickets WHERE recurring_ticket_client_id = $client_id"); // Billing $sql = mysqli_query($mysqli, "SELECT invoice_id FROM invoices WHERE invoice_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $invoice_id = intval($row['invoice_id']); mysqli_query($mysqli, "DELETE FROM invoice_items WHERE item_invoice_id = $invoice_id"); mysqli_query($mysqli, "DELETE FROM payments WHERE payment_invoice_id = $invoice_id"); mysqli_query($mysqli, "DELETE FROM history WHERE history_invoice_id = $invoice_id"); } mysqli_query($mysqli, "DELETE FROM invoices WHERE invoice_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT quote_id FROM quotes WHERE quote_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $quote_id = intval($row['quote_id']); mysqli_query($mysqli, "DELETE FROM quote_items WHERE item_quote_id = $quote_id"); mysqli_query($mysqli, "DELETE FROM history WHERE history_quote_id = $quote_id"); } mysqli_query($mysqli, "DELETE FROM quotes WHERE quote_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT recurring_invoice_id FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $recurring_invoice_id = intval($row['recurring_invoice_id']); mysqli_query($mysqli, "DELETE FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id"); mysqli_query($mysqli, "DELETE FROM history WHERE history_recurring_invoice_id = $recurring_invoice_id"); } mysqli_query($mysqli, "DELETE FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM trips WHERE trip_client_id = $client_id"); // Documentation and the join tables the client delete handler misses $sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $asset_id = intval($row['asset_id']); mysqli_query($mysqli, "DELETE FROM asset_tags WHERE asset_tag_asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM asset_notes WHERE asset_note_asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM asset_history WHERE asset_history_asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM asset_credentials WHERE asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM asset_documents WHERE asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM asset_files WHERE asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM contact_assets WHERE asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM service_assets WHERE asset_id = $asset_id"); mysqli_query($mysqli, "DELETE FROM software_assets WHERE asset_id = $asset_id"); } mysqli_query($mysqli, "DELETE FROM assets WHERE asset_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $contact_id = intval($row['contact_id']); mysqli_query($mysqli, "DELETE FROM contact_tags WHERE contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM contact_notes WHERE contact_note_contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM contact_credentials WHERE contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM contact_documents WHERE contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM contact_files WHERE contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM service_contacts WHERE contact_id = $contact_id"); mysqli_query($mysqli, "DELETE FROM software_contacts WHERE contact_id = $contact_id"); } mysqli_query($mysqli, "DELETE FROM contacts WHERE contact_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT credential_id FROM credentials WHERE credential_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $credential_id = intval($row['credential_id']); mysqli_query($mysqli, "DELETE FROM credential_tags WHERE credential_id = $credential_id"); mysqli_query($mysqli, "DELETE FROM service_credentials WHERE credential_id = $credential_id"); mysqli_query($mysqli, "DELETE FROM software_credentials WHERE credential_id = $credential_id"); mysqli_query($mysqli, "DELETE FROM vendor_credentials WHERE credential_id = $credential_id"); } mysqli_query($mysqli, "DELETE FROM credentials WHERE credential_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT location_id FROM locations WHERE location_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $location_id = intval($row['location_id']); mysqli_query($mysqli, "DELETE FROM location_tags WHERE location_id = $location_id"); } mysqli_query($mysqli, "DELETE FROM locations WHERE location_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT service_id FROM services WHERE service_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $service_id = intval($row['service_id']); mysqli_query($mysqli, "DELETE FROM service_documents WHERE service_id = $service_id"); mysqli_query($mysqli, "DELETE FROM service_domains WHERE service_id = $service_id"); mysqli_query($mysqli, "DELETE FROM service_certificates WHERE service_id = $service_id"); mysqli_query($mysqli, "DELETE FROM service_vendors WHERE service_id = $service_id"); } mysqli_query($mysqli, "DELETE FROM services WHERE service_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT software_id FROM software WHERE software_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $software_id = intval($row['software_id']); mysqli_query($mysqli, "DELETE FROM software_keys WHERE software_key_software_id = $software_id"); mysqli_query($mysqli, "DELETE FROM software_documents WHERE software_id = $software_id"); mysqli_query($mysqli, "DELETE FROM software_files WHERE software_id = $software_id"); } mysqli_query($mysqli, "DELETE FROM software WHERE software_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT document_id FROM documents WHERE document_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $document_id = intval($row['document_id']); mysqli_query($mysqli, "DELETE FROM document_files WHERE document_id = $document_id"); mysqli_query($mysqli, "DELETE FROM document_versions WHERE document_version_document_id = $document_id"); } mysqli_query($mysqli, "DELETE FROM documents WHERE document_client_id = $client_id"); $sql = mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_client_id = $client_id"); while ($row = mysqli_fetch_assoc($sql)) { $vendor_id = intval($row['vendor_id']); mysqli_query($mysqli, "DELETE FROM vendor_credentials WHERE vendor_id = $vendor_id"); mysqli_query($mysqli, "DELETE FROM vendor_documents WHERE vendor_id = $vendor_id"); mysqli_query($mysqli, "DELETE FROM vendor_files WHERE vendor_id = $vendor_id"); } mysqli_query($mysqli, "DELETE FROM vendors WHERE vendor_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM certificates WHERE certificate_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM domains WHERE domain_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM networks WHERE network_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM projects WHERE project_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM contracts WHERE contract_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM client_notes WHERE client_note_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM calendar_events WHERE event_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM logs WHERE log_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM notifications WHERE notification_client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM client_tags WHERE client_id = $client_id"); mysqli_query($mysqli, "DELETE FROM clients WHERE client_id = $client_id"); // Never leave the company's own-client designation pointing at a client that is gone mysqli_query($mysqli, "UPDATE settings SET config_internal_client_id = 0 WHERE config_internal_client_id = $client_id"); } // Company level demo rows carry a DEMO- reference because they hang off no // client. Transfers go first - each one owns an expense and a revenue. $sql = mysqli_query($mysqli, "SELECT transfer_id, transfer_expense_id, transfer_revenue_id FROM transfers LEFT JOIN expenses ON expense_id = transfer_expense_id WHERE expense_reference LIKE 'DEMO-%'"); while ($row = mysqli_fetch_assoc($sql)) { $transfer_id = intval($row['transfer_id']); $expense_id = intval($row['transfer_expense_id']); $revenue_id = intval($row['transfer_revenue_id']); mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_id = $expense_id"); mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_id = $revenue_id"); mysqli_query($mysqli, "DELETE FROM transfers WHERE transfer_id = $transfer_id"); } mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_client_id = 0 AND expense_reference LIKE 'DEMO-%'"); mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_client_id = 0 AND revenue_reference LIKE 'DEMO-%'"); // Accounts are shared company config, so one only goes if nothing at all is // left pointing at it - an account that was already here keeps its history foreach (demoDataAccounts() as $account) { $account_name = escapeSql($account[0]); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT account_id FROM accounts WHERE account_name = '$account_name' LIMIT 1")); $account_id = intval($row['account_id'] ?? 0); if (!$account_id) { continue; } $in_use = 0; foreach (['payments' => 'payment_account_id', 'expenses' => 'expense_account_id', 'revenues' => 'revenue_account_id'] as $table => $column) { $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS total FROM $table WHERE $column = $account_id")); $in_use = $in_use + intval($row['total'] ?? 0); } if (!$in_use) { mysqli_query($mysqli, "DELETE FROM accounts WHERE account_id = $account_id"); } } // Tidy up the marker tag itself once nothing is wearing it $tag_id = demoDataTagId($mysqli); if ($tag_id) { $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(tag_id) AS total FROM client_tags WHERE tag_id = $tag_id")); if (!intval($row['total'] ?? 0)) { mysqli_query($mysqli, "DELETE FROM tags WHERE tag_id = $tag_id"); } } return count($clients); } // ------------------------------ // demoBuildClient // One client and everything that hangs off it. Order matters - locations and // contacts come first because assets, tickets and networks point at them. // ------------------------------ function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { $currency = $context['currency']; $user_id = $context['user_id']; $client_id = starterInsert($mysqli, 'clients', [ 'client_name' => $profile['name'], 'client_type' => $profile['type'], 'client_website' => 'https://www.' . $profile['domain'], 'client_referral' => $profile['referral'], 'client_rate' => $profile['rate'], 'client_currency_code' => $currency, 'client_net_terms' => $profile['net_terms'], 'client_abbreviation' => $profile['abbreviation'], 'client_notes' => $profile['notes'], 'client_created_at' => demoDateTime($profile['age_days'], 9, 14), 'client_accessed_at' => demoDateTime($index % 5, 11, 20), ]); // The marker - this is what removal works off mysqli_query($mysqli, "INSERT INTO client_tags SET client_id = $client_id, tag_id = {$context['tag_id']}"); demoAttachTags($mysqli, 'client_tags', 'client_id', 'tag_id', $client_id, 1, $profile['tags']); // Locations $location_ids = []; foreach ($profile['sites'] as $site_index => $site) { $location_ids[] = starterInsert($mysqli, 'locations', [ 'location_name' => $site['name'], 'location_description' => $site['description'], 'location_country' => 'United States', 'location_address' => $site['address'], 'location_city' => $site['city'], 'location_state' => $site['state'], 'location_zip' => $site['zip'], 'location_phone_country_code' => '1', 'location_phone' => $site['phone'], 'location_hours' => $site['hours'], 'location_primary' => $site_index === 0 ? 1 : 0, 'location_client_id' => $client_id, 'location_created_at' => demoDateTime($profile['age_days'], 9, 20), ]); demoAttachTags($mysqli, 'location_tags', 'location_id', 'tag_id', end($location_ids), 2, $site['tags']); } $primary_location_id = $location_ids[0] ?? 0; $counts['documentation'] += count($location_ids); // Contacts $contact_ids = []; foreach ($profile['people'] as $person_index => $person) { $email = demoContactEmail($person[0], $profile['domain']); $contact_id = starterInsert($mysqli, 'contacts', [ 'contact_name' => $person[0], 'contact_title' => $person[1], 'contact_email' => $email, 'contact_phone_country_code' => '1', 'contact_phone' => $profile['sites'][0]['phone'], 'contact_extension' => (string)(100 + ($person_index * 7)), 'contact_mobile_country_code' => '1', 'contact_mobile' => demoMobileNumber($profile['area_code'], $index, $person_index), 'contact_department' => $person[2], 'contact_primary' => $person_index === 0 ? 1 : 0, 'contact_important' => $person[3], 'contact_billing' => $person[4], 'contact_technical' => $person[5], 'contact_notes' => $person[6], 'contact_location_id' => $primary_location_id, 'contact_client_id' => $client_id, 'contact_created_at' => demoDateTime($profile['age_days'] - $person_index, 10, 5), ]); $contact_ids[] = $contact_id; demoAttachTags($mysqli, 'contact_tags', 'contact_id', 'tag_id', $contact_id, 3, $person[7]); $counts['contacts']++; } $primary_contact_id = $contact_ids[0] ?? 0; $technical_contact_id = $contact_ids[2] ?? $primary_contact_id; // A note on the client and on the technical contact starterInsert($mysqli, 'client_notes', [ 'client_note_type' => 'General', 'client_note' => $profile['account_note'], 'client_note_created_by' => $user_id, 'client_note_client_id' => $client_id, 'client_note_created_at' => demoDateTime(max(2, $profile['age_days'] - 30), 15, 40), ]); if ($technical_contact_id) { starterInsert($mysqli, 'contact_notes', [ 'contact_note_type' => 'General', 'contact_note' => 'Main day to day contact for approvals and site access.', 'contact_note_created_by' => $user_id, 'contact_note_contact_id' => $technical_contact_id, 'contact_note_created_at' => demoDateTime(max(2, $profile['age_days'] - 45), 11, 15), ]); } // Vendor the client owns (ISP, line of business software, etc) $vendor_id = starterInsert($mysqli, 'vendors', [ 'vendor_name' => $profile['vendor'][0], 'vendor_description' => $profile['vendor'][1], 'vendor_contact_name' => 'Business Support', 'vendor_phone_country_code' => '1', 'vendor_phone' => $profile['vendor'][2], 'vendor_email' => 'support@' . $profile['vendor'][3], 'vendor_website' => 'https://www.' . $profile['vendor'][3], 'vendor_hours' => '24/7', 'vendor_sla' => $profile['vendor'][4], 'vendor_account_number' => demoSerial($profile['abbreviation'] . 'vendor', 8), 'vendor_client_id' => $client_id, 'vendor_created_at' => demoDateTime($profile['age_days'] - 5, 13, 0), ]); $counts['documentation']++; // Assets $asset_ids = demoBuildAssets($mysqli, $profile, $client_id, $primary_location_id, $contact_ids, $vendor_id, $counts); // Networks $network_index = 0; foreach ($profile['networks'] as $network) { starterInsert($mysqli, 'networks', [ 'network_name' => $network[0], 'network_description' => $network[1], 'network_vlan' => $network[2], 'network' => $network[3], 'network_subnet' => '24', 'network_gateway' => $network[4], 'network_primary_dns' => $network[5], 'network_secondary_dns' => '1.1.1.1', 'network_dhcp_range' => $network[6], 'network_location_id' => $primary_location_id, 'network_client_id' => $client_id, 'network_created_at' => demoDateTime($profile['age_days'] - 8, 14, 30), ]); $network_index++; $counts['documentation']++; } // Domain and its certificate $domain_id = starterInsert($mysqli, 'domains', [ 'domain_name' => $profile['domain'], 'domain_description' => 'Primary domain - website and email', 'domain_expire' => demoDate(-1 * (60 + ($index * 21))), 'domain_ip' => '203.0.113.' . (10 + $index), 'domain_name_servers' => 'ns1.demo-registrar.example, ns2.demo-registrar.example', 'domain_mail_servers' => $profile['mail_platform'], 'domain_registrar' => $vendor_id, 'domain_client_id' => $client_id, 'domain_created_at' => demoDateTime($profile['age_days'] - 3, 9, 45), ]); starterInsert($mysqli, 'certificates', [ 'certificate_name' => 'www.' . $profile['domain'], 'certificate_description' => 'Public website certificate', 'certificate_domain' => 'www.' . $profile['domain'], 'certificate_issued_by' => $index % 2 === 0 ? "Let's Encrypt" : 'DigiCert', 'certificate_expire' => demoDate(-1 * (20 + ($index * 9))), 'certificate_domain_id' => $domain_id, 'certificate_client_id' => $client_id, 'certificate_created_at' => demoDateTime($profile['age_days'] - 3, 9, 50), ]); $counts['documentation'] += 2; // Credentials - only when the vault actually opened for this session if ($context['vault_open']) { foreach ($profile['credentials'] as $credential) { $credential_id = starterInsert($mysqli, 'credentials', [ 'credential_name' => $credential[0], 'credential_description' => $credential[1], 'credential_uri' => $credential[2], 'credential_username' => encryptCredentialEntry($credential[3]), 'credential_password' => encryptCredentialEntry(demoSerial($profile['abbreviation'] . $credential[0], 14) . '!aA1'), 'credential_note' => $credential[4], 'credential_client_id' => $client_id, 'credential_created_at' => demoDateTime($profile['age_days'] - 6, 16, 10), 'credential_password_changed_at' => demoDateTime(45 + $index, 16, 10), ]); demoAttachTags($mysqli, 'credential_tags', 'credential_id', 'tag_id', $credential_id, 4, $credential[5]); $counts['documentation']++; } } // Services foreach ($profile['services'] as $service) { starterInsert($mysqli, 'services', [ 'service_name' => $service[0], 'service_description' => $service[1], 'service_category' => $service[2], 'service_importance' => $service[3], 'service_backup' => $service[4], 'service_notes' => $service[5], 'service_review_due' => demoDate(-1 * (90 + ($index * 5))), 'service_client_id' => $client_id, 'service_created_at' => demoDateTime($profile['age_days'] - 10, 10, 30), ]); $counts['documentation']++; } // Software and licensing foreach ($profile['software'] as $software) { $software_id = starterInsert($mysqli, 'software', [ 'software_name' => $software[0], 'software_description' => $software[1], 'software_version' => $software[2], 'software_type' => $software[3], 'software_license_type' => $software[4], 'software_seats' => $software[5], 'software_purchase_reference' => 'PO-' . demoSerial($profile['abbreviation'] . $software[0], 6), 'software_purchase' => demoDate(300 + ($index * 3)), 'software_expire' => demoDate(-1 * (65 + ($index * 11))), 'software_notes' => $software[6], 'software_vendor_id' => $vendor_id, 'software_client_id' => $client_id, 'software_created_at' => demoDateTime($profile['age_days'] - 12, 11, 45), ]); starterInsert($mysqli, 'software_keys', [ 'software_key' => demoLicenseKey($profile['abbreviation'] . $software[0]), 'software_key_software_id' => $software_id, ]); $counts['documentation']++; } // Documentation, written at different points rather than all on day one $document_months = demoClientMonths($profile); foreach ($profile['documents'] as $document_index => $document) { $written_months_ago = max(0, $document_months - 1 - ($document_index * 6)); starterInsert($mysqli, 'documents', [ 'document_name' => $document[0], 'document_description' => $document[1], 'document_content' => $document[2], 'document_content_raw' => strip_tags($document[2]), 'document_client_visible' => 0, 'document_created_by' => $user_id, 'document_updated_by' => $user_id, 'document_client_id' => $client_id, 'document_created_at' => demoMonthDateTime($written_months_ago, 12 + $document_index, 15, 20), ], ['document_content', 'document_content_raw']); $counts['documentation']++; } // Support and billing $project_id = demoBuildProjects($mysqli, $profile, $client_id, $context, $counts); demoBuildTickets($mysqli, $profile, $index, $client_id, $contact_ids, $asset_ids, $primary_location_id, $project_id, $context, $counts); demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $context, $counts); // Calendar and mileage if ($context['calendar_id']) { starterInsert($mysqli, 'calendar_events', [ 'event_title' => $profile['abbreviation'] . ' - ' . $profile['onsite_visit'], 'event_location' => $profile['sites'][0]['address'] . ', ' . $profile['sites'][0]['city'], 'event_description' => 'Scheduled onsite visit', 'event_start' => demoDateTime(-1 * (3 + $index), 10, 0), 'event_end' => demoDateTime(-1 * (3 + $index), 12, 30), 'event_client_id' => $client_id, 'event_location_id' => $primary_location_id, 'event_calendar_id' => $context['calendar_id'], 'event_created_at' => demoDateTime(5, 9, 0), ]); } starterInsert($mysqli, 'trips', [ 'trip_date' => demoDate(12 + $index), 'trip_purpose' => 'Onsite - ' . $profile['onsite_visit'], 'trip_source' => 'Office', 'trip_destination' => $profile['sites'][0]['city'] . ', ' . $profile['sites'][0]['state'], 'trip_miles' => 8.5 + ($index * 2.5), 'round_trip' => 1, 'trip_user_id' => $user_id, 'trip_client_id' => $client_id, 'trip_created_at' => demoDateTime(12 + $index, 17, 30), ]); } // ------------------------------ // demoAttachTags // Tags are matched to the library by name - anything not present is skipped // rather than created, so the Tags starter pack stays the single source. // NOTE asset_tags does not follow the convention the other four join tables // use, hence the explicit tag column rather than a hard coded tag_id. // ------------------------------ function demoAttachTags($mysqli, $join_table, $join_column, $tag_column, $row_id, $tag_type, $tag_names) { foreach ($tag_names as $tag_name) { $tag_name = escapeSql($tag_name); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_id FROM tags WHERE tag_name = '$tag_name' AND tag_type = $tag_type LIMIT 1")); $tag_id = intval($row['tag_id'] ?? 0); if ($tag_id) { mysqli_query($mysqli, "INSERT INTO $join_table SET $join_column = $row_id, $tag_column = $tag_id"); } } } // ------------------------------ // demoContactEmail // ------------------------------ function demoContactEmail($name, $domain) { $parts = explode(' ', mb_strtolower($name)); $first = $parts[0] ?? 'user'; $last = end($parts); return mb_substr($first, 0, 1) . preg_replace('/[^a-z]/', '', $last) . '@' . $domain; } // ------------------------------ // demoMobileNumber // 555-01xx is the reserved fictional range - nothing here can dial a real desk. // ------------------------------ function demoMobileNumber($area_code, $index, $offset) { return $area_code . '-555-' . str_pad((string)(100 + ($index * 9) + $offset), 4, '0', STR_PAD_LEFT); } // ------------------------------ // demoLicenseKey // ------------------------------ function demoLicenseKey($seed) { $raw = demoSerial($seed, 20); return implode('-', str_split($raw, 5)); } // ------------------------------ // demoBuildAssets // Builds the fleet from the profile's counts, named the way an MSP names kit. // ------------------------------ function demoBuildAssets($mysqli, $profile, $client_id, $location_id, $contact_ids, $vendor_id, &$counts) { $models = demoAssetModels(); $prefixes = demoAssetPrefixes(); $asset_ids = []; $seed = 0; foreach ($profile['fleet'] as $asset_type => $quantity) { $type_models = $models[$asset_type] ?? [['Generic', 'Standard']]; $prefix = $prefixes[$asset_type] ?? 'AST'; for ($number = 1; $number <= $quantity; $number++) { $model = $type_models[($number - 1) % count($type_models)]; $seed++; // Workstations and laptops belong to a person, infrastructure does not $contact_id = 0; if (in_array($asset_type, ['Desktop', 'Laptop', 'Mobile Phone', 'Tablet']) && $contact_ids) { $contact_id = $contact_ids[($number - 1) % count($contact_ids)]; } $purchase_days = 400 + ($seed * 17); $status = 'Deployed'; if ($number === $quantity && $quantity > 3) { $status = 'Ready to Deploy'; } $asset_id = starterInsert($mysqli, 'assets', [ 'asset_type' => $asset_type, 'asset_name' => $profile['abbreviation'] . '-' . $prefix . '-' . str_pad((string)$number, 2, '0', STR_PAD_LEFT), 'asset_description' => demoAssetDescription($asset_type), 'asset_make' => $model[0], 'asset_model' => $model[1], 'asset_serial' => demoSerial($profile['abbreviation'] . $asset_type . $number), 'asset_os' => demoAssetOs($asset_type, $number), 'asset_status' => $status, 'asset_purchase_reference' => 'PO-' . demoSerial($profile['abbreviation'] . 'po' . $seed, 6), 'asset_purchase_date' => demoDate($purchase_days), 'asset_warranty_expire' => demoDate($purchase_days - 1095), 'asset_install_date' => demoDate($purchase_days - 14), 'asset_physical_location' => demoAssetPhysicalLocation($asset_type), 'asset_vendor_id' => $vendor_id, 'asset_location_id' => $location_id, 'asset_contact_id' => $contact_id, 'asset_client_id' => $client_id, 'asset_created_at' => demoDateTime(max(1, $profile['age_days'] - 20), 12, 0), ]); $asset_ids[] = $asset_id; demoAttachTags($mysqli, 'asset_tags', 'asset_tag_asset_id', 'asset_tag_tag_id', $asset_id, 5, demoAssetTags($asset_type)); $counts['assets']++; } } return $asset_ids; } // ------------------------------ // demoAssetDescription / demoAssetOs / demoAssetPhysicalLocation / demoAssetTags // ------------------------------ function demoAssetDescription($asset_type) { $descriptions = [ 'Firewall/Router' => 'Edge firewall - site to site VPN and content filtering', 'Switch' => 'Access layer switch - PoE for phones and access points', 'Access Point' => 'Ceiling mounted wireless access point', 'Server' => 'Line of business application and file server', 'Desktop' => 'Standard staff workstation', 'Laptop' => 'Mobile staff laptop', 'Printer' => 'Shared multifunction printer', 'Virtual Machine' => 'Virtual server guest', 'Phone' => 'Desk handset', 'Tablet' => 'Shared tablet', 'Mobile Phone' => 'Company mobile handset', 'Camera' => 'Site security camera', ]; return $descriptions[$asset_type] ?? 'Client equipment'; } function demoAssetOs($asset_type, $number) { $systems = [ 'Firewall/Router' => 'FortiOS 7.4.4', 'Switch' => 'Managed switch firmware', 'Access Point' => 'UniFi 7.0', 'Server' => 'Windows Server 2022 Standard', 'Desktop' => 'Windows 11 Pro 24H2', 'Laptop' => 'Windows 11 Pro 24H2', 'Printer' => 'Vendor firmware', 'Virtual Machine' => 'Ubuntu Server 24.04 LTS', 'Phone' => 'Vendor firmware', 'Tablet' => 'iPadOS 18', 'Mobile Phone' => 'iOS 18', 'Camera' => 'Vendor firmware', ]; if ($asset_type === 'Desktop' && $number % 4 === 0) { return 'Windows 10 Pro 22H2'; } return $systems[$asset_type] ?? ''; } function demoAssetPhysicalLocation($asset_type) { if (in_array($asset_type, ['Firewall/Router', 'Switch', 'Server', 'Virtual Machine'])) { return 'Server room rack'; } if ($asset_type === 'Access Point') { return 'Ceiling - open office'; } if ($asset_type === 'Printer') { return 'Copy room'; } return 'Staff desk'; } function demoAssetTags($asset_type) { $tags = [ 'Firewall/Router' => ['Firewall', 'Business Critical', 'Monitored'], 'Switch' => ['Switch', 'Monitored'], 'Access Point' => ['Access Point', 'Monitored'], 'Server' => ['Server', 'Business Critical', 'Backed Up', 'Monitored'], 'Desktop' => ['Workstation', 'Endpoint Protection'], 'Laptop' => ['Laptop', 'Endpoint Protection'], 'Printer' => ['Printer'], 'Virtual Machine' => ['Server', 'Backed Up'], 'Phone' => ['VoIP Handset'], 'Tablet' => ['Mobile'], 'Mobile Phone' => ['Mobile'], ]; return $tags[$asset_type] ?? []; } // ------------------------------ // demoMonthDateTime / demoMonthDate // Anchored to a month rather than a day count, so a two year run lands on // sensible calendar months instead of drifting. // ------------------------------ function demoMonthDateTime($months_ago, $day, $hour = 9, $minute = 0) { return date('Y-m-d H:i:s', mktime($hour, $minute, 0, date('n') - $months_ago, $day, date('Y'))); } function demoMonthDate($months_ago, $day) { return date('Y-m-d', mktime(12, 0, 0, date('n') - $months_ago, $day, date('Y'))); } // ------------------------------ // demoIsFuture // Month anchored rows can land past today depending on what day of the month // the load is run - a payroll run dated the 28th when it is the 24th. Money // that has not happened yet has no business in the ledger, so it is skipped. // ------------------------------ function demoIsFuture($date) { return strtotime($date) > time(); } // ------------------------------ // demoClientMonths // How many months of history a client gets - their whole life with us, capped // at the two year window. Newer clients ramp up rather than appearing fully // formed, which is what makes the trend charts worth looking at. // ------------------------------ function demoClientMonths($profile) { return max(3, min(24, (int)floor($profile['age_days'] / 30))); } // ------------------------------ // demoBuildProjects // A finished project behind them and, for some, one still running. Returns // the live project ID so tickets can be filed against it. // ------------------------------ function demoBuildProjects($mysqli, $profile, $client_id, $context, &$counts) { global $config_project_prefix; $user_id = $context['user_id']; $prefix = $config_project_prefix ?? ''; $months = demoClientMonths($profile); $active_project_id = 0; // Something delivered last year, for the history if ($months >= 14) { starterInsert($mysqli, 'projects', [ 'project_prefix' => $prefix, 'project_number' => demoNextNumber($mysqli, 'config_project_next_number'), 'project_name' => 'Server and network refresh', 'project_description' => 'Replaced end of life server hardware, rebuilt the switching and brought the site onto the standard build.', 'project_due' => demoMonthDate(13, 20), 'project_manager' => $user_id, 'project_client_id' => $client_id, 'project_created_at' => demoMonthDateTime(15, 4, 9, 30), 'project_completed_at' => demoMonthDateTime(13, 18, 16, 45), ]); $counts['projects']++; } // And whatever is live now if (!empty($profile['project'])) { $active_project_id = starterInsert($mysqli, 'projects', [ 'project_prefix' => $prefix, 'project_number' => demoNextNumber($mysqli, 'config_project_next_number'), 'project_name' => $profile['project'][0], 'project_description' => $profile['project'][1], 'project_due' => demoMonthDate(-2, 15), 'project_manager' => $user_id, 'project_client_id' => $client_id, 'project_created_at' => demoMonthDateTime(1, 8, 10, 0), ]); $counts['projects']++; } return $active_project_id; } // ------------------------------ // demoBuildTickets // Two years of closed work behind a live queue. The history is what makes the // ticket, time and technician reports worth opening; the six staged tickets at // the front are what the queue and kanban look like today. // ------------------------------ function demoBuildTickets($mysqli, $profile, $index, $client_id, $contact_ids, $asset_ids, $location_id, $project_id, $context, &$counts) { global $config_ticket_prefix; $user_id = $context['user_id']; $pool = demoTicketPool(); $pool_size = count($pool); $prefix = $config_ticket_prefix ?? ''; $months = demoClientMonths($profile); $sources = ['Email', 'Agent', 'Portal']; // Category IDs are the same handful over and over - look them up once $category_ids = []; foreach ($pool as $entry) { if (!isset($category_ids[$entry['category']])) { $category_ids[$entry['category']] = starterCategoryId($mysqli, $entry['category'], 'Ticket'); } } // ---- Closed history, month by month ---- $per_month = max(2, (int)floor($profile['seats'] / 10)); $sequence = 0; for ($month = $months; $month >= 1; $month--) { for ($n = 0; $n < $per_month; $n++) { $sequence++; $ticket = $pool[(($index * 5) + $sequence) % $pool_size]; $day = 2 + ((($index + $month + $n) * 7) % 25); $hour = 8 + (($sequence + $n) % 9); $contact_id = $contact_ids ? $contact_ids[$sequence % count($contact_ids)] : 0; $asset_id = $asset_ids ? $asset_ids[$sequence % count($asset_ids)] : 0; $ticket_id = starterInsert($mysqli, 'tickets', [ 'ticket_prefix' => $prefix, 'ticket_number' => demoNextNumber($mysqli, 'config_ticket_next_number'), 'ticket_source' => $sources[$sequence % 3], 'ticket_category' => (string)$category_ids[$ticket['category']], 'ticket_subject' => $ticket['subject'], 'ticket_details' => $ticket['details'], 'ticket_priority' => $ticket['priority'], 'ticket_status' => $context['status_closed'], 'ticket_billable' => $ticket['billable'], 'ticket_url_key' => randomString(32), 'ticket_created_at' => demoMonthDateTime($month, $day, $hour, 20), 'ticket_first_response_at' => demoMonthDateTime($month, $day, $hour + 1, 5), 'ticket_resolved_at' => demoMonthDateTime($month, $day + 1, 14, 30), 'ticket_closed_at' => demoMonthDateTime($month, $day + 2, 9, 15), 'ticket_created_by' => $user_id, 'ticket_assigned_to' => $user_id, 'ticket_closed_by' => $user_id, 'ticket_contact_id' => $contact_id, 'ticket_location_id' => $location_id, 'ticket_asset_id' => $asset_id, 'ticket_client_id' => $client_id, ], ['ticket_details']); if ($asset_id) { mysqli_query($mysqli, "INSERT INTO ticket_assets SET ticket_id = $ticket_id, asset_id = $asset_id"); } // The agent reply carries the time, which is what the time reports read $worked = ''; foreach ($ticket['replies'] as $reply) { if ($reply[0] !== 'Client' && !empty($reply[2])) { $worked = $reply[2]; starterInsert($mysqli, 'ticket_replies', [ 'ticket_reply' => $reply[1], 'ticket_reply_type' => $reply[0], 'ticket_reply_time_worked' => $reply[2], 'ticket_reply_by' => $user_id, 'ticket_reply_ticket_id' => $ticket_id, 'ticket_reply_created_at' => demoMonthDateTime($month, $day + 1, 13, 45), ], ['ticket_reply']); break; } } starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'New', 'ticket_history_description' => 'Ticket created', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoMonthDateTime($month, $day, $hour, 20), ]); starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'Resolved', 'ticket_history_description' => 'Ticket resolved', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoMonthDateTime($month, $day + 1, 14, 30), ]); starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'Closed', 'ticket_history_description' => 'Ticket closed', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoMonthDateTime($month, $day + 2, 9, 15), ]); $counts['tickets']++; } } // ---- The live queue ---- // created / first response / resolved / closed, in days back from today $stages = [ ['status' => $context['status_closed'], 'created' => 74, 'response' => 74, 'resolved' => 71, 'closed' => 68], ['status' => $context['status_closed'], 'created' => 41, 'response' => 41, 'resolved' => 39, 'closed' => 36], ['status' => $context['status_resolved'], 'created' => 17, 'response' => 17, 'resolved' => 3, 'closed' => 0], ['status' => $context['status_open'], 'created' => 6, 'response' => 6, 'resolved' => 0, 'closed' => 0], ['status' => $index % 2 === 0 ? $context['status_hold'] : $context['status_open'], 'created' => 2, 'response' => 2, 'resolved' => 0, 'closed' => 0], ['status' => $context['status_new'], 'created' => 0, 'response' => 0, 'resolved' => 0, 'closed' => 0], ]; foreach ($stages as $slot => $stage) { $ticket = $pool[(($index * 6) + $slot) % $pool_size]; $contact_id = $contact_ids ? $contact_ids[$slot % count($contact_ids)] : 0; $asset_id = $asset_ids ? $asset_ids[($index + $slot) % count($asset_ids)] : 0; // The newest ticket is deliberately left unassigned - a real queue has one $assigned_to = $stage['status'] === $context['status_new'] ? 0 : $user_id; $fields = [ 'ticket_prefix' => $prefix, 'ticket_number' => demoNextNumber($mysqli, 'config_ticket_next_number'), 'ticket_source' => $sources[$slot % 3], 'ticket_category' => (string)$category_ids[$ticket['category']], 'ticket_subject' => $ticket['subject'], 'ticket_details' => $ticket['details'], 'ticket_priority' => $ticket['priority'], 'ticket_status' => $stage['status'], 'ticket_billable' => $ticket['billable'], 'ticket_url_key' => randomString(32), 'ticket_created_at' => demoDateTime($stage['created'], 8 + $slot, 25), 'ticket_created_by' => $user_id, 'ticket_assigned_to' => $assigned_to, 'ticket_contact_id' => $contact_id, 'ticket_location_id' => $location_id, 'ticket_asset_id' => $asset_id, 'ticket_client_id' => $client_id, ]; // Hang a couple of the live tickets off the running project if ($project_id && in_array($slot, [3, 4])) { $fields['ticket_project_id'] = $project_id; } if ($stage['status'] !== $context['status_new']) { $fields['ticket_first_response_at'] = demoDateTime($stage['response'], 9 + $slot, 40); } if ($stage['resolved']) { $fields['ticket_resolved_at'] = demoDateTime($stage['resolved'], 15, 10); } if ($stage['closed']) { $fields['ticket_closed_at'] = demoDateTime($stage['closed'], 15, 30); $fields['ticket_closed_by'] = $user_id; } $ticket_id = starterInsert($mysqli, 'tickets', $fields, ['ticket_details']); $counts['tickets']++; if ($asset_id) { mysqli_query($mysqli, "INSERT INTO ticket_assets SET ticket_id = $ticket_id, asset_id = $asset_id"); } // History - opened, worked, and where it ended up starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'New', 'ticket_history_description' => 'Ticket created', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoDateTime($stage['created'], 8 + $slot, 25), ]); if ($stage['status'] !== $context['status_new']) { starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'Open', 'ticket_history_description' => 'Assigned and first response sent', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoDateTime($stage['response'], 9 + $slot, 40), ]); } if ($stage['resolved']) { starterInsert($mysqli, 'ticket_history', [ 'ticket_history_status' => 'Resolved', 'ticket_history_description' => 'Ticket resolved', 'ticket_history_ticket_id' => $ticket_id, 'ticket_history_created_at' => demoDateTime($stage['resolved'], 15, 10), ]); } // Replies - the client raising it, the agent working it, and internal notes $reply_day = $stage['created']; foreach ($ticket['replies'] as $reply_index => $reply) { $reply_day = max($stage['resolved'], $reply_day - 1); $reply_by = $reply[0] === 'Client' ? $contact_id : $user_id; $reply_fields = [ 'ticket_reply' => $reply[1], 'ticket_reply_type' => $reply[0], 'ticket_reply_by' => $reply_by, 'ticket_reply_ticket_id' => $ticket_id, 'ticket_reply_created_at' => demoDateTime($reply_day, 10 + $reply_index, 15), ]; // Only agent time is billable time - client replies never book time if ($reply[0] !== 'Client' && !empty($reply[2])) { $reply_fields['ticket_reply_time_worked'] = $reply[2]; } starterInsert($mysqli, 'ticket_replies', $reply_fields, ['ticket_reply']); } // Tasks on the live tickets only if (!empty($ticket['tasks']) && !$stage['closed']) { foreach ($ticket['tasks'] as $task_order => $task) { $task_fields = [ 'task_name' => $task, 'task_order' => $task_order + 1, 'task_completion_estimate' => 15, 'task_ticket_id' => $ticket_id, 'task_created_at' => demoDateTime($stage['created'], 8 + $slot, 30), ]; if ($task_order === 0) { $task_fields['task_completed_at'] = demoDateTime(max(0, $stage['created'] - 1), 11, 0); $task_fields['task_completed_by'] = $user_id; } starterInsert($mysqli, 'tasks', $task_fields); } } } // A standing recurring ticket - the monthly maintenance visit $recurring_category_id = starterCategoryId($mysqli, 'Maintenance', 'Ticket'); $recurring_ticket_id = starterInsert($mysqli, 'recurring_tickets', [ 'recurring_ticket_category' => (string)$recurring_category_id, 'recurring_ticket_subject' => $profile['recurring_ticket'], 'recurring_ticket_details' => '
Scheduled maintenance visit. Work the task list, log time against this ticket, and note anything needing quoting.
', 'recurring_ticket_priority' => 'Low', 'recurring_ticket_frequency' => 'Monthly', 'recurring_ticket_billable' => 0, 'recurring_ticket_start_date' => demoMonthDate($months, 1), 'recurring_ticket_next_run' => demoDate(-1 * (5 + ($index % 20))), 'recurring_ticket_created_by' => $user_id, 'recurring_ticket_assigned_to' => $user_id, 'recurring_ticket_client_id' => $client_id, 'recurring_ticket_contact_id' => $contact_ids[0] ?? 0, 'recurring_ticket_created_at' => demoMonthDateTime($months, 1, 9, 0), ], ['recurring_ticket_details']); $recurring_tasks = ['Check backup job results', 'Review patch compliance', 'Check disk and memory headroom', 'Walk the site for anything obviously broken']; foreach ($recurring_tasks as $task_order => $task) { starterInsert($mysqli, 'recurring_ticket_tasks', [ 'recurring_ticket_task_name' => $task, 'recurring_ticket_task_order' => $task_order + 1, 'recurring_ticket_task_completion_estimate' => 15, 'recurring_ticket_task_recurring_ticket_id' => $recurring_ticket_id, ]); } $counts['tickets']++; } // ------------------------------ // demoAssetModels / demoAssetPrefixes // ------------------------------ function demoAssetModels() { return [ 'Firewall/Router' => [['Fortinet', 'FortiGate 60F'], ['SonicWall', 'TZ 470'], ['Ubiquiti', 'Dream Machine Pro']], 'Switch' => [['Cisco', 'CBS350-24P'], ['Ubiquiti', 'USW-Pro-24-PoE'], ['NETGEAR', 'GS724TPv2']], 'Access Point' => [['Ubiquiti', 'U6-Pro'], ['Cisco', 'CBW150AX'], ['RUCKUS', 'R550']], 'Server' => [['Dell', 'PowerEdge R650'], ['HPE', 'ProLiant DL360 Gen10'], ['Dell', 'PowerEdge T440']], 'Virtual Machine' => [['Proxmox', 'QEMU Guest'], ['VMware', 'vSphere Guest']], 'Desktop' => [['Dell', 'OptiPlex 7010'], ['HP', 'ProDesk 600 G9'], ['Lenovo', 'ThinkCentre M70q']], 'Laptop' => [['Dell', 'Latitude 5450'], ['HP', 'EliteBook 840 G10'], ['Lenovo', 'ThinkPad T14 Gen 4']], 'Printer' => [['HP', 'LaserJet Pro M404dn'], ['Brother', 'MFC-L8900CDW'], ['Canon', 'imageRUNNER 1643i']], 'Phone' => [['Yealink', 'T46U'], ['Poly', 'VVX 450']], 'Tablet' => [['Apple', 'iPad 10th Gen'], ['Samsung', 'Galaxy Tab A9']], 'Mobile Phone' => [['Apple', 'iPhone 14'], ['Samsung', 'Galaxy S23']], 'Camera' => [['Ubiquiti', 'G4 Bullet'], ['Axis', 'M2036-LE']], ]; } function demoAssetPrefixes() { return [ 'Firewall/Router' => 'FW', 'Switch' => 'SW', 'Access Point' => 'AP', 'Server' => 'SRV', 'Virtual Machine' => 'VM', 'Desktop' => 'WS', 'Laptop' => 'LT', 'Printer' => 'PRN', 'Phone' => 'PH', 'Tablet' => 'TAB', 'Mobile Phone' => 'MOB', 'Camera' => 'CAM', ]; } // ------------------------------ // demoTicketPool // The work an MSP actually sees. Each client draws six at a rolling offset, so // the same issue turning up at more than one client is intentional. // ------------------------------ function demoTicketPool() { return [ [ 'subject' => 'Outlook stuck on disconnected after password reset', 'category' => 'Microsoft 365', 'priority' => 'Medium', 'billable' => 1, 'details' => 'User reset their password this morning and Outlook has been sat on Disconnected since. Webmail works fine.
', 'replies' => [ ['Client', 'Still not connecting after a reboot. Can someone take a look today?
', ''], ['Public', 'Cleared the cached credential in Credential Manager and re-authenticated the profile. Mail is flowing again - let me know if it drops.
', '00:25:00'], ['Internal', 'Stale credential left over from the reset. Worth adding to the password change checklist.
', '00:05:00'], ], 'tasks' => ['Clear cached credentials', 'Re-authenticate profile', 'Confirm mail flow with user'], ], [ 'subject' => 'Workstation running very slowly since last week', 'category' => 'Workstation', 'priority' => 'Low', 'billable' => 1, 'details' => 'Machine takes several minutes to get to a usable desktop and browsing is sluggish once it does.
', 'replies' => [ ['Public', 'Remoted on - disk was at 100% with a stalled update. Cleared the update cache, applied pending patches and rebooted. Boot time is back to normal.
', '00:45:00'], ['Internal', 'Drive is a spinning disk and is on the refresh list. Flagged for the next hardware quote.
', '00:10:00'], ], 'tasks' => ['Check resource usage', 'Clear update cache', 'Confirm with user'], ], [ 'subject' => 'Cannot print to the front office printer', 'category' => 'Printer', 'priority' => 'Medium', 'billable' => 0, 'details' => 'Nothing comes out from any of the front desk machines. The printer display shows ready.
', 'replies' => [ ['Client', 'It was working yesterday afternoon.
', ''], ['Public', 'The printer had picked up a new DHCP address. Set a reservation on the firewall and repointed the print queues. Test pages came out on all three machines.
', '00:35:00'], ], 'tasks' => ['Check printer IP', 'Set DHCP reservation', 'Repoint print queues'], ], [ 'subject' => 'Site internet dropping intermittently', 'category' => 'Network', 'priority' => 'High', 'billable' => 1, 'details' => 'Connection drops for a minute or two several times an hour. Affecting card processing and phones.
', 'replies' => [ ['Public', 'Logged into the firewall - WAN interface is flapping. Opened a ticket with the ISP and gave them the timestamps.
', '00:40:00'], ['Internal', 'ISP found a bad line card at the pole. Replacement scheduled. Monitor for another 48 hours before closing.
', '00:15:00'], ], 'tasks' => ['Pull firewall WAN logs', 'Raise ISP ticket', 'Monitor for 48 hours'], ], [ 'subject' => 'New starter setup - workstation, mailbox and access', 'category' => 'Onboarding', 'priority' => 'Medium', 'billable' => 1, 'details' => 'New hire starting a week Monday. Needs the standard build, a mailbox and access to the shared drives.
', 'replies' => [ ['Public', 'Machine is built and joined, mailbox is licensed, and the group memberships match the department template. Credentials will be handed over on their first morning.
', '01:30:00'], ], 'tasks' => ['Build and join workstation', 'Create mailbox and assign licence', 'Apply department group memberships', 'Handover on day one'], ], [ 'subject' => 'Suspicious invoice email reported by staff', 'category' => 'Phishing Report', 'priority' => 'High', 'billable' => 0, 'details' => 'Staff member forwarded an emailed invoice from what looks like a supplier address but with a changed bank account.
', 'replies' => [ ['Client', 'Nobody clicked anything as far as I know. Wanted to flag it straight away.
', ''], ['Public', 'Confirmed a spoofed display name from an external domain. Blocked the sender, checked mail rules on the mailbox, and confirmed no forwarding rules were added. Good catch reporting it.
', '00:50:00'], ['Internal', 'Worth a short reminder to the finance team about verifying bank detail changes by phone.
', '00:10:00'], ], 'tasks' => ['Confirm sender and headers', 'Block sender', 'Audit mailbox rules', 'Confirm no credential entry'], ], [ 'subject' => 'Backup job reporting failures overnight', 'category' => 'Backup and Recovery', 'priority' => 'High', 'billable' => 0, 'details' => 'Backup console has flagged three consecutive failed runs on the file server.
', 'replies' => [ ['Public', 'Snapshot was failing on a locked database file. Excluded the live file and added the database dump to the job. Last two runs are green and a test restore completed.
', '01:10:00'], ['Internal', 'Restore test evidence saved with the client documentation for the compliance file.
', '00:15:00'], ], 'tasks' => ['Review job logs', 'Adjust job scope', 'Run a test restore', 'File restore evidence'], ], [ 'subject' => 'Wireless dropping in the back offices', 'category' => 'Wireless', 'priority' => 'Medium', 'billable' => 1, 'details' => 'Staff at the far end of the building lose wireless when they move between rooms.
', 'replies' => [ ['Public', 'Survey shows a dead spot behind the store room. Adjusted transmit power on the nearest access point as a stopgap and quoted an additional unit for proper coverage.
', '01:00:00'], ], 'tasks' => ['Survey coverage', 'Adjust AP power', 'Quote additional access point'], ], [ 'subject' => 'Shared drive permissions wrong after department move', 'category' => 'Account and Access', 'priority' => 'Medium', 'billable' => 1, 'details' => 'Two staff moved departments and can still see their old folders but not the new ones.
', 'replies' => [ ['Public', 'Group memberships were updated but the sessions had not refreshed. Corrected the groups, removed the leftover membership and confirmed access after sign out and back in.
', '00:30:00'], ], 'tasks' => ['Audit group membership', 'Correct permissions', 'Confirm access with both users'], ], [ 'subject' => 'Server disk space warning', 'category' => 'Monitoring Alert', 'priority' => 'High', 'billable' => 0, 'details' => 'Monitoring raised a low disk space alert on the system volume - under 8% free.
', 'replies' => [ ['Public', 'Cleared shadow copies and old update files, which recovered enough headroom for now. Recommend extending the volume at the next maintenance window.
', '00:40:00'], ['Internal', 'Volume extension needs a short reboot - put it on the next scheduled visit.
', '00:05:00'], ], 'tasks' => ['Free immediate space', 'Identify growth', 'Schedule volume extension'], ], [ 'subject' => 'Staff member leaving - offboarding', 'category' => 'Offboarding', 'priority' => 'Medium', 'billable' => 1, 'details' => 'Leaving at the end of the week. Mailbox needs to be delegated to their manager and the laptop collected.
', 'replies' => [ ['Public', 'Account disabled at close of business, mailbox converted and delegated, licence reclaimed and the laptop wiped and returned to spare stock.
', '01:15:00'], ], 'tasks' => ['Disable account', 'Delegate mailbox', 'Reclaim licence', 'Collect and wipe laptop'], ], [ 'subject' => 'Phones not ringing at the front desk', 'category' => 'Phone and VoIP', 'priority' => 'High', 'billable' => 1, 'details' => 'Inbound calls go straight to voicemail on the front desk handsets.
', 'replies' => [ ['Public', 'The ring group had lost one of its members after a handset was swapped. Re-added the extension and tested inbound from an outside line.
', '00:35:00'], ], 'tasks' => ['Check ring group', 'Re-add extension', 'Test inbound call'], ], [ 'subject' => 'Line of business application will not launch', 'category' => 'Line of Business Application', 'priority' => 'High', 'billable' => 1, 'details' => 'Application closes immediately on launch for two users since this morning.
', 'replies' => [ ['Client', 'Everyone else seems fine. It is just the two of us.
', ''], ['Public', 'Vendor client version was behind the server after their update. Updated both machines to the matching build and confirmed login.
', '00:55:00'], ['Internal', 'Vendor pushes server side updates without notice - worth asking them to add us to the release list.
', '00:10:00'], ], 'tasks' => ['Confirm client version', 'Update to matching build', 'Confirm login with both users'], ], [ 'subject' => 'Laptop screen cracked - needs replacement', 'category' => 'Hardware Failure', 'priority' => 'Medium', 'billable' => 1, 'details' => 'Laptop was dropped in transit. Screen is cracked and the display is unusable.
', 'replies' => [ ['Public', 'Unit is out of warranty. Quoted a replacement and issued a loaner from spare stock so they can keep working.
', '00:45:00'], ], 'tasks' => ['Check warranty status', 'Issue loaner', 'Quote replacement'], ], [ 'subject' => 'Multi factor prompts looping on mobile', 'category' => 'Account and Access', 'priority' => 'Medium', 'billable' => 0, 'details' => 'Authenticator prompts keep repeating and never complete on the user mobile.
', 'replies' => [ ['Public', 'Removed and re-registered the authenticator entry, then confirmed sign in on both mobile and desktop.
', '00:30:00'], ], 'tasks' => ['Re-register authenticator', 'Confirm sign in'], ], [ 'subject' => 'Quote request - workstation refresh', 'category' => 'Procurement', 'priority' => 'Low', 'billable' => 0, 'details' => 'Client has asked for pricing on replacing the oldest machines in the office.
', 'replies' => [ ['Public', 'Pulled the asset list and put together pricing for the machines past five years old. Quote sent for review.
', '00:40:00'], ], 'tasks' => ['Pull asset age report', 'Build quote', 'Send for approval'], ], ]; } // ------------------------------ // demoDataProfiles // Ten fictional clients. Domains sit on the RFC 2606 reserved .example TLD on // purpose - demo contacts must never be deliverable and a demo domain must // never resolve to somebody's real business if a refresh gets clicked. // The compact spec below is expanded into full profiles underneath it. // ------------------------------ function demoDataProfiles() { $specs = [ [ 'name' => 'Ravenwood Dental Group', 'abbreviation' => 'RDG', 'type' => 'Healthcare', 'domain' => 'ravenwooddental.example', 'referral' => 'Client', 'rate' => 145.00, 'terms' => 15, 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15212', 'area' => '412', 'street' => '1140 Ridge Avenue', 'second_site' => ['Wexford Practice', '3025 Church Road', 'Wexford', 'PA', '15090'], 'age_days' => 880, 'seats' => 26, 'servers' => 2, 'tags' => ['Managed', 'Key Account', 'Compliance', 'Multi Site'], 'notes' => 'Two practices sharing one patient system. Any work touching the imaging server needs to be out of clinic hours.', 'account_note' => 'Practice owner signs off anything over $500. Quarterly review is booked with the office manager, not the owner.', 'onsite_visit' => 'Quarterly review and maintenance', 'recurring_ticket' => 'Monthly maintenance visit - Ravenwood Dental', 'project' => ['Imaging server replacement', 'Replace the end of life imaging server and migrate the patient imaging database with no clinic downtime.'], 'vendor' => ['Keystone Business Fiber', 'Primary internet and failover circuit', '412-555-0140', 'keystonefiber.example', '4 hour response'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 4, 'Server' => 2, 'Desktop' => 14, 'Laptop' => 3, 'Printer' => 3, 'Phone' => 8], 'people' => [ ['Marla Whitfield', 'Practice Owner', 'Management', 1, 1, 0, 'Signs off spend. Prefers a phone call over email.', ['Primary', 'Executive', 'Authorized Approver']], ['Dana Prescott', 'Office Manager', 'Administration', 1, 1, 0, 'Day to day contact for scheduling and invoices.', ['Billing', 'Onsite Point of Contact']], ['Owen Castellano', 'Operations Lead', 'Operations', 0, 0, 1, 'Knows the imaging system better than the vendor does.', ['Technical', 'After Hours']], ['Priya Raghavan', 'Front Desk Supervisor', 'Reception', 0, 0, 0, 'First to report anything broken at the front desk.', []], ], ], [ 'name' => 'Kestrel Precision Manufacturing', 'abbreviation' => 'KPM', 'type' => 'Manufacturing', 'domain' => 'kestrelprecision.example', 'referral' => 'Networking Group', 'rate' => 155.00, 'terms' => 30, 'city' => 'Erie', 'state' => 'PA', 'zip' => '16510', 'area' => '814', 'street' => '4400 Foundry Way', 'second_site' => ['Plant Two', '820 Industrial Parkway', 'Erie', 'PA', '16511'], 'age_days' => 1240, 'seats' => 42, 'servers' => 4, 'tags' => ['Managed', 'Key Account', 'Multi Site', 'After Hours Support'], 'notes' => 'Shop floor runs two shifts. Anything touching the ERP has to wait for the Sunday window.', 'account_note' => 'Plant manager owns the maintenance window. ERP vendor must be on any call involving the production database.', 'onsite_visit' => 'Shop floor equipment check', 'recurring_ticket' => 'Monthly maintenance visit - Kestrel Precision', 'project' => ['Plant Two network rebuild', 'Replace the unmanaged switching in Plant Two, add VLAN separation for shop floor equipment and extend wireless to the yard.'], 'vendor' => ['Lakeshore Telecom', 'Fibre circuits for both plants', '814-555-0175', 'lakeshoretelecom.example', 'Next business day'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 2, 'Switch' => 6, 'Access Point' => 8, 'Server' => 3, 'Virtual Machine' => 4, 'Desktop' => 22, 'Laptop' => 9, 'Printer' => 4, 'Phone' => 14, 'Camera' => 6], 'people' => [ ['Grant Holloway', 'Operations Director', 'Management', 1, 0, 0, 'Approves project work and downtime windows.', ['Primary', 'Executive', 'Authorized Approver']], ['Renata Vasquez', 'Finance Manager', 'Finance', 0, 1, 0, 'Handles purchase orders - invoices need a PO number.', ['Billing']], ['Tobias Lindqvist', 'Maintenance Supervisor', 'Operations', 1, 0, 1, 'Site contact for anything on the shop floor.', ['Technical', 'Onsite Point of Contact', 'After Hours']], ['Alicia Freeman', 'Quality Coordinator', 'Quality', 0, 0, 0, 'Owns the audit paperwork that our documentation feeds.', ['Emergency']], ], ], [ 'name' => 'Blackburn and Meyer LLP', 'abbreviation' => 'BML', 'type' => 'Legal', 'domain' => 'blackburnmeyer.example', 'referral' => 'Partner', 'rate' => 165.00, 'terms' => 15, 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15222', 'area' => '412', 'street' => '600 Grant Street, Suite 1900', 'second_site' => null, 'age_days' => 700, 'seats' => 18, 'servers' => 2, 'tags' => ['Managed', 'Compliance', 'Cyber Insurance', 'Key Account'], 'notes' => 'Cyber insurance renewal drives most of the security work here. Everything is documented for the questionnaire.', 'account_note' => 'Managing partner wants a written summary after any security incident, however small.', 'onsite_visit' => 'Security review and staff walkthrough', 'recurring_ticket' => 'Monthly maintenance visit - Blackburn and Meyer', 'project' => null, 'vendor' => ['Meridian Legal Systems', 'Practice management platform', '412-555-0188', 'meridianlegal.example', '8x5 support'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 3, 'Server' => 2, 'Desktop' => 11, 'Laptop' => 8, 'Printer' => 2, 'Phone' => 12], 'people' => [ ['Eleanor Blackburn', 'Managing Partner', 'Management', 1, 1, 0, 'Final say on anything security related.', ['Primary', 'Executive', 'Authorized Approver']], ['Curtis Meyer', 'Partner', 'Management', 1, 0, 0, 'Travels constantly - mobile is the reliable route.', ['Executive']], ['Sylvia Nakamura', 'Practice Administrator', 'Administration', 1, 1, 1, 'Runs the office and the practice management system.', ['Technical', 'Billing', 'Onsite Point of Contact']], ['Devon Ashcroft', 'Paralegal', 'Legal', 0, 0, 0, 'Reports issues on behalf of the paralegal team.', []], ], ], [ 'name' => 'Harbor Point Credit Union', 'abbreviation' => 'HPCU', 'type' => 'Financial Services', 'domain' => 'harborpointcu.example', 'referral' => 'Chamber of Commerce', 'rate' => 175.00, 'terms' => 30, 'city' => 'Cleveland', 'state' => 'OH', 'zip' => '44113', 'area' => '216', 'street' => '1250 Lakeside Avenue', 'second_site' => ['Parma Branch', '7300 Ridge Road', 'Parma', 'OH', '44129'], 'age_days' => 1500, 'seats' => 34, 'servers' => 3, 'tags' => ['Managed', 'Compliance', 'Cyber Insurance', 'After Hours Support', 'Multi Site'], 'notes' => 'Regulated environment. Change control paperwork is required before anything touches the core banking network.', 'account_note' => 'Annual examination in the autumn - documentation and restore evidence must be current before then.', 'onsite_visit' => 'Branch equipment and compliance check', 'recurring_ticket' => 'Monthly maintenance visit - Harbor Point CU', 'project' => ['Branch firewall standardisation', 'Bring both branches onto the same firewall platform with central logging and documented rule sets for the examination.'], 'vendor' => ['Northcoast Data Services', 'Core banking platform hosting', '216-555-0122', 'northcoastdata.example', '24/7 with 1 hour response'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 2, 'Switch' => 4, 'Access Point' => 5, 'Server' => 3, 'Virtual Machine' => 3, 'Desktop' => 24, 'Laptop' => 6, 'Printer' => 4, 'Phone' => 20, 'Camera' => 8], 'people' => [ ['Vernon Ashby', 'Chief Executive', 'Management', 1, 0, 0, 'Sees the quarterly summary, not the day to day.', ['Primary', 'Executive']], ['Karen Doyle', 'Controller', 'Finance', 0, 1, 0, 'Owns the budget and the invoices.', ['Billing', 'Authorized Approver']], ['Miguel Santoro', 'Operations Manager', 'Operations', 1, 0, 1, 'Coordinates change control and branch access.', ['Technical', 'Onsite Point of Contact', 'After Hours']], ['Hannah Brightwell', 'Branch Manager - Parma', 'Operations', 0, 0, 0, 'Point of contact for the Parma branch.', ['Emergency']], ], ], [ 'name' => 'Sinclair Property Group', 'abbreviation' => 'SPG', 'type' => 'Real Estate', 'domain' => 'sinclairproperty.example', 'referral' => 'Website', 'rate' => 135.00, 'terms' => 30, 'city' => 'Columbus', 'state' => 'OH', 'zip' => '43215', 'area' => '614', 'street' => '88 East Broad Street', 'second_site' => null, 'age_days' => 520, 'seats' => 15, 'servers' => 1, 'tags' => ['Co-Managed', 'Managed'], 'notes' => 'Client has an internal person who handles first line. We pick up escalations and all infrastructure work.', 'account_note' => 'Internal IT contact triages first - tickets should come through them rather than direct from staff.', 'onsite_visit' => 'Office equipment check', 'recurring_ticket' => 'Monthly maintenance visit - Sinclair Property', 'project' => null, 'vendor' => ['Buckeye Broadband Business', 'Office internet', '614-555-0166', 'buckeyebusiness.example', 'Next business day'], 'mail_platform' => 'Google Workspace', 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 3, 'Server' => 1, 'Desktop' => 8, 'Laptop' => 7, 'Printer' => 2, 'Mobile Phone' => 6], 'people' => [ ['Roland Sinclair', 'Principal', 'Management', 1, 1, 0, 'Owner. Wants short answers and no jargon.', ['Primary', 'Executive', 'Authorized Approver']], ['Beatrix Nolan', 'Office Manager', 'Administration', 0, 1, 0, 'Handles invoices and supplier accounts.', ['Billing']], ['Jeremy Okafor', 'Internal IT Coordinator', 'Operations', 1, 0, 1, 'Co-managed counterpart - handles first line internally.', ['Technical', 'Onsite Point of Contact']], ['Nadia Kellerman', 'Lettings Coordinator', 'Operations', 0, 0, 0, 'Field staff - mostly mobile issues.', []], ], ], [ 'name' => 'Northgate Veterinary Clinic', 'abbreviation' => 'NVC', 'type' => 'Veterinary', 'domain' => 'northgatevet.example', 'referral' => 'Friend', 'rate' => 125.00, 'terms' => 15, 'city' => 'Wexford', 'state' => 'PA', 'zip' => '15090', 'area' => '724', 'street' => '910 Perry Highway', 'second_site' => null, 'age_days' => 400, 'seats' => 12, 'servers' => 1, 'tags' => ['Break Fix', 'Managed'], 'notes' => 'Small practice, price sensitive. Moved onto a light agreement after a hardware failure took them down for a day.', 'account_note' => 'Was break fix until the server failure. Watch for scope creep against the light agreement.', 'onsite_visit' => 'Practice equipment check', 'recurring_ticket' => 'Monthly maintenance visit - Northgate Veterinary', 'project' => null, 'vendor' => ['Allegheny Cable Business', 'Internet and phone', '724-555-0119', 'alleghenybusiness.example', 'Best effort'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 1, 'Switch' => 1, 'Access Point' => 2, 'Server' => 1, 'Desktop' => 7, 'Laptop' => 2, 'Printer' => 2, 'Tablet' => 3], 'people' => [ ['Dr Imogen Hartley', 'Practice Owner', 'Management', 1, 1, 0, 'Owner and lead vet. Best reached between appointments.', ['Primary', 'Executive', 'Authorized Approver']], ['Cassidy Boyle', 'Practice Manager', 'Administration', 1, 1, 1, 'Handles everything administrative and technical on site.', ['Technical', 'Billing', 'Onsite Point of Contact']], ['Elliot Marsh', 'Veterinary Nurse', 'Clinical', 0, 0, 0, 'Reports issues from the treatment rooms.', []], ['Georgia Pemberton', 'Reception Lead', 'Reception', 0, 0, 0, 'Front desk and booking system.', []], ], ], [ 'name' => 'Delaney Construction', 'abbreviation' => 'DLC', 'type' => 'Construction', 'domain' => 'delaneyconstruction.example', 'referral' => 'Client', 'rate' => 140.00, 'terms' => 30, 'city' => 'Greensburg', 'state' => 'PA', 'zip' => '15601', 'area' => '724', 'street' => '2200 Route 30 East', 'second_site' => ['Yard and Workshop', '145 Depot Street', 'Latrobe', 'PA', '15650'], 'age_days' => 640, 'seats' => 22, 'servers' => 1, 'tags' => ['Managed', 'Multi Site'], 'notes' => 'Field staff work off laptops and tablets with patchy connectivity. Site offices come and go.', 'account_note' => 'Field kit takes a beating - replacements are expected rather than exceptional. Keep spares on the shelf.', 'onsite_visit' => 'Office and yard equipment check', 'recurring_ticket' => 'Monthly maintenance visit - Delaney Construction', 'project' => null, 'vendor' => ['Westmoreland Wireless', 'Site office connectivity', '724-555-0154', 'westmorelandwireless.example', 'Next business day'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 2, 'Switch' => 2, 'Access Point' => 4, 'Server' => 1, 'Desktop' => 6, 'Laptop' => 14, 'Printer' => 2, 'Tablet' => 6, 'Mobile Phone' => 10], 'people' => [ ['Fiona Delaney', 'Owner', 'Management', 1, 1, 0, 'Runs the business. Usually on a site rather than at a desk.', ['Primary', 'Executive', 'Authorized Approver']], ['Warren Tiller', 'Commercial Manager', 'Finance', 0, 1, 0, 'Approves invoices and equipment purchases.', ['Billing']], ['Sasha Nowak', 'Site Systems Coordinator', 'Operations', 1, 0, 1, 'Sets up and strips down site office kit.', ['Technical', 'Onsite Point of Contact']], ['Bram Whitaker', 'Project Supervisor', 'Operations', 0, 0, 0, 'Field escalation for laptops and tablets.', ['After Hours']], ], ], [ 'name' => 'Aurora Wellness Center', 'abbreviation' => 'AWC', 'type' => 'Healthcare', 'domain' => 'aurorawellness.example', 'referral' => 'Social Media', 'rate' => 130.00, 'terms' => 15, 'city' => 'Morgantown', 'state' => 'WV', 'zip' => '26505', 'area' => '304', 'street' => '415 Chestnut Ridge Road', 'second_site' => null, 'age_days' => 300, 'seats' => 16, 'servers' => 1, 'tags' => ['Managed', 'Onboarding', 'Compliance'], 'notes' => 'Recently onboarded. Documentation is still being built out as we find things.', 'account_note' => 'Onboarding discovery is not finished - expect gaps in the asset list for another month.', 'onsite_visit' => 'Onboarding discovery visit', 'recurring_ticket' => 'Monthly maintenance visit - Aurora Wellness', 'project' => ['Onboarding and documentation', 'Complete discovery, document the environment, standardise endpoint protection and get backups verified.'], 'vendor' => ['Mountaineer Connect', 'Internet and managed wifi', '304-555-0133', 'mountaineerconnect.example', '4 hour response'], 'mail_platform' => 'Google Workspace', 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 4, 'Server' => 1, 'Desktop' => 9, 'Laptop' => 4, 'Printer' => 2, 'Tablet' => 4], 'people' => [ ['Talia Mendoza', 'Clinic Director', 'Management', 1, 1, 0, 'New client contact - still learning what we do.', ['Primary', 'Executive', 'Authorized Approver']], ['Ruth Ellery', 'Business Manager', 'Administration', 0, 1, 0, 'Invoices and supplier accounts.', ['Billing']], ['Colm Bradigan', 'Facilities Coordinator', 'Operations', 1, 0, 1, 'Holds the keys and the cupboard the switch lives in.', ['Technical', 'Onsite Point of Contact']], ['Yusuf Karim', 'Therapist', 'Clinical', 0, 0, 0, 'Reports issues in the treatment rooms.', []], ], ], [ 'name' => 'Pinnacle Freight Systems', 'abbreviation' => 'PFS', 'type' => 'Logistics', 'domain' => 'pinnaclefreight.example', 'referral' => 'Cold Outreach', 'rate' => 150.00, 'terms' => 30, 'city' => 'Youngstown', 'state' => 'OH', 'zip' => '44505', 'area' => '330', 'street' => '3300 Logistics Drive', 'second_site' => ['Distribution Warehouse', '760 Terminal Road', 'Youngstown', 'OH', '44506'], 'age_days' => 1050, 'seats' => 30, 'servers' => 3, 'tags' => ['Managed', 'After Hours Support', 'Multi Site', 'Past Due'], 'notes' => 'Warehouse runs around the clock. Scanner and label printer issues are the bulk of the ticket volume.', 'account_note' => 'Account has run past terms twice this year - finance chases rather than the account manager.', 'onsite_visit' => 'Warehouse scanner and network check', 'recurring_ticket' => 'Monthly maintenance visit - Pinnacle Freight', 'project' => null, 'vendor' => ['Mahoning Valley Fiber', 'Warehouse and office circuits', '330-555-0147', 'mahoningfiber.example', '24/7 with 2 hour response'], 'mail_platform' => 'Microsoft 365', 'fleet' => ['Firewall/Router' => 2, 'Switch' => 5, 'Access Point' => 9, 'Server' => 3, 'Virtual Machine' => 2, 'Desktop' => 16, 'Laptop' => 6, 'Printer' => 5, 'Phone' => 12, 'Tablet' => 8], 'people' => [ ['Desmond Iverson', 'General Manager', 'Management', 1, 0, 0, 'Approves spend and downtime.', ['Primary', 'Executive', 'Authorized Approver']], ['Lorraine Pike', 'Accounts Payable', 'Finance', 0, 1, 0, 'Pays on their own schedule - chase politely.', ['Billing']], ['Ike Ferrante', 'Warehouse Systems Lead', 'Operations', 1, 0, 1, 'Night shift contact for scanner and label printer problems.', ['Technical', 'After Hours', 'Onsite Point of Contact']], ['Simone Vasser', 'Dispatch Supervisor', 'Operations', 0, 0, 0, 'Raises most of the day to day tickets.', ['Emergency']], ], ], [ 'name' => 'Copperline Coffee Roasters', 'abbreviation' => 'CCR', 'type' => 'Retail', 'domain' => 'copperlinecoffee.example', 'referral' => 'Event', 'rate' => 120.00, 'terms' => 15, 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15201', 'area' => '412', 'street' => '2115 Butler Street', 'second_site' => ['Roastery', '48 Preble Avenue', 'Pittsburgh', 'PA', '15233'], 'age_days' => 180, 'seats' => 9, 'servers' => 0, 'tags' => ['Break Fix', 'Onboarding', 'Multi Site'], 'notes' => 'Two retail sites and a roastery. Point of sale and card processing are the only things that really matter to them.', 'account_note' => 'Still deciding whether to move onto a full agreement - keep the value visible on every invoice.', 'onsite_visit' => 'Point of sale and network check', 'recurring_ticket' => 'Monthly maintenance visit - Copperline Coffee', 'project' => null, 'vendor' => ['Steel City Point of Sale', 'Till system and card terminals', '412-555-0198', 'steelcitypos.example', '8x5 support'], 'mail_platform' => 'Google Workspace', 'fleet' => ['Firewall/Router' => 2, 'Switch' => 2, 'Access Point' => 4, 'Desktop' => 3, 'Laptop' => 2, 'Tablet' => 5, 'Printer' => 2], 'people' => [ ['Jonah Rickerby', 'Owner', 'Management', 1, 1, 0, 'Hands on owner - usually behind the counter.', ['Primary', 'Executive', 'Authorized Approver']], ['Mira Lachlan', 'Operations Manager', 'Operations', 1, 1, 1, 'Runs both sites and the roastery.', ['Technical', 'Billing', 'Onsite Point of Contact']], ['Felix Oyelaran', 'Store Lead - Butler Street', 'Retail', 0, 0, 0, 'Front of house point of contact.', []], ['Suzanne Kirby', 'Roastery Supervisor', 'Production', 0, 0, 0, 'Reports issues from the roastery.', []], ], ], ]; $profiles = []; foreach ($specs as $spec) { $profiles[] = demoExpandProfile($spec); } return $profiles; } // ------------------------------ // demoExpandProfile // Turns a compact client spec into the full profile the builders consume. The // documentation, agreement lines and quotes are derived from the spec so the // numbers hang together - seat counts match licence counts match invoice lines. // ------------------------------ function demoExpandProfile($spec) { $name = $spec['name']; $short = $spec['abbreviation']; $domain = $spec['domain']; $seats = $spec['seats']; $servers = $spec['servers']; $mail = $spec['mail_platform']; $phone = $spec['area'] . '-555-0100'; // Sites $sites = [[ 'name' => 'Main Office', 'description' => 'Primary site - server room, main office space and reception', 'address' => $spec['street'], 'city' => $spec['city'], 'state' => $spec['state'], 'zip' => $spec['zip'], 'phone' => $phone, 'hours' => 'Mon-Fri 8:00 - 17:00', 'tags' => ['Head Office', 'Server Room'], ]]; if (!empty($spec['second_site'])) { $sites[] = [ 'name' => $spec['second_site'][0], 'description' => 'Secondary site connected back to the main office', 'address' => $spec['second_site'][1], 'city' => $spec['second_site'][2], 'state' => $spec['second_site'][3], 'zip' => $spec['second_site'][4], 'phone' => $spec['area'] . '-555-0101', 'hours' => 'Mon-Fri 8:00 - 17:00', 'tags' => ['Branch'], ]; } // Networks $networks = [ ['Office LAN', 'Staff workstations, printers and handsets', 10, '192.168.10.0', '192.168.10.1', '192.168.10.1', '192.168.10.50 - 192.168.10.220'], ['Guest Wireless', 'Isolated guest access - no route to the office LAN', 40, '192.168.40.0', '192.168.40.1', '192.168.40.1', '192.168.40.20 - 192.168.40.200'], ]; if ($servers > 1) { $networks[] = ['Server VLAN', 'Servers and management interfaces', 20, '192.168.20.0', '192.168.20.1', '192.168.20.1', 'Static addressing only']; } if (in_array($spec['type'], ['Manufacturing', 'Logistics'])) { $networks[] = ['Equipment VLAN', 'Shop floor and warehouse equipment - no internet access', 30, '192.168.30.0', '192.168.30.1', '192.168.30.1', '192.168.30.40 - 192.168.30.240']; } // Credentials $mail_admin_uri = $mail === 'Google Workspace' ? 'https://admin.google.com' : 'https://admin.microsoft.com'; $credentials = [ ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotate at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly']], [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass']], ['Backup Console', 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account']], ['Switch Management', 'Access layer switch management', 'https://192.168.10.2', 'msp-admin', 'Shared across the access switches at this site.', ['Switch', 'Shared']], ]; // Services $services = [ [$mail, 'Company mail, calendars and identity for ' . $seats . ' users', 'Email', 'High', 'Third party mailbox backup', 'Tenant administered by us. Licence count is reconciled against the monthly invoice.'], ['Managed Backup', 'Image level backup of ' . max(1, $servers) . ' servers with offsite copy', 'Backup', 'High', 'Offsite replication', 'Nightly job with a monthly test restore. Evidence is filed against the client documentation.'], ['Endpoint Protection and Monitoring', 'Agent based protection and monitoring across the fleet', 'Security', 'High', 'Vendor cloud console', 'Alerts raise tickets automatically. Monthly patch compliance is reviewed at the maintenance visit.'], ]; // Software and licensing $software = [ [$mail . ' Business Premium', 'Per user licensing resold and managed', 'Current', 'Software as a Service (SaaS)', 'Subscription', $seats, 'Licence count is reviewed monthly against staff numbers.'], ['Endpoint Protection', 'Managed endpoint protection agent', 'Current', 'Security Software', 'Subscription', $seats + max(1, $servers), 'Covers workstations, laptops and servers.'], [$spec['vendor'][0] . ' Platform', $spec['vendor'][1], 'Vendor managed', 'Web Application', 'Subscription', $seats, 'Vendor supported - we hold the administrative access only.'], ]; // Documentation $documents = [ [ $short . ' - Site Runbook', 'How this site is put together and what to do first when something breaks', '' . $name . ' operates from ' . count($sites) . ' site(s) with the main office at ' . $spec['street'] . ', ' . $spec['city'] . '.
' . 'Office LAN on 192.168.10.0/24 behind the edge firewall. Guest wireless is isolated on VLAN 40 with no route to the office LAN.
' . '' . $mail . ' tenant administered by us. Break glass credentials are held in the credential vault against this client.
' . 'Site access is arranged through the onsite point of contact. Work outside opening hours must be agreed in advance.
', ], [ $short . ' - Backup and Recovery Plan', 'What is protected, how often, and how to get it back', 'Image level backup of ' . max(1, $servers) . ' server(s) plus mailbox level protection for ' . $seats . ' users.
' . 'Nightly incremental with a weekly synthetic full. Offsite copy replicates daily.
' . '30 days local, 12 months offsite.
' . 'Monthly. Evidence is attached to the maintenance ticket and referenced in the compliance file.
', ], [ $short . ' - Vendors and Escalation', 'Who to call, in what order, and what they are responsible for', '' . $spec['vendor'][0] . ' - ' . $spec['vendor'][1] . '. Response target: ' . $spec['vendor'][4] . '.
' . '' . $mail . ' - identity and mail. Raised through the tenant administration portal with our partner credentials.
' . 'Spend and downtime are approved by the authorised approver on the contact list. Do not take verbal approval from anyone else for work that will be billed.
', ], ]; // The monthly agreement, which the invoices are generated from $agreement = [ ['Managed Services - Per User', $seats, 89.00, 'Monitoring, patching, endpoint protection and remote support'], ]; if ($servers > 0) { $agreement[] = ['Managed Services - Per Server', $servers, 145.00, 'Server monitoring, patching and backup verification']; } $agreement[] = [$mail . ' Business Premium', $seats, 22.00, 'Per user licence, resold and managed']; $agreement[] = ['Cloud Backup', max(1, $servers), 65.00, 'Offsite backup with monthly test restore']; // Quotes in flight $refresh_count = max(2, (int)floor($seats / 5)); $quotes = [ [ 'Workstation refresh - machines past five years old', [ ['Business Desktop', $refresh_count, 985.00, 'Small form factor desktop, 16GB memory, 512GB solid state drive'], ['Deployment and Data Migration', $refresh_count, 145.00, 'Build, migrate and hand over per machine'], ['Recycling and Data Destruction', $refresh_count, 25.00, 'Certified disposal of the replaced machines'], ], 'Sent', ], ]; if (!empty($spec['project'])) { $quotes[] = [ $spec['project'][0], [ ['Project Labor', 24, 145.00, 'Design, build, migration and handover'], ['Project Hardware', 1, 4250.00, 'Hardware supplied as part of the project'], ['Out of Hours Cutover', 6, 195.00, 'Cutover work outside operating hours'], ], 'Accepted', ]; } // Client attributable costs $expenses = [ ['Hardware supplied to ' . $name, 1450.00 + ($seats * 12), 'Hardware - Cost of Goods', 47], [$mail . ' licensing for ' . $name, $seats * 14.50, 'Licensing - Cost of Goods', 18], ]; return [ 'name' => $name, 'abbreviation' => $short, 'type' => $spec['type'], 'domain' => $domain, 'referral' => $spec['referral'], 'rate' => number_format($spec['rate'], 2, '.', ''), 'net_terms' => $spec['terms'], 'seats' => $seats, 'servers' => $servers, 'notes' => $spec['notes'], 'account_note' => $spec['account_note'], 'age_days' => $spec['age_days'], 'area_code' => $spec['area'], 'tags' => $spec['tags'], 'sites' => $sites, 'people' => $spec['people'], 'vendor' => $spec['vendor'], 'fleet' => $spec['fleet'], 'networks' => $networks, 'mail_platform' => $mail, 'credentials' => $credentials, 'services' => $services, 'software' => $software, 'documents' => $documents, 'agreement' => $agreement, 'quotes' => $quotes, 'expenses' => $expenses, 'onsite_visit' => $spec['onsite_visit'], 'recurring_ticket' => $spec['recurring_ticket'], 'project' => $spec['project'], ]; } // ------------------------------ // demoDataAccounts // The bank accounts an MSP actually runs. Matched by name, so an install that // already has an account called Operating Checking keeps the one it has. // ------------------------------ function demoDataAccounts() { return [ ['Operating Checking', 'Day to day business banking - client payments in, bills out', 18500.00], ['Business Savings', 'Tax and reserve holding', 42000.00], ['Merchant Settlement', 'Card and online payments waiting to be paid out', 0.00], ['Petty Cash', 'Small cash purchases', 250.00], ]; } // ------------------------------ // demoEnsureAccounts // Returns name => id for every demo account, creating the missing ones. // ------------------------------ function demoEnsureAccounts($mysqli, $currency) { $accounts = []; $existing = []; $sql = mysqli_query($mysqli, "SELECT account_id, account_name FROM accounts"); while ($row = mysqli_fetch_assoc($sql)) { $existing[mb_strtolower($row['account_name'])] = intval($row['account_id']); } foreach (demoDataAccounts() as $account) { $key = mb_strtolower($account[0]); if (isset($existing[$key])) { $accounts[$account[0]] = $existing[$key]; continue; } $accounts[$account[0]] = starterInsert($mysqli, 'accounts', [ 'account_name' => $account[0], 'account_description' => $account[1], 'opening_balance' => number_format($account[2], 2, '.', ''), 'account_currency_code' => $currency, 'account_created_at' => demoMonthDateTime(24, 1, 9, 0), ]); } return $accounts; } // ------------------------------ // demoBuildBilling // The client's money, month by month, for as long as they have been a client. // The agreement drives a monthly invoice; project and hardware work lands on // top of it every few months so revenue is not a flat line. // ------------------------------ function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $context, &$counts) { global $config_invoice_prefix, $config_quote_prefix, $config_recurring_invoice_prefix; $currency = $context['currency']; $months = demoClientMonths($profile); $income_category_id = starterCategoryId($mysqli, 'Managed Services', 'Income'); $project_category_id = starterCategoryId($mysqli, 'Projects', 'Income') ?: $income_category_id; $hardware_category_id = starterCategoryId($mysqli, 'Hardware Sales', 'Income') ?: $income_category_id; // Bank and card money land in different places $bank_account_id = $context['accounts']['Operating Checking'] ?? $context['account_id']; $card_account_id = $context['accounts']['Merchant Settlement'] ?? $bank_account_id; // The agreement itself $recurring_total = 0; foreach ($profile['agreement'] as $line) { $recurring_total = $recurring_total + ($line[1] * $line[2]); } $recurring_invoice_id = starterInsert($mysqli, 'recurring_invoices', [ 'recurring_invoice_prefix' => $config_recurring_invoice_prefix ?? '', 'recurring_invoice_number' => demoNextNumber($mysqli, 'config_recurring_invoice_next_number'), 'recurring_invoice_scope' => 'Monthly managed services agreement', 'recurring_invoice_frequency' => 'month', 'recurring_invoice_next_date' => date('Y-m-01', strtotime('+1 month')), 'recurring_invoice_last_sent' => date('Y-m-01'), 'recurring_invoice_status' => 1, 'recurring_invoice_amount' => number_format($recurring_total, 2, '.', ''), 'recurring_invoice_currency_code' => $currency, 'recurring_invoice_note' => 'Billed on the first of each month in advance.', // Demo contacts must never be mailed, so the cron notification is off 'recurring_invoice_email_notify' => 0, 'recurring_invoice_category_id' => $income_category_id, 'recurring_invoice_client_id' => $client_id, 'recurring_invoice_created_at' => demoMonthDateTime($months, 1, 9, 30), ]); foreach ($profile['agreement'] as $item_order => $line) { demoInvoiceItem($mysqli, 'recurring_invoice_items', 'item_recurring_invoice_id', $recurring_invoice_id, $line, $item_order, demoMonthDateTime($months, 1, 9, 30)); } $counts['billing']++; // Monthly agreement invoices for every month they have been a client $payment_methods = ['ACH', 'Credit Card', 'Check']; $one_off_pool = demoOneOffInvoicePool(); for ($month = $months; $month >= 0; $month--) { $invoice_date = demoMonthDate($month, 1); $created_at = demoMonthDateTime($month, 1, 6, 5); $due_date = date('Y-m-d', strtotime($invoice_date . ' +' . $profile['net_terms'] . ' days')); // The current month is still out, and one client is deliberately behind $outstanding_months = in_array('Past Due', $profile['tags']) ? 3 : 1; $paid = $month >= $outstanding_months; $partial = $paid && $month === 5; $status = 'Paid'; if (!$paid) { $status = $month === 0 ? 'Sent' : 'Viewed'; } elseif ($partial) { $status = 'Partial'; } $invoice_id = starterInsert($mysqli, 'invoices', [ 'invoice_prefix' => $config_invoice_prefix ?? '', 'invoice_number' => demoNextNumber($mysqli, 'config_invoice_next_number'), 'invoice_scope' => 'Managed services - ' . date('F Y', strtotime($invoice_date)), 'invoice_status' => $status, 'invoice_date' => $invoice_date, 'invoice_due' => $due_date, 'invoice_amount' => number_format($recurring_total, 2, '.', ''), 'invoice_currency_code' => $currency, 'invoice_url_key' => randomString(32), 'invoice_category_id' => $income_category_id, 'invoice_recurring_invoice_id' => $recurring_invoice_id, 'invoice_client_id' => $client_id, 'invoice_created_at' => $created_at, ]); foreach ($profile['agreement'] as $item_order => $line) { demoInvoiceItem($mysqli, 'invoice_items', 'item_invoice_id', $invoice_id, $line, $item_order, $created_at); } starterInsert($mysqli, 'history', [ 'history_status' => 'Sent', 'history_description' => 'Invoice emailed to the billing contact', 'history_invoice_id' => $invoice_id, 'history_created_at' => demoMonthDateTime($month, 1, 6, 10), ]); if ($paid) { $method = $payment_methods[($index + $month) % 3]; $amount = $partial ? round($recurring_total / 2, 2) : $recurring_total; demoPayment($mysqli, $invoice_id, $amount, $currency, $method, $method === 'Credit Card' ? $card_account_id : $bank_account_id, $month, $profile, $index); starterInsert($mysqli, 'history', [ 'history_status' => $partial ? 'Partial' : 'Paid', 'history_description' => $partial ? 'Part payment received' : 'Payment received in full', 'history_invoice_id' => $invoice_id, 'history_created_at' => demoMonthDateTime($month, 12, 14, 0), ]); } $counts['billing']++; // Project and hardware work on top of the agreement every few months if (($month + $index) % 6 === 3) { $one_off = $one_off_pool[($index + $month) % count($one_off_pool)]; $one_off_total = 0; foreach ($one_off[1] as $line) { $one_off_total = $one_off_total + ($line[1] * $line[2]); } $one_off_created = demoMonthDateTime($month, 18, 11, 30); $one_off_id = starterInsert($mysqli, 'invoices', [ 'invoice_prefix' => $config_invoice_prefix ?? '', 'invoice_number' => demoNextNumber($mysqli, 'config_invoice_next_number'), 'invoice_scope' => $one_off[0], 'invoice_status' => 'Paid', 'invoice_date' => demoMonthDate($month, 18), 'invoice_due' => date('Y-m-d', strtotime(demoMonthDate($month, 18) . ' +' . $profile['net_terms'] . ' days')), 'invoice_amount' => number_format($one_off_total, 2, '.', ''), 'invoice_currency_code' => $currency, 'invoice_url_key' => randomString(32), 'invoice_category_id' => $one_off[2] === 'hardware' ? $hardware_category_id : $project_category_id, 'invoice_client_id' => $client_id, 'invoice_created_at' => $one_off_created, ]); foreach ($one_off[1] as $item_order => $line) { demoInvoiceItem($mysqli, 'invoice_items', 'item_invoice_id', $one_off_id, $line, $item_order, $one_off_created); } demoPayment($mysqli, $one_off_id, $one_off_total, $currency, 'ACH', $bank_account_id, $month - 1, $profile, $index); $counts['billing']++; } } // Quotes, roughly one every five months, at every stage of the pipeline $quote_statuses = ['Invoiced', 'Accepted', 'Declined', 'Sent', 'Draft']; $quote_slot = 0; for ($month = $months; $month >= 0; $month = $month - 5) { $quote = $profile['quotes'][$quote_slot % count($profile['quotes'])]; $quote_total = 0; foreach ($quote[1] as $line) { $quote_total = $quote_total + ($line[1] * $line[2]); } if (demoIsFuture(demoMonthDate($month, 9))) { $quote_slot++; continue; } // Old quotes are settled one way or the other, recent ones are still moving $status = $month <= 5 ? $quote_statuses[($index + $quote_slot) % 2 + 3] : $quote_statuses[($index + $quote_slot) % 3]; $quote_created = demoMonthDateTime($month, 9, 11, 20); $quote_id = starterInsert($mysqli, 'quotes', [ 'quote_prefix' => $config_quote_prefix ?? '', 'quote_number' => demoNextNumber($mysqli, 'config_quote_next_number'), 'quote_scope' => $quote[0], 'quote_status' => $status, 'quote_date' => demoMonthDate($month, 9), 'quote_expire' => demoMonthDate($month - 1, 9), 'quote_amount' => number_format($quote_total, 2, '.', ''), 'quote_currency_code' => $currency, 'quote_note' => 'Pricing held for 30 days. Lead times quoted at time of order.', 'quote_url_key' => randomString(32), 'quote_category_id' => $project_category_id, 'quote_client_id' => $client_id, 'quote_created_at' => $quote_created, ]); foreach ($quote[1] as $item_order => $line) { demoInvoiceItem($mysqli, 'quote_items', 'item_quote_id', $quote_id, $line, $item_order, $quote_created); } starterInsert($mysqli, 'history', [ 'history_status' => 'Sent', 'history_description' => 'Quote sent for approval', 'history_quote_id' => $quote_id, 'history_created_at' => demoMonthDateTime($month, 9, 11, 30), ]); $quote_slot++; $counts['billing']++; } // Client attributable costs, every other month $expense_slot = 0; for ($month = $months; $month >= 0; $month = $month - 2) { $expense = $profile['expenses'][$expense_slot % count($profile['expenses'])]; if (demoIsFuture(demoMonthDate($month, 14))) { $expense_slot++; continue; } starterInsert($mysqli, 'expenses', [ 'expense_description' => $expense[0], 'expense_amount' => number_format($expense[1], 2, '.', ''), 'expense_currency_code' => $currency, 'expense_date' => demoMonthDate($month, 14), 'expense_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . $expense[0] . $month, 8), 'expense_payment_method' => 'Credit Card', 'expense_vendor_id' => $vendor_id, 'expense_client_id' => $client_id, 'expense_category_id' => starterCategoryId($mysqli, $expense[2], 'Expense'), 'expense_account_id' => $bank_account_id, 'expense_created_at' => demoMonthDateTime($month, 14, 16, 45), ]); $expense_slot++; $counts['billing']++; } // Income that never went through an invoice for ($month = $months; $month >= 0; $month = $month - 12) { if (demoIsFuture(demoMonthDate($month, 22))) { continue; } starterInsert($mysqli, 'revenues', [ 'revenue_date' => demoMonthDate($month, 22), 'revenue_amount' => number_format(1200.00 + ($index * 75), 2, '.', ''), 'revenue_currency_code' => $currency, 'revenue_payment_method' => 'Check', 'revenue_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . 'rev' . $month, 8), 'revenue_description' => 'Prepaid block of support hours', 'revenue_category_id' => starterCategoryId($mysqli, 'Support', 'Income') ?: $income_category_id, 'revenue_account_id' => $bank_account_id, 'revenue_client_id' => $client_id, 'revenue_created_at' => demoMonthDateTime($month, 22, 10, 15), ]); $counts['billing']++; } } // ------------------------------ // demoInvoiceItem // invoice_items, quote_items and recurring_invoice_items are the same shape. // ------------------------------ function demoInvoiceItem($mysqli, $table, $parent_column, $parent_id, $line, $item_order, $created_at) { $subtotal = $line[1] * $line[2]; starterInsert($mysqli, $table, [ 'item_name' => $line[0], 'item_description' => $line[3], 'item_quantity' => $line[1], 'item_price' => number_format($line[2], 2, '.', ''), 'item_subtotal' => number_format($subtotal, 2, '.', ''), 'item_tax' => '0.00', 'item_total' => number_format($subtotal, 2, '.', ''), 'item_order' => $item_order + 1, $parent_column => $parent_id, 'item_created_at' => $created_at, ]); } // ------------------------------ // demoPayment // ------------------------------ function demoPayment($mysqli, $invoice_id, $amount, $currency, $method, $account_id, $month, $profile, $index) { if (!$account_id) { return; } $day = 8 + (($index + $month) % 14); if (demoIsFuture(demoMonthDate($month, $day))) { return; } starterInsert($mysqli, 'payments', [ 'payment_date' => demoMonthDate($month, $day), 'payment_amount' => number_format($amount, 2, '.', ''), 'payment_currency_code' => $currency, 'payment_method' => $method, 'payment_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . 'pay' . $invoice_id, 8), 'payment_account_id' => $account_id, 'payment_invoice_id' => $invoice_id, 'payment_created_at' => demoMonthDateTime($month, $day, 14, 0), ]); } // ------------------------------ // demoOneOffInvoicePool // Project and hardware work billed outside the agreement. // ------------------------------ function demoOneOffInvoicePool() { return [ ['Workstation refresh - supply and deployment', [ ['Business Desktop', 4, 985.00, 'Small form factor desktop, 16GB memory, 512GB solid state drive'], ['Deployment and Data Migration', 4, 145.00, 'Build, migrate and hand over per machine'], ], 'hardware'], ['Firewall replacement and rule migration', [ ['Next Generation Firewall', 1, 1245.00, 'Edge firewall with three years of security subscription'], ['Configuration and Cutover', 8, 145.00, 'Rule migration, cutover and post change checks'], ], 'project'], ['Wireless coverage extension', [ ['Wireless Access Point', 3, 245.00, 'Wi-Fi 6 ceiling mounted access point'], ['Installation and Survey', 6, 145.00, 'Cabling, mounting and post install survey'], ], 'project'], ['Server storage expansion', [ ['Enterprise Solid State Drive', 4, 420.00, 'Mixed use enterprise drive'], ['Out of Hours Labor', 5, 195.00, 'Installation and array rebuild outside operating hours'], ], 'hardware'], ['Mailbox migration and tenant cleanup', [ ['Migration Labor', 16, 145.00, 'Mailbox migration, cutover and post migration support'], ['Tenant Hardening', 4, 145.00, 'Conditional access, MFA enforcement and audit logging'], ], 'project'], ['Backup platform replacement', [ ['Backup Appliance', 1, 2850.00, 'Local backup appliance with offsite replication'], ['Build and Seed', 10, 145.00, 'Build, seed the first full backup and verify a restore'], ], 'project'], ]; } // ------------------------------ // demoBuildCompanyFinancials // The MSP's own money - two years of operating costs, and the transfers that // move card settlements into the bank and the tax reserve into savings. None // of this belongs to a client, so it is marked with a DEMO- reference and the // purge finds it that way. // ------------------------------ function demoBuildCompanyFinancials($mysqli, $context, &$counts) { $currency = $context['currency']; $accounts = $context['accounts']; $operating_id = $accounts['Operating Checking'] ?? 0; $savings_id = $accounts['Business Savings'] ?? 0; $merchant_id = $accounts['Merchant Settlement'] ?? 0; if (!$operating_id) { return; } // Only ever build this once $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(expense_id) AS total FROM expenses WHERE expense_client_id = 0 AND expense_reference LIKE 'DEMO-%'")); if (intval($row['total'] ?? 0)) { return; } // description, amount, category, every N months, day of month $operating_costs = [ ['Office rent', 1850.00, 'Rent and Utilities', 1, 1], ['Payroll', 14250.00, 'Payroll', 1, 28], ['Software and tooling subscriptions', 640.00, 'Software', 1, 6], ['Internet and phone', 285.00, 'Telecom and Internet', 1, 9], ['Fuel and vehicle costs', 215.00, 'Vehicle and Fuel', 1, 21], ['Colocation and connectivity', 495.00, 'Infrastructure', 1, 3], ['Business insurance', 1450.00, 'Insurance', 3, 12], ['Accounting and payroll services', 900.00, 'Professional Services', 3, 15], ['Advertising and sponsorship', 500.00, 'Advertising', 4, 19], ['Training and certification', 750.00, 'Education', 6, 24], ['Tools and test equipment', 385.00, 'Tools and Test Equipment', 6, 11], ]; for ($month = 24; $month >= 0; $month--) { foreach ($operating_costs as $cost) { if ($month % $cost[3] !== 0 || demoIsFuture(demoMonthDate($month, $cost[4]))) { continue; } // Payroll and rent creep up over two years rather than sitting flat $drift = 1 + ((24 - $month) * 0.004); starterInsert($mysqli, 'expenses', [ 'expense_description' => $cost[0], 'expense_amount' => number_format($cost[1] * $drift, 2, '.', ''), 'expense_currency_code' => $currency, 'expense_date' => demoMonthDate($month, $cost[4]), 'expense_reference' => 'DEMO-' . demoSerial('company' . $cost[0] . $month, 8), 'expense_payment_method' => $cost[0] === 'Payroll' ? 'ACH' : 'Credit Card', 'expense_vendor_id' => 0, 'expense_client_id' => 0, 'expense_category_id' => starterCategoryId($mysqli, $cost[2], 'Expense'), 'expense_account_id' => $operating_id, 'expense_created_at' => demoMonthDateTime($month, $cost[4], 17, 0), ]); $counts['company']++; } } // Card settlements landing in the bank, and the quarterly tax reserve for ($month = 24; $month >= 0; $month--) { if ($merchant_id) { demoTransfer($mysqli, $merchant_id, $operating_id, 4250.00 + (($month % 7) * 315), $currency, 'Merchant payout', 'Card settlements paid out to the operating account', $month, 5, $counts); } if ($savings_id && $month % 3 === 0) { demoTransfer($mysqli, $operating_id, $savings_id, 6500.00, $currency, 'Bank Transfer', 'Quarterly tax reserve moved to savings', $month, 16, $counts); } } } // ------------------------------ // demoTransfer // A transfer is an expense on one account and a revenue on the other, joined // by a transfers row - the same three writes the transfer handler makes. // ------------------------------ function demoTransfer($mysqli, $from_account_id, $to_account_id, $amount, $currency, $method, $notes, $month, $day, &$counts) { $date = demoMonthDate($month, $day); if (demoIsFuture($date)) { return; } $created_at = demoMonthDateTime($month, $day, 10, 30); $reference = 'DEMO-' . demoSerial('transfer' . $from_account_id . $to_account_id . $month . $day, 8); $expense_id = starterInsert($mysqli, 'expenses', [ 'expense_date' => $date, 'expense_amount' => number_format($amount, 2, '.', ''), 'expense_currency_code' => $currency, 'expense_reference' => $reference, 'expense_vendor_id' => 0, 'expense_category_id' => 0, 'expense_client_id' => 0, 'expense_account_id' => $from_account_id, 'expense_created_at' => $created_at, ]); $revenue_id = starterInsert($mysqli, 'revenues', [ 'revenue_date' => $date, 'revenue_amount' => number_format($amount, 2, '.', ''), 'revenue_currency_code' => $currency, 'revenue_reference' => $reference, 'revenue_category_id' => 0, 'revenue_account_id' => $to_account_id, 'revenue_client_id' => 0, 'revenue_created_at' => $created_at, ]); starterInsert($mysqli, 'transfers', [ 'transfer_method' => $method, 'transfer_notes' => $notes, 'transfer_expense_id' => $expense_id, 'transfer_revenue_id' => $revenue_id, 'transfer_created_at' => $created_at, ]); $counts['company']++; }