<?php // phpcs:ignore WordPress.Files.FileName.NotHyphenatedLowercase
/*
 * Jetpack CRM
 * https://jetpackcrm.com
 * V1.20
 *
 * Copyright 2020 Automattic
 *
 * Date: 01/11/16
 */

defined( 'ZEROBSCRM_PATH' ) || exit( 0 );

	/*
		This file contains functions from DAL1 & DAL2 which have been translated into DAL2.5
		This, along with DAL2, provide backward compatability with all other extensions etc.
		- Note: First half of this file (customer funcs etc.) is the same as DAL2.LegacySupport.php first half, but going forwards, we only maintain this file.

		Leaving peeps in a few states (until migrated):
		1) Still on DAL1, using DAL.LegacySuport.php
			-> Requires Migration ->
		2) Still on DAL2, using DAL2.PHP, DAL.LegacySupport.php & DAL2.LegacySupport.php, & loading DAL objs but not using (except for contacts, logs, segments)
			-> Requires Migration ->
		3) Up to date fully, using DAL2.Helpers.php & fully using DAL objs.

		.. fresh installs will silently migrate up to 3), older ones will manually have to run the wizs

	*/

// ====================================================================================================================================
// ====================================================================================================================================
// ==================== DAL 2.0 FUNCS =================================================================================================
// ====================================================================================================================================
// ====================================================================================================================================

/*
======================================================
	Unchanged DAL2->3 (Mostly customer/contact + log relatead)
====================================================== */

function zeroBS_getCustomer( $cID = -1, $withInvoices = false, $withQuotes = false, $withTransactions = false ) {

	global $zbs;
	return $zbs->DAL->contacts->getContact(
		$cID,
		array(

			// with what?
			'withCustomFields' => true,
			'withQuotes'       => $withQuotes,
			'withInvoices'     => $withInvoices,
			'withTransactions' => $withTransactions,
			'ignoreowner'      => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

		)
	);
}

function zeroBS_getCustomerName( $contactID = -1 ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactFullNameEtc(
		$contactID,
		array(),
		array(
			'incFirstLineAddr' => true,
			'incID'            => true,
		)
	);
}
function zeroBS_customerName( $contactID = '', $contactArr = false, $incFirstLineAddr = true, $incID = true ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactFullNameEtc(
		$contactID,
		$contactArr,
		array(
			'incFirstLineAddr' => $incFirstLineAddr,
			'incID'            => $incID,
		)
	);
}
function zeroBS_getCustomerNameShort( $contactID = -1 ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactFullNameEtc(
		$contactID,
		array(),
		array(
			'incFirstLineAddr' => false,
			'incID'            => false,
		)
	);
}

function zeroBS_customerAddr( $contactID = '', $contactArr = array(), $addrFormat = 'short', $delimiter = ', ' ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactAddress(
		$contactID,
		array(),
		array(
			'addrFormat' => $addrFormat,
			'delimiter'  => $delimiter,
		)
	);
}

#} Returns a str of address, ($third param = 'short','full')
#} Pass an ID OR a customerMeta array (saves loading ;) - in fact doesn't even work with ID yet... lol)
function zeroBS_customerSecondAddr( $contactID = '', $contactArr = array(), $addrFormat = 'short', $delimiter = ', ' ) {

	global $zbs;
	return $zbs->DAL->contacts->getContact2ndAddress(
		$contactID,
		array(),
		array(
			'addrFormat' => $addrFormat,
			'delimiter'  => $delimiter,
		)
	);
}

function zeroBS_customerEmail( $contactID = '', $contactArr = false ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactEmail( $contactID );
}

function zeroBS_customerMobile( $contactID = '', $contactArr = false ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactMobile( $contactID );
}

function zeroBS_customerAvatarHTML( $contactID = '', $contactArr = false, $size = 100, $extraClasses = '' ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactAvatarHTML( $contactID, $size, $extraClasses );
}

