diff --git a/src/applications/fund/controller/FundInitiativeBackController.php b/src/applications/fund/controller/FundInitiativeBackController.php index e257f3bba1..cfe2c66716 100644 --- a/src/applications/fund/controller/FundInitiativeBackController.php +++ b/src/applications/fund/controller/FundInitiativeBackController.php @@ -1,111 +1,117 @@ 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(); } $initiative_uri = '/'.$initiative->getMonogram(); if ($initiative->isClosed()) { return $this->newDialog() ->setTitle(pht('Initiative Closed')) ->appendParagraph( pht('You can not back a closed initiative.')) ->addCancelButton($initiative_uri); } $v_amount = null; $e_amount = true; $errors = array(); if ($request->isFormPost()) { $v_amount = $request->getStr('amount'); if (!strlen($v_amount)) { $errors[] = pht( 'You must specify how much money you want to contribute to the '. 'initiative.'); $e_amount = pht('Required'); } else { try { $currency = PhortuneCurrency::newFromUserInput( $viewer, $v_amount); + $currency->assertInRange('1.00 USD', null); } catch (Exception $ex) { $errors[] = $ex->getMessage(); $e_amount = pht('Invalid'); } } if (!$errors) { $backer = FundBacker::initializeNewBacker($viewer) ->setInitiativePHID($initiative->getPHID()) ->attachInitiative($initiative) ->setAmountAsCurrency($currency) ->save(); $product = id(new PhortuneProductQuery()) ->setViewer($viewer) ->withClassAndRef('FundBackerProduct', $initiative->getPHID()) ->executeOne(); $account = PhortuneAccountQuery::loadActiveAccountForUser( $viewer, PhabricatorContentSource::newFromRequest($request)); $cart = $account->newCart($viewer); $purchase = $cart->newPurchase($viewer, $product); - $purchase->setBasePriceAsCurrency($currency)->save(); + $purchase + ->setBasePriceAsCurrency($currency) + ->setMetadataValue('backerPHID', $backer->getPHID()) + ->save(); $xactions = array(); $xactions[] = id(new FundBackerTransaction()) ->setTransactionType(FundBackerTransaction::TYPE_STATUS) ->setNewValue(FundBacker::STATUS_IN_CART); $editor = id(new FundBackerEditor()) ->setActor($viewer) ->setContentSourceFromRequest($request); $editor->applyTransactions($backer, $xactions); + $cart->activateCart(); + return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } } $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild( id(new AphrontFormTextControl()) ->setName('amount') ->setLabel(pht('Amount')) ->setValue($v_amount) ->setError($e_amount)); return $this->newDialog() ->setTitle(pht('Back Initiative')) ->setErrors($errors) ->appendChild($form->buildLayoutView()) ->addCancelButton($initiative_uri) ->addSubmitButton(pht('Continue')); } } diff --git a/src/applications/fund/editor/FundInitiativeEditor.php b/src/applications/fund/editor/FundInitiativeEditor.php index eea77f3095..e33f601a80 100644 --- a/src/applications/fund/editor/FundInitiativeEditor.php +++ b/src/applications/fund/editor/FundInitiativeEditor.php @@ -1,123 +1,133 @@ 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; } 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: 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_STATUS: $object->setStatus($xaction->getNewValue()); return; + case FundInitiativeTransaction::TYPE_BACKER: + // TODO: Calculate total funding / backers / etc. + 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_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; } return $errors; } } diff --git a/src/applications/fund/phortune/FundBackerProduct.php b/src/applications/fund/phortune/FundBackerProduct.php index cd26977ed0..06f9f23675 100644 --- a/src/applications/fund/phortune/FundBackerProduct.php +++ b/src/applications/fund/phortune/FundBackerProduct.php @@ -1,64 +1,118 @@ viewer = $viewer; + return $this; + } + + public function getViewer() { + return $this->viewer; + } public function getRef() { return $this->getInitiativePHID(); } public function getName(PhortuneProduct $product) { - return pht('Back Initiative %s', $this->initiativePHID); + $initiative = $this->getInitiative(); + return pht( + 'Back Initiative %s %s', + $initiative->getMonogram(), + $initiative->getName()); } public function getPriceAsCurrency(PhortuneProduct $product) { return PhortuneCurrency::newEmptyCurrency(); } public function setInitiativePHID($initiative_phid) { $this->initiativePHID = $initiative_phid; return $this; } public function getInitiativePHID() { return $this->initiativePHID; } public function setInitiative(FundInitiative $initiative) { $this->initiative = $initiative; return $this; } public function getInitiative() { return $this->initiative; } public function loadImplementationsForRefs( PhabricatorUser $viewer, array $refs) { $initiatives = id(new FundInitiativeQuery()) ->setViewer($viewer) ->withPHIDs($refs) ->execute(); $initiatives = mpull($initiatives, null, 'getPHID'); $objects = array(); foreach ($refs as $ref) { $object = id(new FundBackerProduct()) + ->setViewer($viewer) ->setInitiativePHID($ref); $initiative = idx($initiatives, $ref); if ($initiative) { $object->setInitiative($initiative); } $objects[] = $object; } return $objects; } + public function didPurchaseProduct( + PhortuneProduct $product, + PhortunePurchase $purchase) { + $viewer = $this->getViewer(); + + $backer = id(new FundBackerQuery()) + ->setViewer($viewer) + ->withPHIDs(array($purchase->getMetadataValue('backerPHID'))) + ->executeOne(); + if (!$backer) { + throw new Exception(pht('Unable to load FundBacker!')); + } + + $xactions = array(); + $xactions[] = id(new FundBackerTransaction()) + ->setTransactionType(FundBackerTransaction::TYPE_STATUS) + ->setNewValue(FundBacker::STATUS_PURCHASED); + + $editor = id(new FundBackerEditor()) + ->setActor($viewer) + ->setContentSource($this->getContentSource()); + + $editor->applyTransactions($backer, $xactions); + + + $xactions = array(); + $xactions[] = id(new FundInitiativeTransaction()) + ->setTransactionType(FundInitiativeTransaction::TYPE_BACKER) + ->setNewValue($backer->getPHID()); + + $editor = id(new FundInitiativeEditor()) + ->setActor($viewer) + ->setContentSource($this->getContentSource()); + + $editor->applyTransactions($this->getInitiative(), $xactions); + + return; + } + } diff --git a/src/applications/fund/query/FundBackerQuery.php b/src/applications/fund/query/FundBackerQuery.php index 5a2b89ac84..505de2bdf3 100644 --- a/src/applications/fund/query/FundBackerQuery.php +++ b/src/applications/fund/query/FundBackerQuery.php @@ -1,105 +1,118 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + public function withInitiativePHIDs(array $phids) { $this->initiativePHIDs = $phids; return $this; } public function withBackerPHIDs(array $phids) { $this->backerPHIDs = $phids; return $this; } protected function loadPage() { $table = new FundBacker(); $conn_r = $table->establishConnection('r'); $rows = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); return $table->loadAllFromArray($rows); } protected function willFilterPage(array $backers) { $initiative_phids = mpull($backers, 'getInitiativePHID'); $initiatives = id(new PhabricatorObjectQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($initiative_phids) ->execute(); $initiatives = mpull($initiatives, null, 'getPHID'); foreach ($backers as $backer) { $initiative_phid = $backer->getInitiativePHID(); $initiative = idx($initiatives, $initiative_phid); $backer->attachInitiative($initiative); } return $backers; } private function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); $where[] = $this->buildPagingClause($conn_r); if ($this->ids !== null) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn_r, 'phid IN (%Ls)', $this->phids); } if ($this->initiativePHIDs !== null) { $where[] = qsprintf( $conn_r, 'initiativePHID IN (%Ls)', $this->initiativePHIDs); } if ($this->backerPHIDs !== null) { $where[] = qsprintf( $conn_r, 'backerPHID IN (%Ls)', $this->backerPHIDs); } + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn_r, + 'status IN (%Ls)', + $this->statuses); + } + return $this->formatWhereClause($where); } public function getQueryApplicationClass() { return 'PhabricatorFundApplication'; } } diff --git a/src/applications/fund/query/FundBackerSearchEngine.php b/src/applications/fund/query/FundBackerSearchEngine.php index 82459c7bc0..316f1944a7 100644 --- a/src/applications/fund/query/FundBackerSearchEngine.php +++ b/src/applications/fund/query/FundBackerSearchEngine.php @@ -1,152 +1,154 @@ initiative = $initiative; return $this; } public function getInitiative() { return $this->initiative; } public function getResultTypeDescription() { return pht('Fund Backers'); } public function getApplicationClassName() { return 'PhabricatorFundApplication'; } public function buildSavedQueryFromRequest(AphrontRequest $request) { $saved = new PhabricatorSavedQuery(); $saved->setParameter( 'backerPHIDs', $this->readUsersFromRequest($request, 'backers')); return $saved; } public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new FundBackerQuery()); + $query->withStatuses(array(FundBacker::STATUS_PURCHASED)); + if ($this->getInitiative()) { $query->withInitiativePHIDs( array( $this->getInitiative()->getPHID(), )); } $backer_phids = $saved->getParameter('backerPHIDs'); if ($backer_phids) { $query->withBackerPHIDs($backer_phids); } return $query; } public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { $backer_phids = $saved->getParameter('backerPHIDs', array()); $all_phids = array_mergev( array( $backer_phids, )); $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireViewer()) ->withPHIDs($all_phids) ->execute(); $form ->appendChild( id(new AphrontFormTokenizerControl()) ->setLabel(pht('Backers')) ->setName('backers') ->setDatasource(new PhabricatorPeopleDatasource()) ->setValue(array_select_keys($handles, $backer_phids))); } protected function getURI($path) { if ($this->getInitiative()) { return '/fund/backers/'.$this->getInitiative()->getID().'/'.$path; } else { return '/fund/backers/'.$path; } } public function getBuiltinQueryNames() { $names = array(); $names['all'] = pht('All Backers'); return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; } return parent::buildSavedQueryFromBuiltin($query_key); } protected function getRequiredHandlePHIDsForResultList( array $backers, PhabricatorSavedQuery $query) { $phids = array(); foreach ($backers as $backer) { $phids[] = $backer->getBackerPHID(); $phids[] = $backer->getInitiativePHID(); } return $phids; } protected function renderResultList( array $backers, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($backers, 'FundBacker'); $viewer = $this->requireViewer(); $list = id(new PHUIObjectItemListView()); foreach ($backers as $backer) { $backer_handle = $handles[$backer->getBackerPHID()]; - $currency = $backer->getAmount(); + $currency = $backer->getAmountAsCurrency(); $header = pht( '%s for %s', $currency->formatForDisplay(), $handles[$backer->getInitiativePHID()]->renderLink()); $item = id(new PHUIObjectItemView()) ->setHeader($header) ->addIcon( 'none', phabricator_datetime($backer->getDateCreated(), $viewer)) ->addByline(pht('Backer: %s', $backer_handle->renderLink())); $list->addItem($item); } return $list; } } diff --git a/src/applications/fund/storage/FundBacker.php b/src/applications/fund/storage/FundBacker.php index 38c335b5d2..dd66f8cd6e 100644 --- a/src/applications/fund/storage/FundBacker.php +++ b/src/applications/fund/storage/FundBacker.php @@ -1,120 +1,121 @@ setBackerPHID($actor->getPHID()) ->setStatus(self::STATUS_NEW); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'properties' => self::SERIALIZATION_JSON, ), self::CONFIG_APPLICATION_SERIALIZERS => array( 'amountAsCurrency' => new PhortuneCurrencySerializer(), ), self::CONFIG_COLUMN_SCHEMA => array( 'status' => 'text32', 'amountAsCurrency' => 'text64', ), self::CONFIG_KEY_SCHEMA => array( 'key_initiative' => array( 'columns' => array('initiativePHID'), ), 'key_backer' => array( 'columns' => array('backerPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID(FundBackerPHIDType::TYPECONST); } public function getProperty($key, $default = null) { return idx($this->properties, $key, $default); } public function setProperty($key, $value) { $this->properties[$key] = $value; return $this; } public function getInitiative() { return $this->assertAttached($this->initiative); } public function attachInitiative(FundInitiative $initiative = null) { $this->initiative = $initiative; return $this; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: // If we have the initiative, use the initiative's policy. // Otherwise, return NOONE. This allows the backer to continue seeing // a backer even if they're no longer allowed to see the initiative. $initiative = $this->getInitiative(); if ($initiative) { return $initiative->getPolicy($capability); } return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() == $this->getBackerPHID()); } public function describeAutomaticCapability($capability) { return pht('A backer can always see what they have backed.'); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new FundBackerEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new FundBackerTransaction(); } } diff --git a/src/applications/fund/storage/FundInitiativeTransaction.php b/src/applications/fund/storage/FundInitiativeTransaction.php index fcb75c1fab..e1cca16bef 100644 --- a/src/applications/fund/storage/FundInitiativeTransaction.php +++ b/src/applications/fund/storage/FundInitiativeTransaction.php @@ -1,136 +1,146 @@ getAuthorPHID(); $object_phid = $this->getObjectPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); $type = $this->getTransactionType(); switch ($type) { case FundInitiativeTransaction::TYPE_NAME: if ($old === null) { return pht( '%s created this initiative.', $this->renderHandleLink($author_phid)); } else { return pht( '%s renamed this initiative from "%s" to "%s".', $this->renderHandleLink($author_phid), $old, $new); } break; case FundInitiativeTransaction::TYPE_DESCRIPTION: return pht( '%s edited the description of this initiative.', $this->renderHandleLink($author_phid)); case FundInitiativeTransaction::TYPE_STATUS: switch ($new) { case FundInitiative::STATUS_OPEN: return pht( '%s reopened this initiative.', $this->renderHandleLink($author_phid)); case FundInitiative::STATUS_CLOSED: return pht( '%s closed this initiative.', $this->renderHandleLink($author_phid)); } break; + case FundInitiativeTransaction::TYPE_BACKER: + return pht( + '%s backed this initiative.', + $this->renderHandleLink($author_phid)); } return parent::getTitle(); } public function getTitleForFeed(PhabricatorFeedStory $story) { $author_phid = $this->getAuthorPHID(); $object_phid = $this->getObjectPHID(); $old = $this->getOldValue(); $new = $this->getNewValue(); $type = $this->getTransactionType(); switch ($type) { case FundInitiativeTransaction::TYPE_NAME: if ($old === null) { return pht( '%s created %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } else { return pht( '%s renamed %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } break; case FundInitiativeTransaction::TYPE_DESCRIPTION: return pht( '%s updated the description for %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); case FundInitiativeTransaction::TYPE_STATUS: switch ($new) { case FundInitiative::STATUS_OPEN: return pht( '%s reopened %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); case FundInitiative::STATUS_CLOSED: return pht( '%s closed %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($object_phid)); } break; + case FundInitiativeTransaction::TYPE_BACKER: + return pht( + '%s backed %s.', + $this->renderHandleLink($author_phid), + $this->renderHandleLink($object_phid)); } return parent::getTitleForFeed($story); } public function shouldHide() { $old = $this->getOldValue(); switch ($this->getTransactionType()) { case FundInitiativeTransaction::TYPE_DESCRIPTION: return ($old === null); } return parent::shouldHide(); } public function hasChangeDetails() { switch ($this->getTransactionType()) { case FundInitiativeTransaction::TYPE_DESCRIPTION: return ($this->getOldValue() !== null); } return parent::hasChangeDetails(); } public function renderChangeDetails(PhabricatorUser $viewer) { return $this->renderTextCorpusChangeDetails( $viewer, $this->getOldValue(), $this->getNewValue()); } } diff --git a/src/applications/metamta/contentsource/PhabricatorContentSource.php b/src/applications/metamta/contentsource/PhabricatorContentSource.php index 88329bfe3d..a1cbd061f2 100644 --- a/src/applications/metamta/contentsource/PhabricatorContentSource.php +++ b/src/applications/metamta/contentsource/PhabricatorContentSource.php @@ -1,102 +1,104 @@ } public static function newForSource($source, array $params) { $obj = new PhabricatorContentSource(); $obj->source = $source; $obj->params = $params; return $obj; } public static function newFromSerialized($serialized) { $dict = json_decode($serialized, true); if (!is_array($dict)) { $dict = array(); } $obj = new PhabricatorContentSource(); $obj->source = idx($dict, 'source', self::SOURCE_UNKNOWN); $obj->params = idx($dict, 'params', array()); return $obj; } public static function newConsoleSource() { return self::newForSource( PhabricatorContentSource::SOURCE_CONSOLE, array()); } public static function newFromRequest(AphrontRequest $request) { return self::newForSource( PhabricatorContentSource::SOURCE_WEB, array( 'ip' => $request->getRemoteAddr(), )); } public static function newFromConduitRequest(ConduitAPIRequest $request) { return self::newForSource( PhabricatorContentSource::SOURCE_CONDUIT, array()); } public static function getSourceNameMap() { return array( self::SOURCE_WEB => pht('Web'), self::SOURCE_EMAIL => pht('Email'), self::SOURCE_CONDUIT => pht('Conduit'), self::SOURCE_MOBILE => pht('Mobile'), self::SOURCE_TABLET => pht('Tablet'), self::SOURCE_FAX => pht('Fax'), self::SOURCE_CONSOLE => pht('Console'), self::SOURCE_LEGACY => pht('Legacy'), self::SOURCE_HERALD => pht('Herald'), self::SOURCE_DAEMON => pht('Daemons'), self::SOURCE_LIPSUM => pht('Lipsum'), self::SOURCE_UNKNOWN => pht('Old World'), + self::SOURCE_PHORTUNE => pht('Phortune'), ); } public function serialize() { return json_encode(array( 'source' => $this->getSource(), 'params' => $this->getParams(), )); } public function getSource() { return $this->source; } public function getParams() { return $this->params; } public function getParam($key, $default = null) { return idx($this->params, $key, $default); } } diff --git a/src/applications/phortune/controller/PhortuneCartCheckoutController.php b/src/applications/phortune/controller/PhortuneCartCheckoutController.php index 250f771723..b7bed3794e 100644 --- a/src/applications/phortune/controller/PhortuneCartCheckoutController.php +++ b/src/applications/phortune/controller/PhortuneCartCheckoutController.php @@ -1,183 +1,217 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $viewer = $request->getUser(); $cart = id(new PhortuneCartQuery()) ->setViewer($viewer) ->withIDs(array($this->id)) ->needPurchases(true) ->executeOne(); if (!$cart) { return new Aphront404Response(); } + $cancel_uri = $cart->getCancelURI(); + + switch ($cart->getStatus()) { + case PhortuneCart::STATUS_BUILDING: + return $this->newDialog() + ->setTitle(pht('Incomplete Cart')) + ->appendParagraph( + pht( + 'The application that created this cart did not finish putting '. + 'products in it. You can not checkout with an incomplete '. + 'cart.')) + ->addCancelButton($cancel_uri); + case PhortuneCart::STATUS_READY: + // This is the expected, normal state for a cart that's ready for + // checkout. + break; + case PhortuneCart::STATUS_CHARGED: + // TODO: This is really bad (we took your money and at least partially + // failed to fulfill your order) and should have better steps forward. + + return $this->newDialog() + ->setTitle(pht('Purchase Failed')) + ->appendParagraph( + pht( + 'This cart was charged but the purchase could not be '. + 'completed.')) + ->addCancelButton($cancel_uri); + case PhortuneCart::STATUS_PURCHASED: + return id(new AphrontRedirectResponse())->setURI($cart->getDetailURI()); + default: + throw new Exception( + pht( + 'Unknown cart status "%s"!', + $cart->getStatus())); + } + $account = $cart->getAccount(); $account_uri = $this->getApplicationURI($account->getID().'/'); $methods = id(new PhortunePaymentMethodQuery()) ->setViewer($viewer) ->withAccountPHIDs(array($account->getPHID())) ->withStatuses(array(PhortunePaymentMethod::STATUS_ACTIVE)) ->execute(); $e_method = null; $errors = array(); if ($request->isFormPost()) { // Require CAN_EDIT on the cart to actually make purchases. PhabricatorPolicyFilter::requireCapability( $viewer, $cart, PhabricatorPolicyCapability::CAN_EDIT); $method_id = $request->getInt('paymentMethodID'); $method = idx($methods, $method_id); if (!$method) { $e_method = pht('Required'); $errors[] = pht('You must choose a payment method.'); } if (!$errors) { $provider = $method->buildPaymentProvider(); $charge = id(new PhortuneCharge()) ->setAccountPHID($account->getPHID()) ->setCartPHID($cart->getPHID()) ->setAuthorPHID($viewer->getPHID()) ->setPaymentProviderKey($provider->getProviderKey()) ->setPaymentMethodPHID($method->getPHID()) ->setAmountAsCurrency($cart->getTotalPriceAsCurrency()) ->setStatus(PhortuneCharge::STATUS_PENDING); $charge->openTransaction(); $charge->save(); $cart->setStatus(PhortuneCart::STATUS_PURCHASING); $cart->save(); $charge->saveTransaction(); $provider->applyCharge($method, $charge); - $cart->setStatus(PhortuneCart::STATUS_PURCHASED); - $cart->save(); - - $view_uri = $this->getApplicationURI('cart/'.$cart->getID().'/'); + $cart->didApplyCharge($charge); - return id(new AphrontRedirectResponse())->setURI($view_uri); + $done_uri = $cart->getDoneURI(); + return id(new AphrontRedirectResponse())->setURI($done_uri); } } $cart_box = $this->buildCartContents($cart); $cart_box->setFormErrors($errors); $title = pht('Buy Stuff'); if (!$methods) { $method_control = id(new AphrontFormStaticControl()) ->setLabel(pht('Payment Method')) ->setValue( phutil_tag('em', array(), pht('No payment methods configured.'))); } else { $method_control = id(new AphrontFormRadioButtonControl()) ->setLabel(pht('Payment Method')) ->setName('paymentMethodID') ->setValue($request->getInt('paymentMethodID')); foreach ($methods as $method) { $method_control->addButton( $method->getID(), $method->getFullDisplayName(), $method->getDescription()); } } $method_control->setError($e_method); $payment_method_uri = $this->getApplicationURI( $account->getID().'/card/new/'); $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild($method_control); $add_providers = PhortunePaymentProvider::getProvidersForAddPaymentMethod(); if ($add_providers) { $new_method = phutil_tag( 'a', array( 'class' => 'button grey', 'href' => $payment_method_uri, 'sigil' => 'workflow', ), pht('Add New Payment Method')); $form->appendChild( id(new AphrontFormMarkupControl()) ->setValue($new_method)); } if ($methods || $add_providers) { $form ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Submit Payment')) ->setDisabled(!$methods)); } $provider_form = null; $pay_providers = PhortunePaymentProvider::getProvidersForOneTimePayment(); if ($pay_providers) { $one_time_options = array(); foreach ($pay_providers as $provider) { $one_time_options[] = $provider->renderOneTimePaymentButton( $account, $cart, $viewer); } $one_time_options = phutil_tag( 'div', array( 'class' => 'phortune-payment-onetime-list', ), $one_time_options); $provider_form = new PHUIFormLayoutView(); $provider_form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel('Pay With') ->setValue($one_time_options)); } $payment_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Choose Payment Method')) ->appendChild($form) ->appendChild($provider_form); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($title); return $this->buildApplicationPage( array( $crumbs, $cart_box, $payment_box, ), array( 'title' => $title, )); } } diff --git a/src/applications/phortune/currency/PhortuneCurrency.php b/src/applications/phortune/currency/PhortuneCurrency.php index 5923cafb4a..fd9ea08f0e 100644 --- a/src/applications/phortune/currency/PhortuneCurrency.php +++ b/src/applications/phortune/currency/PhortuneCurrency.php @@ -1,130 +1,178 @@ 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; 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. } else { throw new Exception( pht('Trying to sum a list of unlike currencies.')); } // TODO: This should check for integer overflows, etc. $total += $item->getValue(); } return PhortuneCurrency::newFromValueAndCurrency( $total, self::getDefaultCurrency()); } 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; } private static function throwFormatException($string) { throw new Exception("Invalid currency format ('{$string}')."); } + /** + * 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 948fc87cfa..8e81d79f5b 100644 --- a/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php +++ b/src/applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php @@ -1,89 +1,145 @@ '$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); + } + } diff --git a/src/applications/phortune/product/PhortuneProductImplementation.php b/src/applications/phortune/product/PhortuneProductImplementation.php index 80ef845de0..8419d4b04d 100644 --- a/src/applications/phortune/product/PhortuneProductImplementation.php +++ b/src/applications/phortune/product/PhortuneProductImplementation.php @@ -1,13 +1,31 @@ getName($product); + } + + public function didPurchaseProduct( + PhortuneProduct $product, + PhortunePurchase $purchase) { + return; + } + } diff --git a/src/applications/phortune/query/PhortunePurchaseQuery.php b/src/applications/phortune/query/PhortunePurchaseQuery.php index a9a081ed1c..70aff445a0 100644 --- a/src/applications/phortune/query/PhortunePurchaseQuery.php +++ b/src/applications/phortune/query/PhortunePurchaseQuery.php @@ -1,93 +1,110 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withCartPHIDs(array $cart_phids) { $this->cartPHIDs = $cart_phids; return $this; } protected function loadPage() { $table = new PhortunePurchase(); $conn = $table->establishConnection('r'); $rows = queryfx_all( $conn, 'SELECT purchase.* FROM %T purchase %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn), $this->buildOrderClause($conn), $this->buildLimitClause($conn)); return $table->loadAllFromArray($rows); } protected function willFilterPage(array $purchases) { $carts = id(new PhortuneCartQuery()) ->setViewer($this->getViewer()) ->setParentQuery($this) ->withPHIDs(mpull($purchases, 'getCartPHID')) ->execute(); $carts = mpull($carts, null, 'getPHID'); foreach ($purchases as $key => $purchase) { $cart = idx($carts, $purchase->getCartPHID()); if (!$cart) { unset($purchases[$key]); continue; } $purchase->attachCart($cart); } + $products = id(new PhortuneProductQuery()) + ->setViewer($this->getViewer()) + ->setParentQuery($this) + ->withPHIDs(mpull($purchases, 'getProductPHID')) + ->execute(); + $products = mpull($products, null, 'getPHID'); + + foreach ($purchases as $key => $purchase) { + $product = idx($products, $purchase->getProductPHID()); + if (!$product) { + unset($purchases[$key]); + continue; + } + $purchase->attachProduct($product); + } + + return $purchases; } private function buildWhereClause(AphrontDatabaseConnection $conn) { $where = array(); $where[] = $this->buildPagingClause($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'purchase.id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'purchase.phid IN (%Ls)', $this->phids); } if ($this->cartPHIDs !== null) { $where[] = qsprintf( $conn, 'purchase.cartPHID IN (%Ls)', $this->cartPHIDs); } return $this->formatWhereClause($where); } public function getQueryApplicationClass() { return 'PhabricatorPhortuneApplication'; } } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index bf02a9bd4f..d8e86af0d1 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -1,123 +1,166 @@ setAuthorPHID($actor->getPHID()) - ->setStatus(self::STATUS_READY) + ->setStatus(self::STATUS_BUILDING) ->setAccountPHID($account->getPHID()); $cart->account = $account; $cart->purchases = array(); return $cart; } public function newPurchase( PhabricatorUser $actor, PhortuneProduct $product) { $purchase = PhortunePurchase::initializeNewPurchase($actor, $product) ->setAccountPHID($this->getAccount()->getPHID()) ->setCartPHID($this->getPHID()) ->save(); $this->purchases[] = $purchase; return $purchase; } + public function activateCart() { + $this->setStatus(self::STATUS_READY)->save(); + return $this; + } + + public function didApplyCharge(PhortuneCharge $charge) { + if ($this->getStatus() !== self::STATUS_PURCHASING) { + throw new Exception( + pht( + 'Cart has wrong status ("%s") to call didApplyCharge(), expected '. + '"%s".', + $this->getStatus(), + self::STATUS_PURCHASING)); + } + + $this->setStatus(self::STATUS_CHARGED)->save(); + + foreach ($this->purchases as $purchase) { + $purchase->getProduct()->didPurchaseProduct($purchase); + } + + $this->setStatus(self::STATUS_PURCHASED)->save(); + + return $this; + } + + + public function getDoneURI() { + // TODO: Implement properly. + return '/phortune/cart/'.$this->getID().'/'; + } + + public function getCancelURI() { + // TODO: Implement properly. + return '/'; + } + + public function getDetailURI() { + return '/phortune/cart/'.$this->getID().'/'; + } + public function getCheckoutURI() { return '/phortune/cart/'.$this->getID().'/checkout/'; } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'status' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_account' => array( 'columns' => array('accountPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPHIDConstants::PHID_TYPE_CART); } public function attachPurchases(array $purchases) { assert_instances_of($purchases, 'PhortunePurchase'); $this->purchases = $purchases; return $this; } public function getPurchases() { return $this->assertAttached($this->purchases); } public function attachAccount(PhortuneAccount $account) { $this->account = $account; return $this; } public function getAccount() { return $this->assertAttached($this->account); } public function getTotalPriceAsCurrency() { $prices = array(); foreach ($this->getPurchases() as $purchase) { $prices[] = $purchase->getTotalPriceAsCurrency(); } return PhortuneCurrency::newFromList($prices); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { return $this->getAccount()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getAccount()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht('Carts inherit the policies of the associated account.'); } } diff --git a/src/applications/phortune/storage/PhortuneProduct.php b/src/applications/phortune/storage/PhortuneProduct.php index 66ec33fafe..8defdf6ca7 100644 --- a/src/applications/phortune/storage/PhortuneProduct.php +++ b/src/applications/phortune/storage/PhortuneProduct.php @@ -1,95 +1,103 @@ true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'productClassKey' => 'bytes12', 'productClass' => 'text128', 'productRefKey' => 'bytes12', 'productRef' => 'text128', ), self::CONFIG_KEY_SCHEMA => array( 'key_product' => array( 'columns' => array('productClassKey', 'productRefKey'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPHIDConstants::PHID_TYPE_PDCT); } public static function initializeNewProduct() { return id(new PhortuneProduct()); } public function attachImplementation(PhortuneProductImplementation $impl) { $this->implementation = $impl; } public function getImplementation() { return $this->assertAttached($this->implementation); } public function save() { $this->productClassKey = PhabricatorHash::digestForIndex( $this->productClass); $this->productRefKey = PhabricatorHash::digestForIndex( $this->productRef); return parent::save(); } public function getPriceAsCurrency() { return $this->getImplementation()->getPriceAsCurrency($this); } public function getProductName() { return $this->getImplementation()->getName($this); } + public function getPurchaseName(PhortunePurchase $purchase) { + return $this->getImplementation()->getPurchaseName($this, $purchase); + } + + public function didPurchaseProduct(PhortunePurchase $purchase) { + return $this->getImplementation()->didPurchaseProduct($this, $purchase); + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return PhabricatorPolicies::POLICY_USER; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } public function describeAutomaticCapability($capability) { return null; } } diff --git a/src/applications/phortune/storage/PhortunePurchase.php b/src/applications/phortune/storage/PhortunePurchase.php index 18e622c48e..d4eb56fb19 100644 --- a/src/applications/phortune/storage/PhortunePurchase.php +++ b/src/applications/phortune/storage/PhortunePurchase.php @@ -1,106 +1,125 @@ setAuthorPHID($actor->getPHID()) ->setProductPHID($product->getPHID()) ->setQuantity(1) ->setStatus(self::STATUS_PENDING) ->setBasePriceAsCurrency($product->getPriceAsCurrency()); } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'metadata' => self::SERIALIZATION_JSON, ), self::CONFIG_APPLICATION_SERIALIZERS => array( 'basePriceAsCurrency' => new PhortuneCurrencySerializer(), ), self::CONFIG_COLUMN_SCHEMA => array( 'cartPHID' => 'phid?', 'basePriceAsCurrency' => 'text64', 'quantity' => 'uint32', 'status' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_cart' => array( 'columns' => array('cartPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPHIDConstants::PHID_TYPE_PRCH); } public function attachCart(PhortuneCart $cart) { $this->cart = $cart; return $this; } public function getCart() { return $this->assertAttached($this->cart); } + public function attachProduct(PhortuneProduct $product) { + $this->product = $product; + return $this; + } + + public function getProduct() { + return $this->assertAttached($this->product); + } + public function getFullDisplayName() { - return pht('Goods and/or Services'); + return $this->getProduct()->getPurchaseName($this); } public function getTotalPriceAsCurrency() { return $this->getBasePriceAsCurrency(); } + public function getMetadataValue($key, $default = null) { + return idx($this->metadata, $key, $default); + } + + public function setMetadataValue($key, $value) { + $this->metadata[$key] = $value; + return $this; + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { return $this->getCart()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getCart()->hasAutomaticCapability($capability, $viewer); } public function describeAutomaticCapability($capability) { return pht('Purchases have the policies of their cart.'); } }