<?php
/**
 * @brief		Package Node
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @subpackage	Nexus
 * @since		29 Apr 2014
 */

namespace IPS\nexus;

/* To prevent PHP errors (extending class does not exist) revealing path */

use DateInterval;
use DomainException;
use Exception;
use InvalidArgumentException;
use IPS\Application;
use IPS\Content\Search\Index;
use IPS\DateTime;
use IPS\Db;
use IPS\downloads\File;
use IPS\Email;
use IPS\File as SystemFile;
use IPS\File\Iterator;
use IPS\Helpers\Form;
use IPS\Helpers\Form\CheckboxSet;
use IPS\Helpers\Form\Codemirror;
use IPS\Helpers\Form\Custom;
use IPS\Helpers\Form\Editor;
use IPS\Helpers\Form\Interval;
use IPS\Helpers\Form\Node;
use IPS\Helpers\Form\Radio;
use IPS\Helpers\Form\Select;
use IPS\Helpers\Form\Stack;
use IPS\Helpers\Form\Translatable;
use IPS\Helpers\Form\Upload;
use IPS\Helpers\Form\YesNo;
use IPS\Http\Url;
use IPS\Http\Url\Friendly;
use IPS\IPS;
use IPS\Lang;
use IPS\Math\Number;
use IPS\Member;
use IPS\Member\Group;
use IPS\nexus\extensions\nexus\Item\Package as ItemPackage;
use IPS\nexus\extensions\nexus\Item\UpgradeCharge;
use IPS\nexus\Package\CustomField;
use IPS\nexus\Package\Item;
use IPS\nexus\Purchase\RenewalTerm;
use IPS\Node\Model;
use IPS\Output;
use IPS\Patterns\ActiveRecordIterator;
use IPS\Request;
use IPS\Session;
use IPS\Settings;
use IPS\Theme;
use LogicException;
use OutOfBoundsException;
use OutOfRangeException;
use RuntimeException;
use UnderflowException;
use function count;
use function defined;
use function in_array;
use function intval;
use function is_array;
use function is_null;
use function strlen;
use function substr;
use const IPS\CACHE_PAGE_TIMEOUT;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Package
 */
class Package extends Model
{
	/**
	 * @brief	[ActiveRecord] Multiton Store
	 */
	protected static array $multitons;

	/**
	 * @brief	[ActiveRecord] Caches
	 * @note	Defined cache keys will be cleared automatically as needed
	 */
	protected array $caches = array( 'nexus_reg_product_count' );
	
	/**
	 * @brief	[ActiveRecord] Database Table
	 */
	public static ?string $databaseTable = 'nexus_packages';
	
	/**
	 * @brief	[ActiveRecord] Database Prefix
	 */
	public static string $databasePrefix = 'p_';
		
	/**
	 * @brief	[Node] Order Database Column
	 */
	public static ?string $databaseColumnOrder = 'position';
	
	/**
	 * @brief	[Node] Parent Node ID Database Column
	 */
	public static string $parentNodeColumnId = 'group';
		
	/**
	 * @brief	[Node] Parent Node Class
	 */
	public static string $parentNodeClass = 'IPS\nexus\Package\Group';
	
	/**
	 * @brief	[Node] Node Title
	 */
	public static string $nodeTitle = 'menu__nexus_store_packages';
	
	/**
	 * @brief	[Node] Title prefix.  If specified, will look for a language key with "{$key}_title" as the key
	 */
	public static ?string $titleLangPrefix = 'nexus_package_';

	/**
	 * @brief	[Node] Description suffix.  If specified, will look for a language key with "{$titleLangPrefix}_{$id}_{$descriptionLangSuffix}" as the key
	 */
	public static ?string $descriptionLangSuffix = '_desc';

	/**
	 * Determines if this class can be extended via UI Extension
	 *
	 * @var bool
	 */
	public static bool $canBeExtended = true;

	/**
	 * Package Types
	 *
	 * @return	array
	 */
	public static function packageTypes() : array
	{
		$return = array(
			'Product'	=> 'IPS\nexus\Package\Product',
			'Ad'		=> 'IPS\nexus\Package\Ad'
		);

		foreach( Application::allExtensions( 'nexus', 'PackageType', null, null, null, false ) as $key => $ext )
		{
			$className = mb_substr( $ext, mb_strrpos( $ext, '\\' ) + 1 );
			$return[ IPS::mb_ucfirst( mb_strtolower( $className ) ) ] = $ext;
		}

		return $return;
	}
	
	/* !ActiveRecord */
	
	/**
	 * Construct ActiveRecord from database row
	 *
	 * @param array $data							Row from database table
	 * @param bool $updateMultitonStoreIfExists	Replace current object in multiton store if it already exists there?
	 * @return    static
	 */
	public static function constructFromData( array $data, bool $updateMultitonStoreIfExists = TRUE ): static
	{
		$types		= static::packageTypes();
		$classname	= $types[IPS::mb_ucfirst($data['p_type'])] ?? '';

		if ( $classname and isset( $classname::$packageDatabaseTable ) )
		{
			$data = array_merge( $data, Db::i()->select( '*', $classname::$packageDatabaseTable, array( 'p_id=?', $data['p_id'] ) )->first() );
		}

		/* Initiate an object */
		$obj = ( $classname ) ? new $classname : new static;
		$obj->_new = FALSE;
		
		/* Import data */
		$databasePrefixLength = strlen( static::$databasePrefix );
		foreach ( $data as $k => $v )
		{
			if( static::$databasePrefix AND mb_strpos( $k, static::$databasePrefix ) === 0 )
			{
				$k = substr( $k, $databasePrefixLength );
			}

			$obj->_data[ $k ] = $v;
		}
		$obj->changed = array();
		
		/* Init */
		if ( method_exists( $obj, 'init' ) )
		{
			$obj->init();
		}
				
		/* Return */
		return $obj;
	}
					
	/**
	 * @brief	[Node] ACP Restrictions
	 * @code
	 	array(
	 		'app'		=> 'core',				// The application key which holds the restrictrions
	 		'module'	=> 'foo',				// The module key which holds the restrictions
	 		'map'		=> array(				// [Optional] The key for each restriction - can alternatively use "prefix"
	 			'add'			=> 'foo_add',
	 			'edit'			=> 'foo_edit',
	 			'permissions'	=> 'foo_perms',
	 			'delete'		=> 'foo_delete'
	 		),
	 		'all'		=> 'foo_manage',		// [Optional] The key to use for any restriction not provided in the map (only needed if not providing all 4)
	 		'prefix'	=> 'foo_',				// [Optional] Rather than specifying each  key in the map, you can specify a prefix, and it will automatically look for restrictions with the key "[prefix]_add/edit/permissions/delete"
	 * @endcode
	 */
	protected static ?array $restrictions = array(
		'app'		=> 'nexus',
		'module'	=> 'store',
		'prefix'	=> 'packages_',
	);
	
	/**
	 * Set Default Values
	 *
	 * @return	void
	 */
	public function setDefaultValues() : void
	{
		$this->member_groups = '*';
		$this->date_added = time();
		$this->store = TRUE;
	}
		
	/**
	 * Save Changed Columns
	 *
	 * @return    void
	 */
	public function save(): void
	{
		$data = $this->changed;

		$secondaryTable = array();
		foreach ( $this->changed as $k => $v )
		{
			if ( isset( static::$packageDatabaseColumns ) and in_array( "p_{$k}", static::$packageDatabaseColumns ) )
			{
				$secondaryTable[ "p_{$k}" ] = $v;
				unset( $this->changed[ $k ] );
			}
			elseif ( !in_array( "p_{$k}", array( 'p_id', 'p_name', 'p_seo_name', 'p_desc', 'p_group', 'p_stock', 'p_reg', 'p_store', 'p_member_groups', 'p_allow_upgrading', 'p_upgrade_charge', 'p_allow_downgrading', 'p_downgrade_refund', 'p_base_price', 'p_tax', 'p_renewal_days', 'p_primary_group', 'p_secondary_group', 'p_return_primary', 'p_return_secondary', 'p_position', 'p_associable', 'p_force_assoc', 'p_assoc_error', 'p_discounts', 'p_page', 'p_featured', 'p_upsell', 'p_notify', 'p_type', 'p_custom', 'p_reviewable', 'p_review_moderate', 'p_image', 'p_methods', 'p_renew_options', 'p_group_renewals', 'p_rebuild_thumb', 'p_renewal_days_advance', 'p_date_added', 'p_reviews', 'p_rating', 'p_grace_period', 'p_email_purchase_type', 'p_email_purchase', 'p_email_expire_soon_type', 'p_email_expire_soon', 'p_email_expire_type', 'p_email_expire', 'p_initial_term' ) ) )
			{
				unset( $this->changed[ $k ] );
			}
		}
		
		if ( isset( $data['base_price'] ) )
		{
			$decoded = json_decode( $data['base_price'], TRUE );
			if ( $decoded and is_array( $decoded ) )
			{
				$prices = array( 'id' => $this->id );
				foreach ( $decoded as $currency => $value )
				{
					if ( !Db::i()->checkForColumn( 'nexus_package_base_prices', $currency ) )
					{
						Db::i()->addColumn( 'nexus_package_base_prices', array(
							'name'	=> $currency,
							'type'	=> 'FLOAT'
						) );
					}
					$prices[ $currency ] = $value['amount'];
				}
				Db::i()->replace( 'nexus_package_base_prices', $prices );
			}
		}
		
		$this->date_updated = time();
		parent::save();
		$this->changed = $data;
		
		if ( !empty( $secondaryTable ) AND isset( static::$packageDatabaseTable ) )
		{
			Db::i()->update( static::$packageDatabaseTable, $secondaryTable, array( 'p_id=?', $this->id ) );
		}
	}
	
	/**
	 * [ActiveRecord] Delete Record
	 *
	 * @return    void
	 */
	public function delete(): void
	{
		$this->item()->delete();
		parent::delete();

		if( isset( static::$packageDatabaseTable ) )
		{
			Db::i()->delete( static::$packageDatabaseTable, array( 'p_id=?', $this->id ) );
		}
		
		/* Translatables */
		foreach ( array( '_assoc', '_page', '_email_purchase_subject', '_email_expire_soon_subject', '_email_expire_subject' ) as $suffix )
		{
			Lang::deleteCustom( 'nexus', "nexus_package_{$this->id}{$suffix}" );
		}

		foreach( [ 'pkg-assoc', 'pkg', 'pkg-pg' ] as $id3 )
		{
			SystemFile::unclaimAttachments( 'nexus_Admin', $this->_id, null, $id3 );
		}
	}
	
	/**
	 * [ActiveRecord] Duplicate
	 *
	 * @return	void
	 */
	public function __clone() : void
	{
		if( $this->skipCloneDuplication === TRUE )
		{
			return;
		}

		$primaryTable = array();
		$secondaryTable = array();
		foreach ( $this->_data as $k => $v )
		{
			if ( !in_array( $k, array( 'id', 'reviews', 'unapproved_reviews', 'hidden_reviews' ) ) )
			{
				if ( isset( static::$packageDatabaseColumns ) and in_array( "p_{$k}", static::$packageDatabaseColumns ) )
				{
					$secondaryTable[ "p_{$k}" ] = $v;
				}
				else
				{
					$primaryTable[ "p_{$k}" ] = $v;
				}
			}
		}

		/* We need to update the date_added field so cloned products appear in the new products block */
		$primaryTable['p_date_added'] = time();

		$oldId = $this->_id;
		$id = Db::i()->insert( 'nexus_packages', $primaryTable );

		$secondaryTable['p_id'] = $id;
		Db::i()->insert( static::$packageDatabaseTable, $secondaryTable );
		
		/* Translatables */
		Lang::saveCustom( 'nexus', static::$titleLangPrefix . $id, iterator_to_array( Db::i()->select( 'CONCAT(word_custom, \' ' . Member::loggedIn()->language()->get('copy_noun') . '\') as word_custom, lang_id', 'core_sys_lang_words', array( 'word_key=?', static::$titleLangPrefix . $this->_id ) )->setKeyField( 'lang_id' )->setValueField('word_custom') ) );
		Lang::saveCustom( 'nexus', static::$titleLangPrefix . $id . static::$descriptionLangSuffix, iterator_to_array( Db::i()->select( 'word_custom, lang_id', 'core_sys_lang_words', array( 'word_key=?', static::$titleLangPrefix . $oldId . static::$descriptionLangSuffix ) )->setKeyField( 'lang_id' )->setValueField('word_custom') ) );

		foreach ( array( '_assoc', '_page', '_email_purchase_subject', '_email_expire_soon_subject', '_email_expire_subject' ) as $suffix )
		{
			Lang::saveCustom( 'nexus', static::$titleLangPrefix . $id . $suffix, iterator_to_array( Db::i()->select( 'word_custom, lang_id', 'core_sys_lang_words', array( 'word_key=?', static::$titleLangPrefix . $oldId . $suffix ) )->setKeyField( 'lang_id' )->setValueField('word_custom') ) );
		}

		/* If the description had attachments, link them */
		$attachmentMappings = [];
		foreach( Db::i()->select( '*', 'core_attachments_map', [
			[ 'location_key=?', 'nexus_Admin' ],
			[ 'id1=?', $oldId ],
			[ 'id2 is null' ],
			[ Db::i()->in( 'id3', [ 'pkg-assoc', 'pkg', 'pkg-pg' ] ) ]
		] ) as $attachment )
		{
			$attachment['id1'] = $this->_id;
			$attachmentMappings[] = $attachment;
		}
		if( count( $attachmentMappings ) )
		{
			Db::i()->insert( 'core_attachments_map', $attachmentMappings );
		}

		Db::i()->insert( 'nexus_package_base_prices', Db::i()->select( "{$id} AS id, " . implode( ',', array_map( function( $val )
		{
			return "`{$val}`";
		}, Money::currencies() ) ), 'nexus_package_base_prices', array( 'id=?', $this->_id ) ) );
		
		foreach ( Db::i()->select( '*', 'nexus_package_images', array( 'image_product=?', $this->_id ) ) as $image )
		{
			$file = SystemFile::get( 'nexus_Products', $image['image_location'] );
					
			Db::i()->insert( 'nexus_package_images', array(
				'image_product'		=> $id,
				'image_location'	=> (string) SystemFile::create( 'nexus_Products', $file->originalFilename, $file->contents(), $file->container ),
				'image_primary'		=> $image['image_primary']
			) );
		}
		
		Db::i()->insert( 'nexus_product_options', Db::i()->select( "NULL as opt_id, {$id} AS opt_package, opt_values, opt_stock, opt_base_price, opt_renew_price", 'nexus_product_options', array( 'opt_package=?', $this->_id ) ) );
		
		Db::i()->update( 'nexus_package_fields', "cf_packages=CONCAT( cf_packages, ',{$id}' )", Db::i()->findInSet( 'cf_packages', array( $this->_id ) ) );
		Db::i()->update( 'nexus_referral_rules', "rrule_purchase_packages=CONCAT( rrule_purchase_packages, ',{$id}' )", Db::i()->findInSet( 'rrule_purchase_packages', array( $this->_id ) ) );

		$primaryKey = static::$databaseColumnId;
		$this->$primaryKey = $id;
	}
	
	/* !Properties */
	
	/**
	 * Get the lowest price for multiple packages
	 * May return price ($x) or "From $x" or a price struck and replaced with a discount price
	 *
	 * @param	ActiveRecordIterator	$iterator	Iterator of packages
	 * @param Customer|NULL			$customer	The customer (NULL for currently logged in member)
	 * @param bool $returnMoney
	 * @return    string|Money
	 */
	public static function lowestPriceToDisplay( ActiveRecordIterator $iterator, Customer $customer = NULL, bool $returnMoney = false ): string|Money
	{
		$customer = $customer ?: Customer::loggedIn();
		$currency = ( $customer->member_id === Customer::loggedIn()->member_id ) ? ( ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : $customer->defaultCurrency() ) : $customer->defaultCurrency();
		
		$lowest = NULL;
		$_priceMayChange = FALSE;
		$priceMayChange = FALSE;
		foreach ( $iterator as $package )
		{
			/* @var Package $package */
			$price = $package->_lowestPrice( $customer, $currency, $priceMayChange );
			if ( $lowest !== NULL and $price !== $lowest )
			{
				$_priceMayChange = TRUE;
			}
			if ( $lowest === NULL or $price < $lowest )
			{
				$lowest = $price;
			}
		}

		if( $returnMoney )
		{
			return new Money( $lowest, $currency );
		}
		
		return Theme::i()->getTemplate( 'store', 'nexus' )->price( new Money( $lowest, $currency ), ( $priceMayChange or $_priceMayChange ) );
	}

	
	/**
	 * @brief	Icon
	 */
	public static string $icon = 'box';
	
	/**
	 * @brief	Title
	 */
	public static string $title = 'product';
		
	/**
	 * Basic purchase price
	 * May be further adjusted by renewal terms which add
	 *
	 * @param Customer|NULL $customer			The customer (NULL for currently logged in member)
	 * @param bool $usergroupDiscounts	Account for usergroup discounts?
	 * @param bool $loyaltyDiscounts	Account for loyalty discounts?
	 * @param bool $bulkDiscounts		Account for bulk discounts?
	 * @param int $initialCount		Will assume that number of additional purchases for this package
	 * @param string|NULL $currency			Currency to use or NULL to use customer's default currency
	 * @return    Money
	 * @throws	OutOfBoundsException
	 */
	public function price(?Customer $customer = NULL, bool $usergroupDiscounts = TRUE, bool $loyaltyDiscounts = TRUE, bool $bulkDiscounts = TRUE, int $initialCount = 0, ?string $currency = NULL ): Money
	{
		return static::priceFromData(
			$this->id,
			$this->base_price ? json_decode( $this->base_price, TRUE ) : array(),
			$this->discounts ? json_decode( $this->discounts, TRUE ) : array(),
			$customer,
			$currency,
			$usergroupDiscounts,
			$loyaltyDiscounts,
			$bulkDiscounts,
			$initialCount
		);
	}
	