function zeroBS_customerCount() {

	global $zbs;
	return $zbs->DAL->contacts->getContactCount( array( 'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ) ) );
}

#} Retrieves company post id if associated with customer
// note 2.5+ can have multiple co's, but this'll only return first ID, need to move away from this
function zeroBS_getCustomerCompanyID( $cID = -1 ) {

	global $zbs;
	$coArr = $zbs->DAL->contacts->getContactCompanies( $cID );
	if ( is_array( $coArr ) && count( $coArr ) > 0 ) {
		return $coArr[0]['id'];
	}

	return false;
}

#} sets company id associated with customer (note this'll override any existing val)
// note 2.5+ can have multiple co's, but this'll only add first ID, need to move away from this
function zeroBS_setCustomerCompanyID( $cID = -1, $coID = -1 ) {

	global $zbs;
	if ( ! empty( $cID ) && ! empty( $coID ) ) {

		return $zbs->DAL->contacts->addUpdateContactCompanies(
			array(
				'id'         => $cID,
				'companyIDs' => array( $coID ),
			)
		);

	}

	return false;
}
function zbsCRM_addUpdateCustomerCompany( $customerID = -1, $companyID = -1 ) {

	global $zbs;
	if ( ! empty( $customerID ) && ! empty( $companyID ) ) {

		return $zbs->DAL->contacts->addUpdateContactCompanies(
			array(
				'id'         => $customerID,
				'companyIDs' => array( $companyID ),
			)
		);

	}

	return false;
}

/**
 * Returns a WP ID for a given contact ID, or false if no match.
 *
 * @param int $cID Contact ID.
 * @return int|false
 */
function zeroBS_getCustomerWPID( $cID = -1 ) {

	global $zbs;
	return $zbs->DAL->contacts->getContactWPID( $cID );
}

/**
 * Returns a contact ID for a given WP ID, or false if no match.
 *
 * @param int $wpID WordPress ID.
 * @return int|false
 */
function zeroBS_getCustomerIDFromWPID( $wpID = -1 ) {

	global $zbs;
	return $zbs->DAL->contacts->getContact(
		-1,
		array(
			'WPID'        => $wpID,
			'onlyID'      => 1,
			'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}

#} Sets a WP id against a customer
function zeroBS_setCustomerWPID( $cID = -1, $wpID = -1 ) {

	global $zbs;
	return $zbs->DAL->contacts->addUpdateContactWPID(
		array(
			'id'   => $cID,
			'WPID' => $wpID,
		)
	);
}

function zeroBSCRM_getCustomerTags( $hide_empty = false ) {

	global $zbs;

	return $zbs->DAL->getTagsForObjType(
		array(
			'objtypeid'    => ZBS_TYPE_CONTACT,
			'excludeEmpty' => $hide_empty,
			'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}

// either or
function zeroBSCRM_setContactTags( $cID = -1, $tags = array(), $tagIDs = array(), $mode = 'replace' ) {

	if ( $cID > 0 ) {

		$args = array(

			'id'   => $cID,
			'mode' => $mode,
		);

		// got tags?
		if ( is_array( $tags ) && ! empty( $tags ) ) {
			$args['tags'] = $tags;
		} elseif ( is_array( $tagIDs ) && ! empty( $tagIDs ) ) {
			$args['tagIDs'] = $tagIDs;
		} else {
			return false;
		}

		global $zbs;

		return $zbs->DAL->contacts->addUpdateContactTags( $args );

	}

	return false;
}
function zeroBSCRM_getContactTagsArr( $hide_empty = true ) {

	global $zbs;

	return $zbs->DAL->getTagsForObjType(
		array(
			'objtypeid'    => ZBS_TYPE_CONTACT,
			'excludeEmpty' => $hide_empty,
			'withCount'    => true,
			'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}
function zeroBS_getCustomerIcoHTML( $cID = -1, $additionalClasses = '' ) {

	$thumbHTML = '<i class="fa fa-user" aria-hidden="true"></i>';

	global $zbs;
	$thumbURL = $zbs->DAL->contacts->getContactAvatarURL( $cID );
	if ( ! empty( $thumbURL ) ) {

		$thumbHTML = '<img src="' . $thumbURL . '" alt="" />'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

	}

	return '<div class="zbs-co-img ' . $additionalClasses . '">' . $thumbHTML . '</div>';
}

function zeroBS_getCustomerIDWithEmail( $custEmail = '' ) {
	/**
	 *  @var $custEmail the customer email you want to check if a contact exists for
	 *
	 *  @return returns return $potentialRes->ID from $zbs->DAL->contacts->getContact()..
	 */

	global $zbs;
	return $zbs->DAL->contacts->getContact(
		-1,
		array(
			'email'       => $custEmail,
			'onlyID'      => true,
			'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}

function zeroBS_searchCustomers( $args = array(), $withMoneyData = false ) {

	// here I've shoehorned old into new,
	// NOTE:
	// this WONT return same exact fields

	$args['ignoreowner'] = zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT );

	if ( $withMoneyData ) {

			$args['withInvoices']     = true;
			$args['withTransactions'] = true;
	}

	global $zbs;
	return $zbs->DAL->contacts->getContacts( $args );
}

/**
 * Enables or disables the client portal access for a contact, by ID.
 *
 * @param int    $contact_id The id of the CRM Contact to be enabled or disabled.
 * @param string $enable_or_disable String indicating if the selected contact should be enabled or disabled. Use 'disable' to disable, otherwise the contact will be enabled.
 *
 * @return bool True in case of success, false otherwise.
 */
function zeroBSCRM_customerPortalDisableEnable( $contact_id = -1, $enable_or_disable = 'disable' ) {
	global $zbs;

	if ( jpcrm_perms_manage_options() && ! empty( $contact_id ) ) {
		// Verify this user can be changed.
		// Has to have singular role of `zerobs_customer`. This helps to avoid users changing each others accounts via crm.
		$wp_user_id  = zeroBSCRM_getClientPortalUserID( $contact_id );
		$user_object = get_userdata( $wp_user_id );
		if ( jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) {
			if ( $enable_or_disable === 'disable' ) {
				return $zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $contact_id, 'portal_disabled', true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			} else {
				return $zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $contact_id, 'portal_disabled', false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			}
		}
	}

	return false;
}

/*
 * Resets the password for client portal access for a contact, by ID
 */
function zeroBSCRM_customerPortalPWReset( $contact_id = -1 ) {

	global $zbs;

	if ( jpcrm_perms_manage_options() && ! empty( $contact_id ) ) {

		$wp_user_id    = zeroBS_getCustomerWPID( $contact_id );
		$contact       = $zbs->DAL->contacts->getContact( $contact_id );
		$contact_email = $contact['email'];
		$user_object   = get_userdata( $wp_user_id );

		if ( $wp_user_id > 0 && ! empty( $contact_email ) ) {

			// Verify this user can be changed.
			// Has to have singular role of `zerobs_customer`. This helps to avoid
			// users resetting each others passwords via the CRM.
			if ( ! jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) {

				return false;

			}

			// generate new pw
			$new_password = wp_generate_password( 12, false );

			// update
			wp_set_password( $new_password, $wp_user_id );

			// email?

			// check if the email is active..
			$active = zeroBSCRM_get_email_status( ZBSEMAIL_CLIENTPORTALPWREST );

			if ( $active ) {

				// send welcome email (tracking will now be dealt with by zeroBSCRM_mailDelivery_sendMessage)

				// ==========================================================================================
				// =================================== MAIL SENDING =========================================

				// generate html
				$emailHTML = zeroBSCRM_Portal_generatePWresetNotificationHTML( $new_password, true, $contact );

				// build send array
				$mailArray = array(
					'toEmail'  => $contact_email,
					'toName'   => '',
					'subject'  => zeroBSCRM_mailTemplate_getSubject( ZBSEMAIL_CLIENTPORTALPWREST ),
					'headers'  => zeroBSCRM_mailTemplate_getHeaders( ZBSEMAIL_CLIENTPORTALPWREST ),
					'body'     => $emailHTML,
					'textbody' => '',
					'options'  => array(
						'html' => 1,
					),
					'tracking' => array(
						// tracking :D (auto-inserted pixel + saved in history db)
						'emailTypeID'     => ZBSEMAIL_CLIENTPORTALPWREST,
						'targetObjID'     => $contact_id,
						'senderWPID'      => -10,
						'associatedObjID' => -1, // none
					),
				);

				// Sends email, including tracking, via setting stored route out, (or default if none)
				// and logs trcking :)

				// discern del method
				$mailDeliveryMethod = zeroBSCRM_mailTemplate_getMailDelMethod( ZBSEMAIL_CLIENTPORTALPWREST );
				if ( ! isset( $mailDeliveryMethod ) || empty( $mailDeliveryMethod ) ) {
					$mailDeliveryMethod = -1;
				}

				// send
				$sent = zeroBSCRM_mailDelivery_sendMessage( $mailDeliveryMethod, $mailArray );

				// =================================== / MAIL SENDING =======================================
				// ==========================================================================================

			}

			return true;

		} // if wpid

	}

	return false;
}

// Returns bool of whether or not a specific customer can access client portal
function zeroBSCRM_isCustomerPortalDisabled( $contact_id = -1 ) {

	// No Contact ID, no entry, unless Admin or Jetpack CRM Admin we can let those in.
	$contact_id = (int) $contact_id;
	if ( $contact_id < 1 ) {
		if ( zeroBSCRM_isZBSAdminOrAdmin() ) {
			return false;
		}
		return true;
	} else {

		// return check
		global $zbs;
		return $zbs->DAL->contacts->getContactMeta( $contact_id, 'portal_disabled' );

	}

	// default = closed door
	return true;
}

// loads customer record + creates a portal user for record
// replaces zeroBSCRM_genPortalUser
function zeroBSCRM_createClientPortalUserFromRecord( $cID = -1 ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// existing?
		$existing = zeroBSCRM_getClientPortalUserID( $cID );
		if ( ! empty( $existing ) || $existing > 0 ) {
			return false;
		}

		$email   = $zbs->DAL->contacts->getContactEmail( $cID );
		$contact = $zbs->DAL->contacts->getContact( $cID, array( 'fields' => array( 'zbsc_fname', 'zbsc_lname' ) ) );
		$fname   = '';
		if ( isset( $contact['fname'] ) && ! empty( $contact['fname'] ) ) {
			$fname = $contact['fname'];
		}
		$lname = '';
		if ( isset( $contact['lname'] ) && ! empty( $contact['lname'] ) ) {
			$lname = $contact['lname'];
		}

		// fire
		return zeroBSCRM_createClientPortalUser( $cID, $email, 12, $fname, $lname );

	}

	return false;
}

function zeroBSCRM_getClientPortalUserID( $cID = -1 ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// first lets check if a user already exists with that email..
		$email = $zbs->DAL->contacts->getContactEmail( $cID );
		if ( ! empty( $email ) ) {
			$userID = email_exists( $email );
			if ( $userID != null ) {
				// update_post_meta($cID, 'zbs_portal_wpid', $userID);
				$zbs->DAL->contacts->addUpdateContactWPID(
					array(
						'id'   => $cID,
						'WPID' => $userID,
					)
				);
			}
		} else {
			// no email in meta, but might be linked?
			// $userID = get_post_meta($cID, 'zbs_portal_wpid', true);
			$userID = $zbs->DAL->contacts->getContactWPID( $cID );
		}
		return $userID;
	}
	return false;
}

function zeroBSCRM_getClientPortalUserWPObj( $cID = -1 ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// $user_id = zeroBSCRM_getClientPortalUserID($cID);
		$user_id = $zbs->DAL->contacts->getContactWPID( $cID );

		return new WP_User( $user_id );

	}

	return false;
}

// Function to update the zbs<->wp user link
function zeroBSCRM_setClientPortalUser( $cID = -1, $wpUserID = -1 ) {

	if ( $cID > 0 && $wpUserID > 0 ) {

		global $zbs;
		$zbs->DAL->contacts->addUpdateContactWPID(
			array(
				'id'   => $cID,
				'WPID' => $wpUserID,
			)
		);

		return true;

	}

	return false;
}

function zeroBSCRM_createClientPortalUser( $cID = -1, $email = '', $passwordLength = 12, $first_name = '', $last_name = '' ) {

	// fail if bad params
	if ( empty( $cID ) || empty( $email ) || ! zeroBSCRM_validateEmail( $email ) ) {
		return false;
	}

	// fail if email already exists as a WP user
	if ( email_exists( $email ) ) {
		return false;
	}

	global $zbs;

	$password = wp_generate_password( $passwordLength, false );

	// organise WP user details
	$wpUserDeets = array(
		'user_email' => $email,
		'user_login' => $email,
		'user_pass'  => $password,
		'nickname'   => $email,
		'first_name' => empty( $first_name ) ? '' : $first_name,
		'last_name'  => empty( $last_name ) ? '' : $last_name,
	);

	// create WP user
	$user_id = wp_insert_user( $wpUserDeets );

	// retrieve created user
	$user = new WP_User( $user_id );

	// fail if the user doesn't exist
	if ( ! $user->exists() ) {
		return false;
	}

	// link WP user ID to contact
	$zbs->DAL->contacts->addUpdateContactWPID(
		array(
			'id'   => $cID,
			'WPID' => $user_id,
		)
	);

	// any extra assigned role? (from settings)
	$extraRole = zeroBSCRM_getSetting( 'portalusers_extrarole' );

	// add role(s)
	if ( ! empty( $extraRole ) ) {
		// Set the WP role first, then the JPCRM role
		$user->set_role( $extraRole );
		$user->add_role( 'zerobs_customer' );
	} else {
		$user->set_role( 'zerobs_customer' );
	}

	// check if the email template is active, and if it is, send...
	$active = zeroBSCRM_get_email_status( ZBSEMAIL_CLIENTPORTALWELCOME );
	if ( $active ) {

		// generate html
		$emailHTML = zeroBSCRM_Portal_generateNotificationHTML( $password, true, $email, $cID );

		// build send array
		$mailArray = array(
			'toEmail'  => $email,
			'toName'   => '',
			'subject'  => zeroBSCRM_mailTemplate_getSubject( ZBSEMAIL_CLIENTPORTALWELCOME ),
			'headers'  => zeroBSCRM_mailTemplate_getHeaders( ZBSEMAIL_CLIENTPORTALWELCOME ),
			'body'     => $emailHTML,
			'textbody' => '',
			'options'  => array(
				'html' => 1,
			),
			'tracking' => array(
				// tracking :D (auto-inserted pixel + saved in history db)
				'emailTypeID'     => ZBSEMAIL_CLIENTPORTALWELCOME,
				'targetObjID'     => $cID,
				'senderWPID'      => -10,
				'associatedObjID' => -1, // none
			),
		);

		// Sends email, including tracking, via setting stored route out, (or default if none)
		// and logs tracking :)

		// discern del method
		$mailDeliveryMethod = zeroBSCRM_mailTemplate_getMailDelMethod( ZBSEMAIL_CLIENTPORTALWELCOME );
		if ( ! isset( $mailDeliveryMethod ) || empty( $mailDeliveryMethod ) ) {
			$mailDeliveryMethod = -1;
		}

		// send
		$sent = zeroBSCRM_mailDelivery_sendMessage( $mailDeliveryMethod, $mailArray );

	}

	// IA
	zeroBSCRM_FireInternalAutomator(
		'clientwpuser.new',
		array(
			'id'        => $user_id,
			'againstid' => $cID,
			'userEmail' => $email,
		)
	);
}

// THIS IS NOW DEPRECATED db2+
// (META used to be all deets, it's now normal deets - as table)
#} Quick wrapper to future-proof.
#} Should later replace all get_post_meta's with this
function zeroBS_getCustomerMeta( $cID = -1 ) {

	global $zbs;

	// if (!empty($cID)) return get_post_meta($cID, 'zbs_customer_meta', true);
	// Return contact directly DB2+
	if ( ! empty( $cID ) ) {
		return $zbs->DAL->contacts->getContact( $cID, array( 'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ) ) );
	}

	return false;
}
// generates a 'demo' customer object (excluding custom fields)
function zeroBS_getDemoCustomer() {

	global $zbs, $zbsCustomerFields;

	$ret = array();

	$demoData = array(

		'status'           => array( 'Lead', 'Customer' ),
		'prefix'           => array( 'Mr', 'Mrs', 'Miss' ),
		'fname'            => array( 'John', 'Jim', 'Mike', 'Melvin', 'Janet', 'Jennifer', 'Judy', 'Julie' ),
		'lname'            => array( 'Smith', 'Jones', 'Scott', 'Filbert' ),
		'fullname'         => array( 'John Smith', 'Jim Ellison', 'Mike Myers', 'Melvin Malcolms' ),
		'addr1'            => array( '101 Red Street', '26 Somerset Street', '1 London Road' ),
		'addr2'            => array( 'Winchester', 'Leeds Village', 'Webleck' ),
		'city'             => array( 'London', 'Los Angeles', 'Leeds', 'Exeter' ),
		'county'           => array( 'London', 'Hertfordshire', 'California', 'Montana' ),
		'postcode'         => array( 'A1 1XU', 'AO12 3RR', 'E1 3XG', 'M1 3LF' ),
		'secaddr_addr1'    => array( '101 Red Street', '26 Somerset Street', '1 London Road' ),
		'secaddr_addr2'    => array( 'Winchester', 'Leeds Village', 'Webleck' ),
		'secaddr_city'     => array( 'London', 'Los Angeles', 'Leeds', 'Exeter' ),
		'secaddr_county'   => array( 'London', 'Hertfordshire', 'California', 'Buckinghamshire' ),
		'secaddr_postcode' => array( 'A1 1XU', 'AO12 3RR', 'E1 3XG', 'M1 3LF' ),
		// dirty repetition...
		'secaddr1'         => array( '101 Red Street', '26 Somerset Street', '1 London Road' ),
		'secaddr2'         => array( 'Winchester', 'Leeds Village', 'Webleck' ),
		'seccity'          => array( 'London', 'Los Angeles', 'Leeds', 'Exeter' ),
		'seccounty'        => array( 'London', 'Hertfordshire', 'California', 'Buckinghamshire' ),
		'secpostcode'      => array( 'A1 1XU', 'AO12 3RR', 'E1 3XG', 'M1 3LF' ),
		'hometel'          => array( '01010 123 345', '01234 546 789' ),
		'worktel'          => array( '01010 123 345', '01234 546 789' ),
		'mobtel'           => array( '07812 345 678' ),
		'email'            => array( 'random@email.com', 'not.real@gmail.com', 'nonsense@email.com' ),

	);

	foreach ( $zbsCustomerFields as $key => $value ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$ret[ $key ] = '';
		if ( isset( $demoData[ $key ] ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			$ret[ $key ] = $demoData[ $key ][ wp_rand( 0, count( $demoData[ $key ] ) - 1 ) ]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		}
	}

	// add fullname
	$ret['fullname'] = $demoData['fullname'][ wp_rand( 0, count( $demoData['fullname'] ) - 1 ) ]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

	// fill in some randoms
	$ret['status'] = $demoData['status'][ wp_rand( 0, count( $demoData['status'] ) - 1 ) ]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

	return $ret;
}
function zeroBS_getCustomerExtraMetaVal( $cID = -1, $extraMetaKey = false ) {

	if ( ! empty( $cID ) && ! empty( $extraMetaKey ) ) {

		global $zbs;

		// quick
		$cleanKey = strtolower( str_replace( ' ', '_', $extraMetaKey ) );

		// return get_post_meta($cID, 'zbs_customer_extra_'.$cleanKey, true);
		return $zbs->DAL->contacts->getContactMeta( $cID, 'extra_' . $cleanKey );

	}

	return false;
}

#} sets an extra meta val
function zeroBS_setCustomerExtraMetaVal( $cID = -1, $extraMetaKey = false, $extraMetaVal = false ) {

	if ( ! empty( $cID ) && ! empty( $extraMetaKey ) ) {

		// quick
		$cleanKey = strtolower( str_replace( ' ', '_', $extraMetaKey ) );

		global $zbs;

		// return update_post_meta($cID, 'zbs_customer_extra_'.$cleanKey, $extraMetaVal);
		return $zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $cID, 'extra_' . $extraMetaKey, $extraMetaVal );

	}

	return false;
}

function zeroBS_updateCustomerSocialAccounts( $cID = -1, $accArray = array() ) {

	if ( ! empty( $cID ) && is_array( $accArray ) ) { // return update_post_meta( $cID, 'zbs_customer_socials', $accArray);

		global $zbs;
		#} Enact
		return $zbs->DAL->contacts->addUpdateContact(
			array(
				'id'            => $cID,
				'limitedFields' => array(
					array(
						'key'  => 'zbsc_tw',
						'val'  => $accArray['tw'],
						'type' => '%s',
					),
					array(
						'key'  => 'zbsc_li',
						'val'  => $accArray['li'],
						'type' => '%s',
					),
					array(
						'key'  => 'zbsc_fb',
						'val'  => $accArray['fb'],
						'type' => '%s',
					),
				),
			)
		);

	}

	return false;
}

function zeroBSCRM_getCustomerFiles( $cID = -1 ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// return get_post_meta($cID, 'zbs_customer_files', true);
		// return $zbs->DAL->contacts->getContactMeta($cID,'files');
		return zeroBSCRM_files_getFiles( 'customer', $cID );

	}

	return false;
}
// maintainIndexs keeps the files original index .e.g. 1,2 so that can match when doing portal stuff (as we're using legacy indx)
function zeroBSCRM_getCustomerPortalFiles( $cID = -1 ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// return get_post_meta($cID, 'zbs_customer_files', true);
		// return $zbs->DAL->contacts->getContactMeta($cID,'files');
		$ret       = array();
		$files     = zeroBSCRM_files_getFiles( 'customer', $cID );
		$fileIndex = 0;
		if ( is_array( $files ) ) {
			foreach ( $files as $f ) {

						// APPROVED portal files
				if ( isset( $f['portal'] ) && $f['portal'] == 1 ) {
					$ret[ $fileIndex ] = $f;
				}

				++$fileIndex;

			}
		}
		return $ret;

	}

	return false;
}
function zeroBSCRM_updateCustomerFiles( $cID = -1, $filesArray = false ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		// return update_post_meta($cID, 'zbs_customer_files', $filesArray);
		return $zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $cID, 'files', $filesArray );

	}

	return false;
}

#} As of v1.1 can pass searchphrase
#} As of v1.2 can pass tags
#} As of v2.2 has associated func getCustomersCountIncParams for getting the TOTAL for a search (ignoring pages)
#} As of v2.2 can also get ,$withTags=false,$withAssigned=false,$withLastLog=false
#} As of v2.2 can also pass quickfilters (Damn this has got messy): lead, customer, over100, over200, over300, over400, over500
	// ... in array like ('lead')
#} 2.52+ AVOID using this, call getContacts directly plz, this is just for backward compatibility :)
function zeroBS_getCustomers(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withInvoices = false,
	$withQuotes = false,
	$searchPhrase = '',
	$withTransactions = false,
	$argsOverride = false,
	$companyID = false,
	$hasTagIDs = '',
	$inArr = '',
	$withTags = false,
	$withAssigned = false,
	$withLastLog = false,
	$sortByField = 'ID',
	$sortOrder = 'DESC',
	$quickFilters = false,
	$ownedByID = false,
	$withValues = false
) {
	/* DAL3.0: $withValues */

	#} Query Performance index
	#global $zbsQPI;
	#if (!isset($zbsQPI)) {
	#   $zbsQPI = array();
	#}
	#$zbsQPI['retrieveCustomers2getCustomers'] = zeroBSCRM_mtime_float();

	// $withFullDetails = irrelevant with new DB2 (always returns)
	// $argsOverride CAN NO LONGER WORK :)
	if ( $argsOverride !== false ) {
		zeroBSCRM_DEPRECATEDMSG( 'Use of $argsOverride in zeroBS_getCustomers is no longer relevant (DB2)' );
	}

	global $zbs;

	// this needs translating for new dbfields:
	// FOR NOW
	if ( $sortByField == 'post_id' ) {
		$sortByField = 'ID';
	}
	if ( $sortByField == 'post_title' ) {
		$sortByField = 'zbsc_lname';
	}
	if ( $sortByField == 'post_excerpt' ) {
		$sortByField = 'zbsc_lname';
	}

	/* we need to prepend zbsc_ when not using cf */
	$custFields = $zbs->DAL->getActiveCustomFields( array( 'objtypeid' => ZBS_TYPE_CONTACT ) );

		// needs to check if field name is custom field:
		$sortIsCustomField = false;
	if ( is_array( $custFields ) && array_key_exists( $sortByField, $custFields ) ) {
		$sortIsCustomField = true;
	}
	if ( ! $sortIsCustomField && $sortByField != 'ID' ) {
		$sortByField = 'zbsc_' . $sortByField;
	}

	// catch empties
	if ( empty( $sortByField ) ) {
		$sortByField = 'ID';
	}
	if ( empty( $sortOrder ) ) {
		$sortOrder = 'desc';
	}

	// legacy from dal1
	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase'     => $searchPhrase,
		'inCompany'        => $companyID,
		'inArr'            => $inArr,
		'quickFilters'     => $quickFilters,
		'isTagged'         => $hasTagIDs,
		'ownedBy'          => $ownedByID,

		'withCustomFields' => true,
		'withQuotes'       => $withQuotes,
		'withInvoices'     => $withInvoices,
		'withTransactions' => $withTransactions,
		'withLogs'         => false,
		'withLastLog'      => $withLastLog,
		'withTags'         => $withTags,
		'withOwner'        => $withAssigned,
		'withValues'       => $withValues,

		'sortByField'      => $sortByField,
		'sortOrder'        => $sortOrder,
		'page'             => $actualPage,
		'perPage'          => $perPage,

		'ignoreowner'      => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

	);

	// here ignore owners = true the default, because we're not really forcing ownership anywhere overall,
	// when we do, we should change this/make it check
	if ( $ownedByID !== false ) {

		$args['ignoreowner'] = false;

	}

	return $zbs->DAL->contacts->getContacts( $args );
}

#} As of 2.2 - matches getCustomers but returns a total figure (no deets)
// NOTE, params are same except first 5 + withTransactions removed:
// $withFullDetails=false,$perPage=10,$page=0,$withInvoices=false,$withQuotes=false,$withTransactions=false,
// - trimmed returns for efficiency (is just a count really :o dirty.)
// https://codex.wordpress.org/Class_Reference/WP_Query
function zeroBS_getCustomersCountIncParams(
	$searchPhrase = '',
	$argsOverride = false,
	$companyID = false,
	$hasTagIDs = '',
	$inArr = '',
	$quickFilters = ''
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	// $argsOverride CAN NO LONGER WORK :)
	if ( $argsOverride !== false ) {
		zeroBSCRM_DEPRECATEDMSG( 'Use of $argsOverride in zeroBS_getCustomersCountIncParams is no longer relevant (DB2)' );
	}

	global $zbs;

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inCompany'    => $companyID,
		'inArr'        => $inArr,
		'quickFilters' => $quickFilters,
		'isTagged'     => $hasTagIDs,

		// just count
		'count'        => true,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

	);

	return (int) $zbs->DAL->contacts->getContacts( $args );
}

#} same as above but wrapped in contact view link
function zeroBS_getCustomerIcoLinked( $cID = -1, $incName = false, $extraClasses = '', $maxSize = 100 ) {

	$extraHTML = '';
	if ( $incName ) {

		$cName = zeroBS_getCustomerNameShort( $cID );

		if ( ! empty( $cName ) ) {
			$extraHTML = '<span class="">' . esc_html( $cName ) . '</span>';
		}
	}

	return '<div class="zbs-co-img' . esc_attr( $extraClasses ) . '"><a href = "' . jpcrm_esc_link( 'view', $cID, 'zerobs_customer' ) . '">' . zeroBS_customerAvatarHTML( $cID, -1, $maxSize ) . '</a>' . $extraHTML . '</div>';
}

#} same as above but wrapped in contact view link + semantic ui label img link
function zeroBS_getCustomerIcoLinkedLabel( $cID = -1 ) {

	$extraHTML = '';
	$cName     = zeroBS_getCustomerNameShort( $cID );
	if ( ! empty( $cName ) ) {
		$extraHTML = '<span>' . esc_html( $cName ) . '</span>';
	} else {
		$cEmail = zeroBS_customerEmail( $cID );
		if ( ! empty( $cEmail ) ) {
			$extraHTML = '<span>' . esc_html( $cEmail ) . '</span>';
		}
	}

	$extraClasses = ' ui image label';

	return '<a href="' . jpcrm_esc_link( 'view', $cID, 'zerobs_customer' ) . '" class="' . $extraClasses . '">' . zeroBS_customerAvatarHTML( $cID ) . $extraHTML . '</a>';
}

#} same as above but with no image (for non-avatar mode)
function zeroBS_getCustomerLinkedLabel( $cID = -1 ) {

	$extraHTML = '';
	$cName     = zeroBS_getCustomerNameShort( $cID );
	if ( ! empty( $cName ) ) {
		$extraHTML = '<span>' . esc_html( $cName ) . '</span>';
	} else {
		$cEmail = zeroBS_customerEmail( $cID );
		if ( ! empty( $cEmail ) ) {
			$extraHTML = '<span>' . esc_html( $cEmail ) . '</span>';
		}
	}
	// for empties, add no
	if ( empty( $extraHTML ) ) {
		$extraHTML = '<span>#' . $cID . '</span>';
	}

	$extraClasses = ' ui label';

	return '<a href="' . jpcrm_esc_link( 'view', $cID, 'zerobs_customer' ) . '" class="' . $extraClasses . '">' . $extraHTML . '</a>';
}

/* Centralised delete customer func, including sub-element removal */
function zeroBS_deleteCustomer( $id = -1, $saveOrphans = true ) {

	if ( ! empty( $id ) ) {

		global $zbs;

		return $zbs->DAL->contacts->deleteContact(
			array(
				'id'          => $id,
				'saveOrphans' => $saveOrphans,
			)
		);

	}

	return false;
}

function zeroBS_getCustomerIDWithExternalSource( $externalSource = '', $externalID = '' ) {

	global $zbs;

	#} No empties, no random externalSources :)
	if ( ! empty( $externalSource ) && ! empty( $externalID ) && array_key_exists( $externalSource, $zbs->external_sources ) ) {

		#} If here, is legit.
		$approvedExternalSource = $externalSource;

		global $zbs;

		return $zbs->DAL->contacts->getContact(
			-1,
			array(
				'externalSource'    => $approvedExternalSource,
				'externalSourceUID' => $externalID,
				'onlyID'            => true,
				'ignoreowner'       => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
			)
		);

	}

	return false;
}

function zeroBSCRM_getCustomerTagsByID( $cID = -1, $justIDs = false ) {

	if ( ! empty( $cID ) ) {

		global $zbs;

		return $zbs->DAL->getTagsForObjID(
			array(
				'objtypeid'   => ZBS_TYPE_CONTACT,
				'objid'       => $cID,
				'onlyID'      => $justIDs,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
			)
		);

	}
}

// NOTE: $objType is temporary until DB2 fully rolled out all tables
function zeroBS_getOwner( $objID = -1, $withDeets = true, $objType = -1, $knownOwnerID = -1 ) {

	if ( $objID !== -1 && $objType !== -1 ) {

		$objType = jpcrm_upconvert_obj_type( $objType );

		global $zbs;
		$retObj = false;

		// if passed, save the db call
		if ( $knownOwnerID > 0 ) {

			$userIDofOwner = $knownOwnerID;

		} else {

			$userIDofOwner = $zbs->DAL->getObjectOwner(
				array(
					'objID'     => $objID,
					'objTypeID' => $objType,
				)
			);

		}

		if ( isset( $userIDofOwner ) && ! empty( $userIDofOwner ) ) {

			// check if user can be owner (is zbs admin)
			// check on the assign, is less performance impacting
			// if (! user_can($userIDofOwner,'admin_zerobs_usr') return false;

			if ( $withDeets ) {

				#} Retrieve owner deets
				$retObj = zeroBS_getOwnerObj( $userIDofOwner );

			} else {
				return $userIDofOwner;
			}
		}

		return $retObj;

	}

	return false;
}

/**
 * Retrieves the owner object based on a given WP user ID.
 *
 * This function gets the owner's data without revealing sensitive information
 * (e.g. `user_pass`).
 *
 * @param int $wp_user_id The WordPress user ID. Default is -1.
 *
 * @return array|bool Returns an associative array containing the 'ID' and 'OBJ' (user data object) if successful, false otherwise.
 */
function zeroBS_getOwnerObj( $wp_user_id = -1 ) {
	if ( $wp_user_id > 0 ) {

		$user = get_userdata( $wp_user_id );

		if ( ! isset( $user->ID ) || ! isset( $user->data ) ) {
			return false;
		}

		/**
		 * Ideally we'd restructure this, but the return result is used extensively,
		 * particularly from `zeroBS_getOwner` calls. For now we'll explicitly set what
		 * fields are provided (e.g. don't show `user_pass`).
		 */
		$user_data = (object) array(
			'ID'            => $user->data->ID,
			'user_login'    => $user->data->user_login,
			'user_nicename' => $user->data->user_nicename,
			'display_name'  => $user->data->display_name,
		);

		return array(
			'ID'  => $wp_user_id,
			'OBJ' => $user_data,
		);

	}

	return false;
}

// NOTE - this is very generic & not to be used in future code
// Use the direct $zbs->DAL->contacts->addUpdateContact code example in below rather than this generic.
// kthx.
function zeroBS_setOwner( $objID = -1, $ownerID = -1, $objTypeID = false ) {

	if ( $objID !== -1 && $objTypeID !== false ) {

		// here we check that the potential owner CAN even own
		if ( ! user_can( $ownerID, 'admin_zerobs_usr' ) ) {
			return false;
		}

		global $zbs;

		return $zbs->DAL->setFieldByID(
			array(

				'objID'       => $objID,
				'objTypeID'   => $objTypeID,

				'colname'     => 'zbs_owner',
				'coldatatype' => '%d', // %d/s
				'newValue'    => $ownerID,

			)
		);

	}

	return false;
}

function zeroBSCRM_mergeCustomers( $dominantID = -1, $slaveID = -1 ) {

	if ( ! empty( $dominantID ) && ! empty( $slaveID ) ) {

		// load both
		$master = zeroBS_getCustomer( $dominantID );
		$slave  = zeroBS_getCustomer( $slaveID, true, true, true );

		if ( isset( $master['id'] ) && isset( $slave['id'] ) ) {

			global $zbs;

			try {

				// all set, merge
				$changes            = array();
				$conflictingChanges = array();

				$fieldPrefix = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

				// copy details from slave fields -> master fields
				// where detail not present?
				// into second address?

				$masterNewMeta       = false;
				$masterHasSecondAddr = false;  // this'll let us copy over first from slave if empty :)
				$slaveHasFirstAddr   = false;
				$slaveHasSecondAddr  = false;
				$slaveFirstAddrStr   = '';
				$slaveSecondAddrStr  = '';

				// if this gets filled, it'll be added as aka below
				$slaveEmailAddress = false;

				// because these are just arrays (in meta) - we do a kind of compare, save a new ver,
				// ..and add any mismatches to conflicting changes in a meaningful way

				// DB2 converted these from obj[meta] -> obj

				// first, just copy through slave email if present
				if ( isset( $slave['email'] ) && ! empty( $slave['email'] ) ) {
					$slaveEmailAddress = $slave['email'];
				}

				// we start with the master :)
					$masterNewMeta = $master;

					global $zbsCustomerFields, $zbsAddressFields;

				// first, any empties (excluding addr) in master, get patched from secondary
				foreach ( $zbsCustomerFields as $fieldKey => $fieldDeets ) {

					// ignore addrs here
					if ( ! isset( $fieldDeets['migrate'] ) || $fieldDeets['migrate'] != 'addresses' ) {
						// present in master?
						if ( ! isset( $master[ $fieldKey ] ) || empty( $master[ $fieldKey ] ) ) {

							// NOT PRESENT IN MASTER

							// was not set, or empty, in master
							// present in slave?
							if ( isset( $slave[ $fieldKey ] ) && ! empty( $slave[ $fieldKey ] ) ) {

								// a change :) - note requires zbsc_ here for some annoying reason, leaving for now
								$masterNewMeta[ $fieldPrefix . $fieldKey ] = $slave[ $fieldKey ];

								// hopefully DB2 doesnt..
								// Does for now lol $masterNewMeta[$fieldKey] = $slave[$fieldKey];
								$changes[] = __( 'Copied field', 'zero-bs-crm' ) . ' "' . $fieldDeets[1] . '" ' . __( 'from secondary record over main record, (main was empty).', 'zero-bs-crm' );

							}
						} else {

							// if slave had value?
							// (no need to worry about emails, dealt with separately)
							if ( isset( $slave[ $fieldKey ] ) && ! empty( $slave[ $fieldKey ] ) && $fieldKey !== 'email' ) {

								// master val already present, conflicting change:
								$conflictingChanges[] = __( 'Field not copied', 'zero-bs-crm' ) . ' "' . $fieldDeets[1] . '" ' . __( 'from secondary record over main record, (main had value). Value was', 'zero-bs-crm' ) . ' "' . $slave[ $fieldKey ] . '"';

							}
						}
					} else {

						// ADDRESSES. Here we just use the foreach to check if the master has any secaddr fields
						// just sets a flag used below in logic :)
						if ( str_starts_with( $fieldKey, 'secaddr_' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

							// check presence (of any secaddr_ field)
							if ( isset( $master[ $fieldKey ] ) && ! empty( $master[ $fieldKey ] ) ) {
								$masterHasSecondAddr = true;
							}

								// does slave have secondary?
							if ( isset( $slave[ $fieldKey ] ) && ! empty( $slave[ $fieldKey ] ) ) {

								// clearly has (bits of) second addr
								$slaveHasSecondAddr = true;

								// we also build this str which'll be shown as conflicting change (so we don't "loose" this data)
								if ( ! empty( $slaveSecondAddrStr ) ) {
									$slaveSecondAddrStr .= ', ';
								}
								$slaveSecondAddrStr .= $slave[ $fieldKey ];

							}
						} else {

							// first address
							if ( isset( $slave[ $fieldKey ] ) && ! empty( $slave[ $fieldKey ] ) ) {

								// clearly has (bits of) first addr
								$slaveHasFirstAddr = true;

								// we also build this str which'll be shown as conflicting change (so we don't "loose" this data)
								if ( ! empty( $slaveFirstAddrStr ) ) {
									$slaveFirstAddrStr .= ', ';
								}
								$slaveFirstAddrStr .= $slave[ $fieldKey ];

							}
						}
					}
				}

				// addr's

				// if master has no sec addr, just copy first addr from slave :)
				if ( ! $masterHasSecondAddr ) {

					// copy first addr from slave
					foreach ( $zbsAddressFields as $addrFieldKey => $addrFieldDeets ) {

						// from slave first to master second - note requires zbsc_ here for some annoying reason, leaving for now
						// Hopefully db2 doesnt
						$masterNewMeta[ $fieldPrefix . 'secaddr_' . $addrFieldKey ] = $slave[ $addrFieldKey ];
						// Does for now lol $masterNewMeta['secaddr_'.$addrFieldKey] = $slave[$addrFieldKey];

					}
					$changes[] = __( 'Copied address from secondary record into "secondary address" for main record', 'zero-bs-crm' );

					// any second addr from slave just goes into logs
					if ( $slaveHasSecondAddr ) {

							// provide old addr string
							$conflictingChanges[] = __( 'Address not copied. Secondary address from secondary record could not be copied (master already had two addresses).', 'zero-bs-crm' ) . "\r\n" . __( 'Address', 'zero-bs-crm' ) . ': ' . "\r\n" . $slaveSecondAddrStr;

					}
				} else {

					// master already has two addresses, dump (any) secondary addresses into conflicting changes

					if ( $slaveHasFirstAddr ) {

						// provide old addr string
						$conflictingChanges[] = __( 'Address not copied. Address from secondary record could not be copied (master already had two addresses).', 'zero-bs-crm' ) . "\r\n" . __( 'Address', 'zero-bs-crm' ) . ': ' . "\r\n" . $slaveFirstAddrStr;

					}
					if ( $slaveHasSecondAddr ) {

							// provide old addr string
							$conflictingChanges[] = __( 'Address not copied. Secondary address from secondary record could not be copied (master already had two addresses).', 'zero-bs-crm' ) . "\r\n" . __( 'Address', 'zero-bs-crm' ) . ': ' . "\r\n" . $slaveSecondAddrStr;

					}
				}

				// assign social profiles from slave -> master
				// GET THESE BEFORE updating!
					$masterSocial = $zbs->DAL->contacts->getContactSocials( $dominantID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

					$slaveSocial = $zbs->DAL->contacts->getContactSocials( $slaveID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

				// UPDATE MASTER META:
				zeroBS_addUpdateCustomer( $dominantID, $masterNewMeta, '', '', '', false, false, false, -1, $fieldPrefix );

				$masterNewSocial = $masterSocial;

				global $zbsSocialAccountTypes;

				if ( count( $zbsSocialAccountTypes ) > 0 ) {

					foreach ( $zbsSocialAccountTypes as $socialKey => $socialAccType ) {

						// master / slave has this acc?
						// for simplicity (not perf.) we grab which has which, first
						$masterHas = false;
						$slaveHas  = false;
						if ( is_array( $masterSocial ) && isset( $masterSocial[ $socialKey ] ) && ! empty( $masterSocial[ $socialKey ] ) ) {
							$masterHas = true;
						}
						if ( is_array( $slaveSocial ) && isset( $slaveSocial[ $socialKey ] ) && ! empty( $slaveSocial[ $socialKey ] ) ) {
							$slaveHas = true;
						}

						// what's up.
						if ( $masterHas && $slaveHas ) {

							// conflicting change
							$conflictingChanges[] = __( 'Social account not copied.', 'zero-bs-crm' ) . ' "' . $socialAccType['name'] . '" of "' . $slaveSocial[ $socialKey ] . '" ' . __( 'from secondary record (master already has a ', 'zero-bs-crm' ) . $socialAccType['name'] . ' ' . __( 'account.', 'zero-bs-crm' );

						} elseif ( $masterHas && ! $slaveHas ) {

							// no change

						} elseif ( $slaveHas && ! $masterHas ) {

							// copy slave -> master
							$masterNewSocial[ $socialKey ] = $slaveSocial[ $socialKey ];
							$changes[]                     = __( 'Copied social account from secondary record into main record', 'zero-bs-crm' ) . ' (' . $socialAccType['name'] . ').';

						}
					}

					// UPDATE SOCIAL
					zeroBS_updateCustomerSocialAccounts( $dominantID, $masterNewSocial );

				}

				// assign files from slave -> master

				/*
				Array
				(
					[0] => Array
						(
							[file] => /app/public/wp-content/uploads/zbscrm-store/aa250965422e9aea-Document-20243.pdf
							[url] => http://zbsphp5.dev/wp-content/uploads/zbscrm-store/aa250965422e9aea-Document-20243.pdf
							[type] => application/pdf
							[error] =>
							[priv] => 1
						)

				)
				*/

					$slaveFiles = zeroBSCRM_getCustomerFiles( $slaveID );

				if ( is_array( $slaveFiles ) && count( $slaveFiles ) > 0 ) {

					$masterFiles = zeroBSCRM_getCustomerFiles( $dominantID );

					if ( ! is_array( $masterFiles ) ) {
						$masterFiles = array();
					}

					foreach ( $slaveFiles as $zbsFile ) {

						// add
						$masterFiles[] = $zbsFile;

						// changelog

							$filename = basename( $zbsFile['file'] );

							// if in privatised system, ignore first hash in name
						if ( isset( $zbsFile['priv'] ) ) {

							$filename = substr( $filename, strpos( $filename, '-' ) + 1 );
						}

						$changes[] = __( 'Moved file to main record', 'zero-bs-crm' ) . ' (' . $filename . ')';

					}

					// save master files
					zeroBSCRM_updateCustomerFiles( $dominantID, $masterFiles );

				}

				// assign company from slave -> master

				$masterCompany = zeroBS_getCustomerCompanyID( $dominantID );
				$slaveCompany  = zeroBS_getCustomerCompanyID( $slaveID );
				if ( empty( $masterCompany ) ) {

					// slave co present, update main
					if ( ! empty( $slaveCompany ) ) {

						zeroBS_setCustomerCompanyID( $dominantID, $slaveCompany );
						$changes[] = __( 'Assigned main record to secondary record\'s ' . jpcrm_label_company(), 'zero-bs-crm' ) . ' (#' . $slaveCompany . ').';

					}
				} else {

					// master has co already, does slave?
					if ( ! empty( $slaveCompany ) && $slaveCompany != $masterCompany ) {

						// conflicting change
						$conflictingChanges[] = __( 'Secondary contact was assigned to ' . jpcrm_label_company() . ', whereas main record was assigned to another ' . jpcrm_label_company() . '.', 'zero-bs-crm' ) . ' (#' . $slaveCompany . ').';

					}
				}

				// assign quotes from slave -> master

				// got quotes?
				if ( is_array( $slave['quotes'] ) && count( $slave['quotes'] ) > 0 ) {

					$quoteOffset = zeroBSCRM_getQuoteOffset();

					foreach ( $slave['quotes'] as $quote ) {

						// id for passing to logs
						$qID = '';
						#TRANSITIONTOMETANO
						if ( isset( $quote['zbsid'] ) ) {
							$qID = $quote['zbsid'];
						}

						// for quotes, we just "switch" the owner meta :)
						zeroBSCRM_changeQuoteCustomer( $quote['id'], $dominantID );
						$changes[] = __( 'Assigned quote from secondary record onto main record', 'zero-bs-crm' ) . ' (#' . $qID . ').';

					}
				} // / has quotes

				// assign invs from slave -> master

				// got invoices?
				if ( is_array( $slave['invoices'] ) && count( $slave['invoices'] ) > 0 ) {

					foreach ( $slave['invoices'] as $invoice ) {

						// for invs, we just "switch" the owner meta :)
						zeroBSCRM_changeInvoiceCustomer( $invoice['id'], $dominantID );
						$changes[] = __( 'Assigned invoice from secondary record onto main record', 'zero-bs-crm' ) . ' (#' . $invoice['id'] . ').';

					}
				} // / has invoices

				// assign trans from slave -> master

				// got invoices?
				if ( is_array( $slave['transactions'] ) && count( $slave['transactions'] ) > 0 ) {

					foreach ( $slave['transactions'] as $transaction ) {

						// for trans, we just "switch" the owner meta :)
						zeroBSCRM_changeTransactionCustomer( $transaction['id'], $dominantID );
						$changes[] = __( 'Assigned transaction from secondary record onto main record', 'zero-bs-crm' ) . ' (#' . $transaction['id'] . ').';

					}
				} // / has invoices

				// assign events from slave -> master

				// get events
				$events = zeroBS_getEventsByCustomerID( $slaveID, true, 10000, 0 );
				if ( is_array( $events ) && count( $events ) > 0 ) {

					foreach ( $events as $event ) {

						// for events, we just "switch" the meta val :)
						zeroBSCRM_changeEventCustomer( $event['id'], $dominantID );
						$changes[] = __( 'Assigned task from secondary record onto main record', 'zero-bs-crm' ) . ' (#' . $event['id'] . ').';

					}
				} // / has invoices

				// assign logs(?) from slave -> master

				// for now save these as a random text meta against customer (not sure how to expose as of yet, but don't want to loose)
				$slaveLogs = zeroBSCRM_getContactLogs( $slaveID, true, 10000, 0 ); // id created name meta
				if ( is_array( $slaveLogs ) && count( $slaveLogs ) > 0 ) {

					// no $change here, as this is kinda secret, kthx
					$zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $dominantID, 'merged_customer_log_bk_' . time(), $slaveLogs );

				}

				// assign tags(?) from slave -> master

				// get slave tags as ID array
				$slaveTagsIDs = zeroBSCRM_getCustomerTagsByID( $slaveID, true );
				if ( is_array( $slaveTagsIDs ) && count( $slaveTagsIDs ) > 0 ) {

					// add tags to master (append mode)
					// wp_set_object_terms($dominantID, $slaveTagsIDs, 'zerobscrm_customertag', true );
					$zbs->DAL->addUpdateObjectTags(
						array(
							'objid'   => $dominantID,
							'objtype' => ZBS_TYPE_CONTACT,
							'tagIDs'  => $slaveTagsIDs,
							'mode'    => 'append',
						)
					);
					$changes[] = __( 'Tagged main record with', 'zero-bs-crm' ) . ' ' . count( $slaveTagsIDs ) . ' ' . __( 'tags from secondary record.', 'zero-bs-crm' );

				}

				// AKA / alias

				// second email -> alias first
				if ( ! empty( $slaveEmailAddress ) ) {

					// add as alias
					zeroBS_addCustomerAlias( $dominantID, $slaveEmailAddress );
					$changes[] = __( 'Added secondary record email as alias/aka of main record', 'zero-bs-crm' ) . ' (' . $slaveEmailAddress . ')';

				}

				// Customer image

				// (for now, left to die)

				// delete slave
				zeroBS_deleteCustomer( $slaveID, false );
				$changes[] = __( 'Removed secondary record', 'zero-bs-crm' ) . ' (#' . $slaveID . ')';

				// assign log for changes + conflicting changes

				// strbuild
				$shortDesc = '"' . $slave['name'] . '" (#' . $slave['id'] . ') ' . __( 'into this record', 'zero-bs-crm' );
				$longDesc  = '';

						// changes
				if ( is_array( $changes ) && count( $changes ) > 0 ) {

					$longDesc .= '<strong>' . __( 'Record Changes', 'zero-bs-crm' ) . ':</strong><br />';

					// cycle through em
					foreach ( $changes as $c ) {

						$longDesc .= '<br />' . $c;

					}
				} else {

					$longDesc .= '<strong>' . __( 'No Changes', 'zero-bs-crm' ) . '</strong>';

				}

						// conflicting changes
				if ( is_array( $conflictingChanges ) && count( $conflictingChanges ) > 0 ) {

					$longDesc .= '<br />=============================<br /><strong>' . __( 'Conflicting Changes', 'zero-bs-crm' ) . ':</strong><br />';

					// cycle through em
					foreach ( $conflictingChanges as $c ) {

						$longDesc .= '<br />' . $c;

					}
				} else {

					$longDesc .= '<br />=============================<br /><strong>' . __( 'No Conflicting Changes', 'zero-bs-crm' ) . '</strong>';

				}

				// MASTER LOG :D
				zeroBS_addUpdateContactLog(
					$dominantID,
					-1,
					-1,
					array(
						'type'      => 'Bulk Action: Merge',
						'shortdesc' => $shortDesc,
						'longdesc'  => $longDesc,
					)
				);

				return true;

			} catch ( Exception $e ) {

				// failed somehow!
				echo 'ERROR:' . $e->getMessage();

			}
		} // / if id's

	}

	return false;
}

function zeroBS_addUpdateCustomer(
	$cID = -1,
	$cFields = array(),
	$externalSource = '',
	$externalID = '',
	$customerDate = '',
	$fallBackLog = false,
	$extraMeta = false,
	$automatorPassthrough = false,
	$owner = -1,
	$metaBuilderPrefix = 'zbsc_'
) {

	#} return
	$ret = false;

	if ( isset( $cFields ) && count( $cFields ) > 0 ) {

		#} New flag
		$newCustomer    = false;
		$originalStatus = '';

		global $zbs;

		if ( $cID > 0 ) {

			#} Retrieve / check?
			$postID = $cID;

			#} Build "existing meta" to pass, (so we only update fields pushed here)
			$existingMeta = $zbs->DAL->contacts->getContact( $postID, array() );

			$originalDate = time();
			if ( isset( $existingMeta ) && is_array( $existingMeta ) && isset( $existingMeta['createduts'] ) && ! empty( $existingMeta['createduts'] ) ) {
				$originalDate = $existingMeta['createduts'];
			}

			if ( ! empty( $customerDate ) && $customerDate != '' ) {

				#} DATE PASSED TO THE FUNCTION
				$customerDateTimeStamp = strtotime( $customerDate );
				#} ORIGINAL POST CREATION DATE
				// no need, db2 = UTS $originalDateTimeStamp = strtotime($originalDate);
				$originalDateTimeStamp = $originalDate;

				#} Compare, if $customerDateTimeStamp < then update with passed date
				if ( $customerDateTimeStamp < $originalDateTimeStamp ) {

					// straight in there :)
					$zbs->DAL->contacts->addUpdateContact(
						array(
							'id'            => $postID,
							'limitedFields' => array(
								array(
									'key'  => 'zbsc_created',
									'val'  => $customerDateTimeStamp,
									'type' => '%d',
								),
							),
						)
					);
				}
			}

			// WH changed 20/05/18
			// 20/05/18 - Previously this would reload the EXISTING database data
			// THEN 'override' any passed fields
			// THEN save that down
			// ... this was required when we used old meta objs. (pre db2)
			// ... so if we're now DAL2, we can do away with that and simply pass what's to be updated and mode do_not_update_blanks
			$existingMeta = array();

		} else {

			// DB2: Probably can rethink this whole func, (do we even need it?) e.g. header post mentality used here
			// for now I've just edited in place, but def refactor in time

			#} Set flag
			$newCustomer = true;

			#} Set up empty meta arr

			#} DATE PASSED TO THE FUNCTION
			$customerDateTimeStamp = strtotime( $customerDate );
			#} DAL2 needs timestamp :)
			$existingMeta = array( 'created' => $customerDateTimeStamp );

		}

		#} Build using centralised func below, passing any existing meta (updates not overwrites)
		$zbsCustomerMeta = zeroBS_buildContactMeta( $cFields, $existingMeta, $metaBuilderPrefix, '', true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		$we_have_tags = false; // set to false.. duh..

		// TAG customer (if exists) - clean etc here too
		if ( ! empty( $cFields['tags'] ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			$tags = $cFields['tags']; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			// Sanitize tags
			if ( is_array( $tags ) ) {
				$customer_tags = filter_var_array( $tags, FILTER_UNSAFE_RAW );
				// Formerly this used FILTER_SANITIZE_STRING, which is now deprecated as it was fairly broken. This is basically equivalent.
				// @todo Replace this with something more correct.
				foreach ( $customer_tags as $k => $v ) {
					$customer_tags[ $k ] = strtr(
						strip_tags( $v ), // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags
						array(
							"\0" => '',
							'"'  => '&#34;',
							"'"  => '&#39;',
							'<'  => '',
						)
					);
				}
				$we_have_tags = true;
			}

			if ( $we_have_tags ) {

				$zbsCustomerMeta['tags'] = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				foreach ( $customer_tags as $tag_name ) {

					// Check for existing tag under this name.
					$tag_id = $zbs->DAL->getTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
						-1,
						array(
							'objtype' => ZBS_TYPE_CONTACT,
							'name'    => $tag_name,
							'onlyID'  => true,
						)
					);

					// If tag doesn't exist, create one.
					if ( empty( $tag_id ) ) {
						$tag_id = $zbs->DAL->addUpdateTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
							array(
								'data' => array(
									'objtype' => ZBS_TYPE_CONTACT,
									'name'    => $tag_name,
								),
							)
						);
					}

					// Add tag to list.
					if ( ! empty( $tag_id ) ) {
						$zbsCustomerMeta['tags'][] = $tag_id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
					}
				}
			}
		}

		#} Add external source/externalid
		#} No empties, no random externalSources :)
		$extSourceArr           = -1;
		$approvedExternalSource = ''; #} As this is passed to automator :)
		if ( ! empty( $externalSource ) && ! empty( $externalID ) && array_key_exists( $externalSource, $zbs->external_sources ) ) {

			#} If here, is legit.
			$approvedExternalSource = $externalSource;

			#} Add/Update record flag
			// 2.4+ Migrated away from this method to new update_post_meta($postID, 'zbs_customer_ext_'.$approvedExternalSource, $externalID);
			// 2.52+ Moved to new DAL method :)

			$extSourceArr = array(
				'source' => $approvedExternalSource,
				'uid'    => $externalID,
			);

			// add/update
			// DB2, this is just used below :)zeroBS_updateExternalSource($postID,$extSourceArr);
			$zbsCustomerMeta['externalSources'] = array( $extSourceArr );

		} #} Otherwise will just be a random customer no ext source

		#} Got owner?
		if ( $owner !== -1 ) {
			$zbsCustomerMeta['owner'] = $owner;
		}

		#} Update record (All IA is now fired intrinsicly )
		// DB2 update_post_meta($postID, 'zbs_customer_meta', $zbsCustomerMeta);
		return $zbs->DAL->contacts->addUpdateContact(
			array(
				'id'                   => $cID,
				'data'                 => $zbsCustomerMeta,
				'extraMeta'            => $extraMeta,
				'automatorPassthrough' => $automatorPassthrough,
				'fallBackLog'          => $fallBackLog,
			)
		);

			/*
			This now get's passed above, and dealt with by DAL
			#} Any extra meta keyval pairs?
			$confirmedExtraMeta = false;
			if ( isset( $extraMeta) && is_array( $extraMeta) ) {

				$confirmedExtraMeta = array();

					foreach ( $extraMeta as $k => $v){

					#} This won't fix stupid keys, just catch basic fails...
					$cleanKey = strtolower(str_replace(' ','_',$k));

					#} Brutal update
					//update_post_meta($postID, 'zbs_customer_extra_'.$cleanKey, $v);
					$zbs->DAL->updateMeta(ZBS_TYPE_CONTACT,$postID,'extra_'.$cleanKey,$v);

					#} Add it to this, which passes to IA
					$confirmedExtraMeta[$cleanKey] = $v;

				}

			} */

			/*
			NOW DEALT WITH IN DAL2 :)

			#} INTERNAL AUTOMATOR
			#} &
			#} FALLBACKS

			if ( $newCustomer){

				#} Add to automator
				zeroBSCRM_FireInternalAutomator('customer.new',array(
					'id'=>$postID,
					'customerMeta'=>$zbsCustomerMeta,
					'extsource'=>$approvedExternalSource,
					'automatorpassthrough'=>$automatorPassthrough, #} This passes through any custom log titles or whatever into the Internal automator recipe.
					'customerExtraMeta'=>$confirmedExtraMeta #} This is the "extraMeta" passed (as saved)
				));

				// (WH) Moved this to fire on the IA...
				// do_action('zbs_new_customer', $postID);   //fire the hook here...

			} else {

				#} Customer Update here (automator)?
				#} TODO

				#} FALLBACK
				#} (This fires for customers that weren't added because they already exist.)
				#} e.g. x@g.com exists, so add log "x@g.com filled out form"
				#} Requires a type and a shortdesc
				if (
					isset($fallBackLog) && is_array($fallBackLog)
					&& isset($fallBackLog['type']) && !empty($fallBackLog['type'])
					&& isset($fallBackLog['shortdesc']) && !empty($fallBackLog['shortdesc'])
				) {

					#} Brutal add, maybe validate more?!

					#} Long desc if present:
					$zbsNoteLongDesc = '';
					if ( isset( $fallBackLog['longdesc']) && !empty( $fallBackLog['longdesc']) ) {
						$zbsNoteLongDesc = $fallBackLog['longdesc'];
					}

						#} Only raw checked... but proceed.
						$newOrUpdatedLogID = zeroBS_addUpdateContactLog($postID,-1,-1,array(
							#} Anything here will get wrapped into an array and added as the meta vals
							'type' => $fallBackLog['type'],
							'shortdesc' => $fallBackLog['shortdesc'],
							'longdesc' => $zbsNoteLongDesc
						));

				}

				// catch dirty flag (update of status) (note, after update_post_meta - as separate)
				//if (isset($_POST['zbsc_status_dirtyflag']) && $_POST['zbsc_status_dirtyflag'] == "1"){
				// actually here, it's set above
				if ( isset( $statusChange) && is_array( $statusChange)){

					// status has changed

					// IA
					zeroBSCRM_FireInternalAutomator('customer.status.update',array(
						'id'=>$postID,
						'againstid' => $postID,
						'userMeta'=> $zbsCustomerMeta,
						'from' => $statusChange['from'],
						'to' => $statusChange['to']
						));

				}

			} */

			#} REQ?
			#} MAKE SURE if you change any post_name features you also look at: "NAMECHANGES" in this file (when a post updates it'll auto replace these...)
			#$newCName = zeroBS_customerName('',$zbsMeta,true,false)

			#} Return customerID if success :)
			// $ret = $postID;

	}

	return $ret;
}

/*
======================================================
	Contact -> aliases
	====================================================== */
#} See if already in use/exists
function zeroBS_canUseCustomerAlias( $alias = '' ) {

	// now can call this generic:
	return zeroBS_canUseAlias( ZBS_TYPE_CONTACT, $alias );
}

#} Get specific alias if exists
function zeroBS_getCustomerAlias( $cID = -1, $alias = '' ) {

	return zeroBS_getObjAlias( ZBS_TYPE_CONTACT, $cID, $alias );
}

#} Get specific alias if exists
function zeroBS_getCustomerAliasByID( $cID = -1, $aliasID = -1 ) {

	return zeroBS_getAliasByID( ZBS_TYPE_CONTACT, $cID, $aliasID );
}

#} Get All Aliases against a contact.
function zeroBS_getCustomerAliases( $cID = -1 ) {

	return zeroBS_getObjAliases( ZBS_TYPE_CONTACT, $cID );
}

#} add Aliases to a contact.
function zeroBS_addCustomerAlias( $cID = -1, $alias = '' ) {

	return zeroBS_addObjAlias( ZBS_TYPE_CONTACT, $cID, $alias );
}

#} remove Alias from an contact
function zeroBS_removeCustomerAlias( $cID = -1, $alias = '' ) {

	return zeroBS_removeObjAlias( ZBS_TYPE_CONTACT, $cID, $alias );
}

#} remove Alias from a contact.
function zeroBS_removeCustomerAliasByID( $cID = -1, $aliasID = -1 ) {

	return zeroBS_removeObjAliasByID( ZBS_TYPE_CONTACT, $cID, $aliasID );
}

/*
======================================================
	/ Contact -> aliases
====================================================== */

function zeroBSCRM_getLog( $lID = -1 ) {

	if ( $lID !== -1 ) {

		global $zbs;

		return $zbs->DAL->logs->getLog(
			array(
				'id'          => $lID,
				'incMeta'     => true,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
			)
		);

	}

	return false;
}

function zeroBSCRM_getContactLogs( $customerID = -1, $withFullDetails = false, $perPage = 100, $page = 0, $searchPhrase = '', $argsOverride = false ) {

	if ( ! empty( $customerID ) && $customerID !== -1 ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		global $zbs;
		return $zbs->DAL->logs->getLogsForObj(
			array(

				'objtype'      => ZBS_TYPE_CONTACT,
				'objid'        => $customerID,

				'searchPhrase' => $searchPhrase,

				'incMeta'      => $withFullDetails,

				'sortByField'  => 'zbsl_created',
				'sortOrder'    => 'DESC',
				'page'         => $page,
				'perPage'      => $perPage,
				'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

			)
		);

	}
	return array();
}

function zeroBSCRM_getAllContactLogs( $withFullDetails = false, $perPage = 100, $page = 0, $searchPhrase = '', $argsOverride = false ) {

	global $zbs;
	return $zbs->DAL->logs->getLogsForANYObj(
		array(

			'objtype'      => ZBS_TYPE_CONTACT,

			'searchPhrase' => $searchPhrase,

			'incMeta'      => $withFullDetails,

			'sortByField'  => 'zbsl_created',
			'sortOrder'    => 'DESC',
			'page'         => $page,
			'perPage'      => $perPage,
			'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

		)
	);
}
function zeroBSCRM_getCompanyLogs( $companyID = false, $withFullDetails = false, $perPage = 100, $page = 0, $searchPhrase = '', $argsOverride = false ) {

	// DAL 3+ :)
	if ( ! empty( $companyID ) ) {
		global $zbs;

		return $zbs->DAL->logs->getLogsForObj(
			array(

				'objtype'      => ZBS_TYPE_COMPANY,
				'objid'        => $companyID,

				'searchPhrase' => $searchPhrase,

				'incMeta'      => $withFullDetails,

				'sortByField'  => 'zbsl_created',
				'sortOrder'    => 'DESC',
				'page'         => $page,
				'perPage'      => $perPage,
				'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
			)
		);

	}
	return array();
}

function zeroBSCRM_getObjCreationLog( $objID = -1, $objType = ZBS_TYPE_CONTACT ) {

	if ( ! empty( $objID ) && $objID !== -1 ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		global $zbs;
		return $zbs->DAL->logs->getLogsForObj( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			array(

				'objtype'     => $objType,
				'objid'       => $objID,

				'notetype'    => 'Created',

				'incMeta'     => true,

				'sortByField' => 'zbsl_created',
				'sortOrder'   => 'ASC',
				'page'        => 0,
				'perPage'     => 1,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

			)
		);

	}
}

function zeroBSCRM_getMostRecentContactLog( $objID = false, $withFullDetails = false, $restrictToTypes = false ) {

	if ( ! empty( $objID ) ) {

		global $zbs;

		return $zbs->DAL->logs->getLogsForObj(
			array(

				'objtype'     => ZBS_TYPE_COMPANY,
				'objid'       => $objID,

				'notetypes'   => $restrictToTypes,

				'incMeta'     => $withFullDetails,

				'sortByField' => 'zbsl_created',
				'sortOrder'   => 'DESC',
				'page'        => 0,
				'perPage'     => 1,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

			)
		);

	}
}

function zeroBSCRM_getMostRecentCompanyLog( $objID = false, $withFullDetails = false, $restrictToTypes = false ) {

	if ( ! empty( $objID ) ) {

		global $zbs;

		return $zbs->DAL->logs->getLogsForObj(
			array(

				'objtype'     => ZBS_TYPE_COMPANY,
				'objid'       => $objID,

				'notetypes'   => $restrictToTypes,

				'incMeta'     => $withFullDetails,

				'sortByField' => 'zbsl_created',
				'sortOrder'   => 'DESC',
				'page'        => 0,
				'perPage'     => 1,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),

			)
		);

	}
}

function zeroBS_searchLogs( $querystring ) {

	global $zbs;

	return $zbs->DAL->logs->getLogsForANYObj(
		array(
			'searchPhrase' => $querystring,
			'perPage'      => 100,
			'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}

function zeroBS_allLogs() {

	global $zbs;

	return $zbs->DAL->logs->getLogsForANYObj(
		array(
			'perPage'     => 100,
			'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_CONTACT ),
		)
	);
}

/**
 * Adds or updates a log entry.
 *
 * @param int    $cID        Contact ID.
 * @param int    $logID      Log ID.
 * @param int    $logDate    Log date timestamp.
 * @param array  $noteFields Note fields array. Expected keys: zbsNoteAgainstPostID, zbsNoteType,
 *                           zbsNoteShortDesc, zbsNoteLongDesc. May also include 'meta_assoc_id'
 *                           (e.g. campaign ID for 'email sent' logs) and 'meta_assoc_src'
 *                           (e.g. 'mailcamp').
 * @param string $objType    Object type — e.g. 'contact' or 'zerobs_customer'.
 * @param int    $owner      Owner ID.
 */
function zeroBS_addUpdateLog(
	$cID = -1,
	$logID = -1,
	$logDate = -1,
	$noteFields = array(),
	$objType = '',
	$owner = -1
) {

	global $zbs;

	// DAL3 all obj logs: if ($objType == 'zerobs_customer'){
		// zeroBSCRM_DEPRECATEDMSG('zeroBS_addUpdateLog has been replaced by zeroBS_addUpdateContactLog etc. or (better still) DAL2 calls direct');
		// return zeroBS_addUpdateContactLog($cID,$logID,$logDate,$noteFields,$owner);
	// DAL3 NO CPT LOGS:
		// } else
		// fallback

	// translate zerobs_customer to 1
	$typeID = $zbs->DAL->objTypeID( $objType );

		// got type?
	if ( $typeID !== -1 ) {

		// assume this'll work. (should do.)
		return zeroBS_addUpdateObjLog( $typeID, $cID, $logID, $logDate, $noteFields, $owner );

	}

	// no TYPE
	zeroBSCRM_DEPRECATEDMSG( 'zeroBS_addUpdateLog has been replaced by DAL3 logging. Please do not use, or at least pass an object type.' );
	return false;
}
// really should just be calling direct at this point, or zeroBS_addUpdateObjLog at least
function zeroBS_addUpdateContactLog(
	$cID = -1,
	$logID = -1,
	$logDate = -1,
	$noteFields = array(),
	$owner = -1
) {

	// wrapper for this:
	return zeroBS_addUpdateObjLog( ZBS_TYPE_CONTACT, $cID, $logID, $logDate, $noteFields, $owner );
}

// generic add obj log
function zeroBS_addUpdateObjLog(
	$objTypeID = -1,
	$objID = -1,
	$logID = -1,
	$logDate = -1,
	$noteFields = array(),
	$owner = -1
) {

	if ( $objTypeID > 0 ) {

		$logType      = '';
		$logShortDesc = '';
		$logLongDesc  = '';
		$logMeta      = -1;
		$logCreated   = -1;
		$pinned       = -1;
		if ( isset( $noteFields['type'] ) ) {
			$logType = zeroBSCRM_permifyLogType( $noteFields['type'] );
		}
		if ( isset( $noteFields['shortdesc'] ) ) {
			$logShortDesc = $noteFields['shortdesc'];
		}
		if ( isset( $noteFields['longdesc'] ) ) {
			$logLongDesc = $noteFields['longdesc'];
		}
		if ( isset( $noteFields['meta'] ) ) {
			$logMeta = $noteFields['meta'];
		}
		if ( isset( $noteFields['pinned'] ) ) {
			$pinned = $noteFields['pinned'];
		}
		if ( $logDate !== -1 ) {
			$logCreated = strtotime( $logDate );
		} else {
			$logCreated = -1;
		}

		global $zbs;

		return $zbs->DAL->logs->addUpdateLog(
			array(

				'id'    => $logID,
				'owner' => $owner,

				// fields (directly)
				'data'  => array(

					'objtype'   => $objTypeID,
					'objid'     => $objID,
					'type'      => $logType,
					'shortdesc' => $logShortDesc,
					'longdesc'  => $logLongDesc,

					'meta'      => $logMeta,
					'pinned'    => $pinned,

					'created'   => $logCreated,

				),
			)
		);

	}

		return false;
}

// allows us to lazily 'hotswap' wp_set_post_terms in extensions (e.g. pre DAL2 it'll just fire wp_set_post_terms)
// ... here it does DAL2 equiv
// WH Note: if using old WP method (wp_set_post_terms) can pass tags or tagIDS - DB2 currently only accepts tagIDs - to add in
// ... to get around this I've temp added $usingTagIDS=true flag
// still used in bulk-tagger and groove-connect extensions as of 9 May 1923
function zeroBSCRM_DAL2_set_post_terms( $cID = -1, $tags = array(), $taxonomy = 'zerobscrm_customertag', $append = true, $usingTagIDS = true ) {

	zeroBSCRM_DEPRECATEDMSG( 'zeroBSCRM_DAL2_set_post_terms has been replaced by DAL3 tagging. Please do not use.' );

	global $zbs;

	// if we have tooo....
	$possibleObjTypeID = $zbs->DAL->cptTaxonomyToObjID( $taxonomy );

	if ( $possibleObjTypeID > 0 ) {

		$mode = 'replace';
		if ( $append ) {
			$mode = 'append';
		}

		$fieldName = 'tagIDs';
		if ( ! $usingTagIDS ) {
			$fieldName = 'tags';
		}

		return $zbs->DAL->addUpdateObjectTags(
			array(
				'objid'    => $cID,
				'objtype'  => $possibleObjTypeID,
				$fieldName => $tags,
				'mode'     => $mode,
			)
		);

	}
	return false;
}

// allows us to lazily 'hotswap' wp_set_object_terms in extensions (e.g. pre DAL2 it'll just fire wp_set_object_terms)
// ... here it does DAL2 equiv
// WH Note: if using old WP method (wp_set_object_terms) can pass tags or tagIDS - DB2 currently only accepts tagIDs - to add in
// ... to get around this I've temp added $usingTagIDS=true flag
// still used in several extensions as of 9 May 1923
function zeroBSCRM_DAL2_set_object_terms( $cID = -1, $tags = array(), $taxonomy = 'zerobscrm_customertag', $append = true, $usingTagIDS = true ) {

	zeroBSCRM_DEPRECATEDMSG( 'zeroBSCRM_DAL2_set_object_terms has been replaced by DAL3 tagging. Please do not use.' );

	global $zbs;

	// if we have tooo....
	$possibleObjTypeID = $zbs->DAL->cptTaxonomyToObjID( $taxonomy );

	if ( $possibleObjTypeID > 0 ) {

		$mode = 'replace';
		if ( $append ) {
			$mode = 'append';
		}

		$fieldName = 'tagIDs';
		if ( ! $usingTagIDS ) {
			$fieldName = 'tags';
		}

		return $zbs->DAL->addUpdateObjectTags(
			array(
				'objid'    => $cID,
				'objtype'  => $possibleObjTypeID,
				$fieldName => $tags,
				'mode'     => $mode,
			)
		);

	}
	return false;
	/*
	// we only switch out for customer tags, rest just go old way
	if ( $taxonomy == 'zerobscrm_customertag'){

		global $zbs;

		$mode = 'replace';
		if ( $append ) {
			$mode = 'append';
		}

		$fieldName = 'tagIDs';
		if ( !$usingTagIDS ) {
			$fieldName = 'tags';
		}

		return $zbs->DAL->addUpdateObjectTags(array(
														'objid'         => $cID,
														'objtype'       => ZBS_TYPE_CONTACT,
														$fieldName      => $tags,
														'mode'          => $mode
												));

	} else {

		//https://codex.wordpress.org/Function_Reference/wp_set_object_terms
		return wp_set_object_terms($cID,$tags,$taxonomy,$append);

	} */
}

#} This takes an array source (can be $_POST) and builds out a meta field array for it..
#} This lets us use the same fields array for Metaboxes.php and any custom integrations
#} e.g. $zbsCustomerMeta = zeroBS_buildContactMeta($_POST);
#} e.g. $zbsCustomerMeta = zeroBS_buildContactMeta($importedMetaFields);
#} e.g. $zbsCustomerMeta = zeroBS_buildContactMeta(array('zbsc_fname'=>'Woody'));
#} 27/09/16: Can now also pass starting array, which lets you "override" fields present in $arraySource, without loosing originals not passed
#} 12/04/18: Added prefix so as to be able to pass normal array e.g. fname (by passing empty fieldPrefix)
#} 3.0: this was moved to generic zeroBS_buildObjArr :)
function zeroBS_buildContactMeta( $arraySource = array(), $startingArray = array(), $fieldPrefix = 'zbsc_', $outputPrefix = '', $removeEmpties = false, $autoGenAutonumbers = false ) {

	// moved to generic, just return that :)
	return zeroBS_buildObjArr( $arraySource, $startingArray, $fieldPrefix, $outputPrefix, $removeEmpties, ZBS_TYPE_CONTACT, $autoGenAutonumbers );
}

/*
======================================================
	/ Unchanged DAL2->3 (Mostly customer/contact + log relatead)
====================================================== */

// ====================================================================================================================================
// ====================================================================================================================================
// ==================== / DAL 2.0 FUNCS ===============================================================================================
// ====================================================================================================================================
// ====================================================================================================================================

// ====================================================================================================================================
// ====================================================================================================================================
// ==================== DAL 3.0 FUNCS ===============================================================================================
// ====================================================================================================================================
// ====================================================================================================================================

/*
======================================================
	GENERIC helpers
====================================================== */

	#} This is a fill-in until we deprecate addUpdateTransaction etc. (3.5 or so)
	#} it'll take a DAL1 obj (e.g. transaction with 'orderid') and produce a v3 translated field variant (e.g. orderid => ref (via 'dal1key' attr on obj model))
	#} param $objType = ZBS_TYPE_TRANSACTION
	#} param $fieldPrefix = zbst_ if fields are prefixed with
function zeroBS_translateDAL1toDAL3Obj( $arraySource = array(), $objType = -1, $fieldPrefix = '' ) {

	if ( $objType > 0 ) {

		global $zbs;

		// $objectModel = $zbs->DAL->objModel($objType);
		$objectLayer = $zbs->DAL->getObjectLayerByType( $objType );

		if ( isset( $objectLayer ) ) {

			$ret                  = array();
			$objTranslationMatrix = $objectLayer->getDAL1toDAL3ConversionMatrix();
			if ( ! is_array( $objTranslationMatrix ) ) {
				$objTranslationMatrix = array();
			}

			foreach ( $arraySource as $k => $v ) {

				$kClean = $k;
				if ( ! empty( $fieldPrefix ) ) {
					$kClean = str_replace( $fieldPrefix, '', $k );
				}

				if ( isset( $objTranslationMatrix[ $kClean ] ) ) {

					// is translatable
					$ret[ $fieldPrefix . $objTranslationMatrix[ $kClean ] ] = $v;

				} else {

					// isn't translatable
					$ret[ $k ] = $v;

				}
			}
		} // / has object layer

	} // / has objtype

	return $ret;
}

	#} This takes an array source (can be $_POST) and builds out a meta field array for it..
	#} ... this is a generalised postarray->objarray creator, built from zeroBS_buildContactMeta,
	#} ... now produces all "meta" (objarrays) for all objs. Centralised to keep DRY
	#} 13/03/19: Added $autoGenAutonumbers - if TRUE, empty/non-passed autonumber custom fields will assume fresh + autogen (useful for PORTAL/SYNC generated)
function zeroBS_buildObjArr( $arraySource = array(), $startingArray = array(), $fieldPrefix = 'zbsc_', $outputPrefix = '', $removeEmpties = false, $objType = ZBS_TYPE_CONTACT, $autoGenAutonumbers = false ) {

	#} def
	$retArray = array();

	#} if passed...
	if ( isset( $startingArray ) && is_array( $startingArray ) ) {
		$retArray = $startingArray;
	}

	#} go

	// req.
	global $zbs;

	// DAL3 notes: (See #globalfieldobjsdal3 in fields.php)
	// .. ultimately we default to using the $fields globals, then fallback to the objmodels
	// introduced in DAL3 objs. This allows coverage of both, for now
	// v3.0 RC+ this can be refactored :)
	// Note: To make RC1 I also added in translation, which is perhaps a step toward refactoring this:

	// Some RC1 field translations (requires dal1key against changed obj model fields)
	$arraySource = zeroBS_translateDAL1toDAL3Obj( $arraySource, $objType, $fieldPrefix );

	// retrieve global var name
	$globFieldVarName = $zbs->DAL->objFieldVarName( $objType );

	// nope. (for events in DAL3)
	// ... potentially can turn this off for all non DAL3? may be redundant inside next {}
	if ( $objType !== ZBS_TYPE_TASK && $objType !== ZBS_TYPE_QUOTETEMPLATE && isset( $GLOBALS[ $globFieldVarName ] ) ) {

		$i = 0;

		foreach ( $GLOBALS[ $globFieldVarName ] as $fK => $fV ) {

			++$i;

			// if it's not an autonumber (which generates new on blank passes), set it to empty
			// ... or if it has $autoGenAutonumbers = true,
			if (
				( $fV[0] !== 'autonumber' && ! isset( $retArray[ $outputPrefix . $fK ] ) )
				||
				$autoGenAutonumbers
				) {
				$retArray[ $outputPrefix . $fK ] = '';
			}

			// two EXCEPTIONS:
			// 1) custom field type checkbox, because it adds -0 -1 etc. to options, so this wont fire,
			// 2) Autonumbers which are blank to start with get caught beneath
			// ... see below for checkbox catch
			if ( isset( $arraySource[ $fieldPrefix . $fK ] ) ) {

				switch ( $fV[0] ) {

					case 'tel':
						// validate tel? Should be an user option, allow validation.
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );
						// $retArray[$outputPrefix.$fK] = preg_replace("/[^0-9 .+\-()]/", '', $retArray[$outputPrefix.$fK]);
						break;

					case 'price':
					case 'numberfloat':
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );
						$retArray[ $outputPrefix . $fK ] = preg_replace( '@[^0-9\.]+@i', '-', $retArray[ $outputPrefix . $fK ] );
						$retArray[ $outputPrefix . $fK ] = floatval( $retArray[ $outputPrefix . $fK ] );
						break;

					case 'numberint':
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );
						$retArray[ $outputPrefix . $fK ] = preg_replace( '@[^0-9]+@i', '-', $retArray[ $outputPrefix . $fK ] );
						$retArray[ $outputPrefix . $fK ] = intval( $retArray[ $outputPrefix . $fK ] );
						break;

					case 'textarea':
						// phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
						$retArray[ $outputPrefix . $fK ] = sanitize_textarea_field( $arraySource[ $fieldPrefix . $fK ] );
						break;

					case 'date':
						$safe_text = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

						$retArray[ $outputPrefix . $fK ] = jpcrm_date_str_to_uts( $safe_text, '!Y-m-d', true ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
						break;

					case 'datetime':
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );

						// translate datetime to UTS (without time)
						// ... by default from DAL3.0
						$retArray[ $outputPrefix . $fK ] = zeroBSCRM_locale_dateToUTS( $retArray[ $outputPrefix . $fK ], true );

						break;

					case 'radio':
					case 'select':
						// just get value, easy.
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );

						break;

					// autonumber dealt with below this if {}
					case 'autonumber':
						// pass it along :)
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );

						break;

					// checkbox dealt with below this if {}

					default:
						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );

						break;

				} // / switch type

			} // / if isset (simple) $arraySource[$fieldPrefix.$fK]

			// catch checkboxes
			if ( $fV[0] == 'checkbox' ) {

				// there are several ways that checkbox (multiselect) inputs may be passed, depending on source
				// because this function catches from:
				// - edit page post
				// - client portal profile page
				// - Gravity forms/extension calls
				// - API
				// ... to name a few, it's sensible that we try and catch the variants (low risk/cost here)
				$checkboxArr = array();

				// Checkbox input: Iterative
				// This cycles through `checkboxkey-$i` (up to 64 options) and includes if they're set
				// This is used by our edit pages, client portal profile page etc.
				for ( $checkboxI = 0; $checkboxI < 64; $checkboxI++ ) {

					if ( isset( $arraySource[ $fieldPrefix . $fK . '-' . $checkboxI ] ) ) {

						// retrieve
						$checkboxArr[] = $arraySource[ $fieldPrefix . $fK . '-' . $checkboxI ];

					}
				}

				// Checkbox input: CSV
				// This can be exploded
				// This is used by gravity forms, when multiple 1 word options are checked (and probably elsewhere)
				if ( isset( $arraySource[ $fieldPrefix . $fK ] ) && is_string( $arraySource[ $fieldPrefix . $fK ] ) ) {

					// one option or multi?
					if ( strpos( $arraySource[ $fieldPrefix . $fK ], ',' ) ) {
						$checkboxArr = explode( ',', $arraySource[ $fieldPrefix . $fK ] );
					} else {
						$checkboxArr = array( $arraySource[ $fieldPrefix . $fK ] );
					}
				}

				// Checkbox input: Array
				// This can be straight passed
				// This is used by gravity forms, when at least one option with multiple words are checked (and probably elsewhere, is good to support pass through)
				if ( isset( $arraySource[ $fieldPrefix . $fK ] ) && is_array( $arraySource[ $fieldPrefix . $fK ] ) ) {
					$checkboxArr = $arraySource[ $fieldPrefix . $fK ];
				}

				if ( is_array( $checkboxArr ) ) {

					// sanitize
					$checkboxArr = array_map( 'sanitize_text_field', $checkboxArr );

					// csv em
					$retArray[ $outputPrefix . $fK ] = implode( ',', $checkboxArr );

				} else {

					// none selected, set blank
					$retArray[ $outputPrefix . $fK ] = '';

				}
			} // / if checkbox

			// if autonumber
			if ( $fV[0] == 'autonumber' ) {

				// this is a generated field.
				// if was previously set, sticks with that, if not set, will generate new, based on custom field rule
				// NOTE!!!! if this is NOT SET in customerMeta, it WILL NOT be updated
				// ... this is because when passing incomplete update records (e.g. not passing autonumber)
				// ... it doesn't need a new AUTONUMBER
				// ... so if you want a fresh autonumber, you need to pass with $startingArray[] EMPTY value set

				// if not yet set
				if ( isset( $retArray[ $outputPrefix . $fK ] ) && empty( $retArray[ $outputPrefix . $fK ] ) ) {

					// retrieve based on custom field rule
					$autono = '';

					// retrieve rule
					$formatExample = '';
					if ( isset( $fV[2] ) ) {
						$formatExample = $fV[2];
					}
					if ( ! empty( $formatExample ) && str_contains( $formatExample, '#' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

						// has a rule at least
						$formatParts = explode( '#', $formatExample );

						// build

						// prefix
						if ( ! empty( $formatParts[0] ) ) {
							$autono .= zeroBSCRM_customFields_parseAutoNumberStr( $formatParts[0] );
						}

						// number
						$no = zeroBSCRM_customFields_getAutoNumber( $objType, $fK );
						if ( $no > 0 && $no !== false ) {
							$autono .= $no;
						}

						// suffix
						if ( ! empty( $formatParts[2] ) ) {
							$autono .= zeroBSCRM_customFields_parseAutoNumberStr( $formatParts[2] );
						}

							// if legit, add
						if ( $no > 0 && $no !== false ) {
							$retArray[ $outputPrefix . $fK ] = $autono;
						}
					}
				}
			} // / if autonumber

		} // / foreach field

	} // / if global-based-fill-out

			$replaceMap = array(
				'secaddr1'    => 'secaddr_addr1',
				'secaddr2'    => 'secaddr_addr2',
				'seccity'     => 'secaddr_city',
				'seccounty'   => 'secaddr_county',
				'seccountry'  => 'secaddr_country',
				'secpostcode' => 'secaddr_postcode',
			);

			foreach ( $replaceMap as $d2key => $d1key ) {
				if ( isset( $retArray[ $outputPrefix . $d1key ] ) ) {
					$retArray[ $outputPrefix . $d2key ] = $retArray[ $outputPrefix . $d1key ];
					unset( $retArray[ $outputPrefix . $d1key ] );
				}
			}

			// can also pass some extras :) /social
			// for co + contact
			if ( $objType == ZBS_TYPE_CONTACT || $objType == ZBS_TYPE_COMPANY ) {

				$extras = array( 'tw', 'fb', 'li' );
				foreach ( $extras as $fK ) {

					if ( ! isset( $retArray[ $outputPrefix . $fK ] ) ) {
						$retArray[ $outputPrefix . $fK ] = '';
					}

					if ( isset( $arraySource[ $fieldPrefix . $fK ] ) ) {

						$retArray[ $outputPrefix . $fK ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fK ] );

					}
				}
			}

			// ... Further, from DAL3+ we now have proper object models, which probably should replace "fields"
			// above, but for now, churning through both, as sensitively as possible.

			// get an obj model, if set
			$potentialModel = $zbs->DAL->objModel( $objType );

			// will be objlayer model if set
			if ( is_array( $potentialModel ) ) {

				// cycle through each field + set, if not already set by the above.
				foreach ( $potentialModel as $fieldKey => $fieldDetail ) {

					// there's a few we ignore :)
					if ( in_array( $fieldKey, array( 'ID', 'zbs_site', 'zbs_team' ) ) ) {
						continue;
					}

					// if not already set
					if ( ! isset( $retArray[ $outputPrefix . $fieldKey ] ) ) {

						// retrieve based on type
						switch ( $fieldDetail['format'] ) {

							case 'str':
							case 'curr': // for now, process curr as str. (probs needs to just validate IS CURR)
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {
									$retArray[ $outputPrefix . $fieldKey ] = zeroBSCRM_textProcess( $arraySource[ $fieldPrefix . $fieldKey ] );
								}
								break;

							case 'int':
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {

									$retArray[ $outputPrefix . $fieldKey ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = preg_replace( '@[^0-9]+@i', '-', $retArray[ $outputPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = intval( $retArray[ $outputPrefix . $fieldKey ] );

								}
								break;
							case 'uts':
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {

									$retArray[ $outputPrefix . $fieldKey ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fieldKey ] );

									// in case of UTS dates, the $_POST likely passed may be in date format
									// ... if so, take the model default + translate (if set)
									if ( isset( $fieldDetail['autoconvert'] ) && $fieldDetail['autoconvert'] == 'date' ) {

										// translate "01/12/2018" to UTS (without time)
										$retArray[ $outputPrefix . $fieldKey ] = zeroBSCRM_locale_dateToUTS( $retArray[ $outputPrefix . $fieldKey ], false );

									}
									if ( isset( $fieldDetail['autoconvert'] ) && $fieldDetail['autoconvert'] == 'datetime' ) {

										// translate datetime to UTS (with time)
										$retArray[ $outputPrefix . $fieldKey ] = zeroBSCRM_locale_dateToUTS( $retArray[ $outputPrefix . $fieldKey ], true );

									}

									$retArray[ $outputPrefix . $fieldKey ] = preg_replace( '@[^0-9]+@i', '-', $retArray[ $outputPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = intval( $retArray[ $outputPrefix . $fieldKey ] );

								}
								break;

							case 'bool':
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {
									$retArray[ $outputPrefix . $fieldKey ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = preg_replace( '@[^0-9]+@i', '-', $retArray[ $outputPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = boolval( $retArray[ $outputPrefix . $fieldKey ] );
								}
								break;

							case 'decimal':
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {
									$retArray[ $outputPrefix . $fieldKey ] = sanitize_text_field( $arraySource[ $fieldPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = preg_replace( '@[^0-9]+@i', '-', $retArray[ $outputPrefix . $fieldKey ] );
									$retArray[ $outputPrefix . $fieldKey ] = floatval( $retArray[ $outputPrefix . $fieldKey ] );
								}
								break;

							default: // basically str.
								if ( isset( $arraySource[ $fieldPrefix . $fieldKey ] ) ) {
									$retArray[ $outputPrefix . $fieldKey ] = zeroBSCRM_textProcess( $arraySource[ $fieldPrefix . $fieldKey ] );
								}
								break;

						} // / format switch

					} // / not isset

				} // / foreach

			} // / if has model

			// $removeEmpties
			if ( $removeEmpties ) {

				$ret = array();
				foreach ( $retArray as $k => $v ) {

					$intV = (int) $v;

					if ( ! is_array( $v ) && ! empty( $v ) && $v != '' && $v !== 0 && $v !== -1 && $intV !== -1 ) {
						$ret[ $k ] = $v;
					}
				}

				$retArray = $ret;

			}

			return $retArray;
}

	// generally used for list view reformatting - cleans a contact array into simple format
	// here it takes an array of contacts, and (currently) returns 1 contact simplified
	// This may make more sense in the contact DAL obj layer?
	// >> this has a company variant too, in this file.
function zeroBSCRM_getSimplyFormattedContact( $contacts = array(), $requireOwner = false ) {

	$return = false;

	// DAL3 + has potential for multi-links, so here we just grab first if there
	if ( isset( $contacts ) && is_array( $contacts ) && count( $contacts ) > 0 ) {

		// first only for now...
			$contact = $contacts[0];

			// w adapted so same func can be used (generic) js side
			// works with zeroBSCRMJS_listView_generic_customer
			// provides a simplified ver of customer obj (4 data transit efficiency/exposure)
			$email = '';
		if ( isset( $contact['email'] ) && ! empty( $contact['email'] ) ) {
			$email = $contact['email'];
		}
		global $zbs;
		$return = array(
			'id'       => $contact['id'],
			'avatar'   => $zbs->DAL->contacts->getContactAvatar( $contact['id'] ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
			'fullname' => zeroBS_customerName( '', $contact, false, false ),
			'email'    => $email,
		);

		if ( $requireOwner ) {
			$return['owner'] = zeroBS_getOwner( $contact['id'], true, 'zerobs_customer' );
		}
	}

	return $return;
}

/*
======================================================
	/ GENERIC helpers
====================================================== */

/*
======================================================
	Company helpers
====================================================== */

	#} Get the COUNT of companies.
function zeroBS_companyCount( $status = false ) {

	global $zbs;
	return $zbs->DAL->companies->getCompanyCount(
		array(
			'withStatus'  => $status,
			'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),
		)
	);
}

	// another func which should be skipped 3.0+, just do direct call :)
function zeroBS_getCompany( $coID = -1, $withObjs = false ) {

	if ( $coID !== -1 ) {

		global $zbs;

		#} Super rough. Not sure where we use this, but shouldn't.
		return $zbs->DAL->companies->getCompany(
			$coID,
			array(
				'withQuotes'       => $withObjs,
				'withInvoices'     => $withObjs,
				'withTransactions' => $withObjs,
				'ignoreowner'      => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),
			)
		);

	}

	return false;
}

	#} Wrapper func for "company" type customers
	// note: $inCountry returns with address 1 or 2 in country (added for logReporter for Miguel (custom extension WH))
	// note: $withStatus returns with specific status  (added for logReporter for Miguel (custom extension WH))
	#} Adapted for 3.0+, note deprecated really, should be using DAL->companies->getCompanies directly
function zeroBS_getCompanies(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$searchPhrase = '',
	$argsOverride = false,
	$withInvoices = false,
	$withQuotes = false,
	$withTransactions = false,
	$inCountry = false,
	$ownedByID = false,
	$withStatus = false,
	$inArr = false
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	// $argsOverride CAN NO LONGER WORK :)
	if ( $argsOverride !== false ) {
		zeroBSCRM_DEPRECATEDMSG( 'Use of $argsOverride in zeroBS_getCompanies is no longer relevant (DAL3.0)' );
	}

	global $zbs;

		$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase'     => $searchPhrase,
		'inArr'            => $inArr,
		'inCountry'        => $inCountry,
		'hasStatus'        => $withStatus,
		'ownedBy'          => $ownedByID,

		'withCustomFields' => true,
		'withQuotes'       => $withQuotes,
		'withInvoices'     => $withInvoices,
		'withTransactions' => $withTransactions,
		'withLogs'         => false,
		'withLastLog'      => false,
		'withTags'         => false, // $withTags,
		'withOwner'        => false,

		// 'sortByField'     => $sortByField,
		// 'sortOrder'   => $sortOrder,
		'page'             => $actualPage,
		'perPage'          => $perPage,

		'ignoreowner'      => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),

	);

	// here ignore owners = true the default, because we're not really forcing ownership anywhere overall,
	// when we do, we should change this/make it check
	if ( $ownedByID !== false && is_int( $ownedByID ) && $ownedByID > 0 ) {

		$args['ignoreowner'] = false;

	}

	return $zbs->DAL->companies->getCompanies( $args );
}

	// returns email for a company
function zeroBS_companyEmail( $companyID = '', $companyArr = false ) {

	global $zbs;
	return $zbs->DAL->companies->getCompanyEmail( $companyID );
}

/**
 * Retrieves the company ID based on its name.
 *
 * @param  string $company_name  The name of the company for which the ID is required.
 * @return int|bool              Returns the ID of the company if found, false otherwise.
 */
function zeroBS_getCompanyIDWithName( $company_name = '' ) {
	if ( ! empty( $company_name ) ) {
		global $zbs;
		return $zbs->DAL->companies->get_company_id_by_name( $company_name ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
	}
	return false;
}

	#} ExternalID is name in this case :)
function zeroBS_getCompanyIDWithExternalSource( $externalSource = '', $externalID = '' ) {

	global $zbs;

	#} No empties, no random externalSources :)
	if ( ! empty( $externalSource ) && ! empty( $externalID ) && array_key_exists( $externalSource, $zbs->external_sources ) ) {

		#} If here, is legit.
		$approvedExternalSource = $externalSource;

		global $zbs;

		return $zbs->DAL->companies->getCompany(
			-1,
			array(
				'externalSource'    => $approvedExternalSource,
				'externalSourceUID' => $externalID,
				'onlyID'            => true,
				'ignoreowner'       => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),
			)
		);

	}

	return false;
}

	// This should probably be deprecated and just called directly.
	// for now just translating
function zeroBS_getCompaniesForTypeahead( $searchQueryStr = '' ) {

	/*
	//gets them all, from a brutal SQL
	global $wpdb;

		if ( !empty( $searchQueryStr)){

			// param query
			$sql = "SELECT ID as id, post_title as name, post_date as created FROM $wpdb->posts WHERE post_type = 'zerobs_company' AND post_status = 'publish' AND post_title LIKE %s";
			$q = $wpdb->prepare($sql,'%'.$searchQueryStr.'%');
			$results = $wpdb->get_results($q, ARRAY_A);

		} else {

			// straight query
			$sql = "SELECT ID as id, post_title as name, post_date as created FROM $wpdb->posts WHERE post_type = 'zerobs_company' AND post_status = 'publish'";
			$results = $wpdb->get_results($sql, ARRAY_A);
		}

	return $results;
	*/
	global $zbs;

	return $zbs->DAL->companies->getCompanies(
		array(
			'searchPhrase' => $searchQueryStr,
			'simplified'   => true,
			'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),
		)
	);
}

function zeroBS_getCompanyIDWithEmail( $custEmail = '' ) {

	if ( ! empty( $custEmail ) ) {

		global $zbs;
		return $zbs->DAL->companies->getCompany(
			-1,
			array(
				'email'       => $custEmail,
				'onlyID'      => true,
				'ignoreowner' => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_COMPANY ),
			)
		);

	}

	return false;
}

	#} Add or Update a Company - ideally use $zbs->DAL->companies->addUpdateCompany() rather than this wrapper, in proper code now :)
function zeroBS_addUpdateCompany(
	$coID = -1,
	$coFields = array(),
	$externalSource = '',
	$externalID = '',
	$companyDate = '',
	$fallBackLog = false,
	$extraMeta = false,
	$automatorPassthrough = false,
	$owner = -1,
	$metaBuilderPrefix = 'zbsc_'
) {

	#} Basics - /--needs status
	#} 27/09/16 - WH - Removed need for zeroBS_addUpdateCustomer to have a "status" passed with customer (defaults to lead for now if not present)
	if ( isset( $coFields ) && count( $coFields ) > 0 ) { #} && isset( $coFields['zbsc_status'])

		global $zbs;

		#} New flag
		$newCompany   = false;
		$existingMeta = array();

		if ( $coID > 0 ) {

			#} Build "existing meta" to pass, (so we only update fields pushed here)
			$existingMeta = $zbs->DAL->companies->getCompany( $coID, array() );

			#} need to check the dates here. If a date is passed which is BEFORE the current "created" date then overwrite the date with the new date. If a date is passed which is AFTER the current "created" date, then do not update the date..
			#} date changed - created is only in the wp_posts table in DB v1.0
			$originalDate = time();
			if ( isset( $existingMeta ) && is_array( $existingMeta ) && isset( $existingMeta['created'] ) && ! empty( $existingMeta['created'] ) ) {
				$originalDate = $existingMeta['created'];
			}

			if ( ! empty( $companyDate ) && $companyDate != '' ) {

				#} DATE PASSED TO THE FUNCTION
				$companyDateTimeStamp = strtotime( $companyDate );
				#} ORIGINAL POST CREATION DATE
				// no need, db2 = UTS $originalDateTimeStamp = strtotime($originalDate);
				$originalDateTimeStamp = $originalDate;

				#} Compare, if $companyDateTimeStamp < then update with passed date
				if ( $companyDateTimeStamp < $originalDateTimeStamp ) {

					// straight in there :)
					$zbs->DAL->companies->addUpdateCompany(
						array(
							'id'            => $coID,
							'limitedFields' => array(
								array(
									'key'  => 'zbsco_created',
									'val'  => $companyDateTimeStamp,
									'type' => '%d',
								),
							),
						)
					);
				}
			}

			// WH changed 20/05/18
			// 20/05/18 - Previously this would reload the EXISTING database data
			// THEN 'override' any passed fields
			// THEN save that down
			// ... this was required when we used old meta objs. (pre db2)
			// ... so if we're now DAL2, we can do away with that and simply pass what's to be updated and mode do_not_update_blanks
			$existingMeta = array();

		} else {

			#} Set flag
			$newCompany = true;

			if ( ! empty( $companyDate ) ) {

				#} DATE PASSED TO THE FUNCTION
				$companyDateTimeStamp = strtotime( $companyDate );
				if ( $companyDateTimeStamp > 0 ) {
					$existingMeta = array( 'created' => $companyDateTimeStamp );
				}
			}
		}

		#} Build using centralised func below, passing any existing meta (updates not overwrites)
		$zbsCompanyMeta = zeroBS_buildCompanyMeta( $coFields, $existingMeta, $metaBuilderPrefix, '', true );

		$we_have_tags = false; // set to false.. duh..

		# TAG company (if exists) - clean etc here too
		if ( ! empty( $coFields['tags'] ) ) {

			$tags = $coFields['tags'];

			#} Santize tags
			if ( is_array( $tags ) && count( $tags ) > 0 ) {
				$company_tags = filter_var_array( $tags, FILTER_UNSAFE_RAW );
				// Formerly this used FILTER_SANITIZE_STRING, which is now deprecated as it was fairly broken. This is basically equivalent.
				// @todo Replace this with something more correct.
				foreach ( $company_tags as $k => $v ) {
					$company_tags[ $k ] = strtr(
						strip_tags( $v ),
						array(
							"\0" => '',
							'"'  => '&#34;',
							"'"  => '&#39;',
							'<'  => '',
						)
					);
				}
				$we_have_tags = true;
			}

			if ( $we_have_tags ) {

				$zbsCompanyMeta['tags'] = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
				foreach ( $company_tags as $tag_name ) {

					// Check for existing tag under this name.
					$tag_id = $zbs->DAL->getTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
						-1,
						array(
							'objtype' => ZBS_TYPE_COMPANY,
							'name'    => $tag_name,
							'onlyID'  => true,
						)
					);

					// If tag doesn't exist, create one.
					if ( empty( $tag_id ) ) {
						$tag_id = $zbs->DAL->addUpdateTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
							array(
								'data' => array(
									'objtype' => ZBS_TYPE_COMPANY,
									'name'    => $tag_name,
								),
							)
						);
					}

					// Add tag to list.
					if ( ! empty( $tag_id ) ) {
						$zbsCompanyMeta['tags'][] = $tag_id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
					}
				}
			}
		}

		#} Add external source/externalid
		#} No empties, no random externalSources :)
		$extSourceArr           = -1;
		$approvedExternalSource = ''; #} As this is passed to automator :)
		if ( ! empty( $externalSource ) && ! empty( $externalID ) && array_key_exists( $externalSource, $zbs->external_sources ) ) {

			#} If here, is legit.
			$approvedExternalSource = $externalSource;

			#} Add/Update record flag
			// 2.4+ Migrated away from this method to new update_post_meta($postID, 'zbs_customer_ext_'.$approvedExternalSource, $externalID);
			// 2.52+ Moved to new DAL method :)

			$extSourceArr = array(
				'source' => $approvedExternalSource,
				'uid'    => $externalID,
			);

			// add/update
			// DB2, this is just used below :)zeroBS_updateExternalSource($postID,$extSourceArr);
			$zbsCompanyMeta['externalSources'] = array( $extSourceArr );

		} #} Otherwise will just be a random customer no ext source

		#} Got owner?
		if ( $owner !== -1 ) {
			$zbsCompanyMeta['owner'] = $owner;
		}

		#} Update record (All IA is now fired intrinsicly )
		return $zbs->DAL->companies->addUpdateCompany(
			array(
				'id'                   => $coID,
				'data'                 => $zbsCompanyMeta,
				'extraMeta'            => $extraMeta,
				'automatorPassthrough' => $automatorPassthrough,
				'fallBackLog'          => $fallBackLog,
			)
		);

	} // if fields

	return false;
}

	// v3.0+ this uses the generic zeroBS_buildObjArr, and accepts full args as per contact meta DAL2:
function zeroBS_buildCompanyMeta( $arraySource = array(), $startingArray = array(), $fieldPrefix = 'zbsco_', $outputPrefix = '', $removeEmpties = false, $autoGenAutonumbers = false ) {

	return zeroBS_buildObjArr( $arraySource, $startingArray, $fieldPrefix, $outputPrefix, $removeEmpties, ZBS_TYPE_COMPANY, $autoGenAutonumbers );
}

	/* Centralised delete company func, including sub-element removal */
function zeroBS_deleteCompany( $id = -1, $saveOrphans = true ) {

	if ( ! empty( $id ) ) {

		global $zbs;

		return $zbs->DAL->companies->deleteCompany(
			array(
				'id'          => $id,
				'saveOrphans' => $saveOrphans,
			)
		);

	}

	return false;
}

	// adapted company name builder to use proper DAL3 func
function zeroBS_companyName( $companyID = '', $companyArr = array(), $incFirstLineAddr = true, $incID = true ) {

	global $zbs;
	return $zbs->DAL->companies->getCompanyNameEtc(
		$companyID,
		$companyArr,
		array(
			'incFirstLineAddr' => $incFirstLineAddr,
			'incID'            => $incID,
		)
	);
}

	// adapted company name builder to use proper DAL3 func
function zeroBS_companyAddr( $companyID = '', $companyArr = array(), $addrFormat = 'short', $delimiter = ', ' ) {

	global $zbs;
	return $zbs->DAL->companies->getCompanyAddress(
		$companyID,
		$companyArr,
		array(
			'addrFormat' => $addrFormat,
			'delimiter'  => $delimiter,
		)
	);
}

	// adapted company name builder to use proper DAL3 func
function zeroBS_companySecondAddr( $companyID = '', $companyArr = array(), $addrFormat = 'short', $delimiter = ', ' ) {

	global $zbs;
	return $zbs->DAL->companies->getCompany2ndAddress(
		$companyID,
		$companyArr,
		array(
			'addrFormat' => $addrFormat,
			'delimiter'  => $delimiter,
		)
	);
}

	// sets tags, in future just use direct DAL func plz
function zeroBSCRM_setCompanyTags( $coID = -1, $tags = array(), $tagIDs = array(), $mode = 'replace' ) {

	if ( $coID > 0 ) {

		$args = array(

			'id'   => $coID,

			// EITHER of the following:
				// 'tagIDs'        => -1,
				// 'tags'          => -1,

			'mode' => $mode,
		);

		// got tags?
		if ( is_array( $tags ) && count( $tags ) > 0 ) {
			$args['tags'] = $tags;
		} elseif ( is_array( $tagIDs ) && count( $tagIDs ) > 0 ) {
			$args['tagIDs'] = $tagIDs;
		} else {
			return false;
		}

		global $zbs;
		return $zbs->DAL->companies->addUpdateCompanyTags( $args );

	}

	return false;
}

	// gets tags, in future just use direct DAL func plz
function zeroBSCRM_getCompanyTagsByID( $coID = -1, $justIDs = false ) {

	global $zbs;
	$tags = $zbs->DAL->companies->getCompanyTags( $coID );

	// lazy here, but shouldn't use these old funcs anyhow!
	if ( $justIDs ) {

		$ret = array();
		if ( is_array( $tags ) ) {
			foreach ( $tags as $t ) {
				$ret[] = $t['id'];
			}
		}
		return $ret;

	}

	return $tags;
}

	// generally used for list view reformatting - cleans a company array into simple format
	// here it takes an array of contacts, and (currently) returns 1 company simplified
	// This may make more sense in the company DAL obj layer?
	// >> this has a contact variant too, in this file.
function zeroBSCRM_getSimplyFormattedCompany( $companies = array(), $requireOwner = false ) {

	$return = false;

	// DAL3 + has potential for multi-links, so here we just grab first if there
	if ( isset( $companies ) && is_array( $companies ) && count( $companies ) > 0 ) {

		// first only for now...
		$company = $companies[0];

		// w adapted so same func can be used (generic) js side
		// works with zeroBSCRMJS_listView_generic_customer
		// provides a simplified ver of customer obj (4 data transit efficiency/exposure)
		$email = '';
		if ( isset( $company['email'] ) && ! empty( $company['email'] ) ) {
			$email = $company['email'];
		}
		$return = array(

			// company only has name, id, email currently
			'id'       => $company['id'],
			'fullname' => $company['name'],
			'email'    => $email,

		);
		if ( $requireOwner ) {
			$return['owner'] = zeroBS_getOwner( $company['id'], true, 'zerobs_company' );
		}
	}

	return $return;
}
/*
======================================================
	/ Company helpers
====================================================== */

/*
======================================================
	Quote helpers
====================================================== */

	# Quote Status (from list view)
	// WH note - not sure why we're building HTML here, allowing for now.
	// if returnAsInt - will return -1 for not published, -2 for not accepted, or 14int timestamp for accepted
function zeroBS_getQuoteStatus( $item = false, $returnAsInt = false ) {

	#} marked accepted?
	$accepted = false;
	if ( is_array( $item ) && isset( $item['accepted'] ) ) {
		$accepted = $item['accepted'];
	}

	# HERE TODO:
	# if acceptedArr = output "accepted xyz"
	# else if !templated outut "not yet published"
	# else if templated output "not yet accepted"

	if ( $accepted > 0 ) {

		if ( $returnAsInt ) {
			return $accepted;
		}

		$td = '<strong>' . __( 'Accepted', 'zero-bs-crm' ) . ' ' . date( zeroBSCRM_getDateFormat(), $accepted ) . '</strong>';

	} else {

		#} get extra deets
		$zbsTemplated = $item['template'];
		if ( ! empty( $zbsTemplated ) ) {

			if ( $returnAsInt ) {
				return -2;
			}

			#} is published
			$td = '<strong>' . __( 'Created, not yet accepted', 'zero-bs-crm' ) . '</strong>';

		} else {

			if ( $returnAsInt ) {
				return -1;
			}

			#} not yet published
			$td = '<strong>' . __( 'Not yet published', 'zero-bs-crm' ) . '</strong>';

		}
	}

	return $td;
}

	// Get next available sequential quote ID
function zeroBSCRM_getNextQuoteID() {

	#} Retrieves option, and returns, is dumb for now.
	// DAL1+2: return (int)get_option('quoteindx',$defaultStartingQuoteID)+1;

	// DAL3:
	$potential = (int) zeroBSCRM_getSetting( 'quoteindx', true );
	if ( $potential > 0 ) {
		return $potential + 1;
	} else {
		return zeroBSCRM_getQuoteOffset() + 1;
	}
}

	// set the current max used quoteid
function zeroBSCRM_setMaxQuoteID( $newMax = 0 ) {

	$existingMax = zeroBSCRM_getNextQuoteID();

	if ( $newMax >= $existingMax ) {

		// DAL3:
		global $zbs;
		return $zbs->settings->update( 'quoteindx', $newMax );

	}

	return false;
}

	#} Minified get offset func
function zeroBSCRM_getQuoteOffset() {

	global $zbs;
	$offset = (int) $zbs->settings->get( 'quoteoffset' );

	if ( empty( $offset ) || $offset < 0 ) {
		$offset = 0;
	}

	return $offset;
}

#} Old get func, use proper form if writing fresh code
// used to return array('id','meta','customerid','quotebuilder')
// ... so any existing use may be broken (have mass replaced in core at this point)
// ... use direct ->getQuotes in future anyhow.
// (which is diff format! any use of zeroBS_getQuote is now borked. - couldn't find any though + did proper search.)
function zeroBS_getQuote( $qID = -1, $withQuoteBuilderData = false ) {

	if ( $qID !== -1 ) {

		global $zbs;

		#} Super rough. Not sure where we use this, but shouldn't.
		return $zbs->DAL->quotes->getQuote(
			$qID,
			array(
				'withLineItems' => $withQuoteBuilderData,
				'ignoreowner'   => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_QUOTE ),
			)
		);

	}

	return false;
}

	#} Marks a quote as "accepted" and saves as much related data as poss on accepter
	// again, use DAL to do this in future (zbs->DAL->quotes->addUpdateQuoteStatus directly)
function zeroBS_markQuoteAccepted( $qID = -1, $quoteSignedBy = '' ) {

	if ( $qID !== -1 ) {

		global $zbs;

		return $zbs->DAL->quotes->addUpdateQuoteAccepted(
			array(
				'id'       => $qID,
				'accepted' => time(),
				'signedby' => $quoteSignedBy,
				'ip'       => zeroBSCRM_getRealIpAddr(),
			)
		);

	}

	return false;
}

	#} UNMarks a quote as "accepted" and saves as much related data as poss on accepter
	// again, use DAL to do this in future (zbs->DAL->quotes->addUpdateQuoteStatus directly)
function zeroBS_markQuoteUnAccepted( $qID = -1 ) {

	if ( $qID !== -1 ) {

		global $zbs;

		return $zbs->DAL->quotes->addUpdateQuoteAccepted(
			array(
				'id'       => $qID,
				'accepted' => '',
			)
		);

	}

	return false;
}

// Please use direct dal calls in future work.
function zeroBS_getQuotes(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'quickFilters' => $quickFilters,
		'isTagged'     => $hasTagIDs,

		'withAssigned' => $withCustomerDeets,

		'sortByField'  => $sortByField,
		'sortOrder'    => $sortOrder,
		'page'         => $actualPage,
		'perPage'      => $perPage,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_QUOTE ),

	);

	return $zbs->DAL->quotes->getQuotes( $args );
}

// Please use direct dal calls in future work.
function zeroBS_getQuotesCountIncParams(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'quickFilters' => $quickFilters,
		'isTagged'     => $hasTagIDs,

		// just count thx
		'count'        => true,
		'withAssigned' => false,

		// 'sortByField'     => $sortByField,
		// 'sortOrder'   => $sortOrder,
		'page'         => -1,
		'perPage'      => -1,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_QUOTE ),

	);

	return $zbs->DAL->quotes->getQuotes( $args );
}

// Please use direct dal calls in future work.
function zeroBS_getQuotesForCustomer(
	$customerID = -1,
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$withQuoteBuilderData = true // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- legacy API parameter.
) {

	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'assignedContact' => $customerID,

		// with contact?
		'withAssigned'    => $withCustomerDeets,

		'sortByField'     => 'ID',
		'sortOrder'       => 'DESC',
		'page'            => $actualPage,
		'perPage'         => $perPage,

		'ignoreowner'     => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_QUOTE ),

	);

	return $zbs->DAL->quotes->getQuotes( $args );
}

// Please use direct dal calls in future work.
// phpcs:ignore Squiz.Commenting.FunctionComment.WrongStyle
function zeroBS_getQuoteTemplate( $quoteTemplateID = -1 ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,Squiz.Commenting.FunctionComment.Missing

	if ( $quoteTemplateID > 0 ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		global $zbs;

		return $zbs->DAL->quotetemplates->getQuotetemplate( $quoteTemplateID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase,WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

	}

	return false;
}

// Please use direct dal calls in future work.
function zeroBS_getQuoteTemplates( $withFullDetails = false, $perPage = 10, $page = 0, $searchPhrase = '' ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,Squiz.Commenting.FunctionComment.WrongStyle

	global $zbs;
	return $zbs->DAL->quotetemplates->getQuotetemplates(
		array(
			'searchPhrase'  => $searchPhrase,
			'page'          => $page,
			'perPage'       => $perPage,
			'checkDefaults' => true,
		)
	);

	/*
	was returning

		core post +
			$retObj['meta']             = get_post_meta($ele->ID, 'zbs_quotemplate_meta', true);
			$retObj['zbsdefault']       = get_post_meta($ele->ID, 'zbsdefault', true);

	*/
}

// retrieves a count for listview retrievedata, really
function zeroBS_getQuoteTemplatesCountIncParams( $withFullDetails = false, $perPage = 10, $page = 0, $searchPhrase = '' ) {

	global $zbs;

	return $zbs->DAL->quotetemplates->getQuotetemplates(
		array(
			'searchPhrase' => $searchPhrase,
			'count'        => 1,
			'page'         => -1,
			'perPage'      => -1,
		)
	);
}

// moves a quote from being assigned to one cust, to another
// this is a fill-in to match old DAL2 func, however DAL3+ can accept customer/company,
// ... so use the proper $DAL->addUpdateObjectLinks for fresh code
function zeroBSCRM_changeQuoteCustomer( $id = -1, $contactID = 0 ) {

	if ( ! empty( $id ) && $contactID > 0 ) {

		global $zbs;
		return $zbs->DAL->quotes->addUpdateObjectLinks( $id, array( $contactID ), ZBS_TYPE_CONTACT );

	}

	return false;
}

/*
======================================================
	/ Quote helpers
====================================================== */

/*
======================================================
	Invoice helpers
====================================================== */

	// Get next available sequential invoice ID
function zeroBSCRM_getNextInvoiceID() {

	// DAL3:
	$potential = (int) zeroBSCRM_getSetting( 'invoiceindx', true );
	if ( $potential > 0 ) {
		return $potential + 1;
	} else {
		return zeroBSCRM_getInvoiceOffset() + 1;
	}
}

	// set the current max used invid
function zeroBSCRM_setMaxInvoiceID( $newMax = 0 ) {

	$existingMax = zeroBSCRM_getNextInvoiceID();

	if ( $newMax >= $existingMax ) {

		// DAL3:
		global $zbs;
		return $zbs->settings->update( 'invoiceindx', $newMax );

	}

	return false;
}

	// Minified get offset func
function zeroBSCRM_getInvoiceOffset() {

	global $zbs;
	// this only exists on legacy sites
	$offset = (int) $zbs->settings->get( 'invoffset' );

	if ( empty( $offset ) || $offset < 0 ) {
		$offset = 0;
	}

	return $offset;
}

	// outdated, outmoded, use proper ->DAL calls not this in fresh code
function zeroBS_getInvoices(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'quickFilters' => $quickFilters,
		'isTagged'     => $hasTagIDs,

		'withAssigned' => $withCustomerDeets,

		'sortByField'  => $sortByField,
		'sortOrder'    => $sortOrder,
		'page'         => $actualPage,
		'perPage'      => $perPage,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_INVOICE ),

	);

	return $zbs->DAL->invoices->getInvoices( $args );
}

	// outdated, outmoded, use proper ->DAL calls not this in fresh code
