Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage Nexus * @since 10 Feb 2014 */ namespace IPS\nexus; /* To prevent PHP errors (extending class does not exist) revealing path */ use BadMethodCallException; use DateInterval; use ErrorException; use Exception; use IPS\Application; use IPS\DateTime; use IPS\Db; use IPS\Events\Event; use IPS\File; use IPS\Helpers\Form; use IPS\Helpers\Tree\Tree; use IPS\Http\Url; use IPS\Http\Url\Friendly; use IPS\Member; use IPS\Member\Group; use IPS\nexus\Customer\BillingAgreement; use IPS\nexus\Invoice\Item\Purchase as ItemPurchase; use IPS\nexus\Package\CustomField; use IPS\nexus\Purchase\LicenseKey; use IPS\nexus\Purchase\RenewalTerm; use IPS\Node\Model; use IPS\Patterns\ActiveRecordIterator; use IPS\Request; use IPS\Theme; use LogicException; use OutOfRangeException; use UnexpectedValueException; use function call_user_func_array; use function count; use function defined; use function intval; use function is_null; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Purchase Model * @method onExpirationDateChange() : void * @method onExpireWarning() : bool * @method onRenew(int $cycles = 1): void * @method onTransfer( Member $newCustomer ) : void * @method onCancelWarning() : string|null * @property Customer $member */ class Purchase extends Model { /** * Tree * * @param Url $url URL * @param array $where Where clause * @param string $ref Referer * @param Purchase|NULL $root Root (NULL for all root purchases) * @param bool $includeRoot Show the root? * @return Tree */ public static function tree(Url $url, array $where, string $ref = 'c', ?Purchase $root = NULL, bool $includeRoot = TRUE ) : Tree { $where[] = array( 'ps_show=1' ); return new Tree( $url, 'Purchases', function( $limit ) use ( $url, $where, $ref, $root, $includeRoot ) { if ( $root ) { if ( $includeRoot ) { $where[] = array( 'ps_id=?', $root->id ); } else { $where[] = array( 'ps_parent=?', $root->id ); } } else { $where[] = array( 'ps_parent=0' ); } $rows = array(); foreach( new ActiveRecordIterator( Db::i()->select( '*', 'nexus_purchases', $where, 'ps_start DESC', $limit ), 'IPS\nexus\Purchase' ) as $purchase ) { /* @var Purchase $purchase */ $rows[ $purchase->id ] = $purchase->treeRow( $url, $ref ); } return $rows; }, function( $id ) use ( $url, $ref ) { return Purchase::load( $id )->treeRow( $url, $ref ); }, function( $id ) { return Purchase::load( $id )->parent(); }, function( $id ) use ( $url, $ref, $where ) { $rows = array(); foreach (Purchase::load( $id )->children( NULL, NULL, TRUE, NULL, $where ) as $child ) { $rows[ $child->id ] = $child->treeRow( $url, $ref ); } return $rows; }, NULL, FALSE, FALSE, FALSE, NULL, function() { $where[] = array( 'ps_parent=0' ); return Db::i()->select( 'COUNT(*)', 'nexus_purchases', $where )->first(); } ); } /** * Tree Row * * @param Url $url URL * @param string $ref Referer * @return string */ public function treeRow( Url $url, string $ref ): string { $childCount = $this->childrenCount( NULL, NULL, TRUE, array( array( 'ps_show=1' ) ) ); $hasCustomFields = FALSE; foreach ( $this->custom_fields as $k => $v ) { try { $customFieldValue = CustomField::load( $k )->displayValue( $v, TRUE ); if ( $customFieldValue and trim( $customFieldValue ) ) { $hasCustomFields = TRUE; break; } } catch( OutOfRangeException ) {} } try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $description = $extension::getPurchaseNameInfo( $this ); if( empty( $description ) ) { $description[] = Member::loggedIn()->language()->addToStack( 'purchase_number', FALSE, array( 'sprintf' => array( $this->id ) ) ); } } catch ( OutOfRangeException ) { $description = array(); } if ( $this->grouped_renewals ) { $description[] = Member::loggedIn()->language()->addToStack('purchase_grouped'); } elseif ( $this->renewals ) { $renewals = (string) $this->renewals; if ( $this->renewals->tax ) { $renewals .= Member::loggedIn()->language()->addToStack( 'plus_tax_rate', FALSE, array( 'sprintf' => array( $this->renewals->tax->_title ) ) ); } $description[] = $renewals; } $description = implode( ' · ', $description ); return Theme::i()->getTemplate( 'trees', 'core' )->row( $url, $this->id, ( $hasCustomFields and !$childCount ) ? Theme::i()->getTemplate( 'purchases', 'nexus' )->link( $this, FALSE, FALSE, TRUE ) : $this->_name, $childCount, array_merge( array( 'view' => array( 'link' => $this->acpUrl()->setQueryString( 'popup', true ), 'title' => 'view', 'icon' => 'search', ) ), $this->buttons( $ref ) ), $description, $this->getIcon(), NULL, $this->id == Request::i()->root, NULL, NULL, !$this->active ? ( $this->cancelled ? array( 'style5', 'purchase_canceled' ) : array( 'style6', 'purchase_expired' ) ) : NULL, $hasCustomFields ); } /* ActiveRecord */ /** * @brief Multiton Store */ protected static array $multitons; /** * @brief Database Table */ public static ?string $databaseTable = 'nexus_purchases'; /** * @brief Database Prefix */ public static string $databasePrefix = 'ps_'; /* !Node */ /** * @brief Node Title */ public static string $nodeTitle = 'purchases'; /** * @brief [Node] Parent ID Database Column */ public static ?string $databaseColumnParent = 'parent'; /** * @brief [Node] If the node can be "owned", the owner "type" (typically "member" or "group") and the associated database column */ public static ?array $ownerTypes = array( 'member' => 'member' ); /** * Get title * * @return string */ public function get__title(): string { return $this->name; } /* !Columns */ /** * Set Default Values * * @return void */ public function setDefaultValues() : void { $this->_data['active'] = TRUE; // do directly so it doesn't call onReactivate $this->start = new DateTime; $this->renewal_price = 0; $this->renewal_currency = ''; $this->invoice_pending = NULL; } /** * @brief Name with sticky fields */ protected string $nameWithStickyFields = ''; /** * Get name * * @return string */ public function get_name() : string { if ( !$this->nameWithStickyFields ) { $this->nameWithStickyFields = $this->_data['name']; try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $info = $extension::getPurchaseNameInfo( $this ); if ( count( $info ) ) { $this->nameWithStickyFields .= ' (' . implode( ' · ', $info ) . ')'; } } catch ( OutOfRangeException ) { } } return $this->nameWithStickyFields; } /** * Get name without sticky fields * * @return string */ public function get__name() : string { return $this->_data['name']; } /** * Get member * * @return Member */ public function get_member() : Member { return Customer::load( $this->_data['member'] ); } /** * Set member * * @param Member $member * @return void */ public function set_member( Member $member ) : void { $this->_data['member'] = $member->member_id; } /** * Set active * * @param bool $active Is active? * @return void */ public function set_active( bool $active ) : void { if ( $this->id ) { if ( $this->_data['active'] and !$active ) { $this->_data['active'] = FALSE; $this->onExpire(); } elseif ( !$this->_data['active'] and $active ) { $this->_data['active'] = TRUE; $this->onReactivate(); } } $this->_data['active'] = $active; } /** * Set cancelled * * @param bool $cancelled Is cancelled? * @return void */ public function set_cancelled( bool $cancelled ) : void { if ( $cancelled ) { $this->_data['active'] = FALSE; // We call directly so onExpire doesn't run (as onCancel will run) $this->onCancel(); } else { if ( !$this->expire or $this->expire->getTimestamp() > time() ) { $this->active = TRUE; if ( $this->_data['cancelled'] ) { $this->onReactivate(); } } } $this->_data['cancelled'] = $cancelled; } /** * Get start date * * @return DateTime */ public function get_start() : DateTime { return DateTime::ts( $this->_data['start'] ); } /** * Set start date * * @param DateTime $date The invoice date * @return void */ public function set_start( DateTime $date ) : void { $this->_data['start'] = $date->getTimestamp(); } /** * Get expire date * * @return DateTime|NULL */ public function get_expire() : DateTime|null { return ( isset( $this->_data['expire'] ) and $this->_data['expire'] ) ? DateTime::ts( $this->_data['expire'] ) : NULL; } /** * Set expire date * * @param DateTime|NULL $date The invoice date * @return void */ public function set_expire( ?DateTime $date = NULL ) : void { if ( $date === NULL ) { $this->_data['expire'] = 0; $this->active = !$this->cancelled; } else { $this->_data['expire'] = $date->getTimestamp(); $this->active = ( !$this->cancelled and $date->add( new DateInterval( 'PT' . intval( $this->grace_period ) . 'S' ) )->getTimestamp() > time() ); } if ( $this->id ) { $this->onExpirationDateChange(); } } /** * Get renewal term * * @return RenewalTerm|NULL */ public function get_renewals() : RenewalTerm|null { if( isset( $this->_data['renewals'] ) and $this->_data['renewals'] ) { $tax = NULL; if ( $this->_data['tax'] ) { try { $tax = Tax::load( $this->_data['tax'] ); } catch ( Exception ) { } } return new RenewalTerm( new Money( $this->_data['renewal_price'], $this->_data['renewal_currency'] ), new DateInterval( 'P' . $this->_data['renewals'] . mb_strtoupper( $this->_data['renewal_unit'] ) ), $tax ); } return NULL; } /** * Set renewal term * * @param RenewalTerm|NULL $term The renewal term * @return void */ public function set_renewals( ?RenewalTerm $term = NULL ) : void { if ( $term === NULL OR $term->cost->amount->isZero() ) { $this->_data['renewals'] = 0; } else { $data = $term->getTerm(); $this->_data['renewals'] = $data['term']; $this->_data['renewal_unit'] = $data['unit']; $this->_data['renewal_price'] = $term->cost->amount; $this->_data['renewal_currency'] = $term->cost->currency; $this->_data['tax'] = $term->tax ? $term->tax->id : 0; } } /** * Get custom fields * * @return array */ public function get_custom_fields() : array { return json_decode( $this->_data['custom_fields'], TRUE ) ?: array(); } /** * Set custom fields * * @param array|null $customFields The data * @return void */ public function set_custom_fields( ?array $customFields ) : void { $this->_data['custom_fields'] = json_encode( $customFields ); } /** * Get extra information * * @return array */ public function get_extra() : array { return json_decode( $this->_data['extra'], TRUE ) ?: array(); } /** * Set extra information * * @param array|null $extra The data * @return void */ public function set_extra( ?array $extra ) : void { $this->_data['extra'] = json_encode( $extra ); } /** * Set parent purchase * * @param Purchase|null $purchase * @return void */ public function set_parent( ?Purchase $purchase = NULL ) : void { $this->_data['parent'] = $purchase ? $purchase->id : 0; } /** * Get pending invoice * * @return Invoice|null */ public function get_invoice_pending() : Invoice|null { try { return $this->_data['invoice_pending'] ? Invoice::load( $this->_data['invoice_pending'] ) : NULL; } catch ( OutOfRangeException ) { return NULL; } } /** * Set pending invoice * * @param Invoice|null $invoice * @return void */ public function set_invoice_pending( ?Invoice $invoice = NULL ) : void { $this->_data['invoice_pending'] = $invoice ? $invoice->id : 0; if ( !$invoice ) { $this->_data['invoice_warning_sent'] = FALSE; } } /** * Get member to receive payments * * @return Member|null */ public function get_pay_to() : Member|null { try { return $this->_data['pay_to'] ? Customer::load( $this->_data['pay_to'] ) : NULL; } catch ( Exception ) { return NULL; } } /** * Set member to receive payments * * @param Member $member * @return void */ public function set_pay_to( Member $member ) : void { $this->_data['pay_to'] = $member->member_id; } /** * Get fee to commission on renewal charges * * @return Money|NULL */ public function get_fee() : Money|null { if ( $this->_data['fee'] and $this->renewals ) { try { return new Money( $this->_data['fee'], $this->renewals->cost->currency ); } catch ( Exception ) { } } return NULL; } /** * Set fee to commission on renewal charges * * @param Money|NULL $fee The fee * @return void */ public function set_fee( ?Money $fee = NULL ) : void { $this->_data['fee'] = $fee?->amount; } /** * Get original invoice * * @return Invoice */ public function get_original_invoice() : Invoice { return Invoice::load( $this->_data['original_invoice'] ); } /** * Set original invoice * * @param Invoice $invoice * @return void */ public function set_original_invoice( Invoice $invoice ) : void { $this->_data['original_invoice'] = $invoice->id; } /** * Get grouped renewals information * * @return array|null */ public function get_grouped_renewals() : array|null { return $this->_data['grouped_renewals'] ? json_decode( $this->_data['grouped_renewals'], TRUE ) : NULL; } /** * Set grouped renewals information * * @param array|NULL $data The data * @return void */ public function set_grouped_renewals( ?array $data ) : void { $this->_data['grouped_renewals'] = !is_null( $data ) ? json_encode( $data ) : NULL; } /** * Get billing agreement * * @return BillingAgreement|NULL */ public function get_billing_agreement() : BillingAgreement|null { if ( $this->_data['billing_agreement'] ) { try { return BillingAgreement::load( $this->_data['billing_agreement'] ); } catch ( OutOfRangeException ) { $this->_data['billing_agreement'] = NULL; } } return NULL; } /** * Set billing agreement * * @param BillingAgreement|NULL $billingAgreement The billing agreement * @return void */ public function set_billing_agreement( ?BillingAgreement $billingAgreement = NULL ) : void { $this->_data['billing_agreement'] = $billingAgreement?->id; } /* !Properties and syncing */ /** * @brief Extension */ protected ?string $extension = null; /** * Get extension * * @return string * @throws OutOfRangeException */ protected function extension() : string { if ( $this->extension === NULL ) { /* Load the app. \IPS\Application::load() throws UnexpectedValueException if the Application.php does not exist, which we'll standardize to OutOfRangeException so areas calling this method only need to check for that */ try { $app = Application::load( $this->app ); } catch ( UnexpectedValueException ) { throw new OutOfRangeException; } /* Find the extension */ foreach ( $app->extensions( 'nexus', 'Item', FALSE ) as $ext ) { if ( $ext::$type == $this->type ) { $this->extension = $ext; break; } } /* Don't have it? */ if ( !$this->extension ) { throw new OutOfRangeException; } } return $this->extension; } /** * Get icon * * @return string */ public function getIcon() : string { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::getIcon( $this ); } catch ( OutOfRangeException ) { return 'question'; } } /** * Get icon * * @return string|null */ public function getTypeTitle() : string|null { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::getTypeTitle( $this ); } catch ( OutOfRangeException ) { return NULL; } } /** * Get image * * @return File|null */ public function image() : File|null { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::purchaseImage( $this ); } catch ( OutOfRangeException ) { return NULL; } } /** * Get ACP Page HTML * * @return string */ public function acpPage(): string { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::acpPage( $this ); } catch ( OutOfRangeException | BadMethodCallException ) { return ''; } } /** * ACP Edit Form * * @param Form $form The form * @param RenewalTerm|null $renewals The renewal term * @return void */ public function acpEdit(Form $form, ?RenewalTerm $renewals ): void { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $extension::acpEdit( $this, $form, $renewals ); } catch ( OutOfRangeException ){} } /** * ACP Edit Save * * @param array $values Values from form * @return void */ public function acpEditSave( array $values ): void { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $extension::acpEditSave( $this, $values ); } catch ( OutOfRangeException ){} } /** * Get Client Area Page HTML * * @return array */ public function clientAreaPage(): array { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::clientAreaPage( $this ); } catch ( OutOfRangeException | BadMethodCallException | ErrorException ) { return array( 'packageInfo' => '', 'purchaseInfo' => '' ); } } /** * Perform ACP Action * * @return string|null */ public function acpAction(): string|null { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::acpAction( $this ); } catch ( OutOfRangeException ) { return null; } } /** * Perform Client Area Action * * @return void */ public function clientAreaAction(): void { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $extension::clientAreaAction( $this ); } catch ( OutOfRangeException ){} } /** * Get renewal payment methods IDs * * @return array|NULL */ public function renewalPaymentMethodIds(): array|null { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::renewalPaymentMethodIds( $this ); } catch ( OutOfRangeException ) { return NULL; } } /** * Call on* * * @param string $method Method * @param array $params Params * @return mixed */ public function __call(string $method, array $params ): mixed { if ( mb_substr( $method, 0, 2 ) === 'on' ) { try { $result = call_user_func_array( array( $this->extension(), $method ), array_merge( array( $this ), $params ) ); /* Fire the event, but do it on the original invoice item, not on the purchase itself. This way we support all possible invoice items. */ $price = new Money( "0", $this->member->defaultCurrency() ); if( $this->renewals instanceof RenewalTerm ) { $price = $this->renewals->cost; } $item = new $this->extension( $this->name, $price ); Event::fire( $method, $item, array_merge( array( $this ), $params ) ); return $result; } catch ( OutOfRangeException ) { } } throw new BadMethodCallException; } /** * Purchase can be reactivated in the ACP? * * @param string|NULL $error Error to show, passed by reference * @return bool */ public function canAcpReactivate( ?string &$error=NULL ): bool { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::canAcpReactivate( $this, $error ); } /** * Get output for API * * @param Member|NULL $authorizedMember The member making the API request or NULL for API Key / client_credentials * @return array * @apiresponse int id ID number * @apiresponse string name Name * @apiresponse string itemApp Key for application. For example, 'nexus' for products and renewals; 'downloads' for Downloads files * @apiresponse string itemType Key for item type. For example, 'package' for products; 'file' for Downloads files. * @apiresponse int itemId The ID for the item. For example, the product ID or the file ID. * @apiresponse \IPS\nexus\Customer customer Customer * @apiresponse datetime purchased Purchased date * @apiresponse datetime expires Expiration date * @apiresponse bool active If purchase is currently active (not expired) * @apiresponse bool canceled If purchase has been canceled * @apiresponse \IPS\nexus\Purchase\RenewalTerm renewalTerm Renewal term * @apiresponse object customFields Values for custom fields * @apiresponse \IPS\nexus\Purchase parent Parent purchase * @apiresponse bool show If this purchase shows in the client area and AdminCP * @apiresponse string licenseKey License key * @apiresponse string image If the item has a relevant image (for exmaple, product image, Downloads file screenshot), the URL to it * @apiresponse string url The URL for the customer to view this purchase in the client area */ public function apiOutput( Member $authorizedMember = NULL ): array { $parent = NULL; if ( $this->parent ) { try { $parent = static::load( $this->parent )->apiOutput( $authorizedMember ); } catch ( OutOfRangeException ) {} } return array( 'id' => $this->id, 'name' => $this->name, 'itemApp' => $this->app, 'itemType' => $this->type, 'itemId' => $this->item_id, 'customer' => $this->member->apiOutput( $authorizedMember ), 'purchased' => $this->start->rfc3339(), 'expires' => $this->expire ? $this->expire->rfc3339() : null, 'active' => $this->active, 'canceled' => (bool) $this->cancelled, 'renewalTerm' => $this->renewals?->apiOutput($authorizedMember), 'customFields' => $this->custom_fields, 'parent' => $parent, 'show' => (bool) $this->show, 'licenseKey' => $this->licenseKey() ? $this->licenseKey()->key : null, 'image' => $this->image() ? ( (string) $this->image()->url ) : null, 'url' => (string) $this->url(), ); } /* !License Keys */ /** * @brief License key */ public LicenseKey|null|bool $licenseKey = null; /** * Get license key * * @return LicenseKey|NULL|bool */ public function licenseKey() : LicenseKey|null|bool { if ( $this->licenseKey === NULL ) { try { $this->licenseKey = LicenseKey::load( $this->id, 'lkey_purchase' ); } catch ( OutOfRangeException ) { $this->licenseKey = FALSE; } } return $this->licenseKey ?: NULL; } /** * onExpire * * @return void */ public function onExpire() : void { if ( $licenseKey = $this->licenseKey() ) { $licenseKey->active = FALSE; $licenseKey->save(); } $this->__call( 'onExpire', array() ); } /** * onCancel * * @return void */ public function onCancel() : void { if ( $licenseKey = $this->licenseKey() ) { $licenseKey->active = FALSE; $licenseKey->save(); } $this->__call( 'onCancel', array() ); } /** * onReactivate * * @return void */ public function onReactivate() : void { if ( $licenseKey = $this->licenseKey() ) { $licenseKey->active = TRUE; $licenseKey->save(); } $this->__call( 'onReactivate', array() ); } /** * onDelete * * @return void */ public function onDelete() : void { if ( $licenseKey = $this->licenseKey() ) { $licenseKey->delete(); } $this->__call( 'onDelete', array() ); } /* !Client Area */ /** * Can view? * * @param Member|Group|null $member The member to check (NULL for currently logged in member) * @return bool */ public function canView( Member|Group|null $member=null ): bool { $member = $member ?: Member::loggedIn(); /* Show only purchases from active applications */ try { $app = Application::load( $this->app ); } catch ( UnexpectedValueException ) { return FALSE; } if( !$app->canAccess() ) { return FALSE; } if ( $this->member->member_id === $member->member_id or array_key_exists( $member->member_id, iterator_to_array( $this->member->alternativeContacts( array( Db::i()->findInSet( 'purchases', array( $this->id ) ) ) ) ) ) ) { return TRUE; } return FALSE; } /** * Admin can change expire date / renewal term? * * @return bool */ public function canChangeExpireDate() : bool { if ( $this->billing_agreement and !$this->billing_agreement->canceled ) { return FALSE; } try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::canChangeExpireDate( $this ); } catch ( OutOfRangeException ) { return TRUE; } } /** * Can Renew Until * * @param Member|NULL $member The member to check (NULL for currently logged in member) * @param bool $inCycles Get in cycles rather than a date? * @param bool $admin If TRUE, is for ACP. If FALSE, is for front-end. * @return bool|DateTime|int TRUE means can renew as much as they like. FALSE means cannot renew at all. \IPS\DateTime (or int if $inCycles is TRUE) means can renew until that date */ public function canRenewUntil(?Member $member = NULL, bool $inCycles = FALSE, bool $admin = FALSE ): DateTime|int|bool { /* Can this purchase be renewed at all? */ if ( !$this->canBeRenewed() ) { return FALSE; } $member = $member ?: Member::loggedIn(); if ( !$admin and $this->member->member_id !== $member->member_id and !array_key_exists( $member->member_id, iterator_to_array( $this->member->alternativeContacts( array( Db::i()->findInSet( 'purchases', array( $this->id ) ) . ' AND billing=1' ) ) ) ) ) { return FALSE; } if ( $this->cancelled or !$this->renewals or ( $this->billing_agreement and !$this->billing_agreement->canceled ) ) { return FALSE; } if ( !$admin and $pendingInvoice = $this->invoice_pending and $pendingInvoice->status === $pendingInvoice::STATUS_PENDING ) { return FALSE; } try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $date = $extension::canRenewUntil( $this, $admin ); if ( $inCycles and $date instanceof DateTime ) { $now = DateTime::create(); $cycles = 0; while ( $now->add( $this->renewals->interval )->getTimestamp() < $date->getTimestamp() ) { $cycles++; } return $cycles; } else { return $date; } } catch ( OutOfRangeException ) { return FALSE; } } /** * Purchase can be renewed? * * @return boolean|NULL */ public function canBeRenewed(): bool|null { try { /* @var ItemPurchase $extension */ $extension = $this->extension(); return $extension::canBeRenewed( $this ); } catch ( OutOfRangeException ) { return NULL; } } /** * Can Cancel * * @param Member|NULL $member The member to check (NULL for currently logged in member) * @return bool */ public function canCancel(?Member $member = NULL ): bool { $member = $member ?: Member::loggedIn(); if ( $this->member->member_id !== $member->member_id and !array_key_exists( $member->member_id, iterator_to_array( $this->member->alternativeContacts( array( Db::i()->findInSet( 'purchases', array( $this->id ) ) . ' AND billing=1' ) ) ) ) ) { return FALSE; } return !$this->grouped_renewals and ( !$this->billing_agreement or $this->billing_agreement->canceled ) and $this->renewals and $this->active; } /** * @brief Cached URL */ protected mixed $_url = NULL; /** * Get URL * * @return Url\Internal|string|null */ function url(): Url\Internal|string|null { if( $this->_url === NULL ) { $this->_url = Url::internal( "app=nexus&module=clients&controller=purchases&do=view&id={$this->id}", 'front', 'clientspurchase', array( Friendly::seoTitle( $this->name ) ) ); } return $this->_url; } /* !Actions */ /** * Transfer * * @param Member $newCustomer * @return void */ public function transfer( Member $newCustomer ) : void { $this->onTransfer( $newCustomer ); $this->member = $newCustomer; $this->save(); foreach ( $this->children() as $child ) { $child->transfer( $newCustomer ); } } /** * Delete * * @param bool $deleteChildren If TRUE, child purchases will also be deleted. If FALSE, they will become unassociated. * @return void */ public function delete( bool $deleteChildren = TRUE ): void { File::unclaimAttachments( 'nexus_Purchases', $this->id, NULL, 'purchase' ); foreach ( $this->children( NULL ) as $child ) { if ( $deleteChildren ) { $child->delete(); } else { $child->parent = NULL; $child->save(); } } try { $this->onDelete(); } catch ( BadMethodCallException ) {} parent::delete(); } /* !Grouping */ /** * Group with parent * * @return void * @throws LogicException */ public function groupWithParent() : void { /* Get the parent */ $parent = $this->parent(); if ( !$parent ) { throw new BadMethodCallException('no_parent'); } /* If we have a renewal term, we need to merge it with the parent... */ if ( $this->renewals ) { /* Remember what our term is */ $term = $this->renewals->getTerm(); $this->grouped_renewals = array( 'term' => $term['term'], 'unit' => $term['unit'], 'price' => $this->renewals->cost->amount, 'currency' => $this->renewals->cost->currency, 'tax' => $this->renewals->tax ? $this->renewals->tax->id : 0 ); $this->save(); /* Add our term to the parent... */ if ( !$this->cancelled ) { /* If the parent also has a renewal term, merge them */ if ( $parent->renewals ) { $parent->renewals = new RenewalTerm( $parent->renewals->add( $this->renewals ), $parent->renewals->interval, $parent->renewals->tax ); } /* Otherwise just set the parent to this */ else { $parent->renewals = $this->renewals; if ( !$parent->expire ) { $parent->expire = $this->expire; } } $parent->save(); } /* Cancel any pending invoices as they're no longer valid */ if ( $invoice = $parent->invoice_pending ) { $invoice->status = $invoice::STATUS_CANCELED; $invoice->save(); } if ( $invoice = $this->invoice_pending ) { $invoice->status = $invoice::STATUS_CANCELED; $invoice->save(); } } else { $this->grouped_renewals = array(); } /* Update this purchase */ $this->expire = NULL; $this->save(); } /** * Ungroup from parent * * @return void * @throws BadMethodCallException */ public function ungroupFromParent() : void { /* Get the parent */ $parent = $this->parent(); if ( !$parent ) { throw new BadMethodCallException('no_parent'); } /* If we have a renewal term, we need to remove it from the parent... */ if ( $this->renewals ) { /* Restore parent renewal term */ if ( $parent->renewals and !$this->cancelled ) { $newRenewalPrice = $parent->renewals->subtract( $this->renewals ); if ( $newRenewalPrice->amount->isZero() ) { $parent->renewals = NULL; } else { $parent->renewals = new RenewalTerm( $newRenewalPrice, $parent->renewals->interval, $parent->renewals->tax ); } $parent->save(); } /* Restore this purchase renewal term */ $groupedRenewals = $this->grouped_renewals; if ( $groupedRenewals['term'] and $groupedRenewals['unit'] ) { try { $tax = ( isset( $groupedRenewals['tax'] ) and $groupedRenewals['tax'] ) ? Tax::load( $groupedRenewals['tax'] ) : NULL; } catch ( OutOfRangeException ) { $tax = NULL; } $this->renewals = new RenewalTerm( new Money( $groupedRenewals['price'], $groupedRenewals['currency'] ?? $this->member->defaultCurrency()), new DateInterval( 'P' . $groupedRenewals['term'] . mb_strtoupper( $groupedRenewals['unit'] ) ), $tax ); } $this->expire = $parent->expire; } /* Ungroup */ $this->grouped_renewals = NULL; $this->save(); /* Cancel any pending invoices as they're no longer valid */ if ( $invoice = $parent->invoice_pending ) { $invoice->status = $invoice::STATUS_CANCELED; $invoice->save(); } if ( $invoice = $this->invoice_pending ) { $invoice->status = $invoice::STATUS_CANCELED; $invoice->save(); } } /* !ACP Display */ /** * Get the URL of the AdminCP page for this node * * @param string|null $do The "do" query parameter of the url (e.g. 'form', 'permissions', etc). * * @return Url | NULL */ public function acpUrl( ?string $do="form" ): ?Url { return Url::internal( "app=nexus&module=customers&controller=purchases&do=view&id={$this->id}", 'admin' ); } /** * ACP Buttons * * @param string $ref Referer * @return array */ public function buttons( string $ref='v' ) : array { $return = array(); $url = $this->acpUrl()->setQueryString( 'r', $ref ); if( Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_edit' ) ) { $return['edit'] = array( 'icon' => 'pencil', 'title' => 'edit', 'link' => $url->setQueryString( 'do', 'edit' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('edit') ) ); } $extension = NULL; try { /* @var ItemPurchase $extension */ $extension = $this->extension(); $return = array_merge( $return, $extension::acpButtons( $this, $url ) ); } catch ( OutOfRangeException ) { } $parent = $this->parent(); if ( !$this->grouped_renewals and ( !$this->billing_agreement or $this->billing_agreement->canceled ) and $this->renewals and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_add' ) ) { $return['renew'] = array( 'icon' => 'refresh', 'title' => 'generate_renewal_invoice', 'link' => $url->setQueryString( 'do', 'renew' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('generate_renewal_invoice') ) ); } if ( ( !$this->billing_agreement or $this->billing_agreement->canceled ) and $this->parent() and Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_edit' ) ) { if ( !$this->grouped_renewals ) { $return['group'] = array( 'icon' => 'compress', 'title' => 'group_with_parent', 'link' => $url->setQueryString( 'do', 'group' )->csrf(), 'data' => array( 'confirm' => '', 'confirmSubMessage' => Member::loggedIn()->language()->addToStack('group_with_parent_info') ) ); } else { $return['group'] = array( 'icon' => 'expand', 'title' => 'ungroup_from_parent', 'link' => $url->setQueryString( 'do', 'ungroup' )->csrf(), 'data' => array( 'confirm' => '', 'confirmSubMessage' => Member::loggedIn()->language()->addToStack('ungroup_from_parent_info') ) ); } } if( ( !$this->billing_agreement or $this->billing_agreement->canceled ) and !$this->grouped_renewals and Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_transfer' ) ) { $return['transfer'] = array( 'icon' => 'user', 'title' => 'transfer', 'link' => $url->setQueryString( 'do', 'transfer' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('transfer') ) ); } if ( ( !$this->billing_agreement or $this->billing_agreement->canceled ) and ( !$this->grouped_renewals or !$parent or !$parent->billing_agreement or $parent->billing_agreement->canceled ) and Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_cancel' ) ) { if ( $this->cancelled ) { /* @var ItemPurchase $extension */ if ( $extension AND $extension::canAcpReactivate( $this ) ) { $return['reactivate'] = array( 'icon' => 'check', 'title' => 'reactivate', 'link' => $url->setQueryString( 'do', 'reactivate' )->csrf(), 'data' => array( 'confirm' => '' ) ); } } else { $return['cancel'] = array( 'icon' => 'times', 'title' => 'cancel', 'link' => $url->setQueryString( 'do', 'cancel' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('cancel') ) ); } } if ( ( !$this->billing_agreement or $this->billing_agreement->canceled ) and ( !$this->grouped_renewals or !$parent or !$parent->billing_agreement or $parent->billing_agreement->canceled ) and Member::loggedIn()->hasAcpRestriction( 'nexus', 'customers', 'purchases_delete' ) ) { $return['delete'] = array( 'icon' => 'times-circle', 'title' => 'delete', 'link' => $url->setQueryString( 'do', 'delete' ), 'data' => array( 'delete' => '' ) ); } return $return; } /** * Allow for individual classes to override and * specify a primary image. Used for grid views, etc. * * @return File|null */ public function primaryImage() : ?File { return $this->image(); } }