	/**
	 * Basic purchase price
	 * May be further adjusted by renewal terms which add
	 *
	 * @param int $packageId				Package ID
	 * @param array $prices					Base Price data
	 * @param array $discounts				Discount data
	 * @param Customer|null $customer				The customer (NULL for currently logged in member)
	 * @param string|null $currency				The currency (NULL to autodetect based on $customer)
	 * @param bool $usergroupDiscounts		Account for usergroup discounts?
	 * @param bool $loyaltyDiscounts		Account for loyalty discounts?
	 * @param bool $bulkDiscounts			Account for bulk discounts?
	 * @param int $initialCount			Will assume that number of additional purchases for this package
	 * @return    Money
	 * @throws	OutOfBoundsException
	 */
	public static function priceFromData( int $packageId, array $prices, array $discounts, ?Customer $customer = NULL, ?string $currency = NULL, bool $usergroupDiscounts = TRUE, bool $loyaltyDiscounts = TRUE, bool $bulkDiscounts = TRUE, int $initialCount = 0 ): Money
	{
		/* Base */
		$customer = $customer ?: Customer::loggedIn();
		if ( !$currency )
		{
			$currency = ( $customer->member_id === Customer::loggedIn()->member_id ) ? ( ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : $customer->defaultCurrency() ) : $customer->defaultCurrency();
		}
		if ( !isset( $prices[ $currency ] ) )
		{
			throw new OutOfBoundsException;
		}
		$price = $prices[ $currency ]['amount'];
		
		/* Usergroup discounts */
		if ( $usergroupDiscounts and isset( $discounts['usergroup'] ) )
		{
			foreach ( $discounts['usergroup'] as $discount )
			{
				if ( $discount['price'][ $currency ] < $price )
				{
					if ( $discount['secondary'] )
					{
						$inGroup = $customer->inGroup( $discount['group'] );
					}
					else
					{
						$inGroup = $customer->member_group_id == $discount['group'];
					}
										
					if ( $inGroup )
					{
						$price = $discount['price'][ $currency ];
					}
				}
			}
		}
		
		/* Loyalty discounts */
		if ( $loyaltyDiscounts and isset( $discounts['loyalty'] ) )
		{
			foreach ( $discounts['loyalty'] as $discount )
			{
				$comparingPackageId = ( $discount['package'] ?: $packageId );
				$count = ( $comparingPackageId == $packageId ) ? $initialCount : 0;
				if ( $discount['price'][ $currency ] < $price )
				{
					$count += $customer->member_id ? $customer->previousPurchasesCount( $discount['package'] ?: $packageId, $discount['active'] ) : 0;
				}
				if ( $customer->member_id === Member::loggedIn()->member_id and isset( $_SESSION['cart'] ) )
				{
					foreach ( $_SESSION['cart'] as $item )
					{
						if ( $item->id == $comparingPackageId )
						{
							$count += $item->quantity;
						}
					}
				}
				
				if ( $count >= $discount['owns'] )
				{
					$price = $discount['price'][ $currency ];
				}
			}
		}
		
		/* Bulk Discounts */
		if ( $bulkDiscounts and isset( $discounts['bulk'] ) )
		{
			/* Make sure we're only applying the highest quantity discount */
			rsort( $discounts['bulk'] );
			$discount = array_shift( $discounts['bulk'] );

			$discountPackageId = ( $discount['package'] ?: $packageId );
			$count = ( $discountPackageId == $packageId ) ? $initialCount : 0;
			if ( $customer->member_id === Member::loggedIn()->member_id and isset( $_SESSION['cart'] ) )
			{
				foreach ( $_SESSION['cart'] as $item )
				{
					if ( $item->id == $discountPackageId )
					{
						$count += $item->quantity;
					}
				}
			}

			if ( $count and ( ( $count + 1 ) % ( $discount['buying'] + 1 ) == 0 ) )
			{
				$price = $discount['price'][ $currency ];
			}
		}

		/* Return */
		return new Money( $price, $currency );
	}
		
	/**
	 * Get the lowest price
	 *
	 * @param Customer|NULL $customer					The customer
	 * @param string $currency					Currency
	 * @param bool $priceMayChange				[Reference] will be set to a value indicating if the price might change (i.e. should be displayed as "From $x")
	 * @param Number|null $priceNotIncludingDiscounts	[Reference] will be set to the lowest price not taking discounts into consideration
	 * @param RenewalTerm|null $relevantRenewalTerm		[Reference] will be set to the renewal term which would need to be selected for this price
	 * @return    Number
	 */
	protected function _lowestPrice(?Customer $customer, string $currency, bool &$priceMayChange = FALSE, ?Number &$priceNotIncludingDiscounts = NULL, ?RenewalTerm &$relevantRenewalTerm = NULL ): Number
	{
		$renewOptions = $this->renew_options ? json_decode( $this->renew_options, TRUE ) : array();
		
		$tax = NULL;
		if ( Settings::i()->nexus_show_tax and $this->tax )
		{
			try
			{
				$tax = Tax::load( $this->tax );
			}
			catch ( OutOfRangeException ) { }
		}
		
		return static::lowestPriceFromData( $customer, $currency, $this->id, json_decode( $this->base_price, TRUE ), ( $this->discounts ? json_decode( $this->discounts, TRUE ) : [] ), $renewOptions, $this->stock, $tax, $priceMayChange, $priceNotIncludingDiscounts, $relevantRenewalTerm );
	}
	
	/**
	 * Get the lowest price
	 *
	 * @param Customer $customer					The customer
	 * @param string $currency					Currency
	 * @param int $packageId					Package ID
	 * @param array $prices						Base Price data
	 * @param array $discounts					Discount data
	 * @param array $renewOptions				Renewal options data
	 * @param int $stock						Stock type (-2 for if it adjusts on fields)
	 * @param Tax|NULL $tax						Tax rate
	 * @param bool $priceMayChange				[Reference] will be set to a value indicating if the price might change (i.e. should be displayed as "From $x")
	 * @param Number|null $priceNotIncludingDiscounts	[Reference] will be set to the lowest price not taking discounts into consideration
	 * @param RenewalTerm|null $relevantRenewalTerm		[Reference] will be set to the renewal term which would need to be selected for this price
	 * @return    Number
	 */
	static function lowestPriceFromData(Customer $customer, string $currency, int $packageId, array $prices, array $discounts, array $renewOptions, int $stock, ?Tax $tax, bool &$priceMayChange = FALSE, ?Number &$priceNotIncludingDiscounts = NULL, ?RenewalTerm &$relevantRenewalTerm = NULL ): Number
	{
		/* What's the base price with and without discounts? */
		$baseNoDiscounts = static::priceFromData( $packageId, $prices, $discounts, $customer, $currency, FALSE, FALSE, FALSE )->amount;
		$base = static::priceFromData( $packageId, $prices, $discounts, $customer, $currency )->amount;

		/* Adjustments based on custom fields */
		if ( $stock == -2 )
		{
			$mostReducedOption = NULL;
			foreach ( Db::i()->select( '*', 'nexus_product_options', array( 'opt_package=?', $packageId ) ) as $option )
			{
				$basePriceAdjustments = json_decode( $option['opt_base_price'], TRUE );
				if ( isset( $basePriceAdjustments[ $currency ] ) and ( $basePriceAdjustments[ $currency ] or $basePriceAdjustments[ $currency ] === '0' ) )
				{
					if ( $mostReducedOption === NULL or $basePriceAdjustments[ $currency ] < $mostReducedOption )
					{
						$mostReducedOption = new Number( number_format( $basePriceAdjustments[ $currency ], Money::numberOfDecimalsForCurrency( $currency ), '.', '' ) );
					}
					
					$priceMayChange = TRUE;
				}
			}
			$mostReducedOption = $mostReducedOption ?: new Number('0');
			
			$baseNoDiscounts = $baseNoDiscounts->add( $mostReducedOption );
			$base = $base->add( $mostReducedOption );
		}
		
		/* Init */
		$lowestNoDiscounts = $baseNoDiscounts;
		$lowest = $base;

		/* It's possible the base price is $0, but all the renewal options add, so let's look at that */
		if ( !empty( $renewOptions ) )
		{
			$lowestNoDiscounts = NULL;
			$lowest = NULL;
			foreach ( $renewOptions as $term )
			{
				if ( $term['add'] )
				{
					if ( count( $renewOptions ) > 1 )
					{
						$priceMayChange = TRUE;
					}
					$_lowest = $base->add( new Number( number_format( $term['cost'][ $currency ]['amount'], Money::numberOfDecimalsForCurrency( $currency ), '.', '' ) ) );
					$_lowestNoDiscounts = $baseNoDiscounts->add( new Number( number_format( $term['cost'][ $currency ]['amount'], Money::numberOfDecimalsForCurrency( $currency ), '.', '' ) ) );
				}
				else
				{
					$_lowest = $base;
					$_lowestNoDiscounts = $baseNoDiscounts;
				}
				
				if ( $lowest === NULL or $_lowest->compare( $lowest ) === -1 )
				{
					$lowest = $_lowest;
					if ( $term['add'] )
					{
						$relevantRenewalTerm = new RenewalTerm( new Money( new Number( $term['cost'][ $currency ]['amount'] ), $currency ), new DateInterval( 'P' . $term['term'] . mb_strtoupper( $term['unit'] ) ), $tax, $term['add'] );
					}
					else
					{
						$relevantRenewalTerm = NULL;
					}
				}
				if ( $lowestNoDiscounts === NULL or $_lowestNoDiscounts->compare( $lowestNoDiscounts ) === -1 )
				{
					$lowestNoDiscounts = $_lowestNoDiscounts;
				}
			}
		}
					
		/* Add tax? */
		if ( Settings::i()->nexus_show_tax and $tax )
		{
			/* What is the rate? */
			$taxRate = new Number( $tax->rate( $customer->estimatedLocation() ) );
			
			/* Add it on */
			$lowest = $lowest->add( $lowest->multiply( $taxRate ) );
			$lowestNoDiscounts = $lowestNoDiscounts->add( $lowestNoDiscounts->multiply( $taxRate ) );
		}

		/* Return */
		$priceNotIncludingDiscounts = $lowestNoDiscounts;
		return $lowest;
	}
		
	/**
	 * Price to display in store
	 * May return price ($x) or "From $x" or a price struck and replaced with a discount price or NULL if not available in desired currency
	 *
	 * @param Customer|NULL $customer					The customer (NULL for currently logged in member)
	 * @param bool $includePriceDescription	If TRUE, will include the "Price Description" setting (normally used to indicate if price includes tax)
	 * @return    string|NULL
	 */
	public function priceToDisplay( ?Customer $customer = NULL, bool $includePriceDescription=TRUE ): string|null
	{
		/* Get customer */
		$customer = $customer ?: Customer::loggedIn();
		$currency = ( $customer->member_id === Customer::loggedIn()->member_id ) ? ( ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : $customer->defaultCurrency() ) : $customer->defaultCurrency();
		
		/* Get the price */
		$priceMayChange = FALSE;
		$priceNotIncludingDiscounts = NULL;
		try
		{
			$price = $this->_lowestPrice( $customer, $currency, $priceMayChange, $priceNotIncludingDiscounts );
		}
		catch ( OutOfBoundsException )
		{
			return NULL;
		}

		/* Display */
		if ( $price < $priceNotIncludingDiscounts )
		{
			return Theme::i()->getTemplate( 'store', 'nexus' )->priceDiscounted( new Money( $priceNotIncludingDiscounts, $currency ), new Money( $price, $currency ), $priceMayChange, $includePriceDescription );
		}
		else
		{
			return Theme::i()->getTemplate( 'store', 'nexus' )->price( new Money( $price, $currency ), $priceMayChange, $includePriceDescription );
		}
	}
	
	/**
	 * Get full price info
	 *
	 * @param Customer|NULL $customer					The customer
	 * @return    array|NULL
	 */
	public function fullPriceInfo(?Customer $customer = NULL ): array|null
	{
		$customer = $customer ?: Customer::loggedIn();
		$currency = ( $customer->member_id === Customer::loggedIn()->member_id ) ? ( ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) ? Request::i()->cookie['currency'] : $customer->defaultCurrency() ) : $customer->defaultCurrency();
		
		$renewOptions = $this->renew_options ? json_decode( $this->renew_options, TRUE ) : array();
		
		$tax = NULL;
		if ( Settings::i()->nexus_show_tax and $this->tax )
		{
			try
			{
				$tax = Tax::load( $this->tax );
			}
			catch ( OutOfRangeException ) { }
		}
		