function zeroBS_getInvoicesCountIncParams(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'quickFilters' => $quickFilters,
		'isTagged'     => $hasTagIDs,

		// just count thx
		'count'        => true,
		'withAssigned' => false,

		// 'sortByField'     => $sortByField,
		// 'sortOrder'   => $sortOrder,
		'page'         => -1,
		'perPage'      => -1,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_INVOICE ),

	);

	return $zbs->DAL->invoices->getInvoices( $args );
}

	// DAL3 translated.
	// invs model now looks diff so adapt everywhere with getInvoice
function zeroBS_getInvoice( $invoiceID = -1 ) {

	if ( $invoiceID > 0 ) {

		global $zbs;

		return $zbs->DAL->invoices->getInvoice( $invoiceID );

	}

	return false;
}

	// just do direct call in future, plz
function zeroBS_getInvoicesForCustomer(
	$customerID = -1,
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$orderBy = 'ID',
	$order = 'DESC'
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'assignedContact' => $customerID,

		// with contact?
		'withAssigned'    => $withCustomerDeets,

		'sortByField'     => $orderBy,
		'sortOrder'       => $order,
		'page'            => $actualPage,
		'perPage'         => $perPage,

		'ignoreowner'     => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_INVOICE ),

	);

	return $zbs->DAL->invoices->getInvoices( $args );
}

	// moves a inv from being assigned to one cust, to another
	// this is a fill-in to match old DAL2 func, however DAL3+ can accept customer/company,
	// ... so use the proper $DAL->addUpdateObjectLinks for fresh code
