diff --git a/resources/sql/autopatches/20141007.fundtotal.sql b/resources/sql/autopatches/20141007.fundtotal.sql new file mode 100644 index 0000000000..d9b2d2714c --- /dev/null +++ b/resources/sql/autopatches/20141007.fundtotal.sql @@ -0,0 +1,4 @@ +ALTER TABLE {$NAMESPACE}_fund.fund_initiative + ADD totalAsCurrency VARCHAR(64) NOT NULL COLLATE utf8_bin; + +UPDATE {$NAMESPACE}_fund.fund_initiative SET totalAsCurrency = '0.00 USD'; diff --git a/src/applications/fund/controller/FundInitiativeViewController.php b/src/applications/fund/controller/FundInitiativeViewController.php index ad2f8bf471..2e5c5ca024 100644 --- a/src/applications/fund/controller/FundInitiativeViewController.php +++ b/src/applications/fund/controller/FundInitiativeViewController.php @@ -1,163 +1,176 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $initiative = id(new FundInitiativeQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->executeOne(); if (!$initiative) { return new Aphront404Response(); } $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($initiative->getMonogram()); $title = pht( '%s %s', $initiative->getMonogram(), $initiative->getName()); if ($initiative->isClosed()) { $status_icon = 'fa-times'; $status_color = 'bluegrey'; } else { $status_icon = 'fa-check'; $status_color = 'bluegrey'; } $status_name = idx( FundInitiative::getStatusNameMap(), $initiative->getStatus()); $header = id(new PHUIHeaderView()) ->setObjectName($initiative->getMonogram()) ->setHeader($initiative->getName()) ->setUser($viewer) ->setPolicyObject($initiative) ->setStatus($status_icon, $status_color, $status_name); $properties = $this->buildPropertyListView($initiative); $actions = $this->buildActionListView($initiative); $properties->setActionList($actions); $box = id(new PHUIObjectBoxView()) ->setHeader($header) ->appendChild($properties); $xactions = id(new FundInitiativeTransactionQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($initiative->getPHID())) ->execute(); $timeline = id(new PhabricatorApplicationTransactionView()) ->setUser($viewer) ->setObjectPHID($initiative->getPHID()) ->setTransactions($xactions); return $this->buildApplicationPage( array( $crumbs, $box, $timeline, ), array( 'title' => $title, )); } private function buildPropertyListView(FundInitiative $initiative) { $viewer = $this->getRequest()->getUser(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($initiative); $owner_phid = $initiative->getOwnerPHID(); - $this->loadHandles(array($owner_phid)); + $merchant_phid = $initiative->getMerchantPHID(); + $this->loadHandles( + array( + $owner_phid, + $merchant_phid, + )); $view->addProperty( pht('Owner'), $this->getHandle($owner_phid)->renderLink()); + $view->addProperty( + pht('Payable To Merchant'), + $this->getHandle($merchant_phid)->renderLink()); + + $view->addProperty( + pht('Total Funding'), + $initiative->getTotalAsCurrency()->formatForDisplay()); + $view->invokeWillRenderEvent(); $description = $initiative->getDescription(); if (strlen($description)) { $description = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent($description), 'default', $viewer); $view->addSectionHeader(pht('Description')); $view->addTextContent($description); } return $view; } private function buildActionListView(FundInitiative $initiative) { $viewer = $this->getRequest()->getUser(); $id = $initiative->getID(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $initiative, PhabricatorPolicyCapability::CAN_EDIT); $view = id(new PhabricatorActionListView()) ->setUser($viewer) ->setObject($initiative); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Initiative')) ->setIcon('fa-pencil') ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit) ->setHref($this->getApplicationURI("/edit/{$id}/"))); if ($initiative->isClosed()) { $close_name = pht('Reopen Initiative'); $close_icon = 'fa-check'; } else { $close_name = pht('Close Initiative'); $close_icon = 'fa-times'; } $view->addAction( id(new PhabricatorActionView()) ->setName($close_name) ->setIcon($close_icon) ->setDisabled(!$can_edit) ->setWorkflow(true) ->setHref($this->getApplicationURI("/close/{$id}/"))); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Back Initiative')) ->setIcon('fa-money') ->setDisabled($initiative->isClosed()) ->setWorkflow(true) ->setHref($this->getApplicationURI("/back/{$id}/"))); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('View Backers')) ->setIcon('fa-bank') ->setHref($this->getApplicationURI("/backers/{$id}/"))); return $view; } } diff --git a/src/applications/fund/editor/FundInitiativeEditor.php b/src/applications/fund/editor/FundInitiativeEditor.php index 39df92e51e..2bbf311cc6 100644 --- a/src/applications/fund/editor/FundInitiativeEditor.php +++ b/src/applications/fund/editor/FundInitiativeEditor.php @@ -1,181 +1,192 @@ getTransactionType()) { case FundInitiativeTransaction::TYPE_NAME: return $object->getName(); case FundInitiativeTransaction::TYPE_DESCRIPTION: return $object->getDescription(); case FundInitiativeTransaction::TYPE_STATUS: return $object->getStatus(); case FundInitiativeTransaction::TYPE_BACKER: return null; case FundInitiativeTransaction::TYPE_MERCHANT: return $object->getMerchantPHID(); } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_BACKER: case FundInitiativeTransaction::TYPE_MERCHANT: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundInitiativeTransaction::TYPE_NAME: $object->setName($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_DESCRIPTION: $object->setDescription($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_MERCHANT: $object->setMerchantPHID($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_STATUS: $object->setStatus($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_BACKER: - // TODO: Calculate total funding / backers / etc. + $backer = id(new FundBackerQuery()) + ->setViewer($this->requireActor()) + ->withPHIDs(array($xaction->getNewValue())) + ->executeOne(); + if (!$backer) { + throw new Exception(pht('No such backer!')); + } + + $backer_amount = $backer->getAmountAsCurrency(); + $total = $object->getTotalAsCurrency()->add($backer_amount); + $object->setTotalAsCurrency($total); + return; case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_EDGE: return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_MERCHANT: case FundInitiativeTransaction::TYPE_BACKER: // TODO: Maybe we should apply the backer transaction from here? return; case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_EDGE: return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case FundInitiativeTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Initiative name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } break; case FundInitiativeTransaction::TYPE_MERCHANT: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Payable merchant is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } else if ($xactions) { $merchant_phid = last($xactions)->getNewValue(); // Make sure the actor has permission to edit the merchant they're // selecting. You aren't allowed to send payments to an account you // do not control. $merchants = id(new PhortuneMerchantQuery()) ->setViewer($this->requireActor()) ->withPHIDs(array($merchant_phid)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); if (!$merchants) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You must specify a merchant account you control as the '. 'recipient of funds from this initiative.'), last($xactions)); $errors[] = $error; } } break; } return $errors; } } diff --git a/src/applications/fund/storage/FundInitiative.php b/src/applications/fund/storage/FundInitiative.php index edf3d6407a..f9ed49eab1 100644 --- a/src/applications/fund/storage/FundInitiative.php +++ b/src/applications/fund/storage/FundInitiative.php @@ -1,173 +1,179 @@ pht('Open'), self::STATUS_CLOSED => pht('Closed'), ); } public static function initializeNewInitiative(PhabricatorUser $actor) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($actor) ->withClasses(array('PhabricatorFundApplication')) ->executeOne(); $view_policy = $app->getPolicy(FundDefaultViewCapability::CAPABILITY); return id(new FundInitiative()) ->setOwnerPHID($actor->getPHID()) ->setViewPolicy($view_policy) ->setEditPolicy($actor->getPHID()) - ->setStatus(self::STATUS_OPEN); + ->setStatus(self::STATUS_OPEN) + ->setTotalAsCurrency(PhortuneCurrency::newEmptyCurrency()); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text255', 'description' => 'text', 'status' => 'text32', 'merchantPHID' => 'phid?', + 'totalAsCurrency' => 'text64', + ), + self::CONFIG_APPLICATION_SERIALIZERS => array( + 'totalAsCurrency' => new PhortuneCurrencySerializer(), ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( 'columns' => array('status'), ), 'key_owner' => array( 'columns' => array('ownerPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID(FundInitiativePHIDType::TYPECONST); } public function getMonogram() { return 'I'.$this->getID(); } public function getProjectPHIDs() { return $this->assertAttached($this->projectPHIDs); } public function attachProjectPHIDs(array $phids) { $this->projectPHIDs = $phids; return $this; } public function isClosed() { return ($this->getStatus() == self::STATUS_CLOSED); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() == $this->getOwnerPHID()); } public function describeAutomaticCapability($capability) { return pht( 'The owner of an initiative can always view and edit it.'); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new FundInitiativeEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new FundInitiativeTransaction(); } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return ($phid == $this->getOwnerPHID()); } public function shouldShowSubscribersProperty() { return true; } public function shouldAllowSubscription($phid) { return true; } /* -( PhabricatorTokenRecevierInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array( $this->getOwnerPHID(), ); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } } diff --git a/src/applications/phortune/currency/PhortuneCurrency.php b/src/applications/phortune/currency/PhortuneCurrency.php index 88b9833e4d..573532fb73 100644 --- a/src/applications/phortune/currency/PhortuneCurrency.php +++ b/src/applications/phortune/currency/PhortuneCurrency.php @@ -1,185 +1,193 @@ 1) { self::throwFormatException($string); } if (substr_count($value, '$') > 1) { self::throwFormatException($string); } $value = str_replace('$', '', $value); $value = (float)$value; $value = (int)round(100 * $value); $currency = idx($matches, 2, $default); if ($currency) { switch ($currency) { case 'USD': break; default: throw new Exception("Unsupported currency '{$currency}'!"); } } return self::newFromValueAndCurrency($value, $currency); } public static function newFromValueAndCurrency($value, $currency) { $obj = new PhortuneCurrency(); $obj->value = $value; $obj->currency = $currency; return $obj; } public static function newFromList(array $list) { assert_instances_of($list, 'PhortuneCurrency'); - $total = 0; - $currency = null; + if (!$list) { + return PhortuneCurrency::newEmptyCurrency(); + } + + $total = null; foreach ($list as $item) { - if ($currency === null) { - $currency = $item->getCurrency(); - } else if ($currency === $item->getCurrency()) { - // Adding a value denominated in the same currency, which is - // fine. + if ($total === null) { + $total = $item; } else { - throw new Exception( - pht('Trying to sum a list of unlike currencies.')); + $total = $total->add($item); } - - // TODO: This should check for integer overflows, etc. - $total += $item->getValue(); } - return PhortuneCurrency::newFromValueAndCurrency( - $total, - self::getDefaultCurrency()); + return $total; } public function formatForDisplay() { $bare = $this->formatBareValue(); return '$'.$bare.' '.$this->currency; } public function serializeForStorage() { return $this->formatBareValue().' '.$this->currency; } public function formatBareValue() { switch ($this->currency) { case 'USD': return sprintf('%.02f', $this->value / 100); default: throw new Exception( pht('Unsupported currency ("%s")!', $this->currency)); } } public function getValue() { return $this->value; } public function getCurrency() { return $this->currency; } public function getValueInUSDCents() { if ($this->currency !== 'USD') { throw new Exception(pht('Unexpected currency!')); } return $this->value; } private static function throwFormatException($string) { throw new Exception("Invalid currency format ('{$string}')."); } + public function add(PhortuneCurrency $other) { + if ($this->currency !== $other->currency) { + throw new Exception(pht('Trying to add unlike currencies!')); + } + + $currency = new PhortuneCurrency(); + + // TODO: This should check for integer overflows, etc. + $currency->value = $this->value + $other->value; + $currency->currency = $this->currency; + + return $currency; + } + /** * Assert that a currency value lies within a range. * * Throws if the value is not between the minimum and maximum, inclusive. * * In particular, currency values can be negative (to represent a debt or * credit), so checking against zero may be useful to make sure a value * has the expected sign. * * @param string|null Currency string, or null to skip check. * @param string|null Currency string, or null to skip check. * @return this */ public function assertInRange($minimum, $maximum) { if ($minimum !== null && $maximum !== null) { $min = PhortuneCurrency::newFromString($minimum); $max = PhortuneCurrency::newFromString($maximum); if ($min->value > $max->value) { throw new Exception( pht( 'Range (%s - %s) is not valid!', $min->formatForDisplay(), $max->formatForDisplay())); } } if ($minimum !== null) { $min = PhortuneCurrency::newFromString($minimum); if ($min->value > $this->value) { throw new Exception( pht( 'Minimum allowed amount is %s.', $min->formatForDisplay())); } } if ($maximum !== null) { $max = PhortuneCurrency::newFromString($maximum); if ($max->value < $this->value) { throw new Exception( pht( 'Maximum allowed amount is %s.', $max->formatForDisplay())); } } return $this; } } diff --git a/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php b/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php index 8e81d79f5b..bb817263d1 100644 --- a/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php +++ b/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php @@ -1,145 +1,162 @@ '$0.00 USD', '.01' => '$0.01 USD', '1.00' => '$1.00 USD', '-1.23' => '$-1.23 USD', '50000.00' => '$50000.00 USD', ); foreach ($map as $input => $expect) { $this->assertEqual( $expect, PhortuneCurrency::newFromString($input, 'USD')->formatForDisplay(), "newFromString({$input})->formatForDisplay()"); } } - public function testCurrencyFormatBareValue() { // NOTE: The PayPal API depends on the behavior of the bare value format! $map = array( '0' => '0.00', '.01' => '0.01', '1.00' => '1.00', '-1.23' => '-1.23', '50000.00' => '50000.00', ); foreach ($map as $input => $expect) { $this->assertEqual( $expect, PhortuneCurrency::newFromString($input, 'USD')->formatBareValue(), "newFromString({$input})->formatBareValue()"); } } public function testCurrencyFromString() { $map = array( '1.00' => 100, '1.00 USD' => 100, '$1.00' => 100, '$1.00 USD' => 100, '-$1.00 USD' => -100, '$-1.00 USD' => -100, '1' => 100, '.99' => 99, '$.99' => 99, '-$.99' => -99, '$-.99' => -99, '$.99 USD' => 99, ); foreach ($map as $input => $expect) { $this->assertEqual( $expect, PhortuneCurrency::newFromString($input, 'USD')->getValue(), "newFromString({$input})->getValue()"); } } public function testInvalidCurrencyFromString() { $map = array( '--1', '$$1', '1 JPY', 'buck fiddy', '1.2.3', '1 dollar', ); foreach ($map as $input) { $caught = null; try { PhortuneCurrency::newFromString($input, 'USD'); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception, "{$input}"); } } public function testCurrencyRanges() { $value = PhortuneCurrency::newFromString('3.00 USD'); $value->assertInRange('2.00 USD', '4.00 USD'); $value->assertInRange('2.00 USD', null); $value->assertInRange(null, '4.00 USD'); $value->assertInRange(null, null); $caught = null; try { $value->assertInRange('4.00 USD', null); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $caught = null; try { $value->assertInRange(null, '2.00 USD'); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $caught = null; try { // Minimum and maximum are reversed here. $value->assertInRange('4.00 USD', '2.00 USD'); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $credit = PhortuneCurrency::newFromString('-3.00 USD'); $credit->assertInRange('-4.00 USD', '-2.00 USD'); $credit->assertInRange('-4.00 USD', null); $credit->assertInRange(null, '-2.00 USD'); $credit->assertInRange(null, null); $caught = null; try { $credit->assertInRange('-2.00 USD', null); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $caught = null; try { $credit->assertInRange(null, '-4.00 USD'); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); } + public function testAddCurrency() { + $cases = array( + array('0.00 USD', '0.00 USD', '$0.00 USD'), + array('1.00 USD', '1.00 USD', '$2.00 USD'), + array('1.23 USD', '9.77 USD', '$11.00 USD'), + ); + + foreach ($cases as $case) { + list($l, $r, $expect) = $case; + + $l = PhortuneCurrency::newFromString($l); + $r = PhortuneCurrency::newFromString($r); + $sum = $l->add($r); + + $this->assertEqual($expect, $sum->formatForDisplay()); + } + } + }