Files
itflow/admin/post/demo_data_model.php

2403 lines
125 KiB
PHP

<?php
defined('FROM_POST_HANDLER') || defined('FROM_STARTER_CONTENT') || die("Direct file access is not allowed");
/*
* ITFlow - Demo data library
*
* A fictional book of business for demos, training and screenshots - ten
* clients with the people, kit, documentation, tickets and billing a typical
* MSP would be carrying for them. Loaded from Maintenance > 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' => '<p>Scheduled maintenance visit. Work the task list, log time against this ticket, and note anything needing quoting.</p>',
'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' => '<p>User reset their password this morning and Outlook has been sat on Disconnected since. Webmail works fine.</p>',
'replies' => [
['Client', '<p>Still not connecting after a reboot. Can someone take a look today?</p>', ''],
['Public', '<p>Cleared the cached credential in Credential Manager and re-authenticated the profile. Mail is flowing again - let me know if it drops.</p>', '00:25:00'],
['Internal', '<p>Stale credential left over from the reset. Worth adding to the password change checklist.</p>', '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' => '<p>Machine takes several minutes to get to a usable desktop and browsing is sluggish once it does.</p>',
'replies' => [
['Public', '<p>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.</p>', '00:45:00'],
['Internal', '<p>Drive is a spinning disk and is on the refresh list. Flagged for the next hardware quote.</p>', '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' => '<p>Nothing comes out from any of the front desk machines. The printer display shows ready.</p>',
'replies' => [
['Client', '<p>It was working yesterday afternoon.</p>', ''],
['Public', '<p>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.</p>', '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' => '<p>Connection drops for a minute or two several times an hour. Affecting card processing and phones.</p>',
'replies' => [
['Public', '<p>Logged into the firewall - WAN interface is flapping. Opened a ticket with the ISP and gave them the timestamps.</p>', '00:40:00'],
['Internal', '<p>ISP found a bad line card at the pole. Replacement scheduled. Monitor for another 48 hours before closing.</p>', '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' => '<p>New hire starting a week Monday. Needs the standard build, a mailbox and access to the shared drives.</p>',
'replies' => [
['Public', '<p>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.</p>', '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' => '<p>Staff member forwarded an emailed invoice from what looks like a supplier address but with a changed bank account.</p>',
'replies' => [
['Client', '<p>Nobody clicked anything as far as I know. Wanted to flag it straight away.</p>', ''],
['Public', '<p>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.</p>', '00:50:00'],
['Internal', '<p>Worth a short reminder to the finance team about verifying bank detail changes by phone.</p>', '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' => '<p>Backup console has flagged three consecutive failed runs on the file server.</p>',
'replies' => [
['Public', '<p>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.</p>', '01:10:00'],
['Internal', '<p>Restore test evidence saved with the client documentation for the compliance file.</p>', '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' => '<p>Staff at the far end of the building lose wireless when they move between rooms.</p>',
'replies' => [
['Public', '<p>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.</p>', '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' => '<p>Two staff moved departments and can still see their old folders but not the new ones.</p>',
'replies' => [
['Public', '<p>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.</p>', '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' => '<p>Monitoring raised a low disk space alert on the system volume - under 8% free.</p>',
'replies' => [
['Public', '<p>Cleared shadow copies and old update files, which recovered enough headroom for now. Recommend extending the volume at the next maintenance window.</p>', '00:40:00'],
['Internal', '<p>Volume extension needs a short reboot - put it on the next scheduled visit.</p>', '00:05:00'],
],
'tasks' => ['Free immediate space', 'Identify growth', 'Schedule volume extension'],
],
[
'subject' => 'Staff member leaving - offboarding',
'category' => 'Offboarding',
'priority' => 'Medium',
'billable' => 1,
'details' => '<p>Leaving at the end of the week. Mailbox needs to be delegated to their manager and the laptop collected.</p>',
'replies' => [
['Public', '<p>Account disabled at close of business, mailbox converted and delegated, licence reclaimed and the laptop wiped and returned to spare stock.</p>', '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' => '<p>Inbound calls go straight to voicemail on the front desk handsets.</p>',
'replies' => [
['Public', '<p>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.</p>', '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' => '<p>Application closes immediately on launch for two users since this morning.</p>',
'replies' => [
['Client', '<p>Everyone else seems fine. It is just the two of us.</p>', ''],
['Public', '<p>Vendor client version was behind the server after their update. Updated both machines to the matching build and confirmed login.</p>', '00:55:00'],
['Internal', '<p>Vendor pushes server side updates without notice - worth asking them to add us to the release list.</p>', '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' => '<p>Laptop was dropped in transit. Screen is cracked and the display is unusable.</p>',
'replies' => [
['Public', '<p>Unit is out of warranty. Quoted a replacement and issued a loaner from spare stock so they can keep working.</p>', '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' => '<p>Authenticator prompts keep repeating and never complete on the user mobile.</p>',
'replies' => [
['Public', '<p>Removed and re-registered the authenticator entry, then confirmed sign in on both mobile and desktop.</p>', '00:30:00'],
],
'tasks' => ['Re-register authenticator', 'Confirm sign in'],
],
[
'subject' => 'Quote request - workstation refresh',
'category' => 'Procurement',
'priority' => 'Low',
'billable' => 0,
'details' => '<p>Client has asked for pricing on replacing the oldest machines in the office.</p>',
'replies' => [
['Public', '<p>Pulled the asset list and put together pricing for the machines past five years old. Quote sent for review.</p>', '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',
'<h2>Site overview</h2><p>' . $name . ' operates from ' . count($sites) . ' site(s) with the main office at ' . $spec['street'] . ', ' . $spec['city'] . '.</p>'
. '<h2>Network</h2><p>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.</p>'
. '<h2>Identity and mail</h2><p>' . $mail . ' tenant administered by us. Break glass credentials are held in the credential vault against this client.</p>'
. '<h2>First response</h2><ol><li>Confirm whether the problem is site wide or one user.</li><li>Check the firewall WAN status before anything else if more than one person is affected.</li><li>Check the monitoring console for alerts raised in the last hour.</li><li>Escalate to the vendor only after the internal checks are done.</li></ol>'
. '<h2>Access</h2><p>Site access is arranged through the onsite point of contact. Work outside opening hours must be agreed in advance.</p>',
],
[
$short . ' - Backup and Recovery Plan',
'What is protected, how often, and how to get it back',
'<h2>Scope</h2><p>Image level backup of ' . max(1, $servers) . ' server(s) plus mailbox level protection for ' . $seats . ' users.</p>'
. '<h2>Schedule</h2><p>Nightly incremental with a weekly synthetic full. Offsite copy replicates daily.</p>'
. '<h2>Retention</h2><p>30 days local, 12 months offsite.</p>'
. '<h2>Restore procedure</h2><ol><li>Raise a ticket recording who asked and what is being restored.</li><li>Confirm the restore point with the client before overwriting anything.</li><li>Restore to an alternate location first where possible.</li><li>Record the result against this document and against the ticket.</li></ol>'
. '<h2>Test restores</h2><p>Monthly. Evidence is attached to the maintenance ticket and referenced in the compliance file.</p>',
],
[
$short . ' - Vendors and Escalation',
'Who to call, in what order, and what they are responsible for',
'<h2>Escalation order</h2><ol><li>Confirm the fault and record it on a ticket.</li><li>Check the internal runbook for this site.</li><li>Escalate to the vendor responsible for the failing component.</li><li>Tell the client contact before the vendor call, not after.</li></ol>'
. '<h2>Vendors</h2><p><strong>' . $spec['vendor'][0] . '</strong> - ' . $spec['vendor'][1] . '. Response target: ' . $spec['vendor'][4] . '.</p>'
. '<p><strong>' . $mail . '</strong> - identity and mail. Raised through the tenant administration portal with our partner credentials.</p>'
. '<h2>Client approvals</h2><p>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.</p>',
],
];
// 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']++;
}