function zeroBSCRM_changeInvoiceCustomer( $id = -1, $contactID = 0 ) {

	if ( ! empty( $id ) && $contactID > 0 ) {

		global $zbs;
		return $zbs->DAL->invoices->addUpdateObjectLinks( $id, array( $contactID ), ZBS_TYPE_CONTACT );

	}

	return false;
}

	// grabs invoice customer
function zeroBSCRM_getInvoiceCustomer( $invID = -1 ) {

	if ( ! empty( $invWPID ) ) {

		global $zbs;
		return $zbs->DAL->invoices->getInvoiceContact( $invID );

	}

	return false;
}

	// updates a stauts, if inv exists.
	// use $zbs->DAL->invoices->setInvoiceStatus($invoiceID,$statusStr); for new code
function zeroBS_updateInvoiceStatus( $invoiceID = -1, $statusStr = 'Draft' ) {

	if ( in_array( $statusStr, zeroBSCRM_getInvoicesStatuses() ) ) {

		$potentialInvoice = zeroBS_getInvoice( $invoiceID );
		if ( isset( $potentialInvoice ) && is_array( $potentialInvoice ) ) {

			// dal3
			global $zbs;
			return $zbs->DAL->invoices->setInvoiceStatus( $invoiceID, $statusStr );

		}
	}

	return false;
}

