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 Exception; use IPS\DateTime; use IPS\Db; use IPS\Email; use IPS\Events\Event; use IPS\File; use IPS\GeoLocation; use IPS\Http\Url; use IPS\Math\Number; use IPS\Member; use IPS\nexus\Customer\Address; use IPS\nexus\extensions\nexus\Item\CouponDiscount; use IPS\nexus\extensions\nexus\Item\Package; use IPS\nexus\Invoice\Item; use IPS\nexus\Invoice\Item\Purchase; use IPS\nexus\Invoice\Item\Renewal; use IPS\nexus\Invoice\Item\TaxItem; use IPS\nexus\Invoice\ItemsIterator; use IPS\Patterns\ActiveRecord; use IPS\Patterns\ActiveRecordIterator; use IPS\Settings; use IPS\Theme; use LengthException; use OutOfRangeException; use ReflectionClass; use RuntimeException; use UnderflowException; use function count; use function defined; use function get_called_class; use function in_array; use function intval; use function is_array; use function is_int; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Invoice Model * * @property Customer $member * @property ItemsIterator $items */ class Invoice extends ActiveRecord { const STATUS_PAID = 'paid'; const STATUS_PENDING = 'pend'; const STATUS_EXPIRED = 'expd'; const STATUS_CANCELED = 'canc'; /** * @brief Multiton Store */ protected static array $multitons; /** * @brief Database Table */ public static ?string $databaseTable = 'nexus_invoices'; /** * @brief Database Prefix */ public static string $databasePrefix = 'i_'; /** * Load and check permissions * * @param int $id * @return static * @throws OutOfRangeException */ public static function loadAndCheckPerms( int $id ) : static { $obj = static::load( $id ); if ( !$obj->canView() ) { throw new OutOfRangeException; } return $obj; } /** * Get statuses * * @return array */ public static function statuses() : array { $options = array(); $reflection = new ReflectionClass( get_called_class() ); foreach ( $reflection->getConstants() as $k => $v ) { if ( mb_substr( $k, 0, 7 ) === 'STATUS_' ) { $options[ $v ] = "istatus_{$v}"; } } return $options; } /** * Get invoices table * * @param array|null $where Where clause * @param Url $url * @param string $ref Referer * @return \IPS\Helpers\Table\Db */ public static function table( ?array $where, Url $url, string $ref = 't' ) : \IPS\Helpers\Table\Db { /* Create the table */ $table = new \IPS\Helpers\Table\Db( 'nexus_invoices', $url, $where ); $table->sortBy = $table->sortBy ?: 'i_date'; /* Format Columns */ $table->include = array( 'i_status', 'i_id', 'i_title', 'i_member', 'i_total', 'i_date' ); $table->parsers = array( 'i_status' => function( $val ) { return Theme::i()->getTemplate( 'invoices', 'nexus' )->status( $val ); }, 'i_member' => function ( $val, $row ) { if ( $val ) { return Theme::i()->getTemplate( 'global', 'nexus' )->userLink( Member::load( $val ) ); } elseif ( $row['i_guest_data'] ) { $data = json_decode( $row['i_guest_data'], TRUE ); return htmlspecialchars( Customer::constructFromData( $data['member'] )->cm_name, ENT_DISALLOWED, 'UTF-8', FALSE ); } else { return Theme::i()->getTemplate( 'global', 'nexus' )->userLink( Member::load( 0 ) ); } }, 'i_total' => function( $val, $row ) { return (string) new Money( $val, $row['i_currency'] ); }, 'i_date' => function( $val ) { return DateTime::ts( $val ); } ); /* Buttons */ $table->rowButtons = function( $row ) use( $ref ) { return array_merge( array( 'view' => array( 'icon' => 'search', 'title' => 'invoice_view', 'link' => Url::internal( "app=nexus&module=payments&controller=invoices&do=view&id={$row['i_id']}" ) ), ), Invoice::constructFromData( $row )->buttons( $ref ) ); }; return $table; } /** * @brief Being generated by system? */ public bool $system = FALSE; /** * Set Default Values * * @return void */ public function setDefaultValues() : void { $this->status = static::STATUS_PENDING; $this->items = json_encode( array() ); $this->date = new DateTime; $this->renewal_ids = array(); $this->guest_data = array(); $this->_data['total'] = 0; $this->_data['member'] = 0; $this->status_extra = array(); } /** * Member can view? * * @param Member|NULL $member The member to check for, or NULL for currently logged in member * @return bool */ public function canView( Member $member = null ) : bool { $member = $member ?: Member::loggedIn(); return $this->member->member_id === $member->member_id or array_key_exists( $member->member_id, iterator_to_array( $this->member->alternativeContacts( array( 'billing=1' ) ) ) ); } /** * Set title * * @param string $title * @return void * @throws LengthException */ public function set_title( string $title ) : void { if ( mb_strlen( $title ) > 128 ) { throw new LengthException; } $this->_data['title'] = $title; } /** * @brief Customer|null */ protected ?Customer $_member = null; /** * Get member * * @return Customer */ public function get_member() : Customer { if ( $this->_member === NULL ) { try { $this->_member = Customer::load( $this->_data['member'] ); /* \IPS\nexus\Customer does not necessarily throw an OutOfRangeException if passed a value of 0 (a guest instance is valid) - throw one here so we can bubble up to get the correct information for guests. */ if ( !$this->_member->member_id ) { throw new OutOfRangeException; } } catch ( OutOfRangeException ) { $this->_member = new Customer; if ( $this->_data['member'] ) { $this->_member->_data['cm_first_name'] = Member::loggedIn()->language()->addToStack('deleted_member'); } elseif ( $this->guest_data ) { foreach ( $this->guest_data['member'] as $k => $v ) { $this->_member->_data[ $k ] = $v; $this->_member->changed[ $k ] = $v; } } } } return $this->_member; } /** * Set member * * @param Customer $member * @return void */ public function set_member( Customer $member ) : void { $this->_data['member'] = (int) $member->member_id; $this->_member = NULL; if ( !$this->currency and $member instanceof Customer) { $this->currency = $member->defaultCurrency(); } } /** * @brief Items iterator */ protected ?ItemsIterator $_itemsIterator = null; /** * @brief Tracked */ protected array $_tracked = array(); /** * Get items * * @return ItemsIterator */ public function get_items() : ItemsIterator { if ( $this->_itemsIterator === NULL ) { /* Decode */ $itemsJson = json_decode( $this->_data['items'], TRUE ) ?: array(); /* Check we actually have a valid array with items (very old invoices from IP.Subscriptions may not have anything) and just create a dummy if so */ $itemsJson = array_filter( $itemsJson, function( $val ) { return is_array( $val ) and isset( $val['act'] ); } ); if ( !$itemsJson and $this->total->amount->isGreaterThanZero() ) { $itemsJson = array( array( 'act' => 'charge', 'app' => 'nexus', 'type' => 'charge', 'cost' => $this->total->amountAsString(), 'tax' => NULL, 'quantity' => 1, 'itemName' => $this->title, 'itemID' => NULL, 'methods' => '*', 'cfields' => array(), 'extra' => NULL, ) ); } /* Return it */ $this->_itemsIterator = new ItemsIterator( $itemsJson ); $this->_itemsIterator->currency = $this->currency; } return $this->_itemsIterator; } /** * Get total * * @return Money */ public function get_total() : Money { return new Money( $this->_data['total'], $this->currency ); } /** * Set total * * @param Money $total The total * @return void */ public function set_total(Money $total ) : void { $this->_data['total'] = $total->amount; } /** * Get date * * @return DateTime */ public function get_date() : DateTime { return DateTime::ts( $this->_data['date'] ); } /** * Set date * * @param DateTime $date The invoice date * @return void */ public function set_date( DateTime $date ) : void { $this->_data['date'] = $date->getTimestamp(); } /** * Get paid date * * @return DateTime|NULL */ public function get_paid() : DateTime|null { return ( isset( $this->_data['paid'] ) and $this->_data['paid'] ) ? DateTime::ts( $this->_data['paid'] ) : NULL; } /** * Set paid date * * @param DateTime $date The invoice date * @return void */ public function set_paid( DateTime $date ) : void { $this->_data['paid'] = $date->getTimestamp(); } /** * Get return URI * * @return Url|NULL */ public function get_return_uri() : Url|null { return $this->_data['return_uri'] ? Url::internal( $this->_data['return_uri'] ) : NULL; } /** * Get status information * * @return array */ public function get_status_extra() : array { return json_decode( $this->_data['status_extra'], TRUE ); } /** * Set status information * * @param array|null $extra The data * @return void */ public function set_status_extra( ?array $extra ) : void { $this->_data['status_extra'] = json_encode( $extra ); } /** * Get renewal IDs * * @return array */ public function get_renewal_ids() : array { return explode( ',', $this->_data['renewal_ids'] ); } /** * Set renewal IDs * * @param array $ids The IDs * @return void */ public function set_renewal_ids( array $ids ) : void { $ids = array_unique( $ids ); $ids = array_filter( $ids ); $this->_data['renewal_ids'] = implode( ',', $ids ); } /** * Get billing address * * @return GeoLocation|NULL */ public function get_billaddress() : GeoLocation|null { if ( isset( $this->_data['billaddress'] ) and $this->_data['billaddress'] ) { return GeoLocation::buildFromJson( $this->_data['billaddress'] ); } else { return NULL; } } /** * Set billing address * * @param GeoLocation|null $billingAddress The billing address * @return void */ public function set_billaddress( ?GeoLocation $billingAddress = NULL ) : void { if ( $billingAddress ) { $this->_data['billaddress'] = json_encode( $billingAddress ); $this->_data['billcountry'] = $billingAddress->country; } else { $this->_data['billaddress'] = NULL; $this->_data['billcountry'] = NULL; } } /** * Get guest data * * @return array|null */ public function get_guest_data() : array|null { return $this->_data['guest_data'] ? json_decode( $this->_data['guest_data'], TRUE ) : NULL; } /** * Set guest data * * @param array|null $data The data * @return void */ public function set_guest_data( ?array $data ) : void { $this->_data['guest_data'] = $data ? json_encode( $data ) : NULL; $this->_member = null; } /** * Set a title automatically based on item names * * @return void */ public function setDefaultTitle() : void { $titles = array(); foreach ( $this->items as $item ) { $titles[] = ( $item->name . ( $item->quantity > 1 ? " x{$item->quantity}" : '' ) ); } $thisTitle = implode( ', ', $titles ); if ( mb_strlen( $thisTitle ) > 128 ) { $thisTitle = mb_substr( $thisTitle, 0, 125 ) . '...'; } $this->title = $thisTitle; } /** * Save Changed Columns * * @return void */ public function save(): void { if ( !$this->title ) { $this->setDefaultTitle(); } if ( $this->_new ) { parent::save(); $this->member->log( 'invoice', array( 'id' => $this->id, 'title' => $this->title, 'status' => $this->status, 'system' => $this->system ), $this->system ? FALSE : NULL ); } else { /* Has the invoice status changed? */ $statusChanged = isset( $this->changed['status'] ); parent::save(); /* If the invoice status was changed, fire an event */ if( $statusChanged ) { Event::fire( 'onStatusChange', $this, array( $this->status ) ); } } } /** * Delete * * @return void */ public function delete(): void { File::unclaimAttachments( 'nexus_Purchases', NULL, NULL, "invoice-{$this->id}" ); parent::delete(); foreach ($this->transactions( array( Transaction::STATUS_PENDING, Transaction::STATUS_WAITING, Transaction::STATUS_HELD, Transaction::STATUS_REVIEW, Transaction::STATUS_GATEWAY_PENDING ) ) as $transaction ) { if ( $transaction->status === Transaction::STATUS_PENDING ) { $transaction->delete(); } else { try { $transaction->method->void( $transaction ); } catch ( Exception ){} $extra = $transaction->extra; $extra['history'][] = array( 's' => Transaction::STATUS_REFUSED, 'on' => time(), 'note' => 'invoiceDeleted' ); $transaction->extra = $extra; $transaction->status = Transaction::STATUS_REFUSED; $transaction->save(); } } } /* !Items */ /** * Add item * * @param Item $item The item * @param int|NULL $cartKey Our cart key for this item, or NULL. Used to make sure items are properly associated with their parents if not bought yet. * @return int */ public function addItem( Item $item, ?int $cartKey = NULL ) : int { /* Set basic data */ $data = array( 'act' => $item::$act, 'app' => $item::$application ?? $item->application, 'type' => $item::$type ?? $item->type, 'cost' => $item->price->amountAsString(), 'tax' => $item->tax?->id, 'quantity' => $item->quantity, 'itemName' => $item->name, 'itemID' => $item->id ?? 0, 'methods' => $item->paymentMethodIds ?: '*', 'cfields' => $item->details, 'extra' => $item->extra, ); /* Parent? */ if ( isset( $item->parent ) ) { if ( is_int( $item->parent ) ) { $data['assoc'] = $item->parent; $data['assocBought'] = FALSE; } else { $data['assoc'] = $item->parent->id; $data['assocBought'] = TRUE; } $data['groupParent'] = $item->groupWithParent; } /* Renewal term? */ if ( isset( $item->renewalTerm ) ) { $renewTermData = $item->renewalTerm->getTerm(); $data['renew_term'] = $renewTermData['term']; $data['renew_units'] = $renewTermData['unit']; $data['renew_cost'] = $item->renewalTerm->cost->amountAsString(); if ( $item->renewalTerm->gracePeriod ) { $data['grace_period'] = DateTime::create()->add( $item->renewalTerm->gracePeriod )->getTimestamp() - time(); } } if ( isset( $item->initialInterval ) ) { if( $item->initialInterval->y ) { $data['initial_interval_term'] = $item->initialInterval->y; $data['initial_interval_unit'] = 'y'; } elseif( $item->initialInterval->m ) { $data['initial_interval_term'] = $item->initialInterval->m; $data['initial_interval_unit'] = 'm'; } else { $data['initial_interval_term'] = $item->initialInterval->d; $data['initial_interval_unit'] = 'd'; } } /* Expire date? */ if ( isset( $item->expireDate ) ) { $data['expires'] = $item->expireDate->getTimestamp(); } /* Pay to someone else? */ if ( $item->payTo ) { $data['payTo'] = $item->payTo->member_id; if ( $item->commission ) { $data['commission'] = $item->commission; } if ( $item->fee ) { $data['fee'] = $item->fee->amount; } } /* Add it */ $items = json_decode( $this->_data['items'], TRUE ); if ( $cartKey !== NULL ) { $items[ $cartKey ] = $data; } else { $items[] = $data; } /* Get the ID */ $ids = array_keys( $items ); $id = array_pop( $ids ); /* Save it */ $this->items = json_encode( $items ); $this->_itemsIterator = NULL; /* Is it a renewal */ if ( $item instanceof Renewal ) { $renewalIds = $this->renewal_ids; $renewalIds[] = $item->id; $this->renewal_ids = $renewalIds; } /* Increase the total */ $this->recalculateTotal(); /* Fire an event */ Event::fire( 'onAddToInvoice', $item, [ $this ] ); /* Return the ID */ return $id; } /** * Change item * * @param int $index The index * @param array $data Data to set * @return void */ public function changeItem( int $index, array $data ) : void { $items = json_decode( $this->_data['items'], TRUE ); foreach ( $data as $k => $v ) { $items[ $index ][ $k ] = $v; } $this->items = json_encode( $items ); $this->_itemsIterator = NULL; } /** * Remove item * * @param int $index The index * @return void */ public function removeItem( int $index ) : void { $items = json_decode( $this->_data['items'], TRUE ); unset( $items[ $index ] ); $this->items = json_encode( $items ); $this->_itemsIterator = NULL; $this->recalculateTotal(); } /** * Recalculate the total amount * * @return void */ public function recalculateTotal() : void { $summary = $this->summary(); $this->total = $summary['total']; } /** * Get summary for invoice tables and so on * * @return array */ public function summary() : array { $return = array(); $return['items'] = array(); $subtotal = new Number('0'); $taxableAmounts = array(); $taxClasses = Tax::roots(); /* Reduce taxable amount from coupons */ $couponTaxReductions = array(); $itemsAsArray = iterator_to_array( $this->items ); foreach ( $this->items as $itemId => $item ) { if ( $item instanceof CouponDiscount ) { if ( isset( $item->extra['type'] ) ) { if ( isset( $item->extra['items'] ) ) { $itemCount = count( $item->extra['items'] ); } else { $itemCount = 0; foreach ( $itemsAsArray as $k => $_item ) // We have to clone because $items is an iterator, and we're already looping them, but we need a separate loop { if ( $_item != $item and !( $_item instanceof CouponDiscount ) ) { $itemCount++; } } } foreach ( $itemsAsArray as $k => $_item ) // We have to clone because $items is an iterator, and we're already looping them, but we need a separate loop { if ( $_item != $item and !( $_item instanceof CouponDiscount ) and ( !isset( $item->extra['items'] ) or in_array( $k, $item->extra['items'] ) ) ) { if ( $item->extra['type'] == 'p' ) { $reductionAmount = $_item->linePrice()->amount->multiply( new Number("{$item->extra['value']}") )->divide( new Number('100') ); } else { $reductionAmount = ( new Number( "{$item->extra['value']}" ) )->divide( new Number( "{$itemCount}" ) )->multiply( new Number( '-1' ) ); } if ( isset( $couponTaxReductions[ $k ] ) ) { $couponTaxReductions[ $k ] = $couponTaxReductions[ $k ]->add( $reductionAmount ); } else { $couponTaxReductions[ $k ] = $reductionAmount; } } } } } } /* Work out the line totals */ foreach ( $this->items as $k => $item ) { $subtotal = $subtotal->add( $item->linePrice()->amount ); if ( isset( $item->tax ) ) { $taxAmount = $item->linePrice()->amount; if ( isset( $couponTaxReductions[ $k ] ) ) { $taxAmount = $taxAmount->subtract( $couponTaxReductions[ $k ] ); } if ( isset( $taxableAmounts[ $item->tax->id ] ) ) { $taxableAmounts[ $item->tax->id ] = $taxableAmounts[ $item->tax->id ]->add( $taxAmount ); } else { $taxableAmounts[ $item->tax->id ] = $taxAmount; } } $return['items'][ $k ] = $item; } /* Subtotal (subtotal is caulculated BEFORE tax is added) */ $return['subtotal'] = new Money( $subtotal, $this->currency ); /* Now work out the tax */ $return['tax'] = array(); $tax = new Number('0'); foreach ( $taxableAmounts as $taxId => $amount ) { $rate = $taxClasses[ $taxId ]->rate( $this->billaddress ); $amount = $amount->multiply( new Number( $rate ) ); $amount = new Money( $amount, $this->currency ); $return['tax'][ $taxId ] = array( 'name' => '', 'rate' => $rate, 'amount' => $amount, 'type' => $taxClasses[ $taxId ]->type ); // "name" is no longer used. It is definef here only to not break custom themes. Get the title from the ID instead. $tax = $tax->add( $amount->amount ); } $return['taxTotal'] = new Money( $tax, $this->currency ); /* Discounts applied */ $discount = new Number( '0' ); foreach( $couponTaxReductions as $itemId => $amount ) { $discount = $discount->add( $amount ); } $return['discount'] = new Money( $discount, $this->currency ); /* Add it all together */ $return['total'] = new Money( $subtotal->add( $tax ), $this->currency ); return $return; } /* !Information */ /** * Does the invoice contain items that require a billing address? * * @return bool */ public function hasItemsRequiringBillingAddress() : bool { foreach ( $this->items as $item ) { if ( $item->requiresBillingAddress() ) { return TRUE; } } return FALSE; } /** * Requires login? * * @return bool */ public function requiresLogin() : bool { foreach ( $this->items as $item ) { if ( $item::$requiresAccount ) { return TRUE; } } return FALSE; } /** * Amounts that go to a member * * @return array */ public function payToRecipients() : array { $recipients = array(); foreach ( $this->items as $item ) { if ( $item->payTo ) { if ( !isset( $recipients[ $item->payTo->member_id ] ) ) { $recipients[ $item->payTo->member_id ] = new Number('0'); } $itemRecipientAmounts = $item->recipientAmounts(); $recipients[ $item->payTo->member_id ] = $recipients[ $item->payTo->member_id ]->add( $itemRecipientAmounts['recipient_final']->amount ); } } return $recipients; } /** * Get the commission information * * @param Number $amountReceived The amount the invoice is for, minus amounts being given to other recipients * @return array( 'rule' => \IPS\nexus\CommissionRule, 'referrer' => \IPS\nexus\Customer, 'amount' => \IPS\nexus\Money ) */ public function commission( Number $amountReceived ) : array { if ( !isset( $this->status_extra['commissionrule'] ) ) { try { /* Get the referrer */ /* @var Member $referrer */ $referrer = Member::load( Db::i()->select( 'referred_by', 'core_referrals', array( 'member_id=?', $this->member->member_id ) )->first() ); if ( !$referrer->member_id or $referrer->inGroup( explode( ',', Settings::i()->nexus_no_commission ) ) ) { throw new UnderflowException; } /* Get pertinent values */ $byNumber = Db::i()->select( 'COUNT( i_id )', 'nexus_invoices', array( "i_member=? AND i_status=?", $referrer->member_id, static::STATUS_PAID ) )->first(); $byAmounts = iterator_to_array( Db::i()->select( 'i_currency, SUM( i_total ) as value', 'nexus_invoices', array( "i_member=? AND i_status=?", $referrer->member_id, static::STATUS_PAID ), NULL, NULL, 'i_currency' )->setKeyField( 'i_currency' )->setValueField( 'value' ) ); $forNumber = Db::i()->select( 'COUNT( i_id )', 'nexus_invoices', array( "i_member=? AND i_status=?", $this->member->member_id, static::STATUS_PAID ) )->first(); $forAmounts = iterator_to_array( Db::i()->select( 'i_currency, SUM( i_total ) as value', 'nexus_invoices', array( "i_member=? AND i_status=?", $this->member->member_id, static::STATUS_PAID ), NULL, NULL, 'i_currency' )->setKeyField( 'i_currency' )->setValueField( 'value' ) ); /* Loop the rules to find out the best one */ $maxAmount = new Number('0'); $actingRule = NULL; foreach ( new ActiveRecordIterator( Db::i()->select( '*', 'nexus_referral_rules', array( array( "rrule_by_group='*' OR " . Db::i()->findInSet( 'rrule_by_group', $referrer->groups ) ), array( "rrule_for_group='*' OR " . Db::i()->findInSet( 'rrule_for_group', $this->member->groups ) ) ) ), 'IPS\nexus\CommissionRule' ) as $commissionRule ) { /* Before we go nuts, don't bother if it's not going to give us more commission */ if ( $amountReceived->percentage( $commissionRule->commission )->compare( $maxAmount ) === -1 ) { continue; } if ( $commissionRule->commission_limit and $commissionRule->commission_limit != '*' ) { $commisionLimit = json_decode( $commissionRule->commission_limit, TRUE ); if ( isset( $commisionLimit[ $this->currency ] ) and ( new Number( number_format( $commisionLimit[ $this->currency ]['amount'], Money::numberOfDecimalsForCurrency( $this->currency ), '.', '' ) ) )->compare( $maxAmount ) === -1 ) { continue; } } /* Is the invoice amount right? */ if ( $commissionRule->purchase_amount_op ) { $amount = json_decode( $commissionRule->purchase_amount_unit, TRUE ); if ( !$this->_checkCondition( $this->total->amount, $commissionRule->purchase_amount_op, $amount[ $this->currency ] ) ) { continue; } } /* Does the referring user have the right amount of purchases? */ if ( $commissionRule->by_purchases_op ) { $val = $commissionRule->by_purchases_unit; if ( $commissionRule->by_purchases_type == 'v' ) { $val = json_decode( $val, TRUE ); $val = $val[ $this->currency ]; } if ( !$this->_checkCondition( ( $commissionRule->by_purchases_type == 'n' ? $byNumber : $byAmounts[ $this->currency ] ), $commissionRule->by_purchases_op, $val ) ) { continue; } } /* Does the purchasing user have the right amount of purchases? */ if ( $commissionRule->for_purchases_op ) { $val = $commissionRule->for_purchases_unit; if ( $commissionRule->for_purchases_type == 'v' ) { $val = json_decode( $val, TRUE ); $val = $val[ $this->currency ]; } if ( !$this->_checkCondition( ( $commissionRule->for_purchases_type == 'n' ? $forNumber : $forAmounts[ $this->currency ] ), $commissionRule->for_purchases_op, $val ) ) { continue; } } /* Work out what packages apply */ $amountToApplyCommissionOn = $amountReceived; if ( $commissionRule->purchase_packages ) { if ( $commissionRule->purchase_package_limit ) { $amountToApplyCommissionOn = new Number('0'); } $itemsToCount = array(); foreach ( $this->items as $k => $item ) { $itemType = null; $packageId = null; /* What's the package ID? */ if( $item->isValid( $commissionRule->purchase_packages, $this, $this->member ) ) { $itemType = $item::$title; $packageId = $item->id; } elseif ( $commissionRule->purchase_renewal and $item instanceof Renewal and $item->isValid( $commissionRule->purchase_packages, $this, $this->member ) ) { try { $packageId = $item->getPurchase()->item_id; $itemType = $item->getPurchaseExtension()::$title; } catch ( OutOfRangeException ) { } } if ( !$packageId ) { continue; } /* Add it to the list */ if( !isset( $itemsToCount[ $itemType ] ) ) { $itemsToCount[ $itemType ] = []; } $itemsToCount[ $itemType ][ $k ] = $packageId; if ( $commissionRule->purchase_package_limit ) { $amountToApplyCommissionOn = $amountToApplyCommissionOn->add( $item->price->amount->multiply( new Number( "{$item->quantity}" ) ) ); } } /* If we need ALL of the packages, do we have them all? */ if ( $commissionRule->purchase_any ) { if ( empty( $itemsToCount ) ) { continue; } } else { foreach( $commissionRule->purchase_packages as $itemType => $items ) { $missing = array_diff( $items, ( $itemsToCount[ $itemType ] ?? [] ) ); if( count( $missing ) ) { continue 2; } } } } /* If we're still here, we're applying commission */ $amountToGive = $amountToApplyCommissionOn->percentage( $commissionRule->commission ); if ( $commissionRule->commission_limit and $commissionRule->commission_limit != '*' ) { $limit = json_decode( $commissionRule->commission_limit, TRUE ); if ( isset( $limit[ $this->currency ] ) and $amountToGive->compare( new Number( number_format( $limit[ $this->currency ]['amount'], Money::numberOfDecimalsForCurrency( $this->currency ), '.', '' ) ) ) === 1 ) { $amountToGive = new Number( number_format( $limit[ $this->currency ]['amount'], Money::numberOfDecimalsForCurrency( $this->currency ), '.', '' ) ); } } /* Is this more than what we have? */ if ( ( $amountToGive->compare( $maxAmount ) === 1 ) or ( $amountToGive->isGreaterThanZero() and !$maxAmount->isGreaterThanZero() ) ) { $maxAmount = $amountToGive; $actingRule = $commissionRule; } } /* Return */ $extra = $this->status_extra; $extra['commissionrule'] = $actingRule ? $actingRule->id : 0; $extra['commissionref'] = $referrer->member_id; $extra['commissionamount'] = $maxAmount; $this->status_extra = $extra; } catch ( UnderflowException ) { $extra = $this->status_extra; $extra['commissionrule'] = 0; $extra['commissionref'] = 0; $extra['commissionamount'] = 0; $this->status_extra = $extra; } $this->save(); } try { return array( 'rule' => $this->status_extra['commissionrule'] ? CommissionRule::load( $this->status_extra['commissionrule'] ) : NULL, 'referrer' => $this->status_extra['commissionref'] ? Customer::load( $this->status_extra['commissionref'] ) : NULL, 'amount' => $this->status_extra['commissionamount'] ? new Money( $this->status_extra['commissionamount'], $this->currency ) : NULL ); } catch( OutOfRangeException ) { /* If either the commission rule, or the commission ref, are no longer valid, then let's try again and see if it matches up with a different one. */ $extra = $this->status_extra; unset( $extra['commissionrule'], $extra['commissionref'], $extra['commissionamount'] ); $this->status_extra = $extra; $this->save(); return $this->commission( $amountReceived ); } } /** * Check condition * * @param int|Number $a First parameter * @param string $operator Operator (g = greater than, e = equal to, l = less than) * @param int|Number $b Second parameter * @return bool */ protected function _checkCondition( int|Number $a, string $operator, int|Number $b ) : bool { if ( $a instanceof Number and !( $b instanceof Number ) ) { $b = new Number( "{$b}" ); } if ( !( $a instanceof Number ) and $b instanceof Number ) { $a = new Number( "{$a}" ); } switch ( $operator ) { case 'g': if ( $a instanceof Number ) { return $a->compare( $b ) === 1; } else { return $a > $b; } case 'e': if ( $a instanceof Number ) { return $a->compare( $b ) === 0; } else { return $a == $b; } case 'l': if ( $a instanceof Number ) { return $a->compare( $b ) === 11; } else { return $a < $b; } } return FALSE; } /** * Get amount left to pay * * @param bool $deductHeldTransactions * @return Money */ public function amountToPay( bool $deductHeldTransactions=FALSE ) : Money { $amountToPay = $this->total; $statuses = array( Transaction::STATUS_PAID, Transaction::STATUS_PART_REFUNDED ); if ( $deductHeldTransactions ) { $statuses[] = Transaction::STATUS_HELD; $statuses[] = Transaction::STATUS_REVIEW; $statuses[] = Transaction::STATUS_GATEWAY_PENDING; } foreach ( $this->transactions( $statuses ) as $transaction ) { if ( $transaction->currency === $this->currency ) { $amountToSubtract = $transaction->amount->amount; if ( $transaction->status === Transaction::STATUS_PART_REFUNDED ) { $amountToSubtract = $amountToSubtract->subtract( $transaction->partial_refund->amount ); $amountToSubtract = $amountToSubtract->subtract( $transaction->credit->amount ); } $amountToPay->amount = $amountToPay->amount->subtract( $amountToSubtract ); } } return $amountToPay; } /** * ACP Buttons * * @param string $ref Referer * @return array */ public function buttons( string $ref='v' ) : array { $url = $this->acpUrl()->setQueryString( 'r', $ref ); $return = array(); if ( $this->status !== static::STATUS_PAID and ( $this->member->member_id or $this->guest_data ) and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_add' ) ) { $return['edit'] = array( 'title' => 'edit', 'icon' => 'pencil', 'link' => $url->setQueryString( array( 'do' => 'generate', '_new' => 1 ) ) ); } if ( $this->status === static::STATUS_PENDING and ( $this->member->member_id or $this->guest_data ) ) { if ( Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_edit' ) ) { $return['paid'] = array( 'title' => 'invoice_mark_paid', 'icon' => 'check', 'link' => $url->setQueryString( 'do', 'paid' )->csrf(), 'data' => count( $this->transactions( array( Transaction::STATUS_PENDING, Transaction::STATUS_WAITING, Transaction::STATUS_HELD, Transaction::STATUS_REVIEW, Transaction::STATUS_GATEWAY_PENDING ) ) ) ? array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('invoice_mark_paid') ) : array( 'confirm' => '' ) ); } if ( count( Gateway::manualChargeGateways( $this->member ) ) and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'chargetocard' ) ) { $return['card'] = array( 'title' => 'invoice_charge_to_card', 'icon' => 'credit-card', 'link' => $url->setQueryString( array( 'do' => 'card', '_new' => 1 ) ), ); } if ( isset( $this->member->cm_credits[ $this->currency ] ) and $this->member->cm_credits[ $this->currency ]->amount->isGreaterThanZero() and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_edit' ) ) { $return['credit'] = array( 'title' => 'invoice_charge_to_credit', 'icon' => 'money', 'link' => $url->setQueryString( 'do', 'credit' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('invoice_charge_to_credit') ) ); } } if ( $this->status !== static::STATUS_PAID and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_resend' ) ) { $return['resend'] = array( 'title' => 'invoice_reissue', 'icon' => 'refresh', 'link' => $url->setQueryString( 'do', 'resend' )->csrf(), 'data' => array( 'confirm' => '', 'confirmSubMessage' => Member::loggedIn()->language()->addToStack('invoice_reissue_confirm'), 'confirmType' => 'verify', 'confirmIcon' => 'question', 'confirmButtons' => json_encode( array( 'yes' => Member::loggedIn()->language()->addToStack('invoice_reissue_yes'), 'no' => Member::loggedIn()->language()->addToStack('invoice_reissue_no'), 'cancel' => Member::loggedIn()->language()->addToStack('cancel'), ) ) ) ); } $return['print'] = array( 'title' => 'print', 'icon' => 'print', 'target' => '_blank', 'link' => $url->setQueryString( 'do', 'printout' ), ); $trackUrl = $url->setQueryString( array( 'do' => 'track', 'id' => $this->id ) )->csrf(); $return['track'] = array( 'title' => $this->tracked() ? 'invoice_untrack' : 'invoice_track', 'icon' => 'bullhorn', 'link' => $this->tracked() ? $trackUrl : $trackUrl->setQueryString( 'track', 1 ), 'data' => array( 'confirm' => '' ) ); if ( $this->status !== static::STATUS_CANCELED and Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_edit' ) ) { $return['unpaid'] = array( 'title' => $this->status === static::STATUS_PAID ? 'invoice_mark_unpaid' : 'cancel', 'icon' => 'times', 'link' => $url->setQueryString( 'do', 'unpaid' ), 'data' => array( 'ipsDialog' => '', 'ipsDialog-title' => Member::loggedIn()->language()->addToStack('cancel') ) ); } if ( Member::loggedIn()->hasAcpRestriction( 'nexus', 'payments', 'invoices_delete' ) ) { $return['delete'] = array( 'title' => 'delete', 'icon' => 'times-circle', 'link' => $url->setQueryString( 'do', 'delete' )->csrf()->getSafeUrlFromFilters(), 'data' => array( 'confirm' => '', 'confirmSubMessage' => Member::loggedIn()->language()->addToStack('invoice_delete_warning') ) ); } return $return; } /** * Can payment be split? Returns the minnimum split amount * * @return mixed */ public function canSplitPayment() : mixed { if ( Settings::i()->nexus_split_payments == -1 ) { return FALSE; } if ( Settings::i()->nexus_split_payments ) { $decoded = json_decode( Settings::i()->nexus_split_payments, TRUE ); if ( isset( $decoded[ $this->currency ] ) ) { if ( $decoded[ $this->currency ]['amount'] <= $this->amountToPay()->amount ) { return $decoded[ $this->currency ]['amount']; } } return FALSE; } else { return 0.01; } } /** * 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 title Title * @apiresponse string status 'paid' = Paid; 'pend' = Pending; 'expd' = Expired; 'canc' = Cancelled * @apiresponse datetime issueDate The date the invoice was issued * @apiresponse datetime paidDate The date the invoice was paid * @apiresponse \IPS\nexus\Customer customer Customer * @apiresponse [\IPS\nexus\Invoice\Item] items Items * @apiresponse \IPS\nexus\Money subTotal Subtotal of item costs * @apiresponse [\IPS\nexus\Invoice\Item\TaxItem] tax Tax charges * @apiresponse \IPS\nexus\Money taxTotal Total cost of tax charges * @apiresponse \IPS\nexus\Money total Total * @apiresponse \IPS\nexus\Money outstanding The outstanding amount to be paid * @apiresponse \IPS\GeoLocation billingAddress Billing address * @apiresponse string poNumber PO Number (set by customer) * @apiresponse string notes Notes (set by customer) * @apiresponse [\IPS\nexus\Transaction] transactions Transactions against this invoice * @apiresponse [\IPS\nexus\Purchase] purchases Purchases generated from this invoice (if paid) * @apiresponse string viewUrl The URL to where the customer can view this invoice * @apiresponse string checkoutUrl The URL to where the customer can pay this invoice */ public function apiOutput( Member $authorizedMember = NULL ): array { $summary = $this->summary(); $tax = array(); foreach ( $summary['tax'] as $classId => $data ) { $tax[] = ( new TaxItem( $classId, $data ) )->apiOutput( $authorizedMember ); } return array( 'id' => $this->id, 'title' => $this->title, 'status' => $this->status, 'issueDate' => $this->date->rfc3339(), 'paidDate' => $this->paid?->rfc3339(), 'customer' => $this->member->apiOutput( $authorizedMember ), 'items' => array_map( function( $item ) use ( $authorizedMember ) { return $item->apiOutput( $authorizedMember ); }, $summary['items'] ), 'subTotal' => $summary['subtotal']->apiOutput( $authorizedMember ), 'tax' => $tax, 'taxTotal' => $summary['taxTotal']->apiOutput( $authorizedMember ), 'total' => $summary['total']->apiOutput( $authorizedMember ), 'outstanding' => $this->amountToPay()->apiOutput( $authorizedMember ), 'billingAddress' => $this->billaddress?->apiOutput($authorizedMember), 'poNumber' => $this->po, 'notes' => $this->notes, 'transactions' => array_map( function( $transaction ) use ( $authorizedMember ) { return $transaction->apiOutput( $authorizedMember ); }, iterator_to_array( $this->transactions() ) ), 'purchases' => array_map( function( $purchase ) use ( $authorizedMember ) { return $purchase->apiOutput( $authorizedMember ); }, iterator_to_array( $this->purchasesCreated() ) ), 'viewUrl' => (string) $this->url(), 'checkoutUrl' => (string) $this->checkoutUrl(), ); } /* !Related Resources */ /** * Get transactions * * @param array|null $statuses Statuses to get, or NULL for all * @param array $where Initial where clause * @return ActiveRecordIterator */ public function transactions( ?array $statuses=NULL, array $where = array() ) : ActiveRecordIterator { $where[] = array( 't_invoice=?', $this->id ); if ( $statuses ) { $where[] = array( Db::i()->in( 't_status', $statuses ) ); } return new ActiveRecordIterator( Db::i()->select( '*', 'nexus_transactions', $where ), 'IPS\nexus\Transaction' ); } /** * Get purchases created by this invoice * * @return ActiveRecordIterator */ public function purchasesCreated() : ActiveRecordIterator { return new ActiveRecordIterator( Db::i()->select( '*', 'nexus_purchases', array( 'ps_original_invoice=? AND ps_show=1', $this->id ) ), 'IPS\nexus\Purchase' ); } /* !Actions */ /** * Create account from guest checkout * * @return Customer */ public function createAccountForGuest() : Customer { $guestData = $this->guest_data; $profileFields = $this->guest_data['profileFields']; $securityAnswers = $this->guest_data['securityAnswers'] ?? array(); $spamData = $this->guest_data['spamData']; $cards = $this->guest_data['cards'] ?? array(); /* If the member has already been created (the user made two separate purchases with the same data), use that, otherwise create one */ try { $memberToSave = Customer::load( $this->guest_data['member']['email'], 'email' ); } catch ( OutOfRangeException ) { $memberToSave = new Customer; foreach ( $this->guest_data['member'] as $k => $v ) { $memberToSave->$k = $v; } $memberToSave->save(); $memberToSave->logHistory( 'core', 'account', array( 'type' => 'register_checkout', 'spamCode' => $spamData['code'], 'spamAction' => $spamData['action'], 'invoice' => $this->id ), FALSE ); if( isset( $this->guest_data['agreed_terms'] ) and $this->guest_data['agreed_terms'] ) { $memberToSave->logHistory( 'core', 'terms_acceptance', array( 'type' => 'terms' ) ); } } $this->member = $memberToSave; /* Get the PBR data */ $postBeforeRegister = NULL; if( isset( $this->guest_data['pbr'] ) ) { try { $postBeforeRegister = Db::i()->select( '*', 'core_post_before_registering', array( 'secret=?', $this->guest_data['pbr'] ) )->first(); } catch ( UnderflowException ){} } $referredBy = $this->guest_data['referred_by'] ?? NULL; $this->guest_data = isset( $this->guest_data['guestTransactionKey'] ) ? array( 'guestTransactionKey' => $this->guest_data['guestTransactionKey'] ) : NULL; $this->save(); /* Referrals */ if( $referredBy ) { try { Member::load( $referredBy )->addReferral( $this->member ); } catch ( OutOfRangeException ) {} } /* If we provided answers to security questions in check out, save those */ if ( Settings::i()->security_questions_enabled and in_array( Settings::i()->security_questions_prompt, array( 'register', 'optional' ) ) ) { if ( $securityAnswers ) { foreach ( $securityAnswers as $k => $v ) { Db::i()->insert( 'core_security_answers', array( 'answer_question_id' => $k, 'answer_member_id' => $this->member->member_id, 'answer_answer' => $v ) ); } $this->member->members_bitoptions['has_security_answers'] = TRUE; /* Log MFA Enable */ $this->member->logHistory( 'core', 'mfa', array( 'handler' => 'questions', 'enable' => TRUE ) ); } else { $this->member->members_bitoptions['security_questions_opt_out'] = TRUE; /* Log MFA Optout */ $this->member->logHistory( 'core', 'mfa', array( 'handler' => 'questions', 'enable' => FALSE, 'optout' => TRUE ) ); } $this->member->save(); } /* If we've entered an address during checkout, save it */ if ( $this->billaddress !== NULL ) { $billing = new Address; $billing->member = $this->member; $billing->address = $this->billaddress; $billing->primary_billing = 1; $billing->save(); } $profileFields['member_id'] = $memberToSave->member_id; Db::i()->replace( 'core_pfields_content', $profileFields ); /* If we opted to save a card at checkout, save it */ foreach ( $cards as $card ) { $card['card_member'] = $memberToSave->member_id; Db::i()->insert( 'nexus_customer_cards', $card ); } /* Validation */ if ( Settings::i()->nexus_checkreg_validate or !$this->total->amount->isGreaterThanZero() ) { $this->member->postRegistration( FALSE, TRUE, $postBeforeRegister ); } else { $this->member->validationComplete( FALSE, $postBeforeRegister ); } /* Update associated transactions */ Db::i()->update( 'nexus_billing_agreements', array( 'ba_member' => $this->member->member_id ), array( 'ba_id IN(?)', Db::i()->select( 't_billing_agreement', 'nexus_transactions', array( 't_billing_agreement>0 AND t_invoice=? AND t_member=0', $this->id ) ) ) ); Db::i()->update( 'nexus_transactions', array( 't_member' => $this->member->member_id ), array( 't_invoice=? AND t_member=0', $this->id ) ); /* Did we use any coupons? */ foreach ( $this->items as $item ) { if ( $item instanceof CouponDiscount ) { $coupon = Coupon::load( $item->id ); $uses = $coupon->used_by ? json_decode( $coupon->used_by, TRUE ) : array(); if ( isset( $uses[ $this->member->email ] ) ) { $uses[ $this->member->member_id ] = $uses[ $this->member->email ]; unset( $uses[ $this->member->email ] ); } else { $uses[ $this->member->member_id ] = 1; } $coupon->used_by = json_encode( $uses ); $coupon->save(); } } Event::fire( 'onCreateAccountForGuest', $this, [ $memberToSave, $guestData ] ); return $memberToSave; } /** * Mark as paid * * @param Member|NULL $member The member changing the status. Only set if the status is being manually changed by an admin. * @return Member|NULL If the invoice belonged to a guest, a member will be created by approving and returned here * @note It is the responsibility of the code calling this to log the action on the customer's account *before* calling this method * @throws RuntimeException */ public function markPaid( Member $member = NULL ) : Member|null { $return = NULL; $memberCreated = FALSE; if ( $this->status !== static::STATUS_PAID ) { /* Create the member account if this was a guest */ if ( !$this->member->member_id and $this->requiresLogin() ) { if ( $this->guest_data ) { $memberCreated = TRUE; $return = $this->createAccountForGuest(); } else { throw new RuntimeException('NO_MEMBER_DATA'); } } /* Init */ $extra = $this->status_extra; /* Find any billing agreements */ $billingAgreement = NULL; /* Turn off read/write separation so that we make sure to retrieve the latest records */ $currentValue = \IPS\Db::i()->readWriteSeparation; \IPS\Db::i()->readWriteSeparation = false; foreach ($this->transactions( array( Transaction::STATUS_PAID, Transaction::STATUS_PART_REFUNDED ), array( array( 't_billing_agreement IS NOT NULL' ) ) ) as $transaction ) { $billingAgreement = $transaction->billing_agreement; } \IPS\Db::i()->readWriteSeparation = $currentValue; /* Loop items */ $purchases = array(); $setParentIds = array(); $groupParentIds = array(); $amountReceived = $this->total->amount; foreach ( $this->items as $k => $item ) { /* @var Invoice\Item $item */ /* onPaid */ $item->onPaid( $this ); /* Fire the event here; it doesn't get fired with the other Commerce events */ Event::fire( 'onPaid', $item, array( $this ) ); /* Create a purchase record */ $purchase = NULL; if( $item instanceof Purchase ) { $showPurchaseRecord = $item->showPurchaseRecord(); for ( $i = 0; $i < $item->quantity; $i++ ) { /* Create a purchase record */ $purchase = new \IPS\nexus\Purchase; $purchase->member = $this->member; $purchase->name = $item->name; if ( $item->renewalTerm ) { $purchase->renewals = $item->renewalTerm; $purchase->expire = DateTime::create()->add( $item->initialInterval ?: $item->renewalTerm->interval ); if ( $item->renewalTerm->gracePeriod ) { $purchase->grace_period = DateTime::create()->add( $item->renewalTerm->gracePeriod )->getTimestamp() - time(); } if ( $billingAgreement ) { $purchase->billing_agreement = $billingAgreement; } } if ( $item->expireDate ) { $purchase->expire = $item->expireDate; } $purchase->app = $item::$application; $purchase->type = $item::$type; $purchase->item_id = intval( $item->id ); $purchase->custom_fields = $item->purchaseDetails; $purchase->original_invoice = $this; $purchase->tax = $item->tax ? $item->tax->id : 0; if ( $item->payTo ) { $purchase->pay_to = $item->payTo; $purchase->commission = $item->commission; $purchase->fee = $item->fee ?: NULL; } if ( $item->parent !== NULL ) { if ( $item->parent instanceof \IPS\nexus\Purchase ) { $purchase->parent = $item->parent; } else { $setParentIds[ $k ] = $item->parent; if ( $item->groupWithParent ) { $groupParentIds[ $k ] = TRUE; } } } $purchase->extra = $item->extra; $purchase->show = (int) $showPurchaseRecord; $purchase->save(); if( $purchase->parent and $item->groupWithParent ) { $purchase->groupWithParent(); } /* Make a note of the ID */ if ( !isset( $purchases[ $k ] ) ) { $purchases[ $k ] = array(); } $purchases[ $k ][] = $purchase; /* Claim attachments */ foreach ( Db::i()->select( '*', 'core_attachments_map', array( 'location_key=? AND id1=? AND id3=?', 'nexus_Purchases', $k, "invoice-{$this->id}" ) ) as $attachMap ) { $attachMap['id1'] = $purchase->id; $attachMap['id3'] = 'purchase'; Db::i()->insert( 'core_attachments_map', $attachMap ); } /* Log */ if ( $showPurchaseRecord ) { $this->member->log( 'purchase', array( 'type' => 'new', 'id' => $purchase->id, 'name' => $purchase->name, 'invoice_id' => $this->id, 'invoice_title' => $this->title ), FALSE ); } } } /* Is the money going to another member? */ if ( $item->payTo and !$item->payTo->inGroup( explode( ',', Settings::i()->nexus_no_commission ) ) ) { $recipientAmounts = $item->recipientAmounts(); /* We explicitly re-load $item->payTo in order to ensure the account credit amounts have not changed since the time this invoice request began and now. If an admin began a payout that processed after this page load began, the account credit amount may have been reduced. */ $item->payTo = Customer::constructFromData( Db::i()->select( '*, core_members.member_id AS _member_id', 'core_members', array( 'core_members.member_id=?', $item->payTo->member_id) )->join( 'nexus_customers', 'nexus_customers.member_id=core_members.member_id' )->first() ); $credits = $item->payTo->cm_credits; $credits[ $this->currency ]->amount = $credits[ $this->currency ]->amount->add( $recipientAmounts['recipient_final']->amount ); $item->payTo->cm_credits = $credits; $item->payTo->save(); $amountReceived = $amountReceived->subtract( $recipientAmounts['recipient_final']->amount ); if ( !isset( $extra['commission'][ $item->payTo->member_id ] ) ) { $extra['commission'][ $item->payTo->member_id ] = array( 'amount' => new Number('0') ); } elseif ( !( $extra['commission'][ $item->payTo->member_id ]['amount'] instanceof Number ) ) { $extra['commission'][ $item->payTo->member_id ]['amount'] = new Number( "{$extra['commission'][ $item->payTo->member_id ]['amount']}" ); } $extra['commission'][ $item->payTo->member_id ]['amount'] = $extra['commission'][ $item->payTo->member_id ]['amount']->add( $recipientAmounts['recipient_final']->amount ); $item->payTo->log( 'comission', array( 'type' => 'purchase', 'currency' => $this->currency, 'amount' => $recipientAmounts['recipient_final']->amount, 'new_amount' => $credits[ $this->currency ]->amount, 'member' => $this->member->member_id, 'id' => $purchase ? $purchase->id : 0, 'name' => $item->name ), FALSE ); Email::buildFromTemplate( 'nexus', 'commissionEarned', array( $this, $item, $recipientAmounts['recipient_final'] ), Email::TYPE_TRANSACTIONAL ) ->send( $item->payTo, array_map( function( $contact ) { return $contact->alt_id->email; }, iterator_to_array( $item->payTo->alternativeContacts( array( 'billing=1' ) ) ) ), ( ( in_array( 'commission_earned', explode( ',', Settings::i()->nexus_notify_copy_types ) ) AND Settings::i()->nexus_notify_copy_email ) ? explode( ',', Settings::i()->nexus_notify_copy_email ) : array() ) ); } } /* Referral Commission */ $this->status_extra = $extra; if ( Settings::i()->ref_on and $commission = $this->commission( $amountReceived ) and $commission['amount'] ) { $extra = $this->status_extra; // Reload, the commission method may have changed this. $credits = $commission['referrer']->cm_credits; $credits[ $this->currency ]->amount = $credits[ $this->currency ]->amount->add( $commission['amount']->amount ); $commission['referrer']->cm_credits = $credits; $commission['referrer']->save(); if ( !isset( $extra['commission'][ $commission['referrer']->member_id ] ) ) { $extra['commission'][ $commission['referrer']->member_id ] = array( 'amount' => new Number('0') ); } elseif ( !( $extra['commission'][ $commission['referrer']->member_id ]['amount'] instanceof Number ) ) { $extra['commission'][ $commission['referrer']->member_id ]['amount'] = new Number( "{$extra['commission'][ $commission['referrer']->member_id ]['amount']}" ); } $extra['commission'][ $commission['referrer']->member_id ]['amount'] = $extra['commission'][ $commission['referrer']->member_id ]['amount']->add( $commission['amount']->amount ); $commission['referrer']->log( 'comission', array( 'type' => 'invoice', 'currency' => $this->currency, 'amount' => $commission['amount']->amount, 'new_amount' => $credits[ $this->currency ]->amount, 'member' => $this->member->member_id, 'invoice_id' => $this->id, 'invoice_title' => $this->title ), FALSE ); $record = Db::i()->select( '*', 'core_referrals', array( 'member_id=? AND referred_by=?', $this->member->member_id, $commission['referrer']->member_id ) )->first(); $amounts = $record['amount'] ? json_decode( $record['amount'], TRUE ) : array(); if ( !isset( $amounts[ $this->currency ] ) ) { $amounts[ $this->currency ] = new Number('0'); } elseif ( !( $amounts[ $this->currency ] instanceof Number ) ) { $amounts[ $this->currency ] = new Number( number_format( $amounts[ $this->currency ], Money::numberOfDecimalsForCurrency( $this->currency ), '.', '' ) ); } $amounts[ $this->currency ] = $amounts[ $this->currency ]->add( $commission['amount']->amount ); Db::i()->update( 'core_referrals', array( 'amount' => json_encode( $amounts ) ), array( 'member_id=? AND referred_by=?', $this->member->member_id, $commission['referrer']->member_id ) ); Email::buildFromTemplate( 'nexus', 'commissionEarned', array( $this, NULL, $commission['amount'] ), Email::TYPE_TRANSACTIONAL ) ->send( $commission['referrer'], array_map( function( $contact ) { return $contact->alt_id->email; }, iterator_to_array( $commission['referrer']->alternativeContacts( array( 'billing=1' ) ) ) ), ( ( in_array( 'commission_earned', explode( ',', Settings::i()->nexus_notify_copy_types ) ) AND Settings::i()->nexus_notify_copy_email ) ? explode( ',', Settings::i()->nexus_notify_copy_email ) : array() ) ); } /* Change commission values back to numbers */ if ( isset( $extra['commission'] ) ) { foreach ( $extra['commission'] as $memberId => $data ) { if ( $data['amount'] and $data['amount'] instanceof Number ) { $extra['commission'][ $memberId ]['amount'] = (string) $data['amount']; } } } /* Set parent IDs */ foreach ( $setParentIds as $k => $v ) { foreach ( $purchases[ $k ] as $k2 => $_purchase ) { $_purchase->parent = $purchases[$v][$k2] ?? $purchases[$v][0]; $_purchase->save(); if( isset( $groupParentIds[ $k ] ) ) { $_purchase->groupWithParent(); } } } /* Run callbacks */ foreach ( $purchases as $_purchases ) { foreach ( $_purchases as $purchase ) { $purchase->onPurchaseGenerated( $this ); } } /* Refuse any pending transactions */ foreach ($this->transactions( array( Transaction::STATUS_PENDING, Transaction::STATUS_WAITING, Transaction::STATUS_HELD, Transaction::STATUS_REVIEW, Transaction::STATUS_GATEWAY_PENDING ) ) as $transaction ) { if ( $transaction->status === Transaction::STATUS_PENDING ) { $transaction->delete(); } else { try { $transaction->method->void( $transaction ); } catch ( Exception ){} $textra = $transaction->extra; $textra['history'][] = array( 's' => Transaction::STATUS_REFUSED, 'on' => time(), 'note' => 'invoicePaid' ); $transaction->extra = $textra; $transaction->status = Transaction::STATUS_REFUSED; $transaction->save(); } } /* Update invoice */ $this->status = static::STATUS_PAID; $this->paid = new DateTime; /* Set our billing address automatically if it's not already set */ if( !$this->billaddress ) { $this->billaddress = $this->member->primaryBillingAddress(); } /* Send tracked notifications */ foreach ( Db::i()->select( 'member_id', 'nexus_invoice_tracker', array( 'invoice_id=?', $this->id ) ) as $trackingMember ) { $trackingMember = Member::load( $trackingMember ); Email::buildFromTemplate( 'nexus', 'invoiceNotify', array( $this, $this->summary() ), Email::TYPE_LIST ) ->send( $trackingMember ); } /* Log */ if ( $member ) { $extra['type'] = 'manual'; $extra['setTo'] = static::STATUS_PAID; $extra['setByID'] = $member->member_id; $extra['date'] = time(); } /* Update member total spend */ if ( $memberCreated ) { $this->member->recountTotalSpend(); } /* Save */ $this->status_extra = $extra; $this->save(); } return $return; } /** * Mark as unpaid * * @param string|NULL $status The status to set to * @param Member|NULL $member The member changing the status. Only set if the status is being manually changed by an admin. * @return void */ public function markUnpaid( ?string $status, Member $member = NULL ) : void { /* Do stuff */ $extra = $this->status_extra; if ( $this->status === static::STATUS_PAID ) { /* Loop items */ foreach ( $this->items as $k => $item ) { /* @var Invoice\Item $item */ $item->onUnpaid( $this, $this->status ); /* Fire the event */ Event::fire( 'onUnpaid', $item, array( $this, $this->status ) ); /* Is the money going to another member? */ if ( $item->payTo and !$item->payTo->inGroup( explode( ',', Settings::i()->nexus_no_commission ) ) ) { $recipientAmounts = $item->recipientAmounts(); $this->revokeCommission( $item->payTo, $recipientAmounts['recipient_final'], $item ); } } /* Revoke commissions based on the referral rules */ if( !empty( $extra['commissionref'] ) AND !empty( $extra['commissionamount'] ) ) { $this->revokeCommission( Customer::load( $extra['commissionref'] ), new Money( $extra['commissionamount'], $this->currency ) ); } /* Delete purchases */ foreach ( $this->purchasesCreated() as $purchase ) { $purchase->delete( FALSE ); } } else { foreach ( $this->items as $k => $item ) { /* @var Invoice\Item $item */ $item->onInvoiceCancel( $this ); Event::fire( 'onInvoiceCancel', $item, array( $this ) ); } } /* Set the status */ $this->status = $status; /* Log */ if ( $member ) { $extra['type'] = 'manual'; $extra['setTo'] = $status; $extra['setByID'] = $member->member_id; $extra['date'] = time(); $this->status_extra = $extra; } /* Save */ $this->save(); } /** * Revoke Commission * * @param Member $paidTo Revoke the commission from this member * @param Money $commission Amount of commission to revoke * @param Item|null $item Item commission was for (if applicable) * @return void */ protected function revokeCommission( Member $paidTo, Money $commission, Item $item=NULL ) : void { $credits = $paidTo->cm_credits; $credits[ $this->currency ]->amount = $credits[ $this->currency ]->amount->subtract( $commission->amount ); $paidTo->cm_credits = $credits; $paidTo->save(); $paidTo->log( 'comission', array( 'type' => 'invoice_refund', 'currency' => $this->currency, 'amount' => $commission->amount, 'new_amount' => $credits[ $this->currency ]->amount, 'member' => $this->member->member_id, 'invoice_id' => $this->id, 'invoice_title' => $this->title ), FALSE ); Email::buildFromTemplate( 'nexus', 'commissionRevoked', array( $this, $item, $commission ), Email::TYPE_TRANSACTIONAL ) ->send( $paidTo, array_map( function( $contact ) { return $contact->alt_id->email; }, iterator_to_array( $paidTo->alternativeContacts( array( 'billing=1' ) ) ) ), ( ( in_array( 'commission_earned', explode( ',', Settings::i()->nexus_notify_copy_types ) ) AND Settings::i()->nexus_notify_copy_email ) ? explode( ',', Settings::i()->nexus_notify_copy_email ) : array() ) ); } /** * What will happen if we mark this unpaid? * * @return array */ public function unpaidConsequences() : array { $return = array(); $purchases = $this->purchasesCreated(); if ( count( $purchases ) ) { $return['purchases'] = array(); foreach ( $purchases as $purchase ) { /* @var \IPS\nexus\Purchase $purchase */ $return['purchases'][ $purchase->id ] = Theme::i()->getTemplate( 'purchases', 'nexus' )->link( $purchase ); foreach ( $purchase->children( NULL ) as $child ) { $return['unassociate'][ $purchase->id ][ $child->id ] = Theme::i()->getTemplate( 'purchases', 'nexus' )->link( $child ); } } } foreach ( $this->items as $item ) { /* @var Invoice\Item $item */ if ( $item->payTo ) { $amount = $item->amountForRecipient(); $message = Member::loggedIn()->language()->addToStack('account_credit_remove', FALSE, array( 'sprintf' => array( $amount, $item->payTo->cm_name ) ) ); $credits = $item->payTo->cm_credits; if ( !$credits[ $amount->currency ]->amount->subtract( $amount->amount )->isPositive() ) { $return[] = array( 'message' => $message, 'warning' => Member::loggedIn()->language()->addToStack('account_credit_remove_neg') ); } else { $return[] = $message; } } if( count( $item->onUnpaidDescription( $this ) ) ) { foreach( $item->onUnpaidDescription( $this ) as $desc ) { $return[] = $desc; } } } /* Referral Commission */ $extra = $this->status_extra; if( !empty( $extra['commissionref'] ) AND !empty( $extra['commissionamount'] ) ) { $customer = Customer::load( $extra['commissionref'] ); $amount = new Money( $extra['commissionamount'], $this->currency ); $message = Member::loggedIn()->language()->addToStack( 'account_credit_remove_commission', FALSE, array( 'sprintf' => array( $amount, $customer->cm_name ) ) ); $credits = $customer->cm_credits; if ( !$credits[ $amount->currency ]->amount->subtract( $amount->amount )->isPositive() ) { $return[] = array( 'message' => $message, 'warning' => Member::loggedIn()->language()->addToStack( 'account_credit_remove_neg' ) ); } else { $return[] = $message; } } return $return; } /** * Send the notification to the member that this invoice has been generated * * @return void */ public function sendNotification() : void { Email::buildFromTemplate( 'nexus', 'newInvoice', array( $this, $this->summary() , ( $this->paid ) ? $this->member->language()->get('invoice_mailsub_paid') : $this->member->language()->get('invoice_mailsub_pend') ), Email::TYPE_TRANSACTIONAL ) ->send( $this->member, array_map( function( $contact ) { return $contact->alt_id->email; }, iterator_to_array( $this->member->alternativeContacts( array( 'billing=1' ) ) ) ), ( ( in_array( 'new_invoice', explode( ',', Settings::i()->nexus_notify_copy_types ) ) AND Settings::i()->nexus_notify_copy_email ) ? explode( ',', Settings::i()->nexus_notify_copy_email ) : array() ) ); } /* !URLS */ /** * Get checkout URL * * @return Url */ public function checkoutUrl() : Url { if ( !$this->id ) { $this->save(); } return Url::internal( 'app=nexus&module=checkout&controller=checkout&id=' . $this->id, 'front', 'nexus_checkout' ); } /** * @brief Cached URL */ protected mixed $_url = NULL; /** * Get URL * * @return Url|null */ function url(): Url|null { if( $this->_url === NULL ) { $this->_url = Url::internal( "app=nexus&module=clients&controller=invoices&do=view&id={$this->id}", 'front', 'clientsinvoice' ); } return $this->_url; } /** * ACP "View Invoice" URL * * @return Url */ public function acpUrl() : Url { return Url::internal( "app=nexus&module=payments&controller=invoices&do=view&id={$this->id}", 'admin' ); } /** * Is the member tracking this invoice? * * @param Member|NULL $member The member to check for, or NULL for currently logged in member * @return bool */ public function tracked( Member $member = NULL ) : bool { $member = $member ?: Member::loggedIn(); if( !isset( $this->_tracked[ $member->member_id ] ) ) { try { Db::i()->select( array( 'invoice_id' ), 'nexus_invoice_tracker', array( 'member_id=? and invoice_id=?', $member->member_id, $this->id ) )->first(); $this->_tracked[ $member->member_id ] = TRUE; } catch ( UnderflowException ) { $this->_tracked[ $member->member_id ] = FALSE; } } return $this->_tracked[ $member->member_id ]; } }