		try
		{
			return static::fullPriceInfoFromData( $customer, $currency, $this->id, $this->base_price ? json_decode( $this->base_price, TRUE ) : array(), $this->discounts ? json_decode( $this->discounts, TRUE ) : array(), $renewOptions, $this->stock, $tax, $this->initial_term ? new DateInterval( "P{$this->initial_term}" ) : NULL );
		}
		catch( OutOfBoundsException )
		{
			return NULL;
		}
	}
	
	/**
	 * Get full price info from specific data
	 *
	 * @param Customer|NULL			$customer					The customer
	 * @param string $currency					Currency
	 * @param int $packageId					Package ID
	 * @param array $prices						Base Price data
	 * @param array $discounts					Discount data
	 * @param array $renewOptions				Renewal options data
	 * @param int $stock						Stock type (-2 for if it adjusts on fields)
	 * @param Tax|NULL $tax						Tax rate
	 * @param DateInterval|NULL $initialTerm				The initial term
	 * @return    array|null
	 */
	public static function fullPriceInfoFromData(?Customer $customer, string $currency, int $packageId, array $prices, array $discounts, array $renewOptions, int $stock, ?Tax $tax, ?DateInterval $initialTerm ): array|null
	{
		/* Get customer / currency */
		$customer = $customer ?: Customer::loggedIn();

		/* Get the initial price */
		$priceMayChange = FALSE;
		$priceNotIncludingDiscounts = NULL;
		$relevantRenewalTerm = NULL;
		
		try
		{
			$price = static::lowestPriceFromData( $customer, $currency, $packageId, $prices, $discounts, $renewOptions, $stock, $tax, $priceMayChange, $priceNotIncludingDiscounts, $relevantRenewalTerm );		
		}
		catch ( OutOfBoundsException )
		{
			return NULL;
		}
		
		$primaryPrice = new Money( $price, $currency );
		
		/* Renewals */
		$renewalPrice = NULL;
		$renewalPriceMayChange = FALSE;
		if ( $renewOptions )
		{
			if ( count( $renewOptions ) === 1 and isset( $renewOptions[0]['cost'][ $currency ] ) and $renewOptions[0]['cost'][ $currency ]['amount'] == $price and !$renewOptions[0]['add'] )
			{
				$primaryPrice = new RenewalTerm( $primaryPrice, new DateInterval( "P{$renewOptions[0]['term']}" . mb_strtoupper( $renewOptions[0]['unit'] ) ), $tax );

				if ( $initialTerm !== NULL )
				{
					$initialTerm = ( new RenewalTerm( [], $initialTerm ) )->getTermUnit();
				}
			}
			else
			{
				$renewalPriceMayChange = ( count( $renewOptions ) > 1 );
				if ( !$relevantRenewalTerm )
				{
					if ( count( $renewOptions ) === 1 and isset( $renewOptions[0]['cost'][ $currency ] ) )
					{
						$relevantRenewalTerm = new RenewalTerm( new Money( $renewOptions[0]['cost'][ $currency ]['amount'], $currency ), new DateInterval( "P{$renewOptions[0]['term']}" . mb_strtoupper( $renewOptions[0]['unit'] ) ), $tax );
					}
					else
					{
						foreach ( $renewOptions as $option )
						{
							if ( isset( $option['cost'][ $currency ] ) and ( $relevantRenewalTerm === NULL or $relevantRenewalTerm->cost->amount->compare( new Number( "{$option['cost'][ $currency ]['amount']}" ) ) === 1 ) )
							{
								$relevantRenewalTerm = new RenewalTerm( new Money( $option['cost'][ $currency ]['amount'], $currency ), new DateInterval( "P{$option['term']}" . mb_strtoupper( $option['unit'] ) ), $tax );
							}
						}
					}
				}
				$renewalPrice = $relevantRenewalTerm;
				
				if ( $initialTerm !== NULL )
				{
					$initialTerm = ( new RenewalTerm( [], $initialTerm ) )->getTermUnit();
				}
				else
				{
					if ( $relevantRenewalTerm->cost->amount?->compare( $primaryPrice->amount ) === 0 )
					{
						$priceMayChange = TRUE;
						$primaryPrice = $relevantRenewalTerm;
					}
					else
					{
						$initialTerm = $renewalPrice->getTermUnit();
					}
				}
			}
		}
		

		/* Return */
		return array(
			'primaryPrice'					=> $priceMayChange ? $customer->language()->addToStack( 'price_from', FALSE, array( 'sprintf' => array( $primaryPrice ) ) ) : ( $price->isZero() ? $customer->language()->addToStack('nexus_sub_cost_free') : $primaryPrice ),
			'primaryPriceIsZero'			=> $price ? $price->isZero() : FALSE,
			'primaryPriceDiscountedFrom'	=> ( $price < $priceNotIncludingDiscounts ) ? new Money( $priceNotIncludingDiscounts, $currency ) : NULL,
			'initialTerm'					=> $initialTerm,
			'renewalPrice'					=> $renewalPriceMayChange ? $customer->language()->addToStack( 'price_from', FALSE, array( 'sprintf' => array( $renewalPrice->toDisplay( $customer ) ) ) ) : ($renewalPrice?->toDisplay($customer)),
		);
	}

	/**
	 * @brief	Cached URLs
	 */
	protected mixed $_url = array();

	/**
	 * Get URL
	 *
	 * @param string|null $action		Action
	 * @return    Url
	 */
	public function url( string $action=NULL ): Url
	{
		$_key = $action ? md5( $action ) : NULL;

		if( !isset( $this->_url[ $_key ] ) )
		{
			$this->_url[ $_key ] = Url::internal( "app=nexus&module=store&controller=product&id={$this->id}", 'front', 'store_product', Friendly::seoTitle( Member::loggedIn()->language()->get( 'nexus_package_' . $this->id ) ) );
		
			if ( $action )
			{
				$this->_url[ $_key ] = $this->_url[ $_key ]->setQueryString( 'do', $action );
			}
		}
	
		return $this->_url[ $_key ];
	}

	/**
	 * Get full image URL
	 *
	 * @return string|null
	 */
	public function get_image() : string|null
	{
		return ( $this->_data['image'] ) ? (string) SystemFile::get( 'nexus_Products', $this->_data['image'] )->url : NULL;
	}
		
	/**
	 * Get additional name info
	 *
	 * @param Purchase $purchase	The purchase
	 * @return	array
	 */
	public function getPurchaseNameInfo( Purchase $purchase ) : array
	{
		$stickyFields = array();
		if ( count( $purchase->custom_fields ) )
		{
			$customFields = CustomField::roots();
			foreach ( $purchase->custom_fields as $k => $v )
			{
				if ( $v and isset( $customFields[ $k ] ) and $customFields[ $k ]->sticky )
				{
					$stickyFields[] = strip_tags( CustomField::load( $k )->displayValue( $v, TRUE ) ); // We need to call displayValue so things like dates and select boxes show the right value, but we *also* need to call strip_tags as Url fields will return full links and we need text only. Note that fields which would show something other than a simple text output are prevented from being used in this context
				}
			}
		}
		return $stickyFields;
	}
	
	/**
	 * @brief	Associable packages
	 */
	protected ?array $_associablePackages = NULL;
	
	/**
	 * Associable Packages
	 *
	 * @return	array|null
	 */
	public function associablePackages() : array|null
	{
		if ( $this->_associablePackages === NULL )
		{
			$this->_associablePackages = array();

			if( $this->associable )
			{
				foreach ( explode( ',', $this->associable ) as $id )
				{
					try
					{
						$this->_associablePackages[ $id ] = Package::load( $id );
					}
					catch ( OutOfRangeException ) { }
				}
			}
		}
		return $this->_associablePackages;
	}
	
	/**
	 * [Node] Get buttons to display in tree
	 * Example code explains return value
	 *
	 * @code
	 	* array(
	 		* array(
	 			* 'icon'	=>	'plus-circle', // Name of FontAwesome icon to use
	 			* 'title'	=> 'foo',		// Language key to use for button's title parameter
	 			* 'link'	=> \IPS\Http\Url::internal( 'app=foo...' )	// URI to link to
	 			* 'class'	=> 'modalLink'	// CSS Class to use on link (Optional)
	 		* ),
	 		* ...							// Additional buttons
	 	* );
	 * @endcode
	 * @param Url $url		Base URL
	 * @param	bool	$subnode	Is this a subnode?
	 * @return	array
	 */
	public function getButtons( Url $url, bool $subnode=FALSE ):array
	{
		$buttons = array();
		
		$_buttons = parent::getButtons( $url, $subnode );
		if ( isset( $_buttons['edit'] ) )
		{
			$buttons['edit'] = $_buttons['edit'];
		}
						
		if ( !$this->deleteOrMoveQueued() and ( Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_edit' ) or Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_cancel' ) ) )
		{
			$buttons['cancel_purchases'] = array(
				'icon'	=> 'arrow-right',
				'title'	=> 'mass_change_all_purchases',
				'link'	=> $url->setQueryString( array( 'do' => 'massManagePurchases', 'id' => $this->_id ) ),
				'data'	=> array(
					'ipsDialog'			=> TRUE,
					'ipsDialog-title'	=> $this->_title
				)
			);
		}
		
		if ( isset( $_buttons['delete'] ) )
		{
			unset( $_buttons['delete']['data']['delete'] );
			$_buttons['delete']['data']['confirm'] = TRUE;
		}
		
		$buttons = array_merge( $buttons, $_buttons );
		
		if ( !$this->deleteOrMoveQueued() and Member::loggedIn()->hasAcpRestriction( 'nexus', 'subscriptions', 'subscriptions_manage' ) and Subscription\Package::canConvert( $this ) )
		{
			$buttons['convert_subs'] = array(
				'icon'	=> 'certificate',
				'title'	=> 'nexus_subs_convert',
				'link'	=> Url::internal( 'app=nexus&module=subscriptions&controller=subscriptions&do=convertToSubscription&id=' . $this->_id )
			);
		}

		/* If the product is locked, don't allow any edits or changes */
		if( $this->locked )
		{
			foreach( array( 'edit', 'copy', 'convert_subs' ) as $k )
			{
				if( isset( $buttons[ $k ] ) )
				{
					unset( $buttons[ $k ] );
				}
			}
		}
				
		return $buttons;
	}

	/**
	 * [Node] Get Node Description
	 *
	 * @return	string|null
	 */
	protected function get__description(): ?string
	{
		if( $this->locked )
		{
			return Member::loggedIn()->language()->addToStack( 'product_locked_deprecated' );
		}

		return NULL;
	}

	/**
	 * [Node] Return the custom badge for each row
	 *
	 * @return	NULL|array		Null for no badge, or an array of badge data (0 => CSS class type, 1 => language string, 2 => optional raw HTML to show instead of language string)
	 */
	protected function get__badge(): ?array
	{
		if ( $this->deleteOrMoveQueued() === TRUE )
		{
			return array(
				0	=> 'ipsBadge ipsBadge--intermediary',
				1	=> 'mass_change_purchases_in_progress',
			);
		}

		if( $this->locked )
		{
			return array(
				0	=> 'ipsBadge ipsBadge--negative',
				1 	=> 'product_locked'
			);
		}

		return parent::get__badge();
	}

	/**
	 * cache of packages which are queued for changing purchases
	 * 
	 * @var array|null
	 */
	protected static ?array $packageChangeQueue = null;
	
	/**
	 * Is this node currently queued for deleting or moving content?
	 *
	 * @return	bool
	 */
	public function deleteOrMoveQueued(): bool
	{
		/* If we already know, don't bother */
		if ( is_null( $this->queued ) )
		{
			$this->queued = FALSE;

			if ( !is_array( static::$packageChangeQueue ) )
			{
				static::$packageChangeQueue = iterator_to_array( Db::i()->select( 'data', 'core_queue', array( 'app=? AND `key`=?', 'nexus', 'MassChangePurchases' ) ) );
			}

			foreach( static::$packageChangeQueue AS $row )
			{
				$data = json_decode( $row, TRUE );
				if ( $data['id'] == $this->_id or ( $data['cancel_type'] == 'change' and $data['mass_change_purchases_to'] == $this->_id ) )
				{
					$this->queued = TRUE;
				}
			}
		}

		return $this->queued;
	}
		
	/* !ACP Package Form */
	
	/**
	 * [Node] Add/Edit Form
	 *
	 * @param	Form	$form	The form
	 * @return	void
	 */
	public function form( Form &$form ) : void
	{		
		$groups = array();
		foreach ( Group::groups() as $group )
		{
			$groups[ $group->g_id ] = $group->name;
		}
		$groupsExcludingGuestsAndAdmins = array();
		foreach ( Group::groups( FALSE, FALSE ) as $group )
		{
			$groupsExcludingGuestsAndAdmins[ $group->g_id ] = $group->name;
		}

		$types = array();
		$typeFields = array();
		$typeFieldToggles = array();
		$formId = $this->id ? "form_{$this->id}" : 'form_new';
		foreach ( static::packageTypes() as $key => $class )
		{
			$types[ mb_strtolower( $key ) ] = 'p_type_' . $key;

			/* @var Package $class */
			if ( $formFields = $class::acpFormFields( $this  ) )
			{
				foreach ( $formFields as $group => $fields )
				{
					foreach ( $fields as $field )
					{
						if ( !$field->htmlId )
						{
							$field->htmlId = $field->name;
						}

						$typeFieldToggles[ mb_strtolower( $key ) ][] = $field->htmlId;
						$typeFields[ $group ][] = $field;
					}
				}
			}

			$typeFieldToggles[ mb_strtolower( $key ) ] = array_merge( $typeFieldToggles[mb_strtolower($key)] ?? array(), array( "{$formId}_tab_package_client_area", "{$formId}_header_package_associations", "{$formId}_header_package_associations_desc", 'p_associate', "{$formId}_header_package_renewals", 'p_renews', 'p_lkey' ) );
		}
						
		$renewOptions = array();
		if ( $this->renew_options and $_renewOptions = json_decode( $this->renew_options, TRUE ) and is_array( $_renewOptions ) )
		{
			foreach ( $_renewOptions as $option )
			{
				$costs = array();
				foreach ( $option['cost'] as $cost )
				{
					$costs[ $cost['currency'] ] = new Money( $cost['amount'], $cost['currency'] );
				}
				
				/* Catch any invalid renewal terms, these can occasionally appear from legacy IP.Subscriptions */
				try
				{
					$renewOptions[] = new RenewalTerm( $costs, new DateInterval( "P{$option['term']}" . mb_strtoupper( $option['unit'] ) ), NULL, $option['add'] );
				}
				catch( Exception ) {}
			}
		}
		
		$groupToggles = array();
		$availableFilters = array();
		foreach ( Db::i()->select( array( 'pg_id', 'pg_filters' ), 'nexus_package_groups', "pg_filters IS NOT NULL" ) as $group )
		{
			$groupToggles[ $group['pg_id'] ] = array_map( function( $filterId ) use ( &$availableFilters ) {
				$availableFilters[ $filterId ] = $filterId;
				return "nexus_filter_{$filterId}";
			} , explode( ',', $group['pg_filters'] ) );
			$groupToggles[ $group['pg_id'] ][] = "{$form->id}_header_pg_filters";
		}
								
		$form->addTab('package_settings');
		$form->addHeader('package_settings');
		$form->add( new Radio( 'p_type', ( $this->id ? $this->type : 'product' ), TRUE, array( 'options' => $types, 'toggles' => $typeFieldToggles, 'disabled' => (bool) $this->id ) ) );
		$form->add( new Translatable( 'p_name', NULL, TRUE, array( 'app' => 'nexus', 'key' => $this->id ? "nexus_package_{$this->id}" : NULL ) ) );
		$form->add( new Node( 'p_group', $this->group, TRUE, array( 'class' => 'IPS\nexus\Package\Group', 'subnodes' => FALSE, 'toggleIds' => $groupToggles ) ) );
		$form->add( new Node( 'p_custom_fields', $this->id ? CustomField::roots( NULL, NULL, array( array( Db::i()->findInSet( 'cf_packages', array( $this->id ) ) ) ) ) : array(), FALSE, array( 'class' => 'IPS\nexus\Package\CustomField', 'multiple' => TRUE ) ) );
		foreach ( $typeFields['package_settings'] as $field )
		{
			$form->add( $field );
		}
		$form->addHeader('package_registration');
		$form->add( new YesNo( 'p_reg', $this->reg, FALSE, array(), function( $val )
		{
			if ( $val and ( !Request::i()->p_store_checkbox or ( Request::i()->p_member_groups and !in_array( Settings::i()->guest_group, array_keys( Request::i()->p_member_groups ) ) ) ) )
			{
				throw new DomainException( Member::loggedIn()->language()->addToStack( 'p_reg_err', FALSE, array( 'sprintf' => array( Group::load( Settings::i()->guest_group )->name ) ) ) );
			}
		} ) );
		$form->addHeader('package_associations');
		$form->addMessage('package_associations_desc');
		$form->add( new Radio( 'p_associate', ( $this->force_assoc and count( $this->associablePackages() ) ) ? 2 : ( count( $this->associablePackages() ) ? 1 : 0 ), FALSE, array(
			'options' => array(
				0	=> 'p_associate_none',
				1	=> 'p_associate_optional',
				2	=> 'p_associate_required'
			),
			'toggles'	=> array(
				1	=> array( 'p_associable', 'p_group_renewals', 'p_upsell' ),
				2	=> array( 'p_associable', 'p_group_renewals', 'p_assoc_error_editor', 'p_upsell' )
			)
		), NULL, NULL, NULL, 'p_associate' ) );
		$form->add( new Node( 'p_associable', $this->associablePackages(), FALSE, array( 'class' => 'IPS\nexus\Package\Group', 'multiple' => TRUE, 'permissionCheck' => function( $node )
		{
			return !( $node instanceof Package\Group);
		} ), function( $val )
		{
			if ( $val)
			{
				foreach ( $val AS $item )
				{
					if ( $item->id == $this->id)
					{
						throw new DomainException( 'p_associable_error' );
					}
				}
			}
		}, NULL, NULL, 'p_associable' ) );

		$form->add( new YesNo( 'p_group_renewals', $this->group_renewals ?: FALSE, FALSE, array(), NULL, NULL, NULL, 'p_group_renewals' ) );
		$form->add( new Translatable( 'p_assoc_error', NULL, FALSE, array(
			'app' => 'nexus',
			'key' => $this->id ? "nexus_package_{$this->id}_assoc" : NULL,
			'editor'	=> array(
				'app'			=> 'nexus',
				'key'			=> 'Admin',
				'autoSaveKey'	=> ( $this->id ? "nexus-pkg-{$this->id}-assoc" : "nexus-new-pkg-assoc" ),
				'attachIds'		=> $this->id ? array( $this->id, NULL, 'pkg-assoc' ) : NULL, 'minimize' => 'p_assoc_error_placeholder'
			)
		), NULL, NULL, NULL, 'p_assoc_error_editor' ) );
		$form->add( new YesNo( 'p_upsell', $this->upsell ?: FALSE, FALSE, array(), NULL, NULL, NULL, 'p_upsell' ) );

		$form->addTab( 'package_pricing' );
		$form->addHeader( 'package_purchase_price' );

		$form->add( new \IPS\nexus\Form\Money( 'p_base_price', $this->base_price, TRUE ) );
		$taxToggleIds = array();
		if ( !in_array( 'product', explode( ',', Settings::i()->nexus_require_billing ) ) )
		{
			foreach (Tax::roots() as $tax )
			{
				if ( $tax->type !== 'single' )
				{
					$taxToggleIds[ $tax->_id ] = array( 'elProductTaxWarning' );
				}
				elseif ( $rates = json_decode( $tax->rate, TRUE ) )
				{
					foreach ( $rates as $rate )
					{
						if ( $rate['locations'] !== '*' )
						{
							$taxToggleIds[ $tax->_id ] = array( 'elProductTaxWarning' );
							continue 2;
						}
					}
				}
			}
		}
		$taxField = new Node( 'p_tax', (int) $this->tax, FALSE, array( 'class' => 'IPS\nexus\Tax', 'zeroVal' => 'do_not_tax', 'toggleIds' => $taxToggleIds, 'zeroValTogglesOff' => array( 'elProductTaxWarning' ) ) );
		if ( !in_array( 'product', explode( ',', Settings::i()->nexus_require_billing ) ) )
		{
			$taxField->warningBox = Theme::i()->getTemplate('store')->productTaxWarning();
		}
		$form->add( $taxField );
		$form->addHeader('package_discounts');
		$discounts = $this->discounts ? json_decode( $this->discounts, TRUE ) : [];
		
		$form->add( new YesNo( 'p_set_discounts', ( is_array( $discounts ) AND count( $discounts ) ), FALSE, array( 'togglesOn' => array( 'p_usergroup_discounts', 'p_loyalty_discounts', 'p_bulk_discounts' ) ), NULL, NULL, NULL, 'p_set_discounts' ) );
		$form->add( new Stack( 'p_usergroup_discounts', $discounts['usergroup'] ?? array(), FALSE, array( 'stackFieldType' => 'IPS\nexus\Form\DiscountUsergroup' ), NULL, NULL, NULL, 'p_usergroup_discounts' ) );
		$form->add( new Stack( 'p_loyalty_discounts', $discounts['loyalty'] ?? array(), FALSE, array( 'stackFieldType' => 'IPS\nexus\Form\DiscountLoyalty' ), NULL, NULL, NULL, 'p_loyalty_discounts' ) );
		$form->add( new Stack( 'p_bulk_discounts', $discounts['bulk'] ?? array(), FALSE, array( 'stackFieldType' => 'IPS\nexus\Form\DiscountBulk' ), NULL, NULL, NULL, 'p_bulk_discounts' ) );
				
		$form->addHeader( 'package_renewals' );
		$form->add( new YesNo( 'p_renews', !empty( $renewOptions ), FALSE, array( 'togglesOn' => array( 'p_initial_term', 'p_renew_options', 'p_grace_period', 'p_renew', ( $this->id ? "form_{$this->id}" : 'form_new' ) . '_header_package_client_area_renewals', 'p_email_expire_soon_type', 'p_email_expire_type' ) ), NULL, NULL, NULL, 'p_renews' ) );

		/* The price is ignored on this field anyway, so we can hard-code to $0 */
		$form->add( new \IPS\nexus\Form\RenewalTerm( 'p_initial_term', $this->initial_term ? new RenewalTerm( new Money( "0", "USD" ), new DateInterval( "P" . $this->initial_term ) ) : NULL, FALSE, array( 'initialTerm' => TRUE, 'initialTermLang' => 'term_same_as_renewal', 'lockPrice' => TRUE ), NULL, NULL, NULL, 'p_initial_term' ) );
		$form->add( new Stack( 'p_renew_options', $renewOptions, FALSE, array(
			'stackFieldType'	=> 'IPS\nexus\Form\RenewalTerm',
			'allCurrencies'		=> TRUE,
			'addToBase'			=> TRUE
		), NULL, NULL, NULL, 'p_renew_options' ) );
		$form->add( new Interval( 'p_grace_period', $this->grace_period ?: 0, FALSE, array( 'valueAs' => Interval::DAYS, 'max' => Settings::i()->cm_invoice_expireafter ?: NULL ), NULL, NULL, NULL, 'p_grace_period' ) );
		
		$form->addTab('package_stock_adjustments');
		$fields = new ActiveRecordIterator( Db::i()->select( '*', 'nexus_package_fields', array( array( '( ( cf_type=? OR cf_type=? ) AND cf_multiple=0 )', 'Select', 'Radio' ), Db::i()->findInSet( 'cf_packages', array( $this->id ) ) ) ), 'IPS\nexus\Package\CustomField' );
		if ( count( $fields ) )
		{
			Member::loggedIn()->language()->words['p_stock_price_dynamic_desc'] = '';
		}
		$form->add( new Radio( 'p_stock_price_type', $this->stock == -2 ? 'dynamic' : 'static', TRUE, array(
			'options'	=> array(
				'static'	=> 'p_stock_price_static',
				'dynamic'	=> 'p_stock_price_dynamic',
			),
			'toggles'	=> array(
				'static'	=> array( 'p_stock' ),
				'dynamic'	=> array( 'p_dynamic_stock' )
			),
			'disabled'		=> count( $fields ) ? array() : array( 'dynamic' )
		) ) );
		$form->add( new Form\Number( 'p_stock', $this->id ? ( $this->stock == -2 ? -1 : $this->stock ) : -1, TRUE, array( 'unlimited' => -1 ), NULL, NULL, NULL, 'p_stock' ) );
		$package = $this;
		$form->add( new Custom( 'custom_fields', $this->optionIdKeys(), FALSE, array(
			'rowHtml'	=> function( $input ) use ( $fields, $package )
			{
				return Theme::i()->getTemplate('store')->productOptions( $input, $fields, $package );
			}
		), NULL, NULL, NULL, 'p_dynamic_stock' ) );
		
		
		$form->addTab('package_store');
		$form->addHeader('package_store_permissions');
		$form->add( new YesNo( 'p_store', $this->store, FALSE, array( 'togglesOn' => array( 'p_member_groups', 'p_featured', 'p_desc_editor', 'p_images', 'p_reviewable', "{$form->id}_header_package_store_display", "{$form->id}_header_package_reviews" ) ) ) );
		$form->add( new CheckboxSet( 'p_member_groups', $this->member_groups === '*' ? '*' : explode( ',', $this->member_groups ), FALSE, array( 'options' => $groups, 'multiple' => TRUE, 'unlimited' => '*', 'unlimitedLang' => 'all', 'impliedUnlimited' => TRUE ), NULL, NULL, NULL, 'p_member_groups' ) );
		$form->add( new Node( 'p_methods', ( !$this->methods or $this->methods === '*' ) ? 0 : explode( ',', $this->methods ), TRUE, array( 'class' => 'IPS\nexus\Gateway', 'multiple' => TRUE, 'zeroVal' => 'any' ) ) );
		foreach ( $typeFields['store_permissions'] as $field )
		{
			$form->add( $field );
		}
		$form->addHeader('package_store_display');
		$form->add( new YesNo( 'p_featured', $this->featured, FALSE, array(), NULL, NULL, NULL, 'p_featured' ) );
		$form->add( new Translatable( 'p_desc', NULL, FALSE, array(
			'app' => 'nexus',
			'key' => $this->id ? "nexus_package_{$this->id}_desc" : NULL,
			'editor'	=> array(
				'app'			=> 'nexus',
				'key'			=> 'Admin',
				'autoSaveKey'	=> ( $this->id ? "nexus-pkg-{$this->id}" : "nexus-new-pkg" ),
				'attachIds'		=> $this->id ? array( $this->id, NULL, 'pkg' ) : NULL, 'minimize' => 'p_desc_placeholder'
			)
		), NULL, NULL, NULL, 'p_desc_editor' ) );
		$form->add( new Upload( 'p_images', iterator_to_array( new Iterator( Db::i()->select( 'image_location', 'nexus_package_images', array( 'image_product=?', $this->id ), 'image_primary desc' ), 'nexus_Products' ) ), FALSE, array( 'storageExtension' => 'nexus_Products', 'multiple' => TRUE, 'image' => TRUE, 'template' => "nexus.store.images" ), NULL, NULL, NULL, 'p_images' ) );
		
		if ( $availableFilters )
		{
			$form->addHeader( 'pg_filters' );
		}
		
		$filters = array();
		foreach ( Db::i()->select( '*', 'nexus_package_filters_values', Db::i()->in( 'pfv_filter', $availableFilters ), 'pfv_order' ) as $row )
		{
			if ( $row['pfv_lang'] == Member::loggedIn()->language()->id )
			{
				$filters[ $row['pfv_filter'] ][ $row['pfv_value'] ] = $row['pfv_text'];
			}
		}
		$filterValues = array();
		if ( $this->id )
		{
			foreach ( Db::i()->select( '*', 'nexus_package_filters_map', array( 'pfm_package=?', $this->id ) ) as $row )
			{
				$filterValues[ $row['pfm_filter'] ] = explode( ',', $row['pfm_values'] );
			}
		}
		foreach ( $filters as $filterId => $options )
		{
			$form->add( new CheckboxSet( "nexus_product_filter_{$filterId}", $filterValues[$filterId] ?? array(), FALSE, array( 'options' => $options, 'disableCopy'	=> TRUE ), NULL, NULL, NULL, "nexus_filter_{$filterId}" ) );
		}
		
		$form->addHeader('package_reviews');
		$form->add( new YesNo( 'p_reviewable', $this->reviewable, FALSE, array( 'togglesOn' => array( 'p_review_moderate' ) ), NULL, NULL, NULL, 'p_reviewable' ) );
		$form->add( new YesNo( 'p_review_moderate', $this->review_moderate ?: FALSE, FALSE, array(), NULL, NULL, NULL, 'p_review_moderate' ) );
		$form->addHeader( 'package_notify' );
		$form->add( new Stack( 'p_notify', $this->notify ? explode( ',', $this->notify ) : array(), FALSE, array( 'stackFieldType' => 'Email' ) ) );
		
		$form->addTab( 'package_benefits' );
		$form->add( new Select( 'p_primary_group', $this->primary_group ?: '*', FALSE, array( 'options' => $groupsExcludingGuestsAndAdmins, 'unlimited' => '*', 'unlimitedLang' => 'do_not_change', 'unlimitedToggles' => array( 'p_return_primary' ), 'unlimitedToggleOn' => FALSE ) ) );
		$form->add( new YesNo( 'p_return_primary', $this->return_primary, FALSE, array(), NULL, NULL, NULL, 'p_return_primary' ) );
		$form->add( new Select( 'p_secondary_group', $this->secondary_group ? explode( ',', $this->secondary_group ) : '*', FALSE, array( 'options' => $groupsExcludingGuestsAndAdmins, 'multiple' => TRUE, 'unlimited' => '*', 'unlimitedLang' => 'do_not_change', 'unlimitedToggles' => array( 'p_return_secondary' ), 'unlimitedToggleOn' => FALSE ) ) );
		$form->add( new YesNo( 'p_return_secondary', $this->return_secondary, FALSE, array(), NULL, NULL, NULL, 'p_return_secondary' ) );
		foreach ( $typeFields['package_benefits'] as $field )
		{
			$form->add( $field );
		}
		
		$form->addTab('package_client_area');
		$form->addHeader('package_client_area_display');
		$form->add( new Translatable( 'p_page', NULL, FALSE, array(
			'app' => 'nexus',
			'key' => $this->id ? "nexus_package_{$this->id}_page" : NULL,
			'editor'	=> array(
				'app'			=> 'nexus',
				'key'			=> 'Admin',
				'autoSaveKey'	=> ( $this->id ? "nexus-pkg-{$this->id}-pg" : "nexus-new-pkg-pg" ),
				'attachIds'		=> $this->id ? array( $this->id, NULL, 'pkg-pg' ) : NULL, 'minimize' => 'p_page_placeholder'
			)
		), NULL, NULL, NULL, 'p_page' ) );
		$form->addHeader('package_client_area_renewals');
		$form->add( new YesNo( 'p_renew', $this->renewal_days != 0, FALSE, array( 'togglesOn' => array( 'p_renewal_days', 'p_renewal_days_advance' ) ), NULL, NULL, NULL, 'p_renew' ) );
		$form->add( new Form\Number( 'p_renewal_days', $this->id ? $this->renewal_days : -1, FALSE, array( 'unlimited' => -1, 'unlimitedLang' => 'any_time' ), NULL, NULL, Member::loggedIn()->language()->addToStack('days_before_expiry'), 'p_renewal_days' ) );
		$form->add( new Interval( 'p_renewal_days_advance', $this->id ? $this->renewal_days_advance : -1, FALSE, array( 'valueAs' => Interval::DAYS, 'unlimited' => -1 ), NULL, NULL, NULL, 'p_renewal_days_advance' ) );
		$form->addHeader('package_upgrade_downgrade');
		$form->addMessage('package_upgrade_downgrade_desc');
		$form->add( new YesNo( 'p_allow_upgrading', $this->allow_upgrading, FALSE, array( 'togglesOn' => array( 'p_upgrade_charge' ) ) ) );
		$form->add( new Radio( 'p_upgrade_charge', $this->upgrade_charge, FALSE, array( 'options' => array(
			0	=> 'p_upgrade_charge_none',
			1	=> 'p_upgrade_charge_full',
			2	=> 'p_upgrade_charge_prorate'
		) ), NULL, NULL, NULL, 'p_upgrade_charge' ) );
		$form->add( new YesNo( 'p_allow_downgrading', $this->allow_downgrading, FALSE, array( 'togglesOn' => array( 'p_downgrade_refund' ) ) ) );
		$form->add( new Radio( 'p_downgrade_refund', $this->downgrade_refund, FALSE, array( 'options' => array(
			0	=> 'p_downgrade_refund_none',
			1	=> 'p_downgrade_refund_full',
			2	=> 'p_downgrade_refund_prorate'
		)), NULL, NULL, NULL, 'p_downgrade_refund' ) );
		
		$form->addTab('package_custom_emails');
		$form->addMessage('package_custom_emails_blurb');
		foreach ( Settings::i()->cm_invoice_warning ? array( 'purchase', 'expire_soon', 'expire' ) : array( 'purchase', 'expire' ) as $k )
		{
			$typeKey = "email_{$k}_type";
			$valueKey = "email_{$k}";
			
			$form->add( new Radio( "p_email_{$k}_type", $this->$typeKey, FALSE, array(
				'options' => array(
					''					=> 'p_email_none',
					'wysiwyg'			=> 'p_email_wysiwyg',
					'html_template'		=> 'p_email_html_template',
					'html_raw'			=> 'p_email_html_raw',
				),
				'toggles'	=> array(
					'wysiwyg'			=> array( "p_email_{$k}_subject", "p_email_{$k}_wysiwyg" ),
					'html_template'		=> array( "p_email_{$k}_subject", "p_email_{$k}_html" ),
					'html_raw'			=> array( "p_email_{$k}_subject", "p_email_{$k}_html" )
				)
			), NULL, NULL, NULL, "p_email_{$k}_type" ) );
			$form->add( new Translatable( "p_email_{$k}_subject", NULL, FALSE, array( 'app' => 'nexus', 'key' => $this->id ? "nexus_package_{$this->id}_email_{$k}_subject" : NULL ), NULL, NULL, NULL, "p_email_{$k}_subject" ) );
			$form->add( new Codemirror( "p_email_{$k}_html", $this->$valueKey, FALSE, array(
				'preview' => Url::internal('app=nexus&module=store&controller=packages&do=emailPreview')->csrf(),
				'tags'	=> array(
					'{$purchase->name}'					=> Member::loggedIn()->language()->addToStack('ps_name'),
					'{$purchase->expire->localeDate()}'	=> Member::loggedIn()->language()->addToStack('p_email_tag_expire'),
					'{$purchase->renewals}'				=> Member::loggedIn()->language()->addToStack('ps_renewals'),
					'{$purchase->custom_fields[1]}'		=> Member::loggedIn()->language()->addToStack('p_email_tag_field'),
					'{$purchase->url()}'				=> Member::loggedIn()->language()->addToStack('p_email_tag_url'),
					'{$purchase->licenseKey()->key}'	=> Member::loggedIn()->language()->addToStack('license_key'),
				)
			), NULL, NULL, NULL, "p_email_{$k}_html" ) );
			$form->add( new Editor( "p_email_{$k}_wysiwyg", $this->$valueKey, FALSE, array(
				'app' => 'nexus',
				'key' => 'Admin',
				'autoSaveKey' => $this->id ? "nexus-pkg-{$this->id}-{$k}-email" : "nexus-new-pkg-{$k}-email",
				'allowAttachments' => false
			), NULL, NULL, NULL, "p_email_{$k}_wysiwyg" ) );
		}

        parent::form( $form );
	}
	
	/**
	 * [Node] Save Add/Edit Form
	 *
	 * @param	array	$values	Values from the form
	 * @return    mixed
	 */
	public function saveForm( array $values ): mixed
	{	
		if ( !$this->id )
		{	
			$this->type = ( isset( $values['p_type'] ) ) ? $values['p_type'] : '';
			$this->save();
			unset( static::$multitons[ $this->id ] );
			
			SystemFile::claimAttachments( 'nexus-new-pkg', $this->id, NULL, 'pkg', TRUE );
			SystemFile::claimAttachments( 'nexus-new-pkg-assoc', $this->id, NULL, 'pkg-assoc', TRUE );
			SystemFile::claimAttachments( 'nexus-new-pkg-pg', $this->id, NULL, 'pkg-pg', TRUE );
			
			$types	= static::packageTypes();
			/* @var Package $class */
			$class	= $types[ IPS::mb_ucfirst( $values['p_type'] ) ];
			
			if ( isset( $class::$packageDatabaseTable ) )
			{
				Db::i()->insert( $class::$packageDatabaseTable, array( 'p_id' => $this->id ) );
			}
			
			$obj = $class::load( $this->id );
			return $obj->saveForm( $obj->formatFormValues( $values ) );			
		}
				
		Db::i()->delete( 'nexus_package_filters_map', array( 'pfm_package=?', $this->id ) );
		if ( $this->group and $filters = Package\Group::load( $this->group )->filters )
		{
			foreach (explode( ',', $filters ) as $filterId )
			{
				if ( isset( $values["nexus_product_filter_{$filterId}"] ) and $values["nexus_product_filter_{$filterId}"] )
				{
					Db::i()->insert( 'nexus_package_filters_map', array(
						'pfm_package'	=> $this->id,
						'pfm_filter'	=> $filterId,
						'pfm_values'	=> implode( ',', $values["nexus_product_filter_{$filterId}"] ),
					) );
				}
			}
		}

		
		$return = parent::saveForm( $values );
		
		if ( !$this->custom )
		{
			Index::i()->index( $this->item() );
		}
		
		return $return;
	}
	
	/**
	 * [Node] Format form values from add/edit form for save
	 *
	 * @param	array	$values	Values from the form
	 * @return	array
	 */
	public function formatFormValues( array $values ): array
	{
		if ( !$this->id )
		{
			return $values;
		}
		
		/* Email content */
		foreach ( array( 'purchase', 'expire_soon', 'expire' ) as $k )
		{
			if ( isset( $values["p_email_{$k}_type"] ) )
			{ 
				if ( $values["p_email_{$k}_type"] == 'wysiwyg' )
				{
					$values["p_email_{$k}"] = $values["p_email_{$k}_wysiwyg"];
				}
				elseif ( in_array( $values["p_email_{$k}_type"], array( 'html_template', 'html_raw' ) ) )
				{
					$values["p_email_{$k}"] = $values["p_email_{$k}_html"];
				}
				else
				{
					$values["p_email_{$k}"] = NULL;
				}
			}
			unset( $values["p_email_{$k}_wysiwyg"] );
			unset( $values["p_email_{$k}_html"] );
		}
				
		/* Translatables */
		foreach ( array( 'name' => '', 'desc' => '_desc', 'assoc_error' => '_assoc', 'page' => '_page', 'email_purchase_subject' => '_email_purchase_subject', 'email_expire_soon_subject' => '_email_expire_soon_subject', 'email_expire_subject' => '_email_expire_subject' ) as $key => $suffix )
		{
			if ( isset( $values[ 'p_' . $key ] ) )
			{
				Lang::saveCustom( 'nexus', "nexus_package_{$this->id}{$suffix}", $values[ 'p_' . $key ] );
			}
			unset( $values[ 'p_' . $key ] );
		}
		
		/* Custom Fields */
		if( isset( $values['p_custom_fields'] ) )
		{
			$old = array_keys( CustomField::roots( NULL, NULL, array( array( Db::i()->findInSet( 'cf_packages', array( $this->id ) ) ) ) ) );
			$new = is_array( $values['p_custom_fields'] ) ? array_keys( $values['p_custom_fields'] ) : array();
			$added = array_diff( $new, $old );
			$removed = array_diff( $old, $new );
			if ( count( $added ) )
			{
				foreach ( $added as $id )
				{
					$field = CustomField::load( $id );
					$packages = explode( ',', $field->packages );
					$packages[] = $this->id;
					$field->packages = implode( ',', $packages );
					$field->save();
				}
			}
			if ( count( $removed ) )
			{
				foreach ( $removed as $id )
				{
					$field = CustomField::load( $id );
					$packages = explode( ',', $field->packages );
					unset( $packages[ array_search( $this->id, $packages ) ] );
					$field->packages = implode( ',', $packages );
					$field->save();
				}
			}
			unset( $values['p_custom_fields'] );
		}
		
		/* Normalise */
		if( isset( $values['p_group'] ) )
		{
			$values['p_group'] = $values['p_group']->id;
		}

		if( isset( $values['p_base_price'] ) )
		{
			$values['p_base_price'] = json_encode( $values['p_base_price'] );
		}

		if( isset( $values['p_member_groups'] ) )
		{
			$values['p_member_groups'] = $values['p_member_groups'] === '*' ? '*' : implode( ',', $values['p_member_groups'] );
		}

		if( isset( $values['p_primary_group'] ) )
		{
			$values['p_primary_group'] = $values['p_primary_group'] == '*' ? 0 : $values['p_primary_group'];
		}

		if( isset( $values['p_secondary_group'] ) )
		{
			$values['p_secondary_group'] = $values['p_secondary_group'] == '*' ? '' : implode( ',', $values['p_secondary_group'] );
		}

		if( isset( $values['p_notify'] ) and is_array( $values['p_notify'] ) )
		{
			$values['p_notify'] = implode( ',', $values['p_notify'] );
		}

		if( isset( $values['p_methods'] ) )
		{
			$values['p_methods'] = is_array( $values['p_methods'] ) ? implode( ',', array_keys( $values['p_methods'] ) ) : '*';
		}

		if( isset( $values['p_tax'] ) )
		{
			$values['p_tax'] = $values['p_tax'] ? $values['p_tax']->id : 0;
		}

		if( isset( $values['p_renew'] ) AND isset( $values['p_renewal_days'] ) )
		{
			$values['p_renewal_days'] = $values['p_renew'] ? $values['p_renewal_days'] : 0;
		}
		
		/* Renewal options */
		if( isset( $values['p_renews'] ) )
		{
			if ( $values['p_renews'] )
			{
				$renewOptions = array();
				foreach ( $values['p_renew_options'] as $option )
				{
					$term = $option->getTerm();
					$renewOptions[] = array(
						'cost'	=> $option->cost,
						'term'	=> $term['term'],
						'unit'	=> $term['unit'],
						'add'	=> $option->addToBase
					);
				}
				$values['p_renew_options'] = json_encode( $renewOptions );
				
				if ( isset( $values['p_initial_term'] ) and $values['p_initial_term']->interval )
				{
					$term = $values['p_initial_term']->getTerm();
					$values['p_initial_term'] = $term['term'] . mb_strtoupper( $term['unit'] );
				}
				else
				{
					$values['p_initial_term'] = NULL;
				}
			}
			else
			{
				$values['p_renew_options'] = '';
				$values['p_initial_term'] = NULL;
			}
		}
		else
		{
			$values['p_initial_term'] = NULL;
		}
		
		/* Associate */
		if ( isset( $values['p_associate'] ) )
		{
			switch ( $values['p_associate'] )
			{
				case 0:
					$values['p_associable'] = '';
					$values['p_force_assoc'] = 0;
					break;
				
				case 1:
					$values['p_associable'] = implode( ',', array_map( function( $node ) { return $node->id; }, $values['p_associable'] ) );
					$values['p_force_assoc'] = 0;
					break;
				
				case 2:
					$values['p_associable'] = implode( ',', array_map( function( $node ) { return $node->id; }, $values['p_associable'] ) );
					$values['p_force_assoc'] = 1;
					break;
			}
		}

		/* Product Options */
		Db::i()->delete( 'nexus_product_options', array( 'opt_package=?', $this->id ) );
		if ( isset( $values['p_stock_price_type'], $values['custom_fields'] ) )
		{
			if ( $values['p_stock_price_type'] === 'dynamic' )
			{
				$values['p_stock'] = -2;
				foreach ( $values['custom_fields'] as $k => $data )
				{				
					Db::i()->insert( 'nexus_product_options', array(
						'opt_package'		=> $this->id,
						'opt_values'		=> rawurldecode( $k ),
						'opt_stock'			=> isset( $data['unlimitedStock'] ) ? -1 : intval( $data['stock'] ),
						'opt_base_price'	=> json_encode( $data['bpa'] ),
						'opt_renew_price'	=> isset( $data['rpa'] ) ? json_encode( $data['rpa'] ) : ''
					) );
				}
			}
			unset( $values['p_stock_price_type'] );
			unset( $values['custom_fields'] );
		}
		/* Reset the discounts ? */
		if ( isset( $values['p_set_discounts'] ) AND !$values['p_set_discounts'] )
		{
			unset( $values['p_usergroup_discounts'] );
			unset( $values['p_loyalty_discounts'] );
			unset( $values['p_bulk_discounts'] );
			$values['discounts'] = NULL;
		}
		else
		{
			/* Discounts */
			if (isset($values['p_usergroup_discounts']) OR isset($values['p_loyalty_discounts']) OR isset($values['p_bulk_discounts']))
			{
				$discounts = array();
			}

			if (isset($values['p_usergroup_discounts']))
			{
				foreach ($values['p_usergroup_discounts'] as $data)
				{
					if (isset($data['group']) and $data['group'] and isset($data['price']))
					{
						foreach ($data['price'] as $price)
						{
							if (!$price)
							{
								continue 2;
							}
						}

						$discounts['usergroup'][] = array(
							'group' => $data['group'],
							'price' => $data['price'],
							'secondary' => (int)isset($data['secondary']),
						);
					}
				}
				unset($values['p_usergroup_discounts']);
			}

			if (isset($values['p_loyalty_discounts']))
			{
				foreach ($values['p_loyalty_discounts'] as $data)
				{
					if ($data['owns'])
					{
						foreach ($data['price'] as $price)
						{
							if (!$price)
							{
								continue 2;
							}
						}

						$packageId = $data['package'] ? mb_substr($data['package'], 0, mb_strpos($data['package'], '.')) : 0;

						$discounts['loyalty'][] = array(
							'owns' => $data['owns'],
							'package' => ($data['this'] or ($this->id and $this->id == $packageId)) ? '0' : $packageId,
							'price' => $data['price'],
							'active' => (int)isset($data['active']),
						);
					}
				}
				unset($values['p_loyalty_discounts']);
			}

			if (isset($values['p_bulk_discounts']))
			{
				foreach ($values['p_bulk_discounts'] as $data)
				{
					if ($data['buying'])
					{
						foreach ($data['price'] as $price)
						{
							if (!$price)
							{
								continue 2;
							}
						}

						$packageId = $data['package'] ? mb_substr($data['package'], 0, mb_strpos($data['package'], '.')) : 0;

						$discounts['bulk'][] = array(
							'buying' => $data['buying'],
							'package' => ($data['this'] or ($this->id and $this->id == $packageId)) ? '0' : $packageId,
							'price' => $data['price'],
						);
					}
				}
				unset($values['p_bulk_discounts']);
			}
		}
		
		if( isset( $discounts ) )
		{
			$values['discounts'] = json_encode( $discounts );
		}
		
		/* Images */
		if( isset( $values['p_images'] ) )
		{
			Db::i()->delete( 'nexus_package_images', array( 'image_product=?', $this->id ) );

			if( !empty( $values['p_images'] ) )
			{
				$primary = TRUE;
				foreach ( $values['p_images'] as $_key => $image )
				{
					if (  Request::i()->p_images_primary_image )
					{
						$primary = ( Request::i()->p_images_primary_image == $_key ) ? 1 : 0;
					}

					Db::i()->insert( 'nexus_package_images', array(
						'image_product'		=> $this->id,
						'image_location'	=> (string) $image,
						'image_primary'		=> $primary,
					) );

					if ( $primary )
					{
						$this->image = $image;
					}

					$primary = FALSE;
				}
			}
			else
			{
				$this->image = NULL;
			}
		}

		/* Column does not accept null */
		$values['p_featured'] = isset( $values['p_featured'] ) ? (int) $values['p_featured'] : 0 ;
		$values['p_reviewable'] = isset( $values['p_reviewable'] ) ? (int) $values['p_reviewable'] : 0;
		
		/* Save */
		return $values;
	}
	
	/**
	 * ACP Fields
	 *
	 * @param Package $package	The package
	 * @param bool $custom		If TRUE, is for a custom package
	 * @param bool $customEdit	If TRUE, is editing a custom package
	 * @return    array
	 */
	public static function acpFormFields(Package $package, bool $custom=FALSE, bool $customEdit=FALSE ): array
	{
		return array();
	}
	
	/**
	 * Updateable fields
	 *
	 * @return	array
	 */
	public static function updateableFields() : array
	{
		return array(
			'tax',
			'renew_options',
			'grace_period',
			'primary_group',
			'secondary_group',
			'group_renewals'
		);
	}
	
	/**
	 * Update existing purchases
	 *
	 * @param Purchase $purchase							The purchase
	 * @param array $changes							The old values
	 * @param bool $cancelBillingAgreementIfNecessary	If making changes to renewal terms, TRUE will cancel associated billing agreements. FALSE will skip that change
	 * @return    void
	 */
	public function updatePurchase(Purchase $purchase, array $changes, bool $cancelBillingAgreementIfNecessary=FALSE ): void
	{
		if ( array_key_exists( 'tax', $changes ) )
		{
			if ( !$purchase->billing_agreement or $cancelBillingAgreementIfNecessary )
			{
				if ( $billingAgreement = $purchase->billing_agreement )
				{
					try
					{
						$billingAgreement->cancel();
						$billingAgreement->save();
					}
					catch ( Exception ) { }
				}
				
				$purchase->tax = $this->tax;
				$purchase->save();
			}
		}
		
		if ( array_key_exists( 'renew_options', $changes ) and !empty( $changes['renew_options'] ) )
		{
			$newRenewTerms = json_decode( $this->renew_options, TRUE );

			if( !is_array( $newRenewTerms ) )
			{
				$newRenewTerms = array();
			}
						
			$key = 'x';
			$childPurchaseRenewalCost = NULL;
			if ( $purchase->renewals )
			{
				/* If the purchase has any child purchases, subtract them from the renewal cost to find the actual cost of this product */
				$thisPurchaseRenewalCost = $purchase->renewals->cost->amount;
				$childPurchaseRenewalCost = new Number( "0" );
				foreach( $purchase->children() as $child )
				{
					if( $child->renewals )
					{
						$childPurchaseRenewalCost = $childPurchaseRenewalCost->add( $child->renewals->cost->amount );
						$thisPurchaseRenewalCost = $thisPurchaseRenewalCost->subtract( $child->renewals->cost->amount );
					}
				}

				foreach ( $changes['renew_options'] as $k => $data )
				{
					if ( $data['old'] != 'x' )
					{
						/* Compare against the actual renewal cost */
						if ( $thisPurchaseRenewalCost->compare( new Number( number_format( $data['old']['cost'][ $purchase->renewals->cost->currency ]['amount'], Money::numberOfDecimalsForCurrency( $purchase->renewals->cost->currency ), '.', '' ) ) ) === 0 )
						{
							$term = $purchase->renewals->getTerm();
							if ( $data['old']['term'] == $term['term'] and $data['old']['unit'] == $term['unit'] )
							{
								$key = $k;
								break;
							}
						}
					}
				}
			}
			
			switch ( $changes['renew_options'][ $key ]['new'] )
			{
				case 'z':
					$purchase->renewals = NULL;
					$purchase->save();
					if ( $billingAgreement = $purchase->billing_agreement )
					{
						try
						{
							$billingAgreement->cancel();
							$billingAgreement->save();
						}
						catch ( Exception ) { }
					}
					break;
				case 'y':
					$purchase->renewals = NULL;
					$purchase->active = TRUE;
					$purchase->save();
					if ( $billingAgreement = $purchase->billing_agreement )
					{
						try
						{
							$billingAgreement->cancel();
							$billingAgreement->save();
						}
						catch ( Exception ) { }
					}
					break;
				case 'x':
					$purchase->renewals = NULL;
					$purchase->active = FALSE;
					$purchase->save();
					if ( $billingAgreement = $purchase->billing_agreement )
					{
						try
						{
							$billingAgreement->cancel();
							$billingAgreement->save();
						}
						catch ( Exception ) { }
					}
					break;
				case '-':
					// do nothing
					break;
				default:
					if ( mb_substr( $changes['renew_options'][ $key ]['new'], 0, 1 ) === 'o' )
					{
						if ( !$purchase->billing_agreement or $cancelBillingAgreementIfNecessary )
						{
							if ( $billingAgreement = $purchase->billing_agreement )
							{
								try
								{
									$billingAgreement->cancel();
									$billingAgreement->save();
								}
								catch ( Exception ) { }
							}
							
							$key = mb_substr( $changes['renew_options'][ $key ]['new'], 1 );
							if ( isset( $newRenewTerms[ $key ] ) )
							{
								$tax = NULL;
								if ( $purchase->tax )
								{
									try
									{
										$tax = Tax::load( $purchase->tax );
									}
									catch ( OutOfRangeException ) { }
								}
								
								$currency = $purchase->renewal_currency ?: $purchase->member->defaultCurrency( );

								$originalRenewalCost = $purchase->renewals->cost->amount;

								/* If we had child purchases, add back the renewal amounts */
								$newRenewalCost = new Number( $newRenewTerms[ $key ]['cost'][ $currency ]['amount'] );
								if( $childPurchaseRenewalCost !== NULL )
								{
									$newRenewalCost = $newRenewalCost->add( $childPurchaseRenewalCost );
								}

								$purchase->renewals = new RenewalTerm(
									new Money( $newRenewalCost, $currency ),
									new DateInterval( 'P' . $newRenewTerms[ $key ]['term'] . mb_strtoupper( $newRenewTerms[ $key ]['unit'] ) ),
									$tax
								);
								$purchase->save();

								/* If we have a parent purchase, update the renewals for the parent */
								if( $parent = $purchase->parent() )
								{
									if( $parentRenewals = $parent->renewals )
									{
										$parentRenewalCost = $parentRenewals->cost->amount->subtract( $originalRenewalCost );
										$parentRenewalCost = $parentRenewalCost->add( $newRenewalCost );
										$parent->renewals = new RenewalTerm(
											new Money( $parentRenewalCost, $currency ),
											$parentRenewals->interval,
											$parentRenewals->tax
										);
										$parent->save();
									}
								}
							}
						}
					}
					break;
			}
		}
		
		if ( array_key_exists( 'primary_group', $changes ) or array_key_exists( 'secondary_group', $changes ) )
		{
			$this->_removeUsergroups( $purchase, $changes );
			$this->_addUsergroups( $purchase );
		}
		
		if ( array_key_exists( 'group_renewals', $changes ) )
		{
			if ( $this->group_renewals )
			{
				try
				{
					$purchase->groupWithParent();
				}
				catch ( LogicException ) { }
			}
			else
			{
				try
				{
					$purchase->ungroupFromParent();
				}
				catch ( LogicException ) { }
			}
		}
		
		if ( array_key_exists( 'grace_period', $changes ) )
		{
			$purchase->grace_period = $this->grace_period * 86400;
			$purchase->save();
		}
	}
	
	/* !Store */
	
	/**
	 * Store Form
	 *
	 * @param	Form	$form			The form
	 * @param string $memberCurrency	The currency being used
	 * @return    void
	 */
	public function storeForm( Form $form, string $memberCurrency ): void
	{
		if( $this->subscription )
		{
			$form->hiddenValues['quantity'] = 1;
		}
		else
		{
			$form->add( new Form\Number('quantity', 1, TRUE, array( 'min' => 1 ) ) );
		}
	}
	
	/**
	 * Additional Secondary Page to show on checkout
	 *
	 * @param	array	$values	Values from store form
	 * @return	string|NULL
	 */
	public function storeAdditionalPage( array $values ) : string|null
	{
		return NULL;
	}
	
	/**
	 * Add To Cart
	 *
	 * @param Invoice\Item $item			The item
	 * @param	array										$values			Values from form
	 * @param string $memberCurrency	The currency being used
	 * @return    array    Additional items to add
	 */
	public function addToCart( Invoice\Item $item, array $values, string $memberCurrency ): array
	{
		return array();
	}
	
	/**
	 * Get item
	 *
	 * @return	Item
	 */
	public function item() : Item
	{
		$data = array();
		foreach ( $this->_data as $k => $v )
		{
			$data[ 'p_' . $k ] = $v; 
		}
		
		return Item::constructFromData( $data );
	}
	
	/**
	 * Get option values for stock/price adjustments
	 *
	 * @param	array	$details	Custom field values
	 * @return	array
	 */
	public function optionValues( array $details ) : array
	{
		$optionValues = array();
		if ( $this->stock == -2 )
		{
			foreach( $this->optionIdKeys() as $k )
			{
				$optionValues[ $k ] = ( isset( $details[ $k ] ) ) ? $details[ $k ] : NULL;
			}
		}
		return $optionValues;
	}

	/**
	 * @brief   Temporarily store stock data based on query.
	 */
	protected array $_stockQueryCache = [];
	
	/**
	 * Stock level
	 *
	 * @return	int|null	Integer for a specific stock number or NULL if unlimited or variable number in stock
	 */
	public function stockLevel() : int|null
	{
		switch ( $this->stock )
		{
			case -1: // Unlimited
				return NULL;
			case -2: // Variable
				if( !array_key_exists( $this->id, $this->_stockQueryCache ) )
				{
					if ( Db::i()->select( 'COUNT(*)', 'nexus_product_options', array( 'opt_package=? AND opt_stock<>0', $this->id ) )->first() )
					{
						$this->_stockQueryCache[ $this->id ] = NULL;
					}
					else
					{
						$this->_stockQueryCache[ $this->id ] = 0;
					}
				}

				return $this->_stockQueryCache[ $this->id ];
			default:
				return (int) $this->stock;
		}
	}
	
	/**
	 * Get stock and price for option values
	 *
	 * @param array $optionValues		Option values
	 * @param bool $includeDiscounts	Get discounted price?
	 * @return    array( 'price' => \IPS\nexus\Money, 'stock' => 5, 'renewalAdjustment' => 0 )	'stock' will be -1 for unlimited, 0 for none in stock
	 */
	public function optionValuesStockAndPrice(array $optionValues, bool $includeDiscounts=TRUE ): array
	{
		/* Dynamic */
		if ( $this->stock == -2 )
		{
			$chosenOption = Db::i()->select( '*', 'nexus_product_options', array( 'opt_package=? AND opt_values=?', $this->id, json_encode( $optionValues ) ) )->first();
			
			/* Price */
			$basePriceAdjustments = json_decode( $chosenOption['opt_base_price'], TRUE );
			$defaultPrice = $this->price( NULL, $includeDiscounts, $includeDiscounts, $includeDiscounts );
			if ( isset( $basePriceAdjustments[ $defaultPrice->currency ] ) )
			{
				$price = new Money( $defaultPrice->amount->add( new Number( number_format( $basePriceAdjustments[ $defaultPrice->currency ], Money::numberOfDecimalsForCurrency( $defaultPrice->currency ), '.', '' ) ) ), $defaultPrice->currency );
			}
			else
			{
				$price = $defaultPrice;
			}
			
			$renewalAdjustment = 0;
			if ( $chosenOption['opt_renew_price'] )
			{
				$renewPriceAdjustments = json_decode( $chosenOption['opt_renew_price'], TRUE );
				if ( isset( $renewPriceAdjustments[ $price->currency ] ) )
				{
					$renewalAdjustment = $renewPriceAdjustments[ $price->currency ];
				}
			}
			
			/* Return */
			return array( 'price' => $price, 'stock' => $chosenOption['opt_stock'], 'renewalAdjustment' => $renewalAdjustment );
		}
		/* Static */
		else
		{
			return array( 'price' => $this->price( NULL, $includeDiscounts, $includeDiscounts, $includeDiscounts ), 'stock' => $this->stock, 'renewalAdjustment' => 0 );
		}
	}
	
	/**
	 * Create Item Object
	 *
	 * @param Money $price		Price
	 * @return	ItemPackage
	 */
	public function createItemForCart( Money $price ) : ItemPackage
	{
		$item = new ItemPackage( Member::loggedIn()->language()->get( 'nexus_package_' . $this->id ), $price );
		$item->id = $this->id;
		$item->tax = $this->tax ? Tax::load( $this->tax ) : NULL;
		if ( $this->methods and $this->methods != '*' )
		{
			$item->paymentMethodIds = explode( ',', $this->methods );
		}
		if ( $this->group_renewals )
		{
			$item->groupWithParent = TRUE;
		}

		return $item;
	}
	
	/**
	 * Add items into cart
	 *
	 * @param array $details			Custom field values
	 * @param int $quantity			Quantity to add
	 * @param RenewalTerm|NULL $renewalTerm		Chosen renewal term, if applicable
	 * @param int|Purchase|null $parent				Parent purchase, if applicable
	 * @param array|NULL $values				If adding from the main purchase field, any additional values which may be used for extra items
	 * @return    int
	 * @note	Does not verify stock. Stock check first
	 */
	public function addItemsToCartData(array $details=array(), int $quantity=1, ?RenewalTerm $renewalTerm = NULL, Purchase|int|null $parent=NULL, ?array $values=NULL ): int
	{		
		$optionValues = $this->optionValues( $details );
		
		/* If we do not have a currency in our session data, set it now so that if the language is changed, it is not confused about
			what currency the store should be in */
		if ( ( isset( Request::i()->cookie['currency'] ) and in_array( Request::i()->cookie['currency'], Money::currencies() ) ) )
		{
			$memberCurrency = Request::i()->cookie['currency'];
		}
		else
		{
			$memberCurrency = Customer::loggedIn()->defaultCurrency();
			Request::i()->setCookie( 'currency', $memberCurrency );
		}
					
		/* Work out how we're splitting these */
		$items = array();
		$previousPrice = NULL;
		$inThisBatch = 0;
		for ( $i=0; $i<$quantity;$i++ )
		{
			$price = $this->price( NULL, TRUE, TRUE, TRUE, $i );	
			if ( $price->currency !== $memberCurrency )
			{
				throw new RuntimeException('CURRENCY_MISMATCH');
			}		
			$price = $price->amount;
							
			if ( $previousPrice !== NULL and $previousPrice !== $price )
			{
				$items[] = $inThisBatch;
				$inThisBatch = 0;
			}
						
			$inThisBatch++;
			$previousPrice = $price;
		}
						
		if ( $inThisBatch )
		{
			$items[] = $inThisBatch;
		}
		
		/* And do it */
		foreach ( $items as $quantity )
		{
			/* Get base price */
			$price = $this->price()->amount;
			
			/* Renewal term */
			if ( $renewalTerm and $renewalTerm->addToBase )
			{
				$price = $price->add( $renewalTerm->cost->amount );
			}
						
			/* Adjustments based on custom fields */
			if ( $this->stock == -2 )
			{					
				try
				{
					$chosenOption = Db::i()->select( '*', 'nexus_product_options', array( 'opt_package=? AND opt_values=?', $this->id, json_encode( $optionValues ) ) )->first();
					$basePriceAdjustments = json_decode( $chosenOption['opt_base_price'], TRUE );
					if ( isset( $basePriceAdjustments[ $memberCurrency ] ) )
					{
						$price = $price->add( new Number( number_format( $basePriceAdjustments[ $memberCurrency ], Money::numberOfDecimalsForCurrency( $memberCurrency ), '.', '' ) ) );
					}
				}
				catch ( UnderflowException ) {}
			}
			
			/* Create the item */			
			$item = $this->createItemForCart( new Money( $price, $memberCurrency ) );
			$item->renewalTerm = $renewalTerm;
			if ( $renewalTerm and $this->initial_term )
			{
				$item->initialInterval = new DateInterval( "P{$this->initial_term}" );
			}
			$item->quantity = $quantity;
			$item->details = $details;

			/* Associations */
			if ( $parent !== NULL )
			{
				$item->parent = $parent;
			}
			
			/* Do any package-specific modifications */
			$extraItems = ( $values === NULL ) ? array() : $this->addToCart( $item, $values, $memberCurrency );
			
			/* Hang on, is that the same as anything else in the cart? */
			$added = FALSE;
			foreach ( $_SESSION['cart'] as $_id => $_item )
			{
				if ( $_item->isSameAsOtherItem( $item ) )
				{
					$_SESSION['cart'][$_id]->quantity += $quantity;
					$added = TRUE;
					$cartId = $_id;
					break;
				}
			}
									
			/* Nope, go ahead and add it */
			if ( !$added )
			{
				$_SESSION['cart'][] = $item;
				$keys = array_keys( $_SESSION['cart'] );
				$cartId = array_pop( $keys );
			}
			
			/* Associate extras */
			if ( count( $extraItems ) )
			{
				foreach ( $extraItems as $item )
				{
					$item->parent = $cartId;
					$_SESSION['cart'][] = $item;
				}
			}
		}

		/* Turn off guest page caching */
		if ( CACHE_PAGE_TIMEOUT and !Member::loggedIn()->member_id )
		{
			/* Set the cookie for 20 minutes - if the guest hasn't checked out by then they may no longer see their cart on every page but as long as their session is still valid they will still be able to check out. */
			Request::i()->setCookie( 'noCache', 1, DateTime::ts( time() + 1200 ) );
		}

		/* Return */
		return $cartId;
	}
	
	/* !Client Area */
	
	/**
	 * Show Purchase Record?
	 *
	 * @return	bool
	 */
	public function showPurchaseRecord(): bool
	{
		return TRUE;
	}
	
	/**
	 * Get Client Area Page HTML
	 *
	 * @param Purchase $purchase	The purchase
	 * @return	array( 'packageInfo' => '...', 'purchaseInfo' => '...' )
	 */
	public function clientAreaPage( Purchase $purchase ) : array
	{
		/* Custom Fields */
		$editable = FALSE;
		$customFieldsForm = new Form;
		$customFieldsForm->class = 'ipsForm--vertical ipsForm--client-custom-fields';
		$customFields = CustomField::roots( NULL, NULL, array( array( Db::i()->findInSet( 'cf_packages', array( $this->id ) ) ) ) );
		foreach ( $customFields as $field )
		{
			$value = isset( $purchase->custom_fields[ $field->id ] ) ? $purchase->custom_fields[ $field->id ] : NULL;
			if ( $field->editable )
			{
				if ( $field->type === 'Editor' )
				{
					$field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => array( $purchase->id, $field->id, 'purchase' ) ) );
				}
				$helper = $field->buildHelper( $value );
				$editable = TRUE;
				$customFieldsForm->add( $helper );
			}
			else
			{
				$customFieldValue = $field->displayValue( $value );
				if( $customFieldValue )
				{
					$customFieldsForm->addDummy( $field->_title, $customFieldValue );
				}
			}
		}
		if ( !$editable )
		{
			$customFieldsForm->actionButtons = array();
		}

		if( $values = $customFieldsForm->values() )
		{
			$customFieldsSave = $purchase->custom_fields;
			
			foreach ( $customFields as $field )
			{
				if ( isset( $values[ 'nexus_pfield_' . $field->id ] ) )
				{
					$class = $field->buildHelper();
					if ( $class instanceof Upload )
					{
						$customFieldsSave[ $field->id ] = (string) $values[ 'nexus_pfield_' . $field->id ];
					}
					else
					{
						$customFieldsSave[ $field->id ] = $class::stringValue( $values[ 'nexus_pfield_' . $field->id ] );
					}

					if ( $field->type === 'Editor' )
					{
						$field->claimAttachments( $purchase->id, 'purchase' );
					}
				}
			}
						
			$purchase->custom_fields = $customFieldsSave;
			$purchase->save();
		}

		/* Stuff only the owner or "billing" alternate contact can do */
		$reactivateUrl = NULL;
		$upgradeDowngradeUrl = NULL;
		$upgradeDowngradeLang = 'change_package';
		if ( $purchase->member->member_id === Member::loggedIn()->member_id or array_key_exists( Member::loggedIn()->member_id, iterator_to_array( $purchase->member->alternativeContacts( array( Db::i()->findInSet( 'purchases', array( $purchase->id ) ) . ' AND billing=1' ) ) ) ) )
		{
			/* Reactivate */
			if ( !$purchase->renewals and $renewOptions = json_decode( $this->renew_options, TRUE ) and count( $renewOptions ) and $purchase->can_reactivate and ( !$purchase->billing_agreement or $purchase->billing_agreement->canceled ) )
			{
				$reactivateUrl = Url::internal( "app=nexus&module=clients&controller=purchases&id={$purchase->id}&do=extra&act=reactivate", 'front', 'clientspurchaseextra', Url::seoTitle( $purchase->name ) )->csrf();
			}
			
			/* Upgrade/Downgrade */
			$includesUpgrades = FALSE;
			$includesDowngrades = FALSE;
			if( count( $this->upgradeDowngradeOptions( $purchase, FALSE, $includesUpgrades, $includesDowngrades ) ) )
			{
				$upgradeDowngradeUrl = Url::internal( "app=nexus&module=clients&controller=purchases&id={$purchase->id}&do=extra&act=change", 'front', 'clientspurchaseextra', Url::seoTitle( $purchase->name ) );
				if ( $includesUpgrades and !$includesDowngrades )
				{
					$upgradeDowngradeLang = 'change_package_upgrade';
				}
				elseif ( !$includesUpgrades and $includesDowngrades )
				{
					$upgradeDowngradeLang = 'change_package_downgrade';
				}
			}
		}
		
		/* Associated Downloads Files */
		$associatedFiles = array();
		if ( Application::appIsEnabled( 'downloads' ) and Settings::i()->idm_nexus_on )
		{
			$associatedFiles = File::getItemsWithPermission( array( array( Db::i()->findInSet( 'file_nexus', array( $this->id ) ) ) ) );
		}

		/* Display */
		return array(
			'packageInfo'	=> Member::loggedIn()->language()->checkKeyExists("nexus_package_{$this->id}_page") ? Member::loggedIn()->language()->addToStack("nexus_package_{$this->id}_page") : '',
			'purchaseInfo'	=> Theme::i()->getTemplate( 'purchases' )->package( $this, $customFieldsForm, $reactivateUrl, $upgradeDowngradeUrl, $upgradeDowngradeLang, $associatedFiles ),
		);
	}
	
	/**
	 * Client Area Action
	 *
	 * @param Purchase $purchase	The purchase
	 * @return	string
	 */
	public function clientAreaAction( Purchase $purchase ) : string
	{
		switch ( Request::i()->act )
		{
			case 'change':
				$form = $this->upgradeDowngradeForm( $purchase );
				if ( $values = $form->values() )
				{
					$newPackage				= Package::load( $values['change_package_to'] );
					$chosenRenewalOption	= $values["renew_option_{$newPackage->id}"] ?? NULL;
					
					/* This package may only have one renewal option, in which case it is not displayed in the form - we should auto select it here, if renewal options are available */
					if ( $chosenRenewalOption === NULL )
					{
						$renewOptions = json_decode( $newPackage->renew_options, TRUE ) ?? array();
						if ( count( $renewOptions ) === 1 )
						{
							$chosenRenewalOption = key( $renewOptions );
						}
					}
					
					$invoice = $this->upgradeDowngrade( $purchase, $newPackage, $chosenRenewalOption );
					if ( $invoice )
					{
						Output::i()->redirect( $invoice->checkoutUrl() );
					}
					$purchase->member->log( 'purchase', array( 'type' => 'change', 'id' => $purchase->id, 'old' => $purchase->name, 'name' => $newPackage->titleForLog(), 'system' => FALSE ) );
					Output::i()->redirect( $purchase->url() );
				}
				return (string) $form;
			
			case 'reactivate':
				if( !$purchase->cancelled OR ( $purchase->cancelled AND !$purchase->can_reactivate ) )
				{
					Output::i()->error( 'package_cannot_reactivate_error', '2S437/1', 403, '' );
				}

				$currency = $purchase->original_invoice->currency;
				$renewalOptions = json_decode( $this->renew_options, TRUE );
				$noOptions = count( $renewalOptions ) === 1;

				$tax = NULL;
				if ( $purchase->tax )
				{
					try
					{
						$tax = Tax::load( $purchase->tax );
					}
					catch ( Exception ) { }
				}

				if ( $noOptions )
				{
					Session::i()->csrfCheck();
					$term = array_pop( $renewalOptions );

					$purchase->renewals = new RenewalTerm( new Money( $term['cost'][$currency]['amount'], $currency ), new DateInterval( 'P' . $term['term'] . mb_strtoupper( $term['unit'] ) ), $tax );
					$purchase->cancelled = FALSE;
					$purchase->save();
					
					foreach ( $purchase->children() as $child )
					{
						if ( $child->grouped_renewals )
						{
							$child->groupWithParent();
						}
					}
					
					$purchase->member->log( 'purchase', array( 'type' => 'info', 'id' => $purchase->id, 'name' => $purchase->name, 'info' => 'change_renewals', 'to' => array( 'cost' => $purchase->renewals->cost->amount, 'currency' => $purchase->renewals->cost->currency, 'term' => $purchase->renewals->getTerm() ) ) );

					if ( !$purchase->active and $cycles = $purchase->canRenewUntil( NULL, TRUE ) )
					{
						$url = $cycles === 1 ? $purchase->url()->setQueryString( 'do', 'renew' )->csrf() : $purchase->url()->setQueryString( 'do', 'renew' );
						Output::i()->redirect( $url );
					}
					else
					{
						Output::i()->redirect( $purchase->url() );
					}
				}
			
				$form = new Form( 'reactivate', 'reactivate' );
				$form->class = 'ipsForm--vertical ipsForm--reactivate-package';
				$options = array();
				foreach ( $renewalOptions as $k => $term )
				{
					$options[ $k ] = new RenewalTerm( new Money( $term['cost'][$currency]['amount'], $currency ), new DateInterval( 'P' . $term['term'] . mb_strtoupper( $term['unit'] ) ), $tax );
				}
				$form->add( new Radio( 'ps_renewals', NULL, TRUE, array( 'options' => $options, 'parse' => 'normal' ) ) );
				if ( $values = $form->values() )
				{
					$term = $renewalOptions[ $values['ps_renewals'] ];
					$purchase->renewals = new RenewalTerm( new Money( $term['cost'][$currency]['amount'], $currency ), new DateInterval( 'P' . $term['term'] . mb_strtoupper( $term['unit'] ) ), $tax );
					$purchase->cancelled = FALSE;
					$purchase->save();
					
					foreach ( $purchase->children() as $child )
					{
						if ( $child->grouped_renewals )
						{
							$child->groupWithParent();
						}
					}
					
					$purchase->member->log( 'purchase', array( 'type' => 'info', 'id' => $purchase->id, 'name' => $purchase->name, 'info' => 'change_renewals', 'to' => array( 'cost' => $purchase->renewals->cost->amount, 'currency' => $purchase->renewals->cost->currency, 'term' => $purchase->renewals->getTerm() ) ) );

					if ( !$purchase->active and $cycles = $purchase->canRenewUntil( NULL, TRUE ) )
					{
						$url = $cycles === 1 ? $purchase->url()->setQueryString( 'do', 'renew' )->csrf() : $purchase->url()->setQueryString( 'do', 'renew' );
						Output::i()->redirect( $url );
					}
					else
					{
						Output::i()->redirect( $purchase->url() );
					}
				}
				return (string) $form;
		}

		return '';
	}
	
	/* !ACP */
	
	/**
	 * ACP Add to invoice
	 *
	 * @param Invoice\Item $item			The item
	 * @param	array										$values			Values from form
	 * @param string $k				The key to add to the field names
	 * @param invoice $invoice		The invoice
	 * @return    void
	 */
	public function acpAddToInvoice( Invoice\Item $item, array $values, string $k, invoice $invoice ): void
	{
	
	}	
	
	/**
	 * Get ACP Page Buttons
	 *
	 * @param Purchase $purchase	The purchase
	 * @param Url $url		The page URL
	 * @return    array
	 */
	public function acpButtons(Purchase $purchase, Url $url ): array
	{
		$return = array();
		
		if ( count( $this->upgradeDowngradeOptions( $purchase, TRUE ) ) )
		{
			$return['change'] = array(
				'icon'	=> 'archive',
				'title'	=> 'change_package',
				'link'	=> $url->setQueryString( array( 'do' => 'extra', 'act' => 'change' ) ),
				'data'	=> array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('change_package') )
			);
		}
		
		return $return;
	}
	
	/**
	 * Get ACP Page HTML
	 *
	 * @param Purchase $purchase
	 * @return    string
	 */
	public function acpPage(Purchase $purchase ): string
	{
		return '';
	}
	
	/**
	 * ACP Action
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    string|null
	 */
	public function acpAction(Purchase $purchase ): string|null
	{
		switch ( Request::i()->act )
		{
			case 'change':
				$form = $this->upgradeDowngradeForm( $purchase, TRUE );
				$form->class = 'ipsForm--horizontal ipsForm--acp-purchase-action';
				$form->add( new YesNo( 'change_package_skip_charges', FALSE, TRUE ) );
				if ( $values = $form->values() )
				{
					$newPackage = Package::load( $values['change_package_to'] );
					$invoice = $this->upgradeDowngrade( $purchase, $newPackage, $values["renew_option_{$newPackage->id}"] ?? NULL, $values['change_package_skip_charges'] );
					if ( $invoice )
					{
						Output::i()->redirect( $invoice->acpUrl() );
					}
					$purchase->member->log( 'purchase', array( 'type' => 'change', 'id' => $purchase->id, 'old' => $purchase->name, 'name' => $newPackage->titleForLog(), 'system' => FALSE ) );
					Output::i()->redirect( $purchase->acpUrl() );
				}
				return (string) $form;
		}

		return null;
	}
	
	/** 
	 * ACP Edit Form
	 *
	 * @param Purchase $purchase	The purchase
	 * @param	Form				$form	The form
	 * @param	RenewalTerm|null	$renewals	The renewal term
	 * @return	void
	 */
	public static function acpEdit( Purchase $purchase, Form $form, ?RenewalTerm $renewals ) : void
	{
		$form->addHeader('menu__nexus_store_fields');
		foreach ( new ActiveRecordIterator( Db::i()->select( '*', 'nexus_package_fields', Db::i()->findInSet( 'cf_packages', array( $purchase->item_id ) ) ), 'IPS\nexus\Package\CustomField' ) as $field )
		{
			/* @var CustomField $field */
			if ( $field->type === 'Editor' )
			{
				$field::$editorOptions = array_merge( $field::$editorOptions, array( 'attachIds' => array( $purchase->id, $field->id, 'purchase' ) ) );
			}
			$form->add( $field->buildHelper( isset( $purchase->custom_fields[ $field->id ] ) ? $purchase->custom_fields[ $field->id ] : NULL ) );
		}
	}
	
	/** 
	 * ACP Edit Save
	 *
	 * @param Purchase $purchase	The purchase
	 * @param	array				$values		Values from form
	 * @return    void
	 */
	public function acpEditSave(Purchase $purchase, array $values ): void
	{
		$customFields = $purchase->custom_fields;
				
		foreach ( new ActiveRecordIterator( Db::i()->select( '*', 'nexus_package_fields', Db::i()->findInSet( 'cf_packages', array( $this->id ) ) ), 'IPS\nexus\Package\CustomField' ) as $field )
		{
			/* @var CustomField $field */
			$class = $field->buildHelper();
			if ( $class instanceof Upload )
			{
				$customFields[ $field->id ] = (string) $values[ 'nexus_pfield_' . $field->id ];
			}
			else
			{
				if ( $class instanceof Editor )
				{
					$field->claimAttachments( $purchase->id, 'purchase' );
				}
				$customFields[ $field->id ] = $class::stringValue( $values[ 'nexus_pfield_' . $field->id ] );
			}
		}
		
		$purchase->custom_fields = $customFields;
		$purchase->save();
	}

	/* !Actions */
	
	/**
	 * On Paid
	 *
	 * @param invoice $invoice	The invoice
	 * @return    void
	 */
	public function onPaid(invoice $invoice ): void
	{
		// Blank for hooking
	}
	
	/**
	 * On Unpaid description
	 *
	 * @param Invoice $invoice	The invoice
	 * @return    array
	 */
	public function onUnpaidDescription( Invoice $invoice ): array
	{
		// Blank for hooking
		return array();
	}
	
	/**
	 * On Unpaid
	 *
	 * @param invoice $invoice	The invoice
	 * @param string $status		Status
	 * @return    void
	 */
	public function onUnpaid(Invoice $invoice, string $status ): void
	{
		// Blank for hooking
	}
	
	/**
	 * On Invoice Cancel (when unpaid)
	 *
	 * @param invoice $invoice	The invoice
	 * @return    void
	 */
	public function onInvoiceCancel(Invoice $invoice ): void
	{
		// Blank for hooking
	}

	/**
	 * Warning to display to admin when cancelling a purchase
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    string|null
	 */
	public function onCancelWarning(Purchase $purchase ): string|null
	{
		return NULL;
	}
	
	/**
	 * Check for member
	 * If a user initially checks out as a guest and then logs in during checkout, this method
	 * is ran to check the items they are purchasing can be bought.
	 * Is expected to throw a DomainException with an error message to display to the user if not valid
	 *
	 * @param	Member	$member	The new member
	 * @return    void
	 * @throws	DomainException
	 */
	public function memberCanPurchase( Member $member ): void
	{
		// Handled by subclasses
	}
	
	/**
	 * Can Renew Until
	 *
	 * @param Purchase $purchase	The purchase
	 * @param bool $admin		If TRUE, is for ACP. If FALSE, is for front-end.
	 * @return    bool|DateTime    TRUE means can renew as much as they like. FALSE means cannot renew at all. \IPS\DateTime means can renew until that date
	 */
	public function canRenewUntil(Purchase $purchase, bool $admin ): DateTime|bool
	{
		if ( !$purchase->expire )
		{
			return FALSE;
		}
		
		if ( $admin )
		{
			return TRUE;
		}
		
		if ( !$this->renewal_days )
		{
			return FALSE;
		}
		elseif ( $this->renewal_days != -1 )
		{
			$diff = $purchase->expire->diff( DateTime::create() );
			if ( $diff->invert and $diff->days > $this->renewal_days )
			{
				return FALSE;
			}
		}
		
		if ( $this->renewal_days_advance == -1 )
		{
			return TRUE;
		}
		else
		{
			$baseDate = ( $purchase->expire->getTimestamp() > time() ) ? $purchase->expire : DateTime::create();
			return $baseDate->add( new DateInterval( 'P' . $this->renewal_days_advance . 'D' ) );
		}
	}
	
	/**
	 * On Purchase Generated
	 *
	 * @param Purchase $purchase	The purchase
	 * @param invoice $invoice	The invoice
	 * @return    void
	 */
	public function onPurchaseGenerated(Purchase $purchase, Invoice $invoice ): void
	{		
		$this->_changeStockLevel( -1, $this->_getOptionId( $purchase ) );
		$this->_addUsergroups( $purchase );
		
		if ( $this->notify )
		{
			$email = Email::buildFromTemplate( 'nexus', 'purchaseNotify', array( $purchase, $invoice ), Email::TYPE_LIST );
			foreach ( explode( ',', $this->notify ) as $to )
			{
				$email->send( $to );
			}
		}
		
		$this->sendCustomEmail( 'purchase', $purchase );
	}
	
	/**
	 * On Renew (Renewal invoice paid. Is not called if expiry data is manually changed)
	 *
	 * @param Purchase $purchase	The purchase
	 * @param int $cycles		Cycles
	 * @return    void
	 */
	public function onRenew(Purchase $purchase, int $cycles = 1): void
	{
		// Blank for hooking
	}
	
	/**
	 * On Expiration Date Change
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    void
	 */
	public function onExpirationDateChange(Purchase $purchase ): void
	{
	
	}
	
	/**
	 * On expire soon
	 * If returns TRUE, the normal expire warning email will not be sent
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    bool
	 */
	public function onExpireWarning(Purchase $purchase ): bool
	{
		return $this->sendCustomEmail( 'expire_soon', $purchase );
	}
	
	/**
	 * On Purchase Expired
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    void
	 */
	public function onExpire(Purchase $purchase ): void
	{
		$this->_removeUsergroups( $purchase );
		
		$this->sendCustomEmail( 'expire', $purchase );
	}
	
	/**
	 * On Purchase Canceled
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    void
	 */
	public function onCancel(Purchase $purchase ): void
	{
		$this->_changeStockLevel( 1, $this->_getOptionId( $purchase ) );
		$this->_removeUsergroups( $purchase );
	}
	
	/**
	 * On Purchase Deleted
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    void
	 */
	public function onDelete(Purchase $purchase ): void
	{
		$this->onCancel( $purchase );
	}
	
	/**
	 * On Purchase Reactivated (renewed after being expired or reactivated after being canceled)
	 *
	 * @param Purchase $purchase	The purchase
	 * @return    void
	 */
	public function onReactivate(Purchase $purchase ): void
	{
		$this->_changeStockLevel( -1, $this->_getOptionId( $purchase ) );
		$this->_addUsergroups( $purchase );
	}
	
	/**
	 * On Transfer (is ran before transferring)
	 *
	 * @param Purchase $purchase		The purchase
	 * @param	Member			$newCustomer	New Customer
	 * @return    void
	 */
	public function onTransfer(Purchase $purchase, Member $newCustomer ): void
	{
		$this->_removeUsergroups( $purchase );
		$purchase->member = $newCustomer;
		$this->_addUsergroups( $purchase );
	}
	
	/**
	 * On Upgrade/Downgrade
	 *
	 * @param Purchase $purchase				The purchase
	 * @param Package $newPackage				The package to upgrade to
	 * @param int|RenewalTerm|NULL $chosenRenewalOption	The chosen renewal option
	 * @return    void
	 */
	public function onChange(Purchase $purchase, Package $newPackage, RenewalTerm|int|null $chosenRenewalOption = NULL ): void
	{
	
	}
	
	/**
	 * Send a custom email
	 *
	 * @param string $type		Which custom email
	 * @param Purchase $purchase	The purchase
	 * @return    bool
	 */
	public function sendCustomEmail(string $type, Purchase $purchase ): bool
	{
		$typeKey = "email_{$type}_type";
		$valueKey = "email_{$type}";
		
		if ( $this->$typeKey )
		{
			if ( $this->$typeKey == 'wysiwyg' )
			{
				$email = Email::buildFromContent( $purchase->member->language()->get( "nexus_package_{$this->id}_email_{$type}_subject" ), $this->$valueKey, NULL, Email::TYPE_TRANSACTIONAL, Email::WRAPPER_USE, 'package_customemail' );
			}
			else
			{
				$functionName = 'nexus_customPurchaseEmail_' . $this->id;
				Theme::makeProcessFunction( $this->$valueKey, $functionName, '$purchase' );
				
				$themeFunction = 'IPS\\Theme\\'.$functionName;
				$email = Email::buildFromContent( $purchase->member->language()->get( "nexus_package_{$this->id}_email_{$type}_subject" ), $themeFunction( $purchase ), NULL, Email::TYPE_TRANSACTIONAL, ( $this->$typeKey == 'html_template' ? Email::WRAPPER_USE : Email::WRAPPER_NONE ), 'package_customemail' );
			}
			$email->send( $purchase->member );
			
			return TRUE;
		}
		
		return FALSE;
	}
	
	/* !Upgrading/Downgrading */
	
	/**
	 * Upgrade/Downgrade Options
	 *
	 * @param Purchase $purchase				The purchase
	 * @param bool $overrideRestrictions	If true, will be able to choose any packages in the same group
	 * @param bool $includesUpgrades		Will be set if there are upgrade options, useful for knowing what text to show
	 * @param bool $includesDowngrades		Will be set if there are downgrade options, useful for knowing what text to show
	 * @return    array
	 */
	protected function upgradeDowngradeOptions(Purchase $purchase, bool $overrideRestrictions = FALSE, bool &$includesUpgrades=FALSE, bool &$includesDowngrades=FALSE ): array
	{
		/* Cannot upgrade/downgrade custom or cancelled packages */
		if ( $this->custom or $purchase->cancelled )
		{
			return array();
		}

		/* Cannot upgrade/downgrade without permission unless $overrideRestrictions is set */
		if ( ( !$this->allow_upgrading AND !$this->allow_downgrading ) AND !$overrideRestrictions )
		{
			return array();
		}
		
		/* Cannot upgrade/downgrade with billing agreement */
		if ( ( $purchase->billing_agreement and !$purchase->billing_agreement->canceled ) or ( $parent = $purchase->parent() and $parent->billing_agreement and !$parent->billing_agreement->canceled ) )
		{
			return array();
		}
		
		/* Work out how much this package costs, including considering if the renewal term adds to the base price */
		try
		{
			$currency = $purchase->original_invoice->currency;
		}
		catch ( Exception )
		{
			$currency = $purchase->member->defaultCurrency();
		}

		$prices = NULL;
		$thisPrice = NULL;
		
		if( $this->base_price )
		{
			$prices = $this->base_price ? json_decode( $this->base_price, TRUE ) : NULL;
			$thisPrice = $prices[ $currency ]['amount'];
		}

		if ( $this->renew_options )
		{
			$renewalOptions = json_decode( $this->renew_options, TRUE );
			if ( !empty( $renewalOptions ) )
			{
				$option = array_shift( $renewalOptions );						
				if ( $option['add'] )
				{
					$thisPrice += ( $option['cost'][ $currency ]['amount'] );
				}
			}
		}
		
		/* Loop through our siblings */
		$return = array();
		foreach ( $this->parent()->children() as $package )
		{
			if ( $package->id === $this->id )
			{
				continue;
			}
			
			$prices = $package->base_price ? json_decode( $package->base_price, TRUE ) : NULL;
			if ( !isset( $prices[ $currency ] ) )
			{
				continue;
			}
			$price = $prices[ $currency ]['amount'];
			
			/* Does Renewal add to base price */
			if ( $package->renew_options )
			{
				$renewalOptions = json_decode( $package->renew_options, TRUE );
				if ( !empty( $renewalOptions ) )
				{
					$option = array_shift( $renewalOptions );						
					if ( $option['add'] )
					{
						$price += ( $option['cost'][ $currency ]['amount'] );
					}
				}
			}
			
			/* Upgrade */
			if ( $price >= $thisPrice and ( $this->allow_upgrading or $overrideRestrictions ) )
			{
				/* We want to pro-rata renewals for upgrades, but we do not have any renewals here, so no */
				if ( $package->upgrade_charge == 2 and ! $purchase->renewals )
				{
					continue;
				}

				$return[] = $package;
				$includesUpgrades = TRUE;
			}
			
			/* Downgrade */
			elseif ( $price < $thisPrice and ( $this->allow_downgrading or $overrideRestrictions ) )
			{
				$return[] = $package;
				$includesDowngrades = TRUE;
			}
		}
		
		/* Return */
		return $return;
	}
	
	/**
	 * Upgrade/Downgrade Form
	 *
	 * @param Purchase $purchase				The purchase
	 * @param bool $overrideRestrictions	If true, will be able to choose any packages in the same group
	 * @return    Form
	 */
	protected function upgradeDowngradeForm(Purchase $purchase, bool $overrideRestrictions = FALSE ): Form
	{
		try
		{
			$currency = $purchase->original_invoice->currency;
		}
		catch ( Exception )
		{
			$currency = $purchase->member->defaultCurrency();
		}
		$options = array();
		$desciptions = array();
		$toggles = array();
		$renewFields = array();

		$tax = NULL;
		if ( $purchase->tax )
		{
			try
			{
				$tax = Tax::load( $purchase->tax );
			}
			catch ( Exception ) { }
		}

		foreach ( $this->upgradeDowngradeOptions( $purchase, $overrideRestrictions ) as $package )
		{
			try
			{
				$costToUpgrade = $package->costToUpgrade( $purchase );
			}
			catch ( InvalidArgumentException )
			{
				continue;
			}

			/* Do we need to show the tax? */
			if( $tax AND Settings::i()->nexus_show_tax )
			{
				$costToUpgrade = new Money( $costToUpgrade->amount->add( $costToUpgrade->amount->multiply( new Number( $tax->rate() ) ) ), $currency );
			}
			
			$options[ $package->id ] = $package->_title;
								
			$renewOptions = json_decode( $package->renew_options, TRUE ) ?: array();
			$renewalOptions = array();
			foreach ( $renewOptions as $k => $option )
			{
				$renewalOptions[ $k ] = ( new RenewalTerm( new Money( $option['cost'][$currency]['amount'], $currency ), new DateInterval( 'P' . $option['term'] . mb_strtoupper( $option['unit'] ) ), $tax ) )->toDisplay();
			}
			
			if ( count( $renewalOptions ) === 1 )
			{
				foreach ( $renewalOptions as $k => $v )
				{
					if ( $costToUpgrade->amount->isZero() )
					{
						$desciptions[ $package->id ] = $v;
					}
					elseif( $costToUpgrade->amount->isPositive() )
					{
						$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'upgrade_cost_and_renew', FALSE, array( 'sprintf' => array( $costToUpgrade, $v ) ) );
					}
					else
					{
						$costToUpgrade->amount = $costToUpgrade->amount->multiply( new Number( '-1' ) );
						$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'downgrade_refund_and_renew', FALSE, array( 'sprintf' => array( $costToUpgrade, $v ) ) );
					}
				}
			}
			elseif ( count( $renewalOptions ) === 0 )
			{
				if( $costToUpgrade->amount->isGreaterThanZero() )
				{
					$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'upgrade_cost', FALSE, array( 'sprintf' => array( $costToUpgrade ) ) );
				}
				elseif( !$costToUpgrade->amount->isPositive() )
				{
					$costToUpgrade->amount = $costToUpgrade->amount->multiply( new Number( '-1' ) );
					$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'downgrade_refund', FALSE, array( 'sprintf' => array( $costToUpgrade ) ) );
				}
			}
			else
			{
				$renewalOptionsString = Member::loggedIn()->language()->formatList( $renewalOptions, Member::loggedIn()->language()->get('or_list_format') );
				$renewFieldsDescriptions = array();

				if ( $costToUpgrade->amount->isZero() )
				{
					$desciptions[ $package->id ] = $renewalOptionsString;
				}
				elseif( $costToUpgrade->amount->isGreaterThanZero() )
				{
					$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'upgrade_cost_and_renew', FALSE, array( 'sprintf' => array( $costToUpgrade, $renewalOptionsString ) ) );
				}
				else
				{
					$costToUpgrade->amount = $costToUpgrade->amount->multiply( new Number( '-1' ) );
					$desciptions[ $package->id ] = Member::loggedIn()->language()->addToStack( 'downgrade_refund_and_renew', FALSE, array( 'sprintf' => array( $costToUpgrade, $renewalOptionsString ) ) );
				}
				
				$renewFields[] = new Radio( 'renew_option_' . $package->id, NULL, NULL, array( 'options' => $renewalOptions, 'descriptions' => $renewFieldsDescriptions, 'parse' => 'normal' ), NULL, NULL, NULL, 'renew_option_' . $package->id );
				Member::loggedIn()->language()->words['renew_option_' . $package->id] = Member::loggedIn()->language()->addToStack( 'renewal_term', FALSE );
				$toggles[ $package->id ] = array( 'renew_option_' . $package->id );
			}
		}
		
		$form = new Form;
		$form->class = 'ipsForm--vertical ipsForm--upgrade-downgrade-package';
		$form->add( new Radio( 'change_package_to', NULL, TRUE, array( 'options' => $options, 'descriptions' => $desciptions, 'toggles' => $toggles, 'parse' => 'normal' ) ) );
		foreach ( $renewFields as $field )
		{
			$form->add( $field );
		}
		
		return $form;
	}
	
	/** 
	 * Cost to upgrade to this package (may return negative value for refund)
	 *
	 * @param Purchase $purchase	The purchase to be upgraded
	 * @return    Money|NULL
	 * @throws	InvalidArgumentException
	 */
	protected function costToUpgrade(Purchase $purchase ): Money|null
	{
		$package = Package::load( $purchase->item_id );
		try
		{
			$currency = $purchase->original_invoice->currency;
		}
		catch ( Exception )
		{
			$currency = $purchase->member->defaultCurrency();
		}
		
		$priceOfExistingPackage = json_decode( $package->base_price, TRUE );
		$priceOfExistingPackage = $priceOfExistingPackage[ $currency ]['amount'];
		$renewalOptionsOnOldPackage = json_decode( $package->renew_options, TRUE );
		if ( $purchase->renewals )
		{
			foreach ( $renewalOptionsOnOldPackage as $renewalOption )
			{
				$term = $purchase->renewals->getTerm();
				if ( $renewalOption['add'] and $term['term'] == $renewalOption['term'] and $term['unit'] == $renewalOption['unit'] )
				{
					$priceOfExistingPackage += $renewalOption['cost'][ $currency ]['amount'];
				}
			}
		}
		
		$priceOfThisPackage = json_decode( $this->base_price, TRUE );
		$priceOfThisPackage = $priceOfThisPackage[ $currency ]['amount'];
		$renewalOptionsOnNewPackage = json_decode( $this->renew_options, TRUE );
		if ( $purchase->renewals )
		{
			if ( $renewalOptionsOnNewPackage )
			{
				foreach ( $renewalOptionsOnNewPackage as $renewalOption )
				{
					$term = $purchase->renewals->getTerm();
					if ( $renewalOption['add'] and $term['term'] == $renewalOption['term'] and $term['unit'] == $renewalOption['unit'] )
					{
						$priceOfThisPackage += $renewalOption['cost'][ $currency ]['amount'];
					}
				}
			}
		}
				
		if ( $priceOfThisPackage >= $priceOfExistingPackage )
		{
			$type = $package->upgrade_charge;
		}
		else
		{
			$type = $package->downgrade_refund;
		}
						
		switch ( $type )
		{
			case 0:
				return new Money( 0, $currency );
			
			case 1:
				return new Money( $priceOfThisPackage - $priceOfExistingPackage, $currency );
			
			case 2:
			
				if ( !$purchase->renewals )
				{
					return new Money( 0, $currency );
				}
				if ( !$renewalOptionsOnNewPackage )
				{
					throw new InvalidArgumentException;
				}
				
				/* What is the closest renewal option on the new package? We'll use that one */
				$renewalOptionsInDays = array();
				foreach ( $renewalOptionsOnNewPackage as $renewalTerm )
				{
					$term = ( new RenewalTerm( new Money( $renewalTerm['cost'][ $currency ]['amount'], $currency ), new DateInterval( 'P' . $renewalTerm['term'] . mb_strtoupper( $renewalTerm['unit'] ) ), $purchase->renewals->tax, $renewalTerm['add'] ) );
					$renewalOptionsInDays[ $term->days() ] = $term;
				}
				$closestRenewalOption = null;
				$numberOfDaysInCurrentRenewalTerm = $purchase->renewals->days();
				foreach ( $renewalOptionsInDays as $days => $term )
				{
					if ( $closestRenewalOption === null or abs( $numberOfDaysInCurrentRenewalTerm - $closestRenewalOption ) > abs( $days - $numberOfDaysInCurrentRenewalTerm ) )
					{
						$closestRenewalOption = $days;
					}
				}
				$renewalTermToUse = $renewalOptionsInDays[ $closestRenewalOption ];
				
				/* What is the difference between our current renewal term and the renewal term we're moving to? */
				$diff = $purchase->renewals->diff( $renewalTermToUse );
				
				/* Multiply that by how many days are left */
				$numberOfDaysInCurrentRenewalTerm = new Number( $numberOfDaysInCurrentRenewalTerm );
				$daysLeftUntilExpiry = new Number( (string) ceil( ( $purchase->expire->getTimestamp() - time() ) / 86400 ) );
				if ( !$daysLeftUntilExpiry->isGreaterThanZero() )
				{
					return new Money( new Number('0'), $currency );
				}
				elseif ( $numberOfDaysInCurrentRenewalTerm->compare( $daysLeftUntilExpiry ) === 0 )
				{
					return $diff;
				}
				else
				{			
					return new Money( $diff->amount->divide( $numberOfDaysInCurrentRenewalTerm )->multiply( $daysLeftUntilExpiry ), $currency );
				}
		}

		return null;
	}
	
	/**
	 * Actually Upgrade/Downgrade
	 *
	 * @param Purchase $purchase				The purchase
	 * @param Package $newPackage				The package to upgrade to
	 * @param int|RenewalTerm|NULL $chosenRenewalOption	The chosen renewal option
	 * @param bool $skipCharge				If TRUE, an upgrade charges and downgrade refunds will not be issued
	 * @return    Invoice|null                                                An invoice if an upgrade charge has to be paid, or void if not
	 * @throws	InvalidArgumentException											If $chosenRenewalOption is invalid
	 */
	public function upgradeDowngrade(Purchase $purchase, Package $newPackage, RenewalTerm|int|null $chosenRenewalOption = NULL, bool $skipCharge = FALSE ): Invoice|null
	{		
		/* Charge / Refund */
		if ( !$skipCharge )
		{
			$costToUpgrade = $newPackage->costToUpgrade( $purchase );
			
			/* Upgrade Charge */
			if ( $costToUpgrade->amount->isGreaterThanZero() )
			{
				$item = new UpgradeCharge( sprintf( $purchase->member->language()->get( 'upgrade_charge_item' ), $purchase->member->language()->get( "nexus_package_{$this->id}" ), $purchase->member->language()->get( "nexus_package_{$newPackage->id}" ) ), $costToUpgrade );
				$item->tax = $newPackage->tax ? Tax::load( $newPackage->tax ) : NULL;
				$item->id = $purchase->id;
				$item->extra = array( 'newPackage' => $newPackage->id, 'oldPackage' => $this->id, 'renewalOption' => $chosenRenewalOption, 'previousRenewalTerms' => $purchase->renewals ? array( 'cost' => $purchase->renewals->cost->amount, 'currency' => $purchase->renewals->cost->currency, 'term' => $purchase->renewals->getTerm(), 'tax' => $purchase->tax ) : NULL );

				if ( $newPackage->methods and $newPackage->methods != '*' )
				{
					$item->paymentMethodIds = explode( ',', $newPackage->methods );
				}
				
				$invoice = new invoice;
				$invoice->member = $purchase->member;
				$invoice->currency = $costToUpgrade->currency;
				$invoice->addItem( $item );
				$invoice->return_uri = "app=nexus&module=clients&controller=purchases&do=view&id={$purchase->id}";
				$invoice->renewal_ids = array( $purchase->id );
				$invoice->save();
				return $invoice;
			}
			elseif ( !$costToUpgrade->amount->isPositive() )
			{
				$credits = $purchase->member->cm_credits;
				$credits[ $costToUpgrade->currency ]->amount = $credits[ $costToUpgrade->currency ]->amount->add( $costToUpgrade->amount->multiply( new Number( '-1' ) ) );
				$purchase->member->cm_credits = $credits;
				$purchase->member->save();
			}
		}
		
		/* Work out the new renewal term */
		if ( $chosenRenewalOption instanceof RenewalTerm )
		{
			$term = $chosenRenewalOption;
		}
		else
		{
			$term = NULL;
			$renewalOptions = json_decode( $newPackage->renew_options, TRUE );
			if ( $chosenRenewalOption === NULL )
			{
				if ( $renewalOptions AND count( $renewalOptions ) === 1 )
				{
					$term = array_pop( $renewalOptions );
				}
				elseif ( $renewalOptions AND count( $renewalOptions ) !== 0 )
				{
					throw new InvalidArgumentException;
				}
			}
			else
			{
				if ( isset( $renewalOptions[ $chosenRenewalOption ] ) )
				{
					$term = $renewalOptions[ $chosenRenewalOption ];
				}
				else
				{
					throw new InvalidArgumentException;
				}
			}
			if ( $term )
			{
				try
				{
					$currency = $purchase->original_invoice->currency;
				}
				catch ( OutOfRangeException )
				{
					$currency = $purchase->member->defaultCurrency();
				}
				$term = new RenewalTerm( new Money( $term['cost'][$currency]['amount'], $currency ), new DateInterval( 'P' . $term['term'] . mb_strtoupper( $term['unit'] ) ), $newPackage->tax ? Tax::load( $newPackage->tax ) : NULL );
			}
		}
		
		/* If grouped, ungroup */
		$grouped = $purchase->grouped_renewals;
		if ( $grouped )
		{
			$purchase->ungroupFromParent();
			$purchase->save();
		}
		
		/* Do package-specific stuff */
		$this->onChange( $purchase, $newPackage, $chosenRenewalOption );
		
		/* Remove usergroups */
		$this->_removeUsergroups( $purchase );
		
		/* Ungroup any children */
		$groupedChildren = array();
		foreach ( $purchase->children( NULL ) as $child )
		{
			if ( $child->grouped_renewals )
			{
				$child->ungroupFromParent();
				$groupedChildren[] = $child;
			}
		}
		
		/* If we didn't have an expiry date before, but the new package has a renewal term, set an expiry date */
		if ( !$purchase->expire and $term )
		{
			$purchase->expire = DateTime::create()->add( $term->interval );
		}
		/* OR if we did have an expiry date, but the new package does not have a renewal term, remove it */
		elseif ( !$term )
		{
			$purchase->expire = NULL;
		}

		/* Initial Term, apply the difference if the new package has a longer initial term */
		if( $term and $newPackage->initial_term )
		{
			$initial = new DateInterval( "P{$newPackage->initial_term}" );

			/* If we're still in the initial period of the new package */
			$diff = DateTime::create()->diff( $purchase->start->add( $initial ) );
			if( !$diff->invert and $diff->days > 0 )
			{
				$purchase->expire = $purchase->start->add( $diff );
			}
		}
				
		/* Update Purchase */
		$purchase->name = Member::loggedIn()->language()->get( "nexus_package_{$newPackage->id}" );
		$purchase->item_id = $newPackage->id;
		$purchase->renewals = $term;
		$purchase->save();
		
		/* Regroup children */
		foreach ( $groupedChildren as $child )
		{
			$child->groupWithParent();
		}
		
		/* Re-add usergroups */
		$newPackage->_addUsergroups( $purchase );
		
		/* If grouped, regroup */
		if ( $grouped )
		{
			$purchase->groupWithParent();
			$purchase->save();
		}
		
		/* Cancel any pending invoices */
		if ( $pendingInvoice = $purchase->invoice_pending )
		{
			$pendingInvoice->status = invoice::STATUS_CANCELED;
			$pendingInvoice->save();
			$purchase->invoice_pending = NULL;
			$purchase->save();
		}

		return null;
	}
	
	/* !Stock */
	
	/**
	 * Change Stock Level
	 *
	 * @param int $changeBy	Amount to change by
	 * @param int|NULL $optionId	Product option ID, if applicable
	 * @return    void
	 */
	protected function _changeStockLevel(int $changeBy, ?int $optionId=NULL ): void
	{		
		if ( $this->stock == -2 )
		{
			if ( $optionId !== NULL )
			{
				try
				{
					$stock = Db::i()->select( 'opt_stock', 'nexus_product_options', array( 'opt_id=?', $optionId ) )->first();
					if ( $stock != -1 )
					{
						$newValue = $stock + $changeBy;
						if ( $newValue < 0 )
						{
							$newValue = 0;
						}
						
						Db::i()->update( 'nexus_product_options', array( 'opt_stock' => $newValue ), array( 'opt_id=?', $optionId ) );
					}
				}
				catch ( UnderflowException ) { }
			}
		}
		elseif ( $this->stock > 0 )
		{
			$newValue = $this->stock + $changeBy;
			if ( $newValue < 0 )
			{
				$newValue = 0;
			}
			$this->stock = $newValue;
			$this->save();
		}
	}
	
	/**
	 * @brief	Fields which affect the option ID
	 */
	protected ?array $_optionIdKeys = NULL;
	
	/**
	 * Get field IDs which affect the option ID
	 *
	 * @return	array
	 */
	public function optionIdKeys() : array
	{
		if( $this->_optionIdKeys === NULL )
		{
			$this->_optionIdKeys = array();
			try
			{
				$options = Db::i()->select( 'opt_values', 'nexus_product_options', array( 'opt_package=?', $this->id ) )->first();
				if ( $options = json_decode( $options, TRUE ) )
				{
					$this->_optionIdKeys = array_keys(  $options );
				}

			}
			catch ( UnderflowException ) { }
		}
		return $this->_optionIdKeys;
	}
	
	/**
	 * Get option ID
	 *
	 * @param Purchase $purchase	The purchase
	 * @return	int|NULL
	 */
	protected function _getOptionId(Purchase $purchase ) : int|null
	{
		$optionValues = array();
		foreach( $this->optionIdKeys() as $id )
		{
			$optionValues[ $id ] = $purchase->custom_fields[$id];
		}
		
		try
		{
			return Db::i()->select( 'opt_id', 'nexus_product_options', array( 'opt_package=? AND opt_values=?', $this->id, json_encode( $optionValues ) ) )->first();
		}
		catch ( UnderflowException )
		{
			return NULL;
		}
	}
	
	/* !Usergroups */
	
	/**
	 * Add user groups
	 *
	 * @param Purchase $purchase	The purchase
	 * @return	void
	 */
	public function _addUsergroups(Purchase $purchase ) : void
	{
		$extra = $purchase->extra;
			
		/* Primary Group */
		if (
			$this->primary_group // This package wants to move us into a different group...
			and $this->primary_group != $purchase->member->member_group_id // And we aren't already in that group
			and !in_array( $purchase->member->member_group_id, explode( ',', Settings::i()->cm_protected ) ) // And we're not in a group that is protected from having purchases move them out
			and ( $purchase->active or !$this->return_primary ) // And the purchase is active or this promotion applies even if the purchase isn't active (that might seem weird, but remember expired purchases can be changed/transferred/have their settings edited/etc which calls this method)
			and !( $purchase->member->isAdmin() and !in_array( $this->primary_group, array_keys( Member::administrators()['g'] ) ) ) // And moving groups wouldn't boot us out of the AdminCP
		) {
			/* Save the current group */
			$extra['old_primary_group'] = $purchase->member->member_group_id;
			$purchase->extra = $extra;
			$purchase->save();
			if ( !$purchase->member->cm_return_group )
			{
				$purchase->member->cm_return_group = $purchase->member->member_group_id;
			}
			
			/* And update to the new group */
			$purchase->member->member_group_id = $this->primary_group;
			$purchase->member->members_bitoptions['ignore_promotions'] = true;
			$purchase->member->save();
			$purchase->member->logHistory( 'core', 'group', array( 'type' => 'primary', 'by' => 'purchase', 'action' => 'add', 'id' => $purchase->id, 'name' => $purchase->name, 'old' => $extra['old_primary_group'], 'new' => $purchase->member->member_group_id ) );
		}
						
		/* Secondary Groups */
		$secondary = array_filter( explode( ',', $this->secondary_group ), function( $v ){ return (bool) $v; } );
		$current_secondary = $purchase->member->mgroup_others ? explode( ',', $purchase->member->mgroup_others ) : array();
		$new_secondary = $current_secondary;
		if ( !empty( $secondary ) )
		{
			foreach ( $secondary as $gid )
			{
				if ( !in_array( $gid, $new_secondary ) )
				{
					$new_secondary[] = $gid;
				}
			}
		}
		if ( $current_secondary != $new_secondary and ( $purchase->active or !$this->return_secondary ) )
		{
			$extra['old_secondary_groups'] = $purchase->member->mgroup_others;
			$purchase->extra = $extra;
			$purchase->save();
									
			$purchase->member->mgroup_others = ',' . implode( ',', $new_secondary ) . ',';
			$purchase->member->save();
			$purchase->member->logHistory( 'core', 'group', array( 'type' => 'secondary', 'by' => 'purchase', 'action' => 'add', 'id' => $purchase->id, 'name' => $purchase->name, 'old' => $current_secondary, 'new' => $new_secondary ) );
		}
	}
	
	/**
	 * Remove user groups
	 *
	 * @param Purchase $purchase	The purchase
	 * @param array $basis		The current package data, if needed to override
	 * @return    void
	 */
	public function _removeUsergroups(Purchase $purchase, array $basis = array() ): void
	{		
		$basis = array_merge( $this->_data, $basis );		
		$extra = $purchase->extra;
				
		/* Primary Group */
		if ( $basis['return_primary'] )
		{
			/* We only want to move them back if they haven't been moved again since */
			if ( $purchase->member->member_group_id == $basis['primary_group'] )
			{
				$oldGroup = $purchase->member->member_group_id;
				
				/* Have we made other purchases that have changed their primary group? */
				try
				{
					$next = Db::i()->select( array( 'ps_id', 'ps_name', 'p_primary_group' ), 'nexus_purchases', array( "ps_member=? AND ps_app=? AND ps_type IN('package','subscription') AND ps_active=1 AND p_primary_group<>0 AND ps_id<>?", $purchase->member->member_id, 'nexus', $purchase->id ) )
						->join( 'nexus_packages', 'p_id=ps_item_id' )
						->first();

					/* Make sure this group exists */
					try
					{
						Group::load( $next['p_primary_group'] );
					}
					catch( OutOfRangeException )
					{
						throw new UnderflowException;
					}
					
					$purchase->member->member_group_id = $next['p_primary_group'];
					$purchase->member->save();
					$purchase->member->logHistory( 'core', 'group', array( 'type' => 'primary', 'by' => 'purchase', 'action' => 'change', 'remove_id' => $purchase->id, 'remove_name' => $purchase->name, 'id' => $next['ps_id'], 'name' => $next['ps_name'], 'old' => $oldGroup, 'new' => $purchase->member->member_group_id ) );
				}
				/* No, move them to their original group */
				catch ( UnderflowException )
				{
					/* Does this group exist? */
					try
					{
						Group::load( $purchase->member->cm_return_group );
						$purchase->member->member_group_id = $purchase->member->cm_return_group;
					}
					catch ( OutOfRangeException )
					{
						$purchase->member->member_group_id = Settings::i()->member_group;
					}
										
					/* Save */
					$purchase->member->cm_return_group = 0;
					$purchase->member->members_bitoptions['ignore_promotions'] = false;
					$purchase->member->save();
					$purchase->member->logHistory( 'core', 'group', array( 'type' => 'primary', 'by' => 'purchase', 'action' => 'remove', 'id' => $purchase->id, 'name' => $purchase->name, 'old' => $oldGroup, 'new' => $purchase->member->member_group_id ) );
				}
			}
		}
				
		// Secondary groups
		if ( isset( $extra['old_secondary_groups'] ) and $extra['old_secondary_groups'] !== NULL and $basis['return_secondary'] )
		{			
			/* Work some stuff out */
			$membersSecondaryGroups = $purchase->member->mgroup_others ? array_unique( array_filter( explode( ',', $purchase->member->mgroup_others ) ) ) : array();
			$currentSecondaryGroups = $membersSecondaryGroups;
			$membersPreviousSecondaryGroupsBeforeThisPurchase = array_unique( array_filter( explode( ',', $extra['old_secondary_groups'] ) ) );
			$secondaryGroupsAwardedByThisPurchase = array_unique( array_filter( explode( ',', $basis['secondary_group'] ) ) );
			
			/* Have we made other purchases that have added secondary groups? */
			$secondaryGroupsAwardedByOtherPurchases = array();
			foreach ( Db::i()->select( 'p_secondary_group', 'nexus_purchases', array( "ps_member=? AND ps_app=? AND ps_type IN('package','subscription') AND ps_active=1 AND p_secondary_group IS NOT NULL AND p_secondary_group<>? AND ps_id<>?", $purchase->member->member_id, 'nexus', '', $purchase->id ) )->join( 'nexus_packages', 'p_id=ps_item_id' ) as $secondaryGroups )
			{
				$secondaryGroupsAwardedByOtherPurchases = array_merge( $secondaryGroupsAwardedByOtherPurchases, array_filter( explode( ',', $secondaryGroups ) ) );
			}
			$secondaryGroupsAwardedByOtherPurchases = array_unique( $secondaryGroupsAwardedByOtherPurchases );
			
			/* Loop through */
			foreach ( $secondaryGroupsAwardedByThisPurchase as $groupId )
			{
				/* If we had this group before we made this purchase, we're going to keep it */
				if ( in_array( $groupId, $membersPreviousSecondaryGroupsBeforeThisPurchase ) )
				{
					continue;
				}
				
				/* If we are being awarded this group by a different purchase, we're also going to keep it */
				if ( in_array( $groupId, $secondaryGroupsAwardedByOtherPurchases ) )
				{
					continue;
				}
				
				/* If we're still here, remove it */
				unset( $membersSecondaryGroups[ array_search( $groupId, $membersSecondaryGroups ) ] );
			}

			/* And make sure only valid groups are saved */
			$membersSecondaryGroups = array_filter( $membersSecondaryGroups, function( $group ){
				try
				{
					Group::load( $group );
					return TRUE;
				}
				catch( OutOfRangeException )
				{
					return FALSE;
				}
			});
			
			/* Save */
			if( $currentSecondaryGroups != $membersSecondaryGroups )
			{
				$purchase->member->mgroup_others = implode( ',', $membersSecondaryGroups );
				$purchase->member->save();
				$purchase->member->logHistory( 'core', 'group', array( 'type' => 'secondary', 'by' => 'purchase', 'action' => 'remove', 'id' => $purchase->id, 'name' => $purchase->name, 'old' => $currentSecondaryGroups, 'new' => $membersSecondaryGroups ) );
			}
		}
	}

	/**
	 * Do we have any products for the registration form
	 *
	 * @return	int
	 */
	public static function haveRegistrationProducts() : int
	{
		return Db::i()->select( 'COUNT(*)', 'nexus_packages', 'p_reg=1 AND p_store=1' )->first();
	}

	/**
	 * [Node] Does the currently logged in user have permission to edit this node?
	 *
	 * @return	bool
	 */
	public function canEdit(): bool
	{
		/* Locked packages cannot be edited */
		if( $this->locked )
		{
			return FALSE;
		}

		return parent::canEdit();
	}


	/**
	 * [Node] Does the currently logged in user have permission to copy this node?
	 *
	 * @return	bool
	 */
	public function canCopy(): bool
	{
		/* Locked packages cannot be copied */
		if( $this->locked )
		{
			return FALSE;
		}

		return parent::canCopy();
	}

	/**
	 * [Node] Does the currently logged in user have permission to delete this node?
	 *
	 * @return    bool
	 */
	public function canDelete(): bool
	{
		foreach ( Db::i()->select( 'data', 'core_queue', array( 'app=? AND `key`=?', 'nexus', 'MassChangePurchases' ) ) as $row )
		{
			$data = json_decode( $row, TRUE );
			if ( $data['id'] == $this->_id OR $data['mass_change_purchases_to'] == $this->_id )
			{
				return FALSE;
			}
		}

		return parent::canDelete();
	}

	/**
	 * Allow for individual classes to override and
	 * specify a primary image. Used for grid views, etc.
	 *
	 * @return SystemFile|null
	 */
	public function primaryImage() : ?SystemFile
	{
		if( $this->_data['image'] )
		{
			return SystemFile::get( 'nexus_Products', $this->_data['image'] );
		}

		return parent::primaryImage();
	}

	/**
	 * Get the last modification date for the sitemap
	 *
	 * @return DateTime|null		timestamp of the last modification time for the sitemap
	 */
	public function lastModificationDate(): DateTime|NULL
	{
		return DateTime::ts( $this->date_updated ?: $this->date_added );
	}

	/**
	 * Sitemap Priority
	 *
	 * @return    int|null    NULL to use default
	 */
	public function sitemapPriority(): ?int
	{
		return NULL;
	}
}