/*
======================================================
	Invoice 3.0 helpers
====================================================== */

// this function probably becomes defunct when DAL ready. Is cos right now, if invoice_meta = '' then it's a new invoice, so should return defaults.
function zeroBSCRM_get_invoice_defaults( $obj_id = -1 ) {

	// Settings
	$settings = zeroBSCRM_get_invoice_settings();

	$now          = time();
	$default_date = jpcrm_uts_to_date_str( $now, 'Y-m-d' );

	// If it has reference as autonumber, determine next number.
	if ( $settings['reftype'] === 'autonumber' ) {
		$next_number = $settings['refnextnum'];
		$prefix      = $settings['refprefix'];
		$suffix      = $settings['refsuffix'];
		$id_override = $prefix . $next_number . $suffix;
	} else {
		$id_override = $settings['defaultref'];
	}

	$defaults = array(
		'status'                    => 'Draft',
		'status_label'              => __( 'Draft', 'zero-bs-crm' ),
		'new_invoice'               => true,
		'id'                        => $obj_id,
		'invoice_items'             => array(),
		'invoice_hours_or_quantity' => 'quantity',
		'invoice_contact'           => -1,
		'invoice_company'           => -1,
		'id_override'               => $id_override,
		'date_date'                 => $default_date, // need to sort this out on the way out (in TS to outputtable date for Date Picker)
		'date'                      => $now,
		'due'                       => 0,
		'hash'                      => zeroBSCRM_hashes_GetHashForObj( $obj_id, ZBS_TYPE_INVOICE ),
		'logo_url'                  => $settings['logo'],
		'bill'                      => '',
		'bill_name'                 => '',
		'settings'                  => $settings,
		'product_index'             => zeroBSCRM_getProductIndex(),
		'preview_link'              => '/invoices/hash',
		'pdf_installed'             => zeroBSCRM_isExtensionInstalled( 'pdfinv' ),
		'portal_installed'          => zeroBSCRM_isExtensionInstalled( 'portal' ),
		'totals'                    => array(
			'invoice_discount_total' => 0,
			'invoice_discount_type'  => '%',
			'invoice_postage_total'  => 0,
		),
	);
	return $defaults;
}

#} wrapper as right now it was loading the full settings into the page. Tidy up page to have the translations here.
#} WH - is it possible that some languages here will mess with the output? character encoding wise?
function zeroBSCRM_get_invoice_settings() {

	global $zbs;

	$all_settings = $zbs->settings->getAll();

	$reference_label = zbs_ifAV( $all_settings, 'reflabel', '' );
	if ( empty( $reference_label ) ) {
		$reference_label = __( 'Reference', 'zero-bs-crm' );
	}

	// Check if it is the first invoice
	$first_invoice = ! $zbs->DAL->invoices->getFullCount();

	$invoice_settings = array(
		'b2bmode'           => zbs_ifAV( $all_settings, 'companylevelcustomers', false ),
		'invtax'            => zbs_ifAV( $all_settings, 'invtax', '' ),
		'invpandp'          => zbs_ifAV( $all_settings, 'invpandp', '' ),
		'invdis'            => zbs_ifAV( $all_settings, 'invdis', '' ),
		'logo'              => zbs_ifAV( $all_settings, 'invoicelogourl', '' ),
		'bizname'           => zbs_ifAV( $all_settings, 'businessname', '' ),
		'yourname'          => zbs_ifAV( $all_settings, 'businessyourname', '' ),
		'defaultref'        => zbs_ifAV( $all_settings, 'defaultref', '' ),
		'reftype'           => zbs_ifAV( $all_settings, 'reftype', '' ),
		'refprefix'         => zbs_ifAV( $all_settings, 'refprefix', '' ),
		'refnextnum'        => zbs_ifAV( $all_settings, 'refnextnum', '' ),
		'refsuffix'         => zbs_ifAV( $all_settings, 'refsuffix', '' ),
		'isfirstinv'        => $first_invoice,
		'invhash'           => zbs_ifAV( $all_settings, 'easyaccesslinks', '' ),
		'hideid'            => zbs_ifAV( $all_settings, 'invid', false ),
		'businessextra'     => nl2br( zeroBSCRM_textExpose( zbs_ifAV( $all_settings, 'businessextra', '' ) ) ),
		'businessyouremail' => zbs_ifAV( $all_settings, 'businessyouremail', '' ),
		'businessyoururl'   => zbs_ifAV( $all_settings, 'businessyoururl', '' ),
		'settings_slug'     => admin_url( 'admin.php?page=' . $zbs->slugs['settings'] ) . '&tab=invbuilder',
		'biz_settings_slug' => admin_url( 'admin.php?page=' . $zbs->slugs['settings'] ) . '&tab=bizinfo',
		'addnewcontacturl'  => jpcrm_esc_link( 'create', -1, 'zerobs_customer' ),
		'addnewcompanyurl'  => jpcrm_esc_link( 'create', -1, 'zerobs_company' ),
		'contacturlprefix'  => jpcrm_esc_link( 'edit', -1, 'zerobs_customer', true ),
		'companyurlprefix'  => jpcrm_esc_link( 'edit', -1, 'zerobs_company', true ),
		'lang'              => array(
			'invoice_number'      => zeroBSCRM_slashOut( __( 'ID', 'zero-bs-crm' ), true ),
			'invoice_date'        => zeroBSCRM_slashOut( __( 'Invoice date', 'zero-bs-crm' ), true ),
			'invoice_status'      => zeroBSCRM_slashOut( __( 'Status', 'zero-bs-crm' ), true ),
			'status_unpaid'       => zeroBSCRM_slashOut( __( 'Unpaid', 'zero-bs-crm' ), true ),
			'status_paid'         => zeroBSCRM_slashOut( __( 'Paid', 'zero-bs-crm' ), true ),
			'status_overdue'      => zeroBSCRM_slashOut( __( 'Overdue', 'zero-bs-crm' ), true ),
			'status_draft'        => zeroBSCRM_slashOut( __( 'Draft', 'zero-bs-crm' ), true ),
			'status_deleted'      => zeroBSCRM_slashOut( __( 'Deleted', 'zero-bs-crm' ), true ),
			'reference'           => zeroBSCRM_slashOut( $reference_label, true ),
			'autogenerated'       => zeroBSCRM_slashOut( __( 'Generated on save', 'zero-bs-crm' ), true ),
			'refsettings'         => zeroBSCRM_slashOut( __( 'Set up your reference type here', 'zero-bs-crm' ), true ),
			'nextref'             => zeroBSCRM_slashOut( __( 'Next reference expected', 'zero-bs-crm' ), true ),
			'due_date'            => zeroBSCRM_slashOut( __( 'Due date', 'zero-bs-crm' ), true ),
			'frequency'           => zeroBSCRM_slashOut( __( 'Frequency', 'zero-bs-crm' ), true ),
			'update'              => zeroBSCRM_slashOut( __( 'Update', 'zero-bs-crm' ), true ),
			'remove'              => zeroBSCRM_slashOut( __( 'Remove', 'zero-bs-crm' ), true ),
			'biz_info'            => zeroBSCRM_slashOut( __( 'Your business information', 'zero-bs-crm' ), true ),
			'add_edit'            => zeroBSCRM_slashOut( __( 'Edit ' . jpcrm_label_company() . ' Details', 'zero-bs-crm' ), true ),
			'add_logo'            => zeroBSCRM_slashOut( __( 'Add your logo', 'zero-bs-crm' ), true ),
			'send_to'             => zeroBSCRM_slashOut( __( 'Assign invoice to', 'zero-bs-crm' ), true ),
			'customise'           => zeroBSCRM_slashOut( __( 'Customise', 'zero-bs-crm' ), true ),
			'hours'               => zeroBSCRM_slashOut( __( 'Hours', 'zero-bs-crm' ), true ),
			'quantity'            => zeroBSCRM_slashOut( __( 'Quantity', 'zero-bs-crm' ), true ),
			'description'         => zeroBSCRM_slashOut( __( 'Description', 'zero-bs-crm' ), true ),
			'price'               => zeroBSCRM_slashOut( __( 'Price', 'zero-bs-crm' ), true ),
			'rate'                => zeroBSCRM_slashOut( __( 'Rate', 'zero-bs-crm' ), true ),
			'tax'                 => zeroBSCRM_slashOut( __( 'Tax', 'zero-bs-crm' ), true ),
			'add_row'             => zeroBSCRM_slashOut( __( 'Add row', 'zero-bs-crm' ), true ),
			'remove_row'          => zeroBSCRM_slashOut( __( 'Remove row', 'zero-bs-crm' ), true ),
			'amount'              => zeroBSCRM_slashOut( __( 'Amount', 'zero-bs-crm' ), true ),
			'discount'            => zeroBSCRM_slashOut( __( 'Discount', 'zero-bs-crm' ), true ),
			'shipping'            => zeroBSCRM_slashOut( __( 'Shipping', 'zero-bs-crm' ), true ),
			'tax_on_shipping'     => zeroBSCRM_slashOut( __( 'Tax on shipping', 'zero-bs-crm' ), true ),
			'due'                 => array(
				'none'      => zeroBSCRM_slashOut( __( 'No due date', 'zero-bs-crm' ), true ),
				'on'        => zeroBSCRM_slashOut( __( 'Due on receipt', 'zero-bs-crm' ), true ),
				'ten'       => zeroBSCRM_slashOut( __( 'Due in 10 days', 'zero-bs-crm' ), true ),
				'fifteen'   => zeroBSCRM_slashOut( __( 'Due in 15 days', 'zero-bs-crm' ), true ),
				'thirty'    => zeroBSCRM_slashOut( __( 'Due in 30 days', 'zero-bs-crm' ), true ),
				'fortyfive' => zeroBSCRM_slashOut( __( 'Due in 45 days', 'zero-bs-crm' ), true ),
				'sixty'     => zeroBSCRM_slashOut( __( 'Due in 60 days', 'zero-bs-crm' ), true ),
				'ninety'    => zeroBSCRM_slashOut( __( 'Due in 90 days', 'zero-bs-crm' ), true ),
			),
			'preview'             => zeroBSCRM_slashOut( __( 'Preview', 'zero-bs-crm' ), true ),
			'dl_pdf'              => zeroBSCRM_slashOut( __( 'Download PDF', 'zero-bs-crm' ), true ),
			'bill_to'             => zeroBSCRM_slashOut( __( 'Enter email address or name', 'zero-bs-crm' ), true ),
			'edit_record'         => zeroBSCRM_slashOut( __( 'Edit record', 'zero-bs-crm' ), true ),
			'no_tax'              => zeroBSCRM_slashOut( __( 'None', 'zero-bs-crm' ), true ),
			'taxgrouplabel'       => zeroBSCRM_slashOut( __( 'Rates', 'zero-bs-crm' ), true ),
			'subtotal'            => zeroBSCRM_slashOut( __( 'Subtotal', 'zero-bs-crm' ), true ),
			'total'               => zeroBSCRM_slashOut( __( 'Total', 'zero-bs-crm' ), true ),
			'amount_due'          => zeroBSCRM_slashOut( __( 'Amount due', 'zero-bs-crm' ), true ),
			'partial_table'       => zeroBSCRM_slashOut( __( 'Payments', 'zero-bs-crm' ), true ),
			'incomplete'          => zeroBSCRM_slashOut( __( 'Incomplete', 'zero-bs-crm' ), true ),
			'rowtitleplaceholder' => zeroBSCRM_slashOut( __( 'Item title', 'zero-bs-crm' ), true ),
			'rowdescplaceholder'  => zeroBSCRM_slashOut( __( 'Item description', 'zero-bs-crm' ), true ),
			'noname'              => zeroBSCRM_slashOut( __( 'Unnamed', 'zero-bs-crm' ), true ), // no name on typeahead,
			'noemail'             => zeroBSCRM_slashOut( __( 'No email', 'zero-bs-crm' ), true ), // no email on typeahead,
			'contact'             => zeroBSCRM_slashOut( __( 'Contact', 'zero-bs-crm' ), true ), // contact view button (if assigned)
			'company'             => zeroBSCRM_slashOut( jpcrm_label_company(), true ), // contact view button (if assigned)
			'view'                => zeroBSCRM_slashOut( __( 'View', 'zero-bs-crm' ), true ),
			'addnewcontact'       => zeroBSCRM_slashOut( __( 'Add new contact', 'zero-bs-crm' ), true ),
			'newcompany'          => zeroBSCRM_slashOut( __( 'new ' . jpcrm_label_company(), 'zero-bs-crm' ), true ),
			'or'                  => zeroBSCRM_slashOut( __( 'or', 'zero-bs-crm' ), true ),

			// send email modal
			'send_email'          => zeroBSCRM_slashOut( __( 'Email invoice', 'zero-bs-crm' ), true ),
			'sendthisemail'       => zeroBSCRM_slashOut( __( 'Send this invoice via email:', 'zero-bs-crm' ), true ),
			'toemail'             => zeroBSCRM_slashOut( __( 'To email:', 'zero-bs-crm' ), true ),
			'toemailplaceholder'  => zeroBSCRM_slashOut( __( 'e.g. mike@gmail.com', 'zero-bs-crm' ), true ),
			'attachassoc'         => zeroBSCRM_slashOut( __( 'Attach associated files', 'zero-bs-crm' ), true ),
			'attachpdf'           => zeroBSCRM_slashOut( __( 'Attach as PDF', 'zero-bs-crm' ), true ),
			'sendthemail'         => zeroBSCRM_slashOut( __( 'Send', 'zero-bs-crm' ), true ),
			'sendneedsassignment' => zeroBSCRM_slashOut( __( 'To send an email, this invoice needs to be assigned to a contact or company with a valid email address.', 'zero-bs-crm' ), true ),
			'sendingemail'        => zeroBSCRM_slashOut( __( 'Sending email...', 'zero-bs-crm' ), true ),
			'senttitle'           => zeroBSCRM_slashOut( __( 'Invoice sent', 'zero-bs-crm' ), true ),
			'sent'                => zeroBSCRM_slashOut( __( 'Your invoice has been sent by email', 'zero-bs-crm' ), true ),
			'senderrortitle'      => zeroBSCRM_slashOut( __( 'Error sending', 'zero-bs-crm' ), true ),
			'senderror'           => zeroBSCRM_slashOut( __( 'There was an error sending this invoice via email.', 'zero-bs-crm' ), true ),

		),
	);
	return $invoice_settings;
}

	#} Invoicing Pro - needs product index
	// WH: Don't like the lazy naming
function zeroBSCRM_getProductIndex() {
	$product_index = array();
	apply_filters( 'zbs_product_index_array', $product_index );
	return $product_index;
}

	// wrapper now for zeroBSCRM_hashes_GetObjFromHash
function zeroBSCRM_quotes_getFromHash( $hash = '' ) {

	return zeroBSCRM_hashes_GetObjFromHash( $hash, -1, ZBS_TYPE_QUOTE );
}

	// NOTE ON FOLLOWING:
	// ... this is MS's centralised func which centralises Inv data req. but it's still clunky.
	// ... here I've shimmed in DAL3 data -> this clunky centralised model.
	// ... could do with a complete rewrite to use proper DAL3 models tbh.
	// for now doing what can without taking months over it.

	/**
	 *  This file has the various functions used to control the invoice metaboxes
	 *  Wrappers so can be used throughout and switched over when it comes to it
	 *
	 *  The current metabox output, has also been changed to draw with JS now given
	 *  the added complexity of the tax table and discount per line
	 *
	 *  The calculation routine has also been reviewed to calculate the tax due
	 *  AFTER the line items discount has been applied
	 *
	 *  Drawing a new line was already available in JS, but the initial load (new) and edit
	 *  were messily drawn in PHP
	 *
	 *  Now it simply stores the invoice meta as one big data JSON structure outlined below
	 *  data format described below
	 *
	 *  JSON object for invoice
	 *
	 *  invoiceObj = {
	 * !                 invoice_id: 5,        // ID in the database - usually the invoice ID.
	 * !                 invoice_custom_id: -1 // the ID if over-written by settings (WH REMOVED, use id_override)
	 *
	 * !                 status:  paid,        // not defined in settings - should be? (draft, unpaid, paid, overdue)
	 *
	 *                  preview_link:         // generated from hash
	 *                  pdf_dl_link:          // downloaded on fly
	 *
	 * !                  hash:                 // the invoice hash (for front end accessible pages)
	 *
	 *                  pdf_template:         // the template to use
	 *                  portal_template:      // allow the choice of portal template (0 = default)
	 *                  email_template:       // allow the choice of email template (0 = default)
	 *
	 *                  invoice_frequency:    // invoicing pro only (0 = once only, 1 = week, 2 = month, 3 = year)
	 *
	 *                  invoice_number:       // this is over-ridable in settings
	 *                  invoice_date:         // date of the invoice
	 *                  invoice_due:          // when due -1 (no due date), 0 (on receipt), 10, 15, 30, 45, 60, 90 (days in advance of invoice date)
	 *
	 * !                  invoice_ref:          // internal reference number
	 *
	 *                  invoice_parent:       // invoice pro only (0 for parent), id of parent if child
	 *
	 *                  invoice_pay_via:      // 0 online, 1 bank transfer, 2 (both) - Zak addition to show online payment only for some
	 *
	 *
	 * !                  invoice_logo_url:    // url of the invoice logo (default, or custom per invoice)
	 *                  invoice_business_details:   // the details from settings to go on the invoice (also in settings obj)
	 *
	 *
	 *                  invoice_send_to:       // email to send the invoice to
	 * !                 invoice_contact:       // 0 or contact ID
	 * !                 invoice_company:       // 0 or company ID
	 *                  invoice_address_to:    // 0 contact or 1 company. So if assigned to Mike, can be address to a company (i.e. Mike Stott: Jetpack CRM, Mike Stott: Epic Plugins) etc
	 *
	 *
	 *                  invoice_hours_or_quantity:    0 for hours, 1 for quantity
	 *
	 *                  invoice_items:   {
	 *                                      item_id: (line_item ID)
	 *                                      order:   (order in list, i.e. 0,1,2,3,4,5)
	 *                                      title:
	 *                                      description:
	 *                                      unit:
	 *                                      price:
	 *                                      tax_ids: {
	 *                                              id: 1, rate: 20,
	 *                                              id: 2, rate: 19
	 *                                      },
	 *
	 *                                    },{
	 *
	 *                                    }
	 *
	 *                  invoice_discount:   0,
	 *                  invoice_shipping:   0,
	 *                  invoice_shipping_tax: {
	 *                                      tax_ids:{
	 *                                             id: 1, rate: 20,
	 *                                             id: 2, rate: 19
	 *                                      }
	 *                  },
	 *
	 *                  invoice_tip:        0, 1 (allow tip) - not in UI yet
	 *                  invoice_partial:    0, 1 (allow partial payment) - in UI already (i.e. can assign multiple transactions) need to handle it via checkout (i.e. pay full amount, or pay instalments)
	 *
	 *                  transactions: {                             //the transactions against the invoice (array to allow for partial payments)
	 *                                      transaction_id: 5,
	 *                                      amount: 200,
	 *                                      status: paid,
	 *                                  },
	 *                  invoice_attachments: {
	 *                              id: 1,
	 *                              url:  uploaded_url
	 *                              send: 0,1
	 *                  },
	 *                  invoice_custom_fields: {
	 *                          id: 1,
	 *                          label: "vesting period",
	 *                          type:  "date",
	 *                          value: "20/10/2019"
	 *                  },
	 *                  //what the invoice settings are (biz info, tax etc)
	 *                  settings: {
	 *
	 *                  }
	 *
	 *                }
	 *
	 *
	 *   tax_linesObj = {
	 *                      id:
	 *                      name:    (e.g. VAT, GST)
	 *                      rate:    (%)
	 *                  }
	 */

	// this gets the data (from the current DAL and outputs it to the UI) - can get via jQuery
	// once happy it works and fills the current databse. This will need switching over come
	// the new DAL database structure but allows me to work with the UI now ahead of time.

// wh Centralised, is ultimately output via ajax zeroBSCRM_AJAX_getInvoice function in Control.Invoices
// was called zeroBSCRM_getInvoiceData -> zeroBSCRM_invoicing_getInvoiceData
function zeroBSCRM_invoicing_getInvoiceData( $invID = -1 ) {

	global $zbs;

	$data = array();

	// viable id?
	if ( $invID > 0 ) {

		// build response
		$data['invoiceObj'] = array();

		$invoice = $zbs->DAL->invoices->getInvoice(
			$invID,
			array(

				// if these two passed, will search based on these
				'idOverride'        => false, // direcetly checks 1:1 match id_override
				'searchPhrase'      => false, // more generic, searches id_override (reference) (and not lineitems (toadd?))

				'externalSource'    => false,
				'externalSourceUID' => false,

				// with what?
				'withLineItems'     => true,
				'withCustomFields'  => true,
				'withTransactions'  => true, // gets trans associated with inv as well
				'withAssigned'      => true, // return ['contact'] & ['company'] objs if has link
				'withTags'          => true,
				'withOwner'         => true,

				// returns scalar ID of line
				'onlyID'            => false,

				'fields'            => false, // false = *, array = fieldnames

			)
		);

		if ( ! is_array( $invoice ) ) {

			// get blank defaults - this is for a de-headed serpent? (don't think should ever exist)
			$data['invoiceObj'] = zeroBSCRM_get_invoice_defaults( $invID );

		} else {

			// process the loaded data
			// ... this made a lot of sense Pre DAL3, but much should be dealt with by DAL now
			// ... wh done best to leave only necessary here:
			$now = time();

			$invoice_date_uts     = isset( $invoice['date_date'] ) ? $invoice['date'] : $now;
			$invoice['date_date'] = jpcrm_uts_to_date_str( $invoice_date_uts, 'Y-m-d' );

			$invoice_due_date_uts     = isset( $invoice['due_date'] ) ? $invoice['due_date'] : $now;
			$invoice['due_date_date'] = jpcrm_uts_to_date_str( $invoice_due_date_uts, 'Y-m-d' );

			// this should load it all anyhow :) (DAL3+)
			$data['invoiceObj'] = $invoice;

			// Settings
			$settings = zeroBSCRM_get_invoice_settings();

			// catch any empty shiz? seems to be what was happening.
			if ( ! isset( $data['invoiceObj']['invoice_logo_url'] ) ) {
				$data['invoiceObj']['invoice_logo_url'] = $settings['logo'];
			}

			// these two are kind of just aliases? Use straight $invoiceObj[contact] etc.
			$data['invoiceObj']['invoice_contact'] = false;
			if ( isset( $invoice['contact'] ) && is_array( $invoice['contact'] ) && count( $invoice['contact'] ) > 0 ) {
				$data['invoiceObj']['invoice_contact'] = $invoice['contact'][0];
			}
			$data['invoiceObj']['invoice_company'] = false;
			if ( isset( $invoice['company'] ) && is_array( $invoice['company'] ) && count( $invoice['company'] ) > 0 ) {
				$data['invoiceObj']['invoice_company'] = $invoice['company'][0];
			}
			$data['invoiceObj']['new_invoice'] = false;

			// these should probs use $invoice['contact'] etc. leaving for now for time.
			$billing_email = '';
			$billing_name  = '';
			if ( isset( $data['invoiceObj']['invoice_contact'] ) && is_array( $data['invoiceObj']['invoice_contact'] ) && isset( $data['invoiceObj']['invoice_contact']['id'] ) ) {

				if ( isset( $data['invoiceObj']['invoice_contact']['email'] ) ) {
					$billing_email = $data['invoiceObj']['invoice_contact']['email'];
				}
				if ( isset( $data['invoiceObj']['invoice_contact']['name'] ) ) {
					$billing_name = $data['invoiceObj']['invoice_contact']['name'];
				}
				if ( empty( $billing_name ) ) {
					$billing_name = $zbs->DAL->contacts->getContactNameWithFallback( $data['invoiceObj']['invoice_contact']['id'] );
				}
			} elseif ( isset( $data['invoiceObj']['invoice_company'] ) && is_array( $data['invoiceObj']['invoice_company'] ) && isset( $data['invoiceObj']['invoice_company']['id'] ) ) {

				if ( isset( $data['invoiceObj']['invoice_company']['email'] ) ) {
					$billing_email = $data['invoiceObj']['invoice_company']['email'];
				}
				if ( isset( $data['invoiceObj']['invoice_company']['name'] ) ) {
					$billing_name = $data['invoiceObj']['invoice_company']['name'];
				}
			}
			$data['invoiceObj']['bill'] = $billing_email;
			// add billing name here
			$data['invoiceObj']['bill_name'] = $billing_name;

			// handle if due is not set
			$data['invoiceObj']['due'] = -1; // default
			if ( isset( $invoice['due'] ) ) {
				$data['invoiceObj']['due'] = $invoice['due'];
			}

			$data['invoiceObj']['invoice_items'] = $invoice['lineitems'];

			// needs translating
			$hoursOrQuantity    = (int) $invoice['hours_or_quantity']; // 0 = hours, 1 = quantity
			$hoursOrQuantityStr = 'hours';
			if ( $hoursOrQuantity > 0 ) {
				$hoursOrQuantityStr = 'quantity';
			}
			$data['invoiceObj']['invoice_hours_or_quantity'] = $hoursOrQuantityStr;

			// are PDF engine and Client Portal installed?
			$data['invoiceObj']['pdf_installed']    = zeroBSCRM_isExtensionInstalled( 'pdfinv' );
			$data['invoiceObj']['portal_installed'] = zeroBSCRM_isExtensionInstalled( 'portal' );

			// if we have Client Portal installed, build URLS
			$preview_link = null;

			if ( $data['invoiceObj']['portal_installed'] ) {

				// Retrieve invoice endpoint & portal root URL
				$invoice_endpoint = $zbs->modules->portal->get_endpoint( ZBS_TYPE_INVOICE );
				$portalLink       = zeroBS_portal_link();
				if ( ! str_ends_with( $portalLink, '/' ) ) {  // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
					$portalLink .= '/';
				}

				// if invoice has a hash this will be a hash URL, otherwise it uses the invoice ID
				if ( $settings['invhash'] ) {
					$preview_link = esc_url( $portalLink . $invoice_endpoint . '/zh-' . $invoice['hash'] );
				} else {
					$preview_link = esc_url( $portalLink . $invoice_endpoint . '/' . $invID );
				}
			}

			// if a hash is set (or admin) load the invoice for logged out users, if agreed
			// will still need to load the contactID and info in the Stripe call too even if logged out
			$data['invoiceObj']['preview_link'] = $preview_link;

			// urgh. this was how we got the settings object.
			// refine this too.

			// SETTINGS array (process from main ZBS settings object)
			// Add our custom setting here
			$settings['invoicing_disable_partial_payments'] = zeroBSCRM_getSetting( 'invoicing_disable_partial_payments' );
			$data['invoiceObj']['settings']                 = $settings;

			// WH shim - converts DAL3 single record attrs into an array as MS expects?
			$data['invoiceObj']['totals']                           = array();
			$data['invoiceObj']['totals']['invoice_discount_total'] = $invoice['discount'];
			$data['invoiceObj']['totals']['invoice_discount_type']  = $invoice['discount_type'];
			$data['invoiceObj']['totals']['invoice_postage_total']  = $invoice['shipping'];
			$data['invoiceObj']['totals']['tax']                    = $invoice['tax'];

			// shipping total needs to return 0 in some cases if not set it is empty. GRR @ mike DB1.0 data.
			if ( ! array_key_exists( 'invoice_postage_total', $data['invoiceObj']['totals'] ) ) {
				$data['invoiceObj']['totals']['invoice_postage_total'] = 0;
			}

			// Invoice PARTIALS
			$data['invoiceObj']['partials'] = $invoice['transactions'];

		}

		// update to get from tax table UI. Below is dummy data for UI work (UI tax table TO DO)
		$data['tax_linesObj'] = zeroBSCRM_taxRates_getTaxTableArr();

		return $data;

	}

	return false;
}

/*
======================================================
		/ Invoice 3.0 helpers
	====================================================== */

#} General function to check the amount due on an invoice, if <= mark as paid.
// Adapted to work V3.0+
// ... ultimately just uses zeroBSCRM_invoicing_invOutstandingBalance to check for balance + marks if paid off
function zeroBSCRM_check_amount_due_mark_paid( $invoice_id = -1 ) {

	if ( $invoice_id > 0 ) {

		global $zbs;

		$outstandingBalance = $zbs->DAL->invoices->getOutstandingBalance( $invoice_id );

		// got balance?
		if ( $outstandingBalance <= 0 && $outstandingBalance !== false ) {

			// mark invoice as paid
			$status_str     = 'Paid';
			$invoice_update = $zbs->DAL->invoices->setInvoiceStatus( $invoice_id, $status_str ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

			return $invoice_update;

		}
	}

	return false;
}

/*
======================================================
		/ Invoice helpers
	====================================================== */

/*
======================================================
		Transactions helpers
	====================================================== */

// Please use direct dal calls in future work.
function zeroBS_getTransaction( $tID = -1 ) {

	if ( $tID !== -1 ) {

		/*
		return array(
			'id'=>$tID,
			'meta'=>get_post_meta($tID, 'zbs_transaction_meta', true),
			'customerid'=>get_post_meta($tID, 'zbs_parent_cust', true),
			'companyid'=>get_post_meta($tID, 'zbs_parent_co', true)
			);
		*/

		global $zbs;

		return $zbs->DAL->transactions->getTransaction( $tID );

	} else {
		return false;
	}
}

// Please use direct dal calls in future work.
function zeroBS_getTransactions(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$hasTagIDs = array(),
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$withTags = false,
	$quickFilters = array(),
	$external_source_uid = false
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase'        => $searchPhrase,
		'inArr'               => $inArray,
		'isTagged'            => $hasTagIDs,
		'quickFilters'        => $quickFilters,

		'withAssigned'        => $withCustomerDeets,
		'withTags'            => $withTags,

		'sortByField'         => $sortByField,
		'sortOrder'           => $sortOrder,
		'page'                => $actualPage,
		'perPage'             => $perPage,

		'ignoreowner'         => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TRANSACTION ),

		'external_source_uid' => $external_source_uid,

	);

	return $zbs->DAL->transactions->getTransactions( $args );
}

// Please use direct dal calls in future work.
function zeroBS_getTransactionsCountIncParams(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false,
	$searchPhrase = '',
	$hasTagIDs = array(),
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$withTags = false,
	$quickFilters = array()
) {

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'isTagged'     => $hasTagIDs,
		'quickFilters' => $quickFilters,

		// just count thx
		'count'        => true,
		'withAssigned' => false,

		// 'sortByField'     => $sortByField,
		// 'sortOrder'   => $sortOrder,
		'page'         => -1,
		'perPage'      => -1,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TRANSACTION ),

	);

	return $zbs->DAL->transactions->getTransactions( $args );
}

	// Please use direct dal calls in future work.
function zeroBS_getTransactionsForCustomer(
	$customerID = -1,
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$withCustomerDeets = false
) {
	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'assignedContact' => $customerID,

		// with contact?
		'withAssigned'    => $withCustomerDeets,

		// 'sortByField'     => $orderBy,
		// 'sortOrder'   => $order,
		'page'            => $actualPage,
		'perPage'         => $perPage,

		'ignoreowner'     => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TRANSACTION ),

	);

	return $zbs->DAL->transactions->getTransactions( $args );
}

	// Please use direct dal calls in future work.
function zeroBS_getTransactionIDWithExternalSource( $transactionExternalSource = '', $transactionExternalID = '' ) {

	// retrieve external sources from $zbs now
	global $zbs;

	#} No empties, no random externalSources :)
	if ( ! empty( $transactionExternalSource ) && ! empty( $transactionExternalID ) && array_key_exists( $transactionExternalSource, $zbs->external_sources ) ) {

		// return id if exists
		return $zbs->DAL->transactions->getTransaction(
			-1,
			array(
				'externalSource'    => $transactionExternalSource,
				'externalSourceUID' => $transactionExternalID,
				'onlyID'            => true,
				'ignoreowner'       => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TRANSACTION ),
			)
		);

	}

	return false;
}

/**
 * Adds or updates a transaction.
 *
 * Deprecated in v3.0+ — avoid using these centralised funcs, use direct DAL calls instead.
 *
 * @param int    $tID                       Transaction ID.
 * @param array  $tFields                   Transaction fields array. Required keys: 'orderid', 'customer',
 *                                          'status', 'total'. Recommended keys: 'date', 'currency', 'item',
 *                                          'net', 'tax', 'fee', 'discount', 'tax_rate'.
 * @param string $transactionExternalSource External source identifier.
 * @param string $transactionExternalID     External ID.
 * @param string $transactionDate           Transaction date.
 * @param array  $transactionTags           Transaction tags.
 * @param bool   $fallBackLog               Whether to create a fallback log.
 * @param bool   $extraMeta                 Extra metadata.
 * @param bool   $automatorPassthrough      Automator passthrough flag.
 * @param string $arrBuilderPrefix          Array builder prefix.
 */
function zeroBS_addUpdateTransaction(
	$tID = -1,
	$tFields = array(),
	$transactionExternalSource = '',
	$transactionExternalID = '',
	$transactionDate = '',
	$transactionTags = array(),
	$fallBackLog = false,
	$extraMeta = false,
	$automatorPassthrough = false,
	$arrBuilderPrefix = 'zbst_'
) {

	// zeroBSCRM_DEPRECATEDMSG('ZBS Function Deprecated in v3.0+. zeroBS_addUpdateTransaction should now be replaced with proper zbs->DAL->calls');

	global $zbs;

	#} Basics - /--needs unique ID, total MINIMUM
	if ( isset( $tFields ) && count( $tFields ) > 0 ) {

		#} New flag
		$newTrans = false;

		if ( $tID > 0 ) {

			#} Build "existing meta" to pass, (so we only update fields pushed here)
			$existingMeta = $zbs->DAL->transactions->getTransaction( $tID, array() );

			// Do date comparison + update that where relevant
			$originalDate = time();
			if ( isset( $existingMeta ) && is_array( $existingMeta ) && isset( $existingMeta['created'] ) && ! empty( $existingMeta['created'] ) ) {
				$originalDate = $existingMeta['created'];
			}
			if ( ! empty( $transactionDate ) && $transactionDate != '' ) {

				#} DATE PASSED TO THE FUNCTION
				$transactionDateTimestamp = strtotime( $transactionDate );
				#} ORIGINAL POST CREATION DATE
				// no need, db2 = UTS $originalDateTimeStamp = strtotime($originalDate);
				$originalDateTimeStamp = $originalDate;

				#} Compare, if $transactionDateTimestamp < then update with passed date
				if ( $transactionDateTimestamp < $originalDateTimeStamp ) {

					// straight in there :)
					$zbs->DAL->transactions->addUpdateTransaction(
						array(
							'id'            => $tID,
							'limitedFields' => array(
								array(
									'key'  => 'zbst_created',
									'val'  => $transactionDateTimestamp,
									'type' => '%d',
								),
							),
						)
					);
				}
			}
		} else {

			#} Set flag
			$newTrans = true;

			#} DATE PASSED TO THE FUNCTION
			$transactionDateTimestamp = strtotime( $transactionDate );
			$tFields['created']       = $transactionDateTimestamp;

		}

		// this is a DAL2 legacy:
		$existingMeta = array();

		#} Build using centralised func below, passing any existing meta (updates not overwrites)
		$transactionMeta = zeroBS_buildTransactionMeta( $tFields, $existingMeta, $arrBuilderPrefix );

		// format it for DAL3 addition
		$args = array(

			'id'                   => $tID,
			'data'                 => $transactionMeta,
			'extraMeta'            => $extraMeta,
			'automatorPassthrough' => $automatorPassthrough,
			'fallBackLog'          => $fallBackLog,

		);
		// few DAL2 -> DAL3 translations:

		// owner?
		if ( isset( $tFields['owner'] ) > 0 ) {
			$args['owner'] = $tFields['owner'];
		}

		// contact/companies?
		if ( isset( $tFields['customer'] ) && $tFields['customer'] > 0 ) {
			$args['data']['contacts'] = array( (int) $tFields['customer'] );
		}
		if ( isset( $tFields['company'] ) && $tFields['company'] > 0 ) {
			$args['data']['companies'] = array( (int) $tFields['company'] );
		}

		#} Add external source/externalid
		#} No empties, no random externalSources :)
		$approvedExternalSource = ''; #} As this is passed to automator :)

		if ( ! empty( $transactionExternalSource ) && ! empty( $transactionExternalID ) && array_key_exists( $transactionExternalSource, $zbs->external_sources ) ) {

			#} If here, is legit.
			$approvedExternalSource = $transactionExternalSource;

			$extSourceArr = array(
				'source' => $approvedExternalSource,
				'uid'    => $transactionExternalID,
			);

			$args['data']['externalSources'] = array( $extSourceArr );

		} #} Otherwise will just be a random obj no ext source

		#} For now a brutal pass through:
		// wh: not sure why this was here? if (isset($tFields['trans_time']) && !empty($tFields['trans_time'])) $zbsTransactionMeta['trans_time'] = (int)$tFields['trans_time'];

		# TAG obj (if exists) - clean etc here too
		if ( isset( $transactionTags ) && is_array( $transactionTags ) ) {

			$transactionTags = filter_var_array( $transactionTags, FILTER_UNSAFE_RAW );
			// Formerly this used FILTER_SANITIZE_STRING, which is now deprecated as it was fairly broken. This is basically equivalent.
			// @todo Replace this with something more correct.
			foreach ( $transactionTags as $k => $v ) {
				$transactionTags[ $k ] = strtr(
					strip_tags( $v ),
					array(
						"\0" => '',
						'"'  => '&#34;',
						"'"  => '&#39;',
						'<'  => '',
					)
				);
			}

			$args['data']['tags'] = array();
			foreach ( $transactionTags as $tag_name ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

				// Check for existing tag under this name.
				$tag_id = $zbs->DAL->getTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
					-1,
					array(
						'objtype' => ZBS_TYPE_TRANSACTION,
						'name'    => $tag_name,
						'onlyID'  => true,
					)
				);

				// If tag doesn't exist, create one.
				if ( empty( $tag_id ) ) {
					$tag_id = $zbs->DAL->addUpdateTag( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
						array(
							'data' => array(
								'objtype' => ZBS_TYPE_TRANSACTION,
								'name'    => $tag_name,
							),
						)
					);
				}

				// Add tag to list.
				if ( ! empty( $tag_id ) ) {
					$args['data']['tags'][] = $tag_id;
				}
			}
		}

		// Update record (All IA is now fired intrinsicaly)
		return $zbs->DAL->transactions->addUpdateTransaction( $args ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
	}

	return false;
}

	// Please use direct dal calls in future work, not this.
	#} Quick wrapper to future-proof.
	#} Should later replace all get_post_meta's with this
function zeroBS_getTransactionMeta( $tID = -1 ) {

	global $zbs;

	// in DAL3 it's just a normal get
	if ( ! empty( $tID ) ) {
		return $zbs->DAL->transactions->getTransaction( $tID );
	}

	return false;
}

	// filters array for fields currently used in fields.php
	// v3.0+ this uses the generic zeroBS_buildObjArr, and accepts full args as per contact meta DAL2:
function zeroBS_buildTransactionMeta( $arraySource = array(), $startingArray = array(), $fieldPrefix = 'zbst_', $outputPrefix = '', $removeEmpties = false, $autoGenAutonumbers = false ) {

	return zeroBS_buildObjArr( $arraySource, $startingArray, $fieldPrefix, $outputPrefix, $removeEmpties, ZBS_TYPE_TRANSACTION, $autoGenAutonumbers );
}

	// moves a tran from being assigned to one cust, to another
	// this is a fill-in to match old DAL2 func, however DAL3+ can accept customer/company,
	// ... so use the proper $DAL->addUpdateObjectLinks for fresh code
function zeroBSCRM_changeTransactionCustomer( $id = -1, $contactID = 0 ) {

	if ( ! empty( $id ) && $contactID > 0 ) {

		global $zbs;
		return $zbs->DAL->transactions->addUpdateObjectLinks( $id, array( $contactID ), ZBS_TYPE_CONTACT );

	}

	return false;
}

	// moves a tran from being assigned to one company, to another
	// this is a fill-in to match old DAL2 func, however DAL3+ can accept customer/company,
	// ... so use the proper $DAL->addUpdateObjectLinks for fresh code
function zeroBSCRM_changeTransactionCompany( $id = -1, $companyID = 0 ) {

	if ( ! empty( $id ) && $companyID > 0 ) {

		global $zbs;
		return $zbs->DAL->transactions->addUpdateObjectLinks( $id, array( $companyID ), ZBS_TYPE_COMPANY );

	}

	return false;
}

/*
======================================================
	/ Transactions helpers
====================================================== */

/*
======================================================
	Event helpers
====================================================== */

	// old way of doing - also should really be "get list of events/tasks for a contact"
function zeroBSCRM_getTaskList( $cID = -1 ) {

	$ret = array();

	if ( $cID > 0 ) {

		global $zbs;

		return $zbs->DAL->events->getEvents(
			array(
				'assignedContact' => $cID,
				'ignoreowner'     => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TASK ),
			)
		);

		/*
		these translated for DAL3

		$i = 0;
		foreach ( $tasks as $task){
			$ret[$i]['title'] = $task->post_title;
			$ret[$i]['ID'] = $task->ID;
			$ret[$i]['meta'] = get_post_meta($task->ID,'zbs_event_meta',true);
			$ret[$i]['actions'] = get_post_meta($task->ID,'zbs_event_actions',true);

			// titles moved into meta with MS new task ui, wh bringing them out here:
			if ( empty( $task->post_title) && is_array( $ret[$i]['meta']) && isset( $ret[$i]['meta']['title']) && !empty( $ret[$i]['meta']['title'])){
				$ret[$i]['title'] = $ret[$i]['meta']['title'];
			}

			$i++;
		}*/

		return $ret;
	}

	return array();
}

	// adapted to DAL3
	// NOTE: $withFullDetails is redundant here
	// NOTE: as with all dal3 translations, objs no longer have ['meta'] etc.
	// USE direct DAL calls in code, not this, for future proofing
function zeroBS_getEvents(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$ownedByID = false,
	$search_term = '',
	$sortByField = '',
	$sortOrder = 'DESC',
	$hasTagIDs = array()
) {

	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		'withAssigned' => true,
		'withOwner'    => true,

		'isTagged'     => $hasTagIDs,

		'sortByField'  => $sortByField,
		'sortOrder'    => $sortOrder,

		'page'         => $actualPage,
		'perPage'      => $perPage,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TASK ),

	);
	if ( $ownedByID > 0 ) {
		$args['ownedBy'] = $ownedByID;
	}
	if ( ! empty( $search_term ) ) {
		$args['searchPhrase'] = $search_term;
	}

	return $zbs->DAL->events->getEvents( $args );
}

	// adapted to DAL3
	// NOTE: $withFullDetails is redundant here
	// NOTE: as with all dal3 translations, objs no longer have ['meta'] etc.
	// USE direct DAL calls in code, not this, for future proofing
function zeroBS_getEventsByCustomerID( $cID = -1, $withFullDetails = false, $perPage = 10, $page = 0 ) {

	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		'assignedContact' => $cID,

		'page'            => $actualPage,
		'perPage'         => $perPage,

		'ignoreowner'     => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_TASK ),

	);

	return $zbs->DAL->events->getEvents( $args );
}

	// moves an event from being assigned to one cust, to another
	// this is a fill-in to match old DAL2 func, however DAL3+ can accept customer/company,
	// ... so use the proper $DAL->addUpdateObjectLinks for fresh code
function zeroBSCRM_changeEventCustomer( $id = -1, $contactID = 0 ) {

	if ( ! empty( $id ) && $contactID > 0 ) {

		global $zbs;
		return $zbs->DAL->events->addUpdateObjectLinks( $id, array( $contactID ), ZBS_TYPE_CONTACT );

	}

	return false;
}

	// Add an event
function zeroBS_addUpdateEvent( $eventID = -1, $eventFields = array(), $reminders = array() ) {

	// if using 'from' and 'to', probably using v1 dal, so translate dates:
	if ( isset( $eventFields['from'] ) ) {
		$eventFields['from'] = strtotime( $eventFields['from'] );
	}
	if ( isset( $eventFields['to'] ) ) {
		$eventFields['to'] = strtotime( $eventFields['to'] );
	}

	#} Build using centralised func below, passing any existing meta (updates not overwrites)
	$removeEmpties = false;
	$zbsEventMeta  = zeroBS_buildObjArr( $eventFields, array(), '', '', $removeEmpties, ZBS_TYPE_TASK );

	// Some sanitation MS has added. Really, DAL isn't place to sanitize,
	// ... by time it gets here it should be sanitized (e.g. a level up)
	// ... leaving as I translate this to DAL3
	// $zbsEventMeta = filter_var_array($eventFields,FILTER_SANITIZE_STRING);

	// format it for DAL3 addition
	$args = array(

		'data' => $zbsEventMeta,

	);

	global $zbs;

	// few DAL2 -> DAL3 translations:

	// owner?
	if ( isset( $eventFields['owner'] ) > 0 ) {
		$args['owner'] = $eventFields['owner'];
	}

	// contact/companies?
	if ( isset( $eventFields['customer'] ) && $eventFields['customer'] > 0 ) {
		$args['data']['contacts'] = array( $eventFields['customer'] );
	}
	if ( isset( $eventFields['company'] ) && $eventFields['company'] > 0 ) {
		$args['data']['companies'] = array( $eventFields['company'] );
	}

	$args['data']['reminders'] = array();

	// reminders into new DAL2 eventreminder format:
	if ( is_array( $reminders ) && count( $reminders ) > 0 ) {
		foreach ( $reminders as $reminder ) {

			// this just adds with correct fields
			$args['data']['reminders'][] = array(

				'event'     => (int) $eventID,
				'remind_at' => (int) $reminder['remind_at'], // just assume is int - garbage in, garbage out ($reminder['remind_at']) ? $reminder['remind_at'] : false; // if int, this
				'sent'      => ( isset( $reminder['sent'] ) && $reminder['sent'] > 0 ) ? $reminder['sent'] : -1,

			);

		}
	}

	// updating....
	if ( $eventID > 0 ) {
		$args['id'] = (int) $eventID;
	}

	// simples
	return $zbs->DAL->events->addUpdateEvent( $args );
}

/*
======================================================
		/ Event helpers
	====================================================== */

/*
======================================================
		Form helpers
	====================================================== */

	// Please use direct dal calls in future work.
	// simple wrapper for Form
function zeroBS_getForm( $formID = -1 ) {

	if ( $formID > 0 ) {

		/*
		return array(
			'id'=>$fID,

			// mikes init fields
			'meta'=>get_post_meta($fID,'zbs_form_field_meta',true),
			'style'=>get_post_meta($fID, 'zbs_form_style', true),
			'views'=>get_post_meta($fID, 'zbs_form_views', true),
			'conversions'=>get_post_meta($fID, 'zbs_form_conversions', true)

			);
		*/

		global $zbs;

		return $zbs->DAL->forms->getForm( $formID );

	}

	return false;
}

	// Please use direct dal calls in future work.
function zeroBS_getForms(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// quickFilters not used for forms :) *yet

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'isTagged'     => $hasTagIDs,

		'sortByField'  => $sortByField,
		'sortOrder'    => $sortOrder,
		'page'         => $actualPage,
		'perPage'      => $perPage,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_FORM ),

	);

	return $zbs->DAL->forms->getForms( $args );
}

	// Please use direct dal calls in future work.
function zeroBS_getFormsCountIncParams(
	$withFullDetails = false,
	$perPage = 10,
	$page = 0,
	$searchPhrase = '',
	$inArray = array(),
	$sortByField = '',
	$sortOrder = 'DESC',
	$quickFilters = array(),
	$hasTagIDs = array()
) {

	// quickFilters not used for forms :) *yet

	// $withFullDetails = irrelevant with new DB2 (always returns)
	global $zbs;

	$actualPage = $page;
	if ( $actualPage < 0 ) {
		$actualPage = 0;
	}

	// make ARGS
	$args = array(

		// Search/Filtering (leave as false to ignore)
		'searchPhrase' => $searchPhrase,
		'inArr'        => $inArray,
		'isTagged'     => $hasTagIDs,

		// just count thx
		'count'        => true,

		'page'         => -1,
		'perPage'      => -1,

		'ignoreowner'  => zeroBSCRM_DAL2_ignoreOwnership( ZBS_TYPE_FORM ),

	);

	return $zbs->DAL->forms->getForms( $args );
}

/*
======================================================
	/ Form helpers
====================================================== */

/*
======================================================
	Settings helpers
====================================================== */

	#} Minified get setting func
function zeroBSCRM_getSetting( $key, $freshFromDB = false ) {

	global $zbs;
	$zbs->checkSettingsSetup();
	return $zbs->settings->get( $key, $freshFromDB );
}

/*
======================================================
	/ Settings helpers
====================================================== */

/*
======================================================
	Alias / AKA helpers
====================================================== */
	// Aliases - direct SQL here, could do with moving to DAL3

	#} (Generic) See if already in use/exists
function zeroBS_canUseAlias( $objType = ZBS_TYPE_CONTACT, $alias = '' ) {

	if ( ! empty( $alias ) ) {

		// verify email?

		if ( ! zeroBSCRM_validateEmail( $alias ) ) {
			return false;
		}

		// is in use?

		// is customer with this email?
		$existing = zeroBS_getCustomerIDWithEmail( $alias );

		if ( ! empty( $existing ) ) {
			return false;
		}

		global $wpdb, $ZBSCRM_t;

		$query = $wpdb->prepare( 'SELECT ID FROM ' . $ZBSCRM_t['aka'] . ' WHERE aka_type = %d AND aka_alias = %s', $objType, $alias );

		$aliasID = $wpdb->get_var( $query );

		// has alias in there already?
		if ( ! empty( $aliasID ) ) {
			return false;
		}

		// usable
		return true;

	}

	return false;
}

	#} Get specific alias if exists
function zeroBS_getObjAlias( $objType = ZBS_TYPE_CONTACT, $objID = -1, $alias = '' ) {

	if ( ! empty( $objID ) && ! empty( $alias ) ) {

		global $wpdb, $ZBSCRM_t;

		$query = $wpdb->prepare( 'SELECT ID,aka_alias,aka_created,aka_lastupdated FROM ' . $ZBSCRM_t['aka'] . ' WHERE aka_type = %d AND aka_id = %d AND aka_alias = %s', $objType, $objID, $alias );

		$alias = $wpdb->get_row( $query, ARRAY_A );

		// check it + return
		if ( is_array( $alias ) ) {
			return $alias;
		}
	}

	return false;
}

	#} Get specific alias if exists
function zeroBS_getAliasByID( $objType = ZBS_TYPE_CONTACT, $objID = -1, $aliasID = -1 ) {

	if ( ! empty( $objID ) && ! empty( $aliasID ) ) {

		global $wpdb, $ZBSCRM_t;

		$query = $wpdb->prepare( 'SELECT ID,aka_alias,aka_created,aka_lastupdated FROM ' . $ZBSCRM_t['aka'] . ' WHERE aka_type = %d AND aka_id = %d AND ID = %d', $objType, $objID, $aliasID );

		$alias = $wpdb->get_row( $query, ARRAY_A );

		// check it + return
		if ( is_array( $alias ) ) {
			return $alias;
		}
	}

	return false;
}

	#} Get All Aliases against an obj.
function zeroBS_getObjAliases( $objType = ZBS_TYPE_CONTACT, $objID = -1 ) {

	if ( ! empty( $objID ) ) {

		global $wpdb, $ZBSCRM_t;

		$query = $wpdb->prepare( 'SELECT ID,aka_alias,aka_created,aka_lastupdated FROM ' . $ZBSCRM_t['aka'] . ' WHERE aka_type = %d AND aka_id = %d', $objType, $objID );

		$aliases = $wpdb->get_results( $query, ARRAY_A );

		// check it + return
		if ( is_array( $aliases ) && count( $aliases ) > 0 ) {
			return $aliases;
		}
	}

	return false;
}

#} add Aliases to an obj.
function zeroBS_addObjAlias( $objType = ZBS_TYPE_CONTACT, $objID = -1, $alias = '' ) {

	if ( ! empty( $objID ) && ! empty( $alias ) ) {

		// check not already there
		$existing = zeroBS_getObjAlias( $objType, $objID, $alias );
		if ( ! is_array( $existing ) ) {

			// insert

			global $wpdb, $ZBSCRM_t;

			if ( $wpdb->insert(
				$ZBSCRM_t['aka'],
				array(
					'aka_type'        => $objType,
					'aka_id'          => $objID,
					'aka_alias'       => $alias,
					'aka_created'     => time(),
					'aka_lastupdated' => time(),
				),
				array(
					'%d',
					'%d',
					'%s',
					'%d',
					'%d',
				)
			) ) {

				// success
				return $wpdb->insert_id;

			} else {
				return false;
			}
		} else {

			// return true, already exists
			return true;

		}
	}

	return false;
}

#} remove Alias from an obj.
function zeroBS_removeObjAlias( $objType = ZBS_TYPE_CONTACT, $objID = -1, $alias = '' ) {

	if ( ! empty( $objID ) && ! empty( $alias ) ) {

		// check there/find ID
		$existing = zeroBS_getObjAlias( $objType, $objID, $alias );

		if ( is_array( $existing ) ) {

			// just brutal :)

			global $wpdb, $ZBSCRM_t;

			return $wpdb->delete( $ZBSCRM_t['aka'], array( 'ID' => $existing['ID'] ), array( '%d' ) );

		}
	}

	return false;
}

#} remove Alias from an obj.
function zeroBS_removeObjAliasByID( $objType = ZBS_TYPE_CONTACT, $objID = -1, $aliasID = -1 ) {

	if ( ! empty( $objID ) && ! empty( $aliasID ) ) {

		// check there/find ID
		$existing = zeroBS_getAliasByID( $objType, $objID, $aliasID );

		if ( is_array( $existing ) ) {

			// just brutal :)

			global $wpdb, $ZBSCRM_t;

			return $wpdb->delete( $ZBSCRM_t['aka'], array( 'ID' => $existing['ID'] ), array( '%d' ) );

		}
	}

	return false;
}

/*
======================================================
	/ Alias / AKA helpers
====================================================== */

/*
======================================================
	Value Calculator / helpers
====================================================== */

/**
 * Calculates the total value associated with a contact or company entity.
 *
 * This function sums the total of invoices and transactions associated with a given entity.
 * It also accounts for the 'jpcrm_total_value_fields' settings to determine whether to include
 * invoices and transactions in the total value. Additionally, if both invoices and transactions
 * are included, and the 'transactions_paid_total' is set and greater than 0, it subtracts this
 * value from the total.
 *
 * @param array $entity The entity array containing 'invoices_total', 'transactions_total', and optionally 'transactions_paid_total'.
 *
 * @return float The calculated total value. It includes the invoices and transactions totals based on the settings,
 *               and adjusts for 'transactions_paid_total' if applicable.
 */
function jpcrm_get_total_value_from_contact_or_company( $entity ) {
	global $zbs;
	$total_value        = 0.0;
	$invoices_total     = isset( $entity['invoices_total'] ) ? $entity['invoices_total'] : 0.0;
	$transactions_total = isset( $entity['transactions_total'] ) ? $entity['transactions_total'] : 0.0;
	// For compatibility reasons we include all values if the jpcrm_total_value_fields setting is inexistent.
	$settings                     = $zbs->settings->getAll();
	$include_invoices_in_total    = true;
	$include_transations_in_total = true;
	if ( isset( $settings['jpcrm_total_value_fields'] ) ) {
		$include_invoices_in_total    = isset( $settings['jpcrm_total_value_fields']['invoices'] ) && $settings['jpcrm_total_value_fields']['invoices'] === 1;
		$include_transations_in_total = isset( $settings['jpcrm_total_value_fields']['transactions'] ) && $settings['jpcrm_total_value_fields']['transactions'] === 1;
	}
	$total_value  = 0;
	$total_value += $include_invoices_in_total ? $invoices_total : 0;
	$total_value += $include_transations_in_total ? $transactions_total : 0;
	if ( $include_invoices_in_total && $include_transations_in_total && isset( $entity['transactions_paid_total'] ) && $entity['transactions_paid_total'] > 0 ) {
		$total_value -= $entity['transactions_paid_total'];
	}

	return $total_value;
}

	// evolved for dal3.0
	// left in place + translated, but FAR better to just use 'withValues' => true on a getContact call directly.
	// THIS STAYS THE SAME FOR DB2 until trans+invoices MOVED OVER #DB2ROUND2
	#} Main function to return a customers "total value"
	#} At MVP that means Invoices + Transactions
function zeroBS_customerTotalValue( $contactID = '', $customerInvoices = array(), $customerTransactions = array() ) {

	global $zbs;

	$contactWithVals = $zbs->DAL->contacts->getContact(
		$contactID,
		array(
			'withCustomFields' => false,
			'withValues'       => true,
		)
	);

	// throwaway obj apart from totals
	// later could optimise, but better to optimise 1 level up and not even use this func
	if ( isset( $contactWithVals['total_value'] ) ) {
		return $contactWithVals['total_value'];
	}
	return 0;
}

/**
 * Adds up value of quotes for a customer...
 *
 * Evolved for dal3.0
 * Left in place + translated, but FAR better to just use 'withValues' => true on a getContact call directly.
 *
 * @param string $contact_id Contact ID.
 * @param array  $customer_quotes Customer quotes.
 * @return int Total.
 */
function zeroBS_customerQuotesValue( $contact_id = '', $customer_quotes = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable

	global $zbs;

	$contact_with_vals = $zbs->DAL->contacts->getContact( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		$contact_id,
		array(
			'withCustomFields' => false,
			'withValues'       => true,
		)
	);

	// throwaway obj apart from totals
	// later could optimise, but better to optimise 1 level up and not even use this func
	if ( isset( $contact_with_vals['quotes_total'] ) ) {
		return $contact_with_vals['quotes_total'];
	}

	return 0;
}

/**
 * Adds up value of invoices for a customer...
 *
 * Evolved for dal3.0
 * Left in place + translated, but FAR better to just use 'withValues' => true on a getContact call directly.
 *
 * @param string $contact_id Contact ID.
 * @param array  $customer_invoices Customer invoices.
 * @return int Total.
 */
function zeroBS_customerInvoicesValue( $contact_id = '', $customer_invoices = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable

	global $zbs;

	$contact_with_vals = $zbs->DAL->contacts->getContact( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		$contact_id,
		array(
			'withCustomFields' => false,
			'withValues'       => true,
		)
	);

	// throwaway obj apart from totals
	// later could optimise, but better to optimise 1 level up and not even use this func
	if ( isset( $contact_with_vals['invoices_total'] ) ) {
		return $contact_with_vals['invoices_total'];
	}

	return 0;
}

/**
 * Adds up value of transactions for a customer...
 *
 * Evolved for dal3.0
 * Left in place + translated, but FAR better to just use 'withValues' => true on a getContact call directly.
 * THIS STAYS THE SAME FOR DB2 until trans MOVED OVER #DB2ROUND2
 *
 * @param string $contact_id Contact ID.
 * @param array  $customer_transactions Customer transactions.
 * @return int Total.
 */
function zeroBS_customerTransactionsValue( $contact_id = '', $customer_transactions = array() ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable

	global $zbs;

	$contact_with_vals = $zbs->DAL->contacts->getContact( // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
		$contact_id,
		array(
			'withCustomFields' => false,
			'withValues'       => true,
		)
	);

	// throwaway obj apart from totals
	// later could optimise, but better to optimise 1 level up and not even use this func
	if ( isset( $contact_with_vals['transactions_total'] ) ) {
		return $contact_with_vals['transactions_total'];
	}

	return 0;
}

/*
======================================================
	/ Value Calculator / helpers
====================================================== */

// ===============================================================================
// ========  Security Logs (used for Quote + Trans hashlink access) ==============

	// this is fired on all req (expects a "fini" followup fire of next func to mark "success")
	// (defaults to failed req.)
function zeroBSCRM_security_logRequest( $reqType = 'unknown', $reqHash = '', $reqID = -1 ) {

	// don't log requests for admins, who by nature, can see all
	// needs to match zeroBSCRM_security_finiRequest precheck
	if ( zeroBSCRM_isZBSAdminOrAdmin() ) {
		return false;
	}

	global $wpdb, $ZBSCRM_t;

	// if user logged in, also log id
	$userID       = -1;
	$current_user = wp_get_current_user();
	if ( isset( $current_user->ID ) ) {
		$userID = (int) $current_user->ID;
	}
	$userIP = zeroBSCRM_getRealIpAddr();

	// validate these a bit
	$validTypes = array( 'quoteeasy', 'inveasy' );
	if ( ! in_array( $reqType, $validTypes ) ) {
		$reqType = 'na';
	}
	$reqHash = sanitize_text_field( $reqHash );
	if ( strlen( $reqHash ) > 128 ) {
		$reqHash = '';
	}
	$reqID = (int) $reqID;

	if ( $wpdb->insert(
		$ZBSCRM_t['security_log'],
		array(

			// 'zbs_site' => zeroBSCRM_installSite(),
			// 'zbs_team' => zeroBSCRM_installTeam(),
			'zbs_owner'         => -1, // zeroBSCRM_currentUserID(),

			'zbssl_reqtype'     => $reqType,
			'zbssl_ip'          => $userIP,
			'zbssl_reqhash'     => $reqHash,

			'zbssl_reqid'       => $reqID,
			'zbssl_loggedin_id' => $userID,
			'zbssl_reqstatus'   => -1, // guilty until proven...
			'zbssl_reqtime'     => time(),
		),
		array(
			'%d',

			'%s',
			'%s',
			'%s',

			'%d',
			'%d',
			'%d',
			'%d',
		)
	) ) {

		// success
		return $wpdb->insert_id;

	}

	return false;
}

	// after security validated,
function zeroBSCRM_security_finiRequest( $requestID = -1 ) {

	// don't log requests for admins, who by nature, can see all
	// needs to match zeroBSCRM_security_logRequest precheck
	if ( zeroBSCRM_isZBSAdminOrAdmin() ) {
		return false;
	}

	// basic check
	$requestID = (int) $requestID;

	if ( $requestID > 0 ) {

		global $wpdb, $ZBSCRM_t;

		// for now just brutal update, not even comparing IP
		if ( $wpdb->update(
			$ZBSCRM_t['security_log'],
			array(
				'zbssl_reqstatus' => 1,
			),
			array( // where
				'ID' => $requestID,
			),
			array(
				'%d',
			),
			array(
				'%d',
			)
		) !== false ) {

			// return id
			return $requestID;

		}
	}

	return false;
}

	// checks if blocked
function zeroBSCRM_security_blockRequest( $reqType = 'unknown' ) {

	// don't log requests for admins, who by nature, can see all
	// needs to match zeroBSCRM_security_logRequest etc. above
	if ( zeroBSCRM_isZBSAdminOrAdmin() ) {
		return false;
	}

	global $zbs, $wpdb, $ZBSCRM_t;

	// see if more than X (5?) failed request accessed by this ip within last Y (48h?)
	$userIP      = zeroBSCRM_getRealIpAddr();
	$sinceTime   = time() - 172800; // 48h = 172800
	$maxFails    = 5;
	$query       = $wpdb->prepare( 'SELECT COUNT(ID) FROM ' . $ZBSCRM_t['security_log'] . ' WHERE zbssl_ip = %s AND zbssl_reqstatus <> %d AND zbssl_reqtime > %d', array( $userIP, 1, $sinceTime ) );
	$countFailed = (int) $wpdb->get_var( $query );

	// less than ..
	if ( $countFailed < $maxFails ) {
		return false;
	}

	return true;
}

	// removes all security logs older than setting (72h at addition)
	// this is run DAILY by a cron job in ZeroBSCRM.CRON.php
function zeroBSCRM_clearSecurityLogs() {

	global $zbs, $wpdb, $ZBSCRM_t;

	// older than
	$deleteOlderThanTime = time() - 259200; // 72h = 259200

	// delete
	$wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $ZBSCRM_t['security_log'] . ' WHERE zbssl_reqtime < %d', $deleteOlderThanTime ) );
}

function zeroBSCRM_hashes_GetHashForObj( $objID = -1, $objTypeID = -1 ) {

	if ( $objID > 0 && $objTypeID > 0 ) {

		global $zbs;
		$hash = $zbs->DAL->meta( $objTypeID, $objID, 'zbshash', '' );

		// Return with PREFIX (makes it interpretable later on as this is shared between invID + invHash (for example) at endpoint /invoices/*hashorid)
		if ( ! empty( $hash ) ) {
			return 'zh-' . $hash;
		}
	}

	return false;
}

// NOTE: This is now GENERIC, for quotes/invs whatever has meta :) (DAL3+ pass objTypeID)
// function is this a hash of an INVOICE. Could be refined when DB2.0
// function for checking if a hash is valid
// ... THIS WAS refactored for v3.0, now uses hash cols on objs :)
function zeroBSCRM_hashes_GetObjFromHash( $hash = '', $pay = -1, $objTypeID = -1 ) {

	// def
	$ret = array(
		'success' => false,
		'data'    => array(),
	);

	// SANITIZE
	$hash = sanitize_text_field( $hash ); // sanitize it here

	// if prefix still present, chunk off
	if ( str_starts_with( $hash, 'zh-' ) ) {
		$hash = substr( $hash, 3 );
	}

	// get if poss
	if ( ! empty( $hash ) && $objTypeID > 0 ) {

		global $zbs;

		switch ( $objTypeID ) {

			case ZBS_TYPE_INVOICE:
				// retrieve, if any
				$invoice = $zbs->DAL->invoices->getInvoice(
					-1,
					array(
						'hash'         => $hash,
						'withAssigned' => true,
					)
				);

				// got inv?
				if ( is_array( $invoice ) && isset( $invoice['id'] ) ) {

					$contactID = -1;
					// return the customer information that the invoice will need (i.e. Stripe customerID) same function will be used
					// in invoice checkout process (invoice pro) when being paid for using a HASH URL.

					if ( $pay > 0 ) {

						// paying so need the customerID from settings otherwise just viewing so dont need to expose data
						// WH: I've added this for future ease:
						if ( is_array( $invoice ) && isset( $invoice['contact'] ) && is_array( $invoice['contact'] ) && count( $invoice['contact'] ) > 0 ) {
							$contactID = $invoice['contact'][0]['id'];
						}
						// $companyID = -1;  if (is_array($invoice) && isset($invoice['company']) && is_array($invoice['company']) && count($invoice['company']) > 0) $companyID = $invoice['company'][0]['id'];

					}
					$ret['success'] = true;
					$ret['data']    = array(
						'ID'  => $invoice['id'],
						'cID' => $contactID,
					);

				}

				break;
			case ZBS_TYPE_QUOTE:
				// retrieve, if any
				$quote = $zbs->DAL->quotes->getQuote(
					-1,
					array(
						'hash'         => $hash,
						'withAssigned' => true,
					)
				);

				// got quote?
				if ( is_array( $quote ) && isset( $quote['id'] ) ) {

					$ret['success'] = true;
					$ret['data']    = array(
						'ID'  => $quote['id'],
						'cID' => -1, // not req for quotes?
					);

				}

				break;

		} // / switch

	} // / if hash + objtypeid

	return $ret;
}

// ======== / Security Logs (used for Quote + Trans hashlink access) =============
// ===============================================================================

// ===============================================================================
// =======================  Tax Table Helpers ====================================

// takes a subtotal and a (potential csv) of ID's of taxtable lines
// returns a £0 net value of the tax to be applied
function zeroBSCRM_taxRates_getTaxValue( $subtotal = 0.0, $taxRateIDCSV = '' ) {

	$tax = 0.0;

	// retrieve tax rate(s)
	if ( ! empty( $taxRateIDCSV ) ) {

		$taxRateTable = zeroBSCRM_taxRates_getTaxTableArr( true );

		// get (multiple) id's
		$taxRatesToApply = array();
		if ( strpos( $taxRateIDCSV, ',' ) ) {

			$taxRateIDs = explode( ',', $taxRateIDCSV );
			if ( ! is_array( $taxRateIDs ) ) {
				$taxRatesToApply = array();
			} else {
				$taxRatesToApply = $taxRateIDs;
			}
		} else {
			$taxRatesToApply[] = (int) $taxRateIDCSV;
		}

		if ( is_array( $taxRatesToApply ) ) {
			foreach ( $taxRatesToApply as $taxRateID ) {

				$rateID = (int) $taxRateID;
				if ( isset( $taxRateTable[ $rateID ] ) ) {

					// get rate
					$rate = 0.0;
					if ( isset( $taxRateTable[ $rateID ]['rate'] ) ) {
						$rate = (float) $taxRateTable[ $rateID ]['rate'];
					}

					// calc + add
					$tax += round( $subtotal * ( $rate / 100 ), 2 );

				} // else not set?

			} // / foreach tax rate to apply
		}

		return $tax;

	}

	return 0.0;
}

// gets single tax rate by id
function zeroBSCRM_taxRates_getTaxRate( $taxRateID = '' ) {

	// retrieve tax rate(s)
	if ( ! empty( $taxRateID ) ) {

		$taxRateID = (int) $taxRateID;

		global $ZBSCRM_t, $wpdb;

		// for v3.0, brutal direct sql
		$query = 'SELECT * FROM ' . $ZBSCRM_t['tax'] . ' WHERE ID = %d ORDER BY ID ASC';
		try {

			#} Prep & run query
			$queryObj     = $wpdb->prepare( $query, array( $taxRateID ) );
			$potentialRes = $wpdb->get_row( $queryObj, OBJECT );

		} catch ( Exception $e ) {

		}

		#} Interpret results (Result Set - multi-row)
		if ( isset( $potentialRes ) && isset( $potentialRes->ID ) ) {

			return zeroBSCRM_taxRates_tidy_taxRate( $potentialRes );

		}
	}

	return array();
}

// retrieve tax table as array
function zeroBSCRM_taxRates_getTaxTableArr( $indexByID = false ) {

	/*
	// demo/dummy data
	return array(

			//these will be populated based on the array
			1  => array(
				'id'    => 1,
				'tax'   => 20,
				'name'  => 'VAT'
			),

			2 => array(
				'id'    => 2,
				'tax'   => 19,
				'name'  => 'GST'
			)

		); */

	global $ZBSCRM_t, $wpdb;

	// for v3.0, brutal direct sql
	$query             = 'SELECT * FROM ' . $ZBSCRM_t['tax'] . ' ORDER BY ID ASC';
	$potentialTaxRates = $wpdb->get_results( $query, OBJECT );

	#} Interpret results (Result Set - multi-row)
	if ( isset( $potentialTaxRates ) && is_array( $potentialTaxRates ) && count( $potentialTaxRates ) > 0 ) {

		$res = array();

		#} Has results, tidy + return
		foreach ( $potentialTaxRates as $resDataLine ) {

			if ( $indexByID ) {

				$lineID         = (int) $resDataLine->ID;
				$res[ $lineID ] = zeroBSCRM_taxRates_tidy_taxRate( $resDataLine );

			} else {

				$res[] = zeroBSCRM_taxRates_tidy_taxRate( $resDataLine );

			}
		}

		return $res;
	}

	return array();
}

/**
 * Generate a lookup key for tax rate duplicate detection
 *
 * @param string $name Tax rate name.
 * @param float  $rate Tax rate percentage.
 * @return string Normalized lookup key.
 */
function jpcrm_tax_rates_generate_lookup_key( $name, $rate ) {
	return strtolower( trim( $name ) ) . '_' . number_format( (float) $rate, 4, '.', '' );
}

	/**
	 * adds or updates a taxrate object
	 *
	 * @param array $args Associative array of arguments
	 *              id (not req.), owner (not req.) data -> key/val
	 *
	 * @return int line ID
	 */
function zeroBSCRM_taxRates_addUpdateTaxRate( $args = array() ) {

	global $ZBSCRM_t, $wpdb;

	#} ============ LOAD ARGS =============
	$defaultArgs = array(

		'id'    => -1,
		'owner' => -1,

		// fields (directly)
		'data'  => array(

			'name'    => '',
			'rate'    => 0.0,
			'created' => -1, // override date? :(

		),

	);
	foreach ( $defaultArgs as $argK => $argV ) {
		$$argK = $argV;
		if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
			if ( is_array( $args[ $argK ] ) ) {
				$newData = $$argK;
				if ( ! is_array( $newData ) ) {
					$newData = array();
				}
				foreach ( $args[ $argK ] as $subK => $subV ) {
					$newData[ $subK ] = $subV;
				}
				$$argK = $newData;
			} else {
				$$argK = $args[ $argK ];
			}
		}
	}
	#} =========== / LOAD ARGS ============

	#} ========== CHECK FIELDS ============

	$id = (int) $id;

	// if owner = -1, add current
	if ( $owner === -1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
		$owner = zeroBSCRM_user();
	}

	// check if exists already (where no id passed)
	if ( $id < 1 && ! empty( $data['name'] ) && isset( $data['rate'] ) ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable

		// Get all tax rates from cache or database
		$all_tax_rates = wp_cache_get( 'all_tax_rates', 'zbscrm_tax_rates' );

		if ( false === $all_tax_rates ) {
			// Cache miss - load all tax rates
			// phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			$table_name = $ZBSCRM_t['tax'];
			$sql        = "SELECT ID, zbsc_tax_name, zbsc_rate
					FROM `{$table_name}`
					ORDER BY ID DESC";

			/* phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- Safe: table name is internal mapping, not user input */
			$results = $wpdb->get_results( $sql, ARRAY_A );

			// Build lookup array for fast searching
			$all_tax_rates = array();
			if ( $results ) {
				foreach ( $results as $rate ) {
					$lookup_key                   = jpcrm_tax_rates_generate_lookup_key( $rate['zbsc_tax_name'], $rate['zbsc_rate'] );
					$all_tax_rates[ $lookup_key ] = (int) $rate['ID'];
				}
			}

			// Cache for shorter time due to frequent updates
			wp_cache_set( 'all_tax_rates', $all_tax_rates, 'zbscrm_tax_rates', 5 * MINUTE_IN_SECONDS );
		}

		// Check if this combination exists
		$lookup_key       = jpcrm_tax_rates_generate_lookup_key( $data['name'], $data['rate'] ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable
		$existing_rate_id = isset( $all_tax_rates[ $lookup_key ] ) ? $all_tax_rates[ $lookup_key ] : 0;

		if ( $existing_rate_id > 0 ) {
			// Return error for duplicate tax rate
			return new WP_Error( 'duplicate_tax_rate', __( 'A tax rate with this name and rate already exists.', 'zero-bs-crm' ) );
		}
	}

	#} ========= / CHECK FIELDS ===========

	$dataArr = array(

		// ownership
		// no need to update these (as of yet) - can't move teams etc.
		// 'zbs_site' => zeroBSCRM_installSite(),
		// 'zbs_team' => zeroBSCRM_installTeam(),
		'zbs_owner'        => $owner,

		// fields
		'zbsc_tax_name'    => $data['name'],
		'zbsc_rate'        => $data['rate'],
		'zbsc_lastupdated' => time(),
	);

	$dataTypes = array( // field data types
		'%d',

		'%s',
		'%s',
		'%d',
	);

	if ( isset( $data['created'] ) && ! empty( $data['created'] ) && $data['created'] !== -1 ) {
		$dataArr['zbsc_created'] = $data['created'];
		$dataTypes[]             = '%d';
	}

	if ( isset( $id ) && ! empty( $id ) && $id > 0 ) {

			#} Check if obj exists (here) - for now just brutal update (will error when doesn't exist)

			#} Attempt update
		if ( $wpdb->update(
			$ZBSCRM_t['tax'],
			$dataArr,
			array( // where
				'ID' => $id,
			),
			$dataTypes,
			array( // where data types
				'%d',
			)
		) !== false ) {

				// Successfully updated - clear cache and return id
				wp_cache_delete( 'all_tax_rates', 'zbscrm_tax_rates' );
				return $id;

		} else {

			// FAILED update
			return false;

		}
	} else {

		// set created if not set
		if ( ! isset( $dataArr['zbsc_created'] ) ) {
			$dataArr['zbsc_created'] = time();
			$dataTypes[]             = '%d';
		}

		// add team etc
		$dataArr['zbs_site'] = zeroBSCRM_site();
		$dataTypes[]         = '%d';
		$dataArr['zbs_team'] = zeroBSCRM_team();
		$dataTypes[]         = '%d';

		#} No ID - must be an INSERT
		if ( $wpdb->insert(
			$ZBSCRM_t['tax'],
			$dataArr,
			$dataTypes
		) > 0 ) {

				#} Successfully inserted, lets return new ID
				$newID = $wpdb->insert_id;

				// Clear cache after successful insert
				wp_cache_delete( 'all_tax_rates', 'zbscrm_tax_rates' );

				return $newID;

		} else {

			#} Failed to Insert
			return false;

		}
	}

		return false;
}

	/**
	 * deletes a Taxrate object
	 *
	 * @param array $args Associative array of arguments
	 *              id
	 *
	 * @return int success;
	 */
function zeroBSCRM_taxRates_deleteTaxRate( $args = array() ) {

	global $ZBSCRM_t, $wpdb;

	#} ============ LOAD ARGS =============
	$defaultArgs = array(

		'id' => -1,

	);
	foreach ( $defaultArgs as $argK => $argV ) {
		$$argK = $argV;
		if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
			if ( is_array( $args[ $argK ] ) ) {
				$newData = $$argK;
				if ( ! is_array( $newData ) ) {
					$newData = array();
				}
				foreach ( $args[ $argK ] as $subK => $subV ) {
					$newData[ $subK ] = $subV;
				}
				$$argK = $newData;
			} else {
				$$argK = $args[ $argK ];
			}
		}
	}
	#} =========== / LOAD ARGS ============

	#} Check ID & Delete :)
	$id = (int) $id;
	if ( ! empty( $id ) && $id > 0 ) {
		return zeroBSCRM_db2_deleteGeneric( $id, 'tax' );
	}

	return false;
}

	/**
	 * tidy's the object from wp db into clean array
	 *
	 * @param array $obj (DB obj)
	 *
	 * @return array (clean obj)
	 */
function zeroBSCRM_taxRates_tidy_taxRate( $obj = false ) {

	$res = false;

	if ( isset( $obj->ID ) ) {
		$res          = array();
		$res['id']    = $obj->ID;
		$res['owner'] = $obj->zbs_owner;

		$res['name'] = $obj->zbsc_tax_name;
		$res['rate'] = $obj->zbsc_rate;

		// to maintain old obj more easily, here we refine created into datestamp
		$res['created']    = zeroBSCRM_locale_utsToDatetime( $obj->zbsc_created );
		$res['createduts'] = $obj->zbsc_created; // this is the UTS (int14)

		$res['lastupdated'] = $obj->zbsc_lastupdated;

	}

		return $res;
}

// ======================= / Tax Table Helpers ===================================
// ===============================================================================

// ===============================================================================
// =======================  File Upload Related Funcs ============================

	// retrieve all files for a (customer)whatever
function zeroBSCRM_files_getFiles( $fileType = '', $objID = -1 ) {

	global $zbs;

	$filesArrayKey = zeroBSCRM_files_key( $fileType );

	if ( ! empty( $filesArrayKey ) && $objID > 0 ) {

		// DAL2+
		// bit gross hard-typed, could be genericified as all is using is >DAL()->getMeta
		switch ( $fileType ) {

			case 'customer':
			case 'contact':
				return $zbs->DAL->contacts->getContactMeta( $objID, 'files' );
				break;

			case 'quotes':
			case 'quote':
				return $zbs->DAL->quotes->getQuoteMeta( $objID, 'files' );
				break;

			case 'invoices':
			case 'invoice':
				return $zbs->DAL->invoices->getInvoiceMeta( $objID, 'files' );
				break;

			case 'companies':
			case 'company':
				return $zbs->DAL->companies->getCompanyMeta( $objID, 'files' );
				break;

			// no default

		}
	}

	return array();
}

// updates files array for a (whatever)
function zeroBSCRM_files_updateFiles( $fileType = '', $objID = -1, $filesArray = -1 ) {

	global $zbs;

	$filesArrayKey = zeroBSCRM_files_key( $fileType );

	if ( ! empty( $filesArrayKey ) && $objID > 0 ) {

		// DAL2+
		// bit gross hard-typed, could be genericified as all is using is >DAL()->getMeta
		switch ( $fileType ) {

			case 'customer':
			case 'contact':
				$zbs->DAL->updateMeta( ZBS_TYPE_CONTACT, $objID, 'files', $filesArray );
				break;

			case 'quotes':
			case 'quote':
				$zbs->DAL->updateMeta( ZBS_TYPE_QUOTE, $objID, 'files', $filesArray );
				break;

			case 'invoices':
			case 'invoice':
				$zbs->DAL->updateMeta( ZBS_TYPE_INVOICE, $objID, 'files', $filesArray );
				break;

			case 'companies':
			case 'company':
				$zbs->DAL->updateMeta( ZBS_TYPE_COMPANY, $objID, 'files', $filesArray );
				break;

			// no default

		}

		return $filesArray;

	}

	return false;
}

// gets meta key for file type arr
function zeroBSCRM_files_key( $fileType = '' ) {

	switch ( $fileType ) {

		case 'customer':
		case 'contact':
			return 'zbs_customer_files';

			break;
		case 'quotes':
		case 'quote':
			return 'zbs_customer_quotes';

			break;
		case 'invoices':
		case 'invoice':
			return 'zbs_customer_invoices';

			break;

		case 'companies':
		case 'company':
			return 'zbs_company_files';

			break;

	}

	return '';
}

// ======================= / File Upload Related Funcs ===========================
// ===============================================================================

// ===============================================================================
// ===========   TEMPHASH (remains same for DAL2->3) =============================

/**
 * checks validity of a temporary hash object
 *
 * @return int success;
 */
function zeroBSCRM_checkValidTempHash( $objid = -1, $type = '', $hash = '' ) {

	// get a valid hash
	$hash = zeroBSCRM_getTempHash( -1, $type, $hash, 1 );

	// check id
	if ( isset( $hash ) && is_array( $hash ) && isset( $hash['objid'] ) ) {
		if ( $objid == $hash['objid'] ) {
			return true;
		}
	}

	return false;
}

/**
 * retrieves a temporary hash object
 *
 * @return int success;
 */
function zeroBSCRM_getTempHash( $id = -1, $type = '', $hash = '', $status = -99 ) {

	$id = (int) $id;
	if ( ! empty( $id ) && $id > 0 ) {

		global $ZBSCRM_t, $wpdb;

		$additionalWHERE = ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$queryVars       = array(); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		$queryVars[] = $id; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		$whereStr    = 'ID = %d'; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

		if ( ! empty( $type ) ) {

			$queryVars[]     = $type;
			$additionalWHERE = 'AND zbstemphash_objtype = %s ';

		} // else will be from ANY type

		if ( $status != -99 ) {

			$queryVars[]     = $status;
			$additionalWHERE = 'AND zbstemphash_status = %d ';

		}

		/* -- prep started, see: #OWNERSHIP */

		$sql = 'SELECT * FROM ' . $ZBSCRM_t['temphash'] . ' WHERE ' . $whereStr . ' ' . $additionalWHERE . 'ORDER BY ID ASC LIMIT 0,1';

		$potentialReponse = $wpdb->get_row( $wpdb->prepare( $sql, $queryVars ), OBJECT );

		if ( isset( $potentialReponse ) && isset( $potentialReponse->ID ) ) {

			#} Retrieved :) fill + return

			// tidy
			$res = zeroBS_tidy_temphash( $potentialReponse );

			return $res;
		}
	}

	return false;
}

/**
 * adds or updates a temporary hash object
 *
 * @return int success;
 */
function zeroBSCRM_addUpdateTempHash( $id = -1, $objstatus = -1, $objtype = '', $objid = -1, $objhash = '', $returnHashArr = false ) {

	// globals
	global $ZBSCRM_t, $wpdb;

	// got id?
	$id = (int) $id;
	if ( ! empty( $id ) && $id > 0 ) {

		// check exists?

		// for now just brutal update.
		if ( $wpdb->update(
			$ZBSCRM_t['temphash'],
			array(
				// 'zbs_site' => zeroBSCRM_installSite(),
				// 'zbs_team' => zeroBSCRM_installTeam(),
				// 'zbs_owner' => zeroBSCRM_currentUserID(),

				'zbstemphash_status'      => (int) $objstatus,
				'zbstemphash_objtype'     => $objtype,
				'zbstemphash_objid'       => (int) $objid,
				'zbstemphash_objhash'     => $objhash,

				// 'zbsmaillink_created' => time(),
				'zbstemphash_lastupdated' => time(),
			),
			array( // where
				'ID' => $id,
			),
			array(
				'%d',
				'%s',
				'%d',
				'%s',
				'%d',
			),
			array(
				'%d',
			)
		) !== false ) {

					// if "return hash"
			if ( $returnHashArr ) {
				return array(
					'id'   => $id,
					'hash' => $objhash,
				);
			}

					// return id
					return $id;

		}
	} else {

		// insert

		// create hash if not created :)
		if ( empty( $objhash ) ) {
			$objhash = zeroBSCRM_GenerateTempHash();
		}

		// go
		if ( $wpdb->insert(
			$ZBSCRM_t['temphash'],
			array(
				// 'zbs_site' => zeroBSCRM_installSite(),
				// 'zbs_team' => zeroBSCRM_installTeam(),
				// 'zbs_owner' => zeroBSCRM_currentUserID(),

				'zbstemphash_status'      => (int) $objstatus,
				'zbstemphash_objtype'     => $objtype,
				'zbstemphash_objid'       => (int) $objid,
				'zbstemphash_objhash'     => $objhash,

				'zbstemphash_created'     => time(),
				'zbstemphash_lastupdated' => time(),
			),
			array(
				// '%d',  // site
				// '%d',  // team
				// '%d',  // owner

				'%d',
				'%s',
				'%d',
				'%s',
				'%d',
				'%d',
			)
		) > 0 ) {

				// inserted, let's move on
				$newID = $wpdb->insert_id;

				// if "return hash"
			if ( $returnHashArr ) {
				return array(
					'id'   => $id,
					'hash' => $objhash,
				);
			}

				return $newID;
		}
	}

	return false;
}
/**
 * deletes a temporary hash object
 *
 * @param array $args Associative array of arguments
 *              id
 *
 * @return int success;
 */
function zeroBSCRM_deleteTempHash( $args = array() ) {

	// Load Args
	$defaultArgs = array(

		'id' => -1,

	);
	foreach ( $defaultArgs as $argK => $argV ) {
		$$argK = $argV;
		if ( is_array( $args ) && isset( $args[ $argK ] ) ) {
			$$argK = $args[ $argK ];
		}
	}

	// globals
	global $ZBSCRM_t, $wpdb;

	$id = (int) $id;
	if ( ! empty( $id ) && $id > 0 ) {
		return zeroBSCRM_db2_deleteGeneric( $id, 'temphash' );
	}

	return false;
}

function zeroBS_tidy_temphash( $obj = false ) {

	$res = false;

	if ( isset( $obj->ID ) ) {
		$res                = array();
		$res['id']          = $obj->ID;
		$res['created']     = $obj->zbstemphash_created;
		$res['lastupdated'] = $obj->zbstemphash_lastupdated;

		$res['status']  = $obj->zbstemphash_status;
		$res['objtype'] = $obj->zbstemphash_objtype;
		$res['objid']   = $obj->zbstemphash_objid;
		$res['objhash'] = $obj->zbstemphash_objhash;
	}

	return $res;
}
// generates generic HASH (used for links etc.)
function zeroBSCRM_GenerateTempHash( $str = -1, $length = 20 ) {

	#} Brutal hash generator, for now
	if ( ! empty( $str ) ) {

		#} Semi-nonsense, not "secure"
		// $newMD5 = md5($postID.time().'fj30948hjfaosindf');

		$newMD5 = wp_generate_password( 64, false );

		return substr( $newMD5, 0, $length - 1 );

	}

	return '';
}

// =========== / TEMPHASH   ======================================================
// ===============================================================================

/*
======================================================
	General/WP helpers
====================================================== */

	// in effect this is: get owner (WP USER)'s email
	// currently only used on Automations extension
	// use jpcrm_get_obj_owner_wordpress_email() in future...
function zeroBS_getAssigneeEmail( $cID = -1 ) {
	return jpcrm_get_obj_owner_wordpress_email( $cID, ZBS_TYPE_CONTACT );
}

	// returns an obj owner's email as set against their WordPress account
function jpcrm_get_obj_owner_wordpress_email( $objID, $objTypeID ) {

	global $zbs;
	if ( $objID > 0 && $zbs->DAL->isValidObjTypeID( $objTypeID ) ) {

		$ownerID = zeroBS_getOwner( $objID, false, $objTypeID );

		if ( $ownerID > 0 ) {
			return get_the_author_meta( 'user_email', $ownerID );
		}
	}

	return false;
}

	// in effect this is: get owner (WP USER)'s mobile
	// use zeroBS_getObjOwnerWPMobile in future... (renamed, bad naming)
function zeroBS_getAssigneeMobile( $wpUID = -1 ) {
	return zeroBS_getObjOwnerWPMobile( $wpUID );
}

	// returns owner of obj's mobile (from WP USER)
function zeroBS_getObjOwnerWPMobile( $objID = -1, $objType = 'zerobs_customer' ) {

	if ( $objID > 0 ) {

		$ownerID = zeroBS_getOwner( $objID, false, $objType );

		if ( $ownerID > 0 ) {
			return zeroBS_getWPUsersMobile( $ownerID );
		}
	}

	return false;
}

	// returns an obj owner's mobile number as per their wp account
function zeroBS_getWPUsersMobile( $uID = -1 ) {
	if ( $uID !== -1 ) {
		if ( ! empty( $uID ) ) {
			$mobile_number = get_user_meta( 'mobile_number', $uID );
			$mobile_number = apply_filters( 'zbs_filter_mobile', $mobile_number );
			return $mobile_number;
		}
		return false;
	}
}

	/*
	* Gets formatted display name for user (tries to retrieve fname lname)
	*/
function jpcrm_wp_user_name( $wordpress_user_id = -1 ) {

	$user_info = get_userdata( $wordpress_user_id );
	if ( ! $user_info ) {
		return false;
	}

	// start with display name
	$user_name = $user_info->display_name;

	// else try and use fname lname
	if ( empty( $user_name ) ) {
		$user_name = $user_info->user_firstname;
		if ( ! empty( $user_info->user_lastname ) ) {

			if ( ! empty( $user_name ) ) {
				$user_name .= ' ';
			}

			$user_name .= $user_info->user_lastname;

		}
	}

	// else fall back to nice name
	if ( empty( $user_name ) ) {
		$user_name = $user_info->user_nicename;
	}

	// else email?
	if ( empty( $user_name ) ) {
		$user_name = $user_info->user_email;
	}

	return $user_name;
}

	// ======= Statuses wrappers - bit antiquated  now...

		// outdated wrapper
function zeroBS_getTransactionsStatuses() {
	return zeroBSCRM_getTransactionsStatuses();
}

function zeroBSCRM_getCustomerStatuses( $asArray = false ) {

	global $zbs;

	$setting = $zbs->DAL->setting( 'customisedfields', false );

	$zbsStatusStr = '';

	#} stored here: $settings['customisedfields']
	if ( is_array( $setting ) && isset( $setting['customers']['status'] ) && is_array( $setting['customers']['status'] ) ) {
		$zbsStatusStr = $setting['customers']['status'][1];
	}
	if ( empty( $zbsStatusStr ) ) {
		#} Defaults:
		global $zbsCustomerFields;
		if ( is_array( $zbsCustomerFields ) ) {
			$zbsStatusStr = implode( ',', $zbsCustomerFields['status'][3] );
		}
	}

	if ( $asArray ) {

		if ( str_contains( '#' . $zbsStatusStr, ',' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase

			$arr = explode( ',', $zbsStatusStr );
			$ret = array();
			foreach ( $arr as $x ) {
				$z = trim( $x );
				if ( ! empty( $z ) ) {
					$ret[] = $z;
				}
			}

			return $ret;

		}
	}

	return $zbsStatusStr;
}

/**
 * Retrieve valid transaction statuses
 *
 * @param bool $return_array Return an array instead of a CSV.
 */
function zeroBSCRM_getTransactionsStatuses( $return_array = false ) {
	global $zbs;

	$setting = $zbs->DAL->setting( 'customisedfields', false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

	$zbs_status_str = '';

	if ( is_array( $setting ) && isset( $setting['transactions']['status'] ) && is_array( $setting['transactions']['status'] ) ) {
		$zbs_status_str = $setting['transactions']['status'][1];
	}
	if ( empty( $zbs_status_str ) ) {
		global $zbsTransactionFields; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		if ( is_array( $zbsTransactionFields ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
			$zbs_status_str = implode( ',', $zbsTransactionFields['status'][3] ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
		}
	}

	if ( $return_array ) {

		if ( str_contains( $zbs_status_str, ',' ) ) {
			return explode( ',', $zbs_status_str );
		} else {
			return array();
		}
	}

	return $zbs_status_str;
}

/**
 * Retrieve an array of valid invoice statuses
 */
function zeroBSCRM_getInvoicesStatuses() {
	// for DAL3+ these are hard typed, probably need to sit in the obj:
	return array(
		'Draft',
		'Unpaid',
		'Paid',
		'Overdue',
		'Deleted',
	);
}

function zeroBSCRM_getCompanyStatusesCSV() {

	global $zbs;

	$setting = $zbs->DAL->setting( 'customisedfields', false );

	$zbsStatusStr = '';

	#} stored here: $settings['customisedfields']
	if ( is_array( $setting ) && isset( $setting['companies']['status'] ) && is_array( $setting['companies']['status'] ) ) {
		$zbsStatusStr = $setting['companies']['status'][1];
	}
	if ( empty( $zbsStatusStr ) ) {
		#} Defaults:
		global $zbsCompanyFields;
		if ( is_array( $zbsCompanyFields ) ) {
			$zbsStatusStr = implode( ',', $zbsCompanyFields['status'][3] );
		}
	}

	return $zbsStatusStr;
}

/**
 * Retrieve an array of valid company statuses
 */
function zeroBSCRM_getCompanyStatuses() {
	$statuses_str = zeroBSCRM_getCompanyStatusesCSV();

	if ( str_contains( $statuses_str, ',' ) ) {
		return explode( ',', $statuses_str );
	} else {
		return array();
	}
}

		// ======= / Statuses wrappers - bit antiquated  now...

	// DELETES ALL rows from any table, based on ID
	// no limits! be careful.
function zeroBSCRM_db2_deleteGeneric( $id = -1, $tableKey = '' ) {

	// req
	global $ZBSCRM_t, $wpdb;

	// lazy id check
	$id = (int) $id;
	if ( $id > 0 && ! empty( $tableKey ) && array_key_exists( $tableKey, $ZBSCRM_t ) ) {

		return $wpdb->delete(
			$ZBSCRM_t[ $tableKey ],
			array( // where
				'ID' => $id,
			),
			array(
				'%d',
			)
		);

	}

	return false;
}

	// this has a js equivilent in global.js: zeroBSCRMJS_telURLFromNo
function zeroBSCRM_clickToCallPrefix() {

	$click2CallType = zeroBSCRM_getSetting( 'clicktocalltype' );

	if ( $click2CallType === 1 ) {
		return 'tel:';
	}
	if ( $click2CallType === 2 ) {
		return 'callto:';
	}
	if ( $click2CallType === 3 ) {
		return 'sip:';
	}
}

function zeroBS_getCurrentUserUsername() {

	// https://codex.wordpress.org/Function_Reference/wp_get_current_user

	$current_user = wp_get_current_user();
	if ( ! ( $current_user instanceof WP_User ) ) {
		return;
	}
	return $current_user->user_login;
}

	#} ZBS users page - returns list of WP user IDs, which have a ZBS role and includes name / email, etc
function zeroBSCRM_crm_users_list() {
	// from Permissions
	/*
	remove_role('zerobs_admin');
	remove_role('zerobs_customermgr');
	remove_role('zerobs_quotemgr');
	remove_role('zerobs_invoicemgr');
	remove_role('zerobs_transactionmgr');
	remove_role('zerobs_customer');
	remove_role('zerobs_mailmgr');

	*/
	// NOT zerbs_customer - this is people who have purchased (i.e. WooCommerce folk)
	$role      = array( 'zerobs_customermgr', 'zerobs_admin', 'administrator', 'zerobs_quotemgr', 'zerobs_invoicemgr', 'zerobs_transactionmgr', 'zerobs_mailmgr' );
	$crm_users = get_users(
		array(
			'role__in' => $role,
			'orderby'  => 'ID',
		)
	);

	// this will return what WP holds (and can interpret on the outside.)
	return $crm_users;
}

	// returns a system setting for ignore ownership
	// ... ownership ignored, unless the setting is on + not admin
function zeroBSCRM_DAL2_ignoreOwnership( $objType = 1 ) {

	global $zbs;

	// FOR NOW EVERYONE CAN SEE EVERYTHING
	// Later add - strict ownership? isn't this a platform UPSELL?
	// if ($zbs->settings->get('perusercustomers') && !current_user_can('administrator')) return false;

	return true;
}

function zeroBSCRM_DEPRECATEDMSG( $msg = '' ) {

	echo '<div class="zbs info msg">' . $msg . '</div>';
	error_log( strip_tags( $msg ) );
}

	/**
	 * This takes a passed object type (old or new) and returns the new type.
	 *
	 * @param string|int - an object type in old or new format, e.g.:
	 *   old: 'zerobs_customer'
	 *   new: 1, ZBS_TYPE_CONTACT
	 *
	 * @return int|bool false - the object type ID if it exists, false if not
	 */
function jpcrm_upconvert_obj_type( $obj_type = -1 ) {
	global $zbs;

	if ( $zbs->DAL->isValidObjTypeID( $obj_type ) ) {
		// already a valid new obj?
		return (int) $obj_type;
	} else {
		// upconvert old type into new
		return $zbs->DAL->objTypeID( $obj_type );
	}
}

	/**
	 * Backward compat - `zbsLink` got renamed to `jpcrm_esc_link` in 5.5
	 **/
function zbsLink( $key = '', $id = -1, $type = 'zerobs_customer', $prefixOnly = false, $taxonomy = false ) {

	return jpcrm_esc_link( $key, $id, $type, $prefixOnly, $taxonomy );
}

	/**
	 * Core Link building function
	 * Produces escaped raw URLs for links within wp-admin based CRM areas
	 *
		Examples:
		echo '<a href="'.jpcrm_esc_link('edit',-1,'contact',false,false).'">New Contact</a>';
		echo '<a href="'.jpcrm_esc_link('edit',$id,'contact',false,false).'">Edit Contact</a>';

	 * Notes:
	 * - accepts new (contact,ZBS_TYPE_CONTACT) or old (zerobs_customer) references (but use NEW going forward)
	 * - previously called `zbsLink`
	 **/
function jpcrm_esc_link( $key = '', $id = -1, $type = 'zerobs_customer', $prefixOnly = false, $taxonomy = false ) {

	global $zbs;

	// infer objTypeID (turns contact|zerobs_contact -> ZBS_TYPE_CONTACT)
	$objTypeID = jpcrm_upconvert_obj_type( $type );

	// switch through potentials
	switch ( $key ) {

		case 'list':
			$url = admin_url( 'admin.php?page=' . $zbs->slugs['dash'] );

			// switch based on type.
			switch ( $objTypeID ) {

				case ZBS_TYPE_CONTACT:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['managecontacts'] );
					break;
				case ZBS_TYPE_COMPANY:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['managecompanies'] );
					break;
				case ZBS_TYPE_QUOTE:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['managequotes'] );
					break;
				case ZBS_TYPE_INVOICE:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['manageinvoices'] );
					break;
				case ZBS_TYPE_TRANSACTION:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['managetransactions'] );
					break;
				case ZBS_TYPE_FORM:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['manageformscrm'] );
					break;
				case ZBS_TYPE_TASK:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['manage-tasks'] );
					break;
				case ZBS_TYPE_SEGMENT:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['segments'] );
					break;
				case ZBS_TYPE_QUOTETEMPLATE:
					$url = admin_url( 'admin.php?page=' . $zbs->slugs['quote-templates'] );
					break;

			}

			// rather than return admin.php?page=list, send to dash if not these ^
			return esc_url_raw( $url );

			break;

		case 'view':
			// view page (theoretically returns for all obj types, even tho contact + company only ones using view pages atm)
			if ( $objTypeID > 0 ) {

				if ( $id > 0 ) {

					// view with actual ID
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=view&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' . $id ) );

				} elseif ( $prefixOnly ) {

					// prefix only
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=view&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' ) );

				}
			} // / got objType
			break;

		case 'edit':
			// edit page (returns for all obj types)
			if ( $objTypeID > 0 ) {

				if ( $id > 0 ) {

					// view with actual ID
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=edit&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' . $id ) );

				} elseif ( $prefixOnly ) {

					// prefix only
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=edit&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' ) );

				}
			} // / got objType
			break;
		case 'create':
			// create page (returns for all obj types)
			if ( $objTypeID > 0 ) {

				return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=edit&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) ) );

			} // / got objType

			// mail campaigns specific catch
			if ( $type == 'mailcampaign' || $type == 'mailsequence' ) {
				global $zeroBSCRM_MailCampaignsslugs;
				if ( isset( $zeroBSCRM_MailCampaignsslugs ) ) {
					return esc_url_raw( admin_url( 'admin.php?page=' . $zeroBSCRM_MailCampaignsslugs['editcamp'] ) );
				}
			}

			break;

		case 'delete':
			// delete page
			if ( $objTypeID > 0 ) {

				if ( $id > 0 ) {

					// view with actual ID
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=delete&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' . $id ) );

				} elseif ( $prefixOnly ) {

					// prefix only
					return esc_url_raw( admin_url( 'admin.php?page=zbs-add-edit&action=delete&zbstype=' . $zbs->DAL->objTypeKey( $objTypeID ) . '&zbsid=' ) );

				}
			} // / got objType
			break;
		case 'tags':
			// Tag manager page (returns for all obj types)
			if ( $objTypeID > 0 ) {

				return esc_url_raw( admin_url( 'admin.php?page=' . $zbs->slugs['tagmanager'] . '&tagtype=' . $zbs->DAL->objTypeKey( $objTypeID ) ) );

			} // / got objType

			break;

		case 'listtagged':
			// List view -> tagged (returns for all obj types)
			if ( $objTypeID > 0 ) {

				// exception: event tags
				if ( $objTypeID == ZBS_TYPE_TASK ) {

					return esc_url_raw( admin_url( 'admin.php?page=' . $zbs->slugs['manage-tasks-list'] . ( is_string( $taxonomy ) ? '&zbs_tag=' . $taxonomy : '' ) ) );

				}

				return esc_url_raw( admin_url( 'admin.php?page=' . $zbs->DAL->listViewSlugFromObjID( $objTypeID ) . ( is_string( $taxonomy ) ? '&zbs_tag=' . $taxonomy : '' ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase,WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase

			} // / got objType
			break;

		case 'email':
			switch ( $objTypeID ) {

				case ZBS_TYPE_CONTACT:
					if ( $id > 0 ) {

						// email with actual ID
						return esc_url_raw( zeroBSCRM_getAdminURL( $zbs->slugs['emails'] ) . '&zbsprefill=' . $id );

					} elseif ( $prefixOnly ) {

						// page only
						return esc_url_raw( zeroBSCRM_getAdminURL( $zbs->slugs['emails'] ) . '&zbsprefill=' );

					}

					break;

			}

			break;

	}

	// if $key isn't in switch, assume it's a slug :)
	return esc_url_raw( admin_url( 'admin.php?page=' . $key ) );

	// none? DASH then!
	// return esc_url_raw( admin_url('admin.php?page=zerobscrm-dash') );
}

#} This is run by main init :) (Installs Quote Templates etc.)
function zeroBSCRM_installDefaultContent() {

	global $zbs;

	#} Quote Builder, defaults
	$quoteBuilderDefaultsInstalled = zeroBSCRM_getSetting( 'quotes_default_templates' );

	if ( ! is_array( $quoteBuilderDefaultsInstalled ) ) {

		#} Need installing!
		$installedQuoteTemplates = array();

		#} Load content
		$quoteBuilderDefaultTemplates = array();

		#} Web Design: Example
		$templatedHTML = file_get_contents( ZEROBSCRM_PATH . 'html/quotes/quote-template-web-design.html' );
		if ( ! empty( $templatedHTML ) ) {
			$quoteBuilderDefaultTemplates['webdesignexample'] = array(
				'title' => __( 'Web Design: Example', 'zero-bs-crm' ),
				'html'  => $templatedHTML,
				'value' => 500.00,
			);
		}

		#} Install..
		if ( count( $quoteBuilderDefaultTemplates ) > 0 ) {
			foreach ( $quoteBuilderDefaultTemplates as $template ) {

				// Insert via DAL3
				$newTemplateID = $zbs->DAL->quotetemplates->addUpdateQuotetemplate(
					array(
						// fields (directly)
						'data'      => array(

							'title'       => $template['title'],
							'value'       => $template['value'],
							'date_str'    => '',
							'date'        => '',
							'content'     => $template['html'],
							'notes'       => '',
							'currency'    => '',
							'created'     => time(),
							'lastupdated' => time(),

						),

						'extraMeta' => array( 'zbsdefault' => 1 ),
					)
				);

				if ( $newTemplateID > 0 ) {
					$installedQuoteTemplates[] = $newTemplateID;
				}
			}
		}

		#} Log installed
		$zbs->settings->update( 'quotes_default_templates', $installedQuoteTemplates );

	}
}

/*
======================================================
	/ General/WP helpers
====================================================== */
