diff --git a/src/applications/fund/controller/FundInitiativeViewController.php b/src/applications/fund/controller/FundInitiativeViewController.php index ee52315937..24d5dbe083 100644 --- a/src/applications/fund/controller/FundInitiativeViewController.php +++ b/src/applications/fund/controller/FundInitiativeViewController.php @@ -1,187 +1,188 @@ 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); + ->setTransactions($xactions) + ->setShouldTerminate(true); 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(); $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); } $risks = $initiative->getRisks(); if (strlen($risks)) { $risks = PhabricatorMarkupEngine::renderOneObject( id(new PhabricatorMarkupOneOff())->setContent($risks), 'default', $viewer); $view->addSectionHeader(pht('Risks/Challenges')); $view->addTextContent($risks); } 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/FundBackerEditor.php b/src/applications/fund/editor/FundBackerEditor.php index 067f9ce64c..aabd1d8e60 100644 --- a/src/applications/fund/editor/FundBackerEditor.php +++ b/src/applications/fund/editor/FundBackerEditor.php @@ -1,69 +1,77 @@ getTransactionType()) { case FundBackerTransaction::TYPE_STATUS: return $object->getStatus(); + case FundBackerTransaction::TYPE_REFUND: + return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundBackerTransaction::TYPE_STATUS: + case FundBackerTransaction::TYPE_REFUND: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundBackerTransaction::TYPE_STATUS: $object->setStatus($xaction->getNewValue()); return; + case FundBackerTransaction::TYPE_REFUND: + return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case FundBackerTransaction::TYPE_STATUS: + case FundBackerTransaction::TYPE_REFUND: return; } return parent::applyCustomExternalTransaction($object, $xaction); } } diff --git a/src/applications/fund/editor/FundInitiativeEditor.php b/src/applications/fund/editor/FundInitiativeEditor.php index cbb4074a7e..9fdde7c8b9 100644 --- a/src/applications/fund/editor/FundInitiativeEditor.php +++ b/src/applications/fund/editor/FundInitiativeEditor.php @@ -1,200 +1,235 @@ getTransactionType()) { case FundInitiativeTransaction::TYPE_NAME: return $object->getName(); case FundInitiativeTransaction::TYPE_DESCRIPTION: return $object->getDescription(); case FundInitiativeTransaction::TYPE_RISKS: return $object->getRisks(); case FundInitiativeTransaction::TYPE_STATUS: return $object->getStatus(); case FundInitiativeTransaction::TYPE_BACKER: + case FundInitiativeTransaction::TYPE_REFUND: 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_RISKS: case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_BACKER: + case FundInitiativeTransaction::TYPE_REFUND: case FundInitiativeTransaction::TYPE_MERCHANT: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { + $type = $xaction->getTransactionType(); + switch ($type) { case FundInitiativeTransaction::TYPE_NAME: $object->setName($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_DESCRIPTION: $object->setDescription($xaction->getNewValue()); return; case FundInitiativeTransaction::TYPE_RISKS: $object->setRisks($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: - $backer = id(new FundBackerQuery()) - ->setViewer($this->requireActor()) - ->withPHIDs(array($xaction->getNewValue())) - ->executeOne(); - if (!$backer) { - throw new Exception(pht('No such backer!')); + case FundInitiativeTransaction::TYPE_REFUND: + $amount = $xaction->getMetadataValue( + FundInitiativeTransaction::PROPERTY_AMOUNT); + $amount = PhortuneCurrency::newFromString($amount); + + if ($type == FundInitiativeTransaction::TYPE_REFUND) { + $total = $object->getTotalAsCurrency()->subtract($amount); + } else { + $total = $object->getTotalAsCurrency()->add($amount); } - $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()) { + $type = $xaction->getTransactionType(); + switch ($type) { case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_RISKS: case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_MERCHANT: + return; case FundInitiativeTransaction::TYPE_BACKER: - // TODO: Maybe we should apply the backer transaction from here? + case FundInitiativeTransaction::TYPE_REFUND: + $backer = id(new FundBackerQuery()) + ->setViewer($this->requireActor()) + ->withPHIDs(array($xaction->getNewValue())) + ->executeOne(); + if (!$backer) { + throw new Exception(pht('Unable to load FundBacker!')); + } + + $subx = array(); + + if ($type == FundInitiativeTransaction::TYPE_BACKER) { + $subx[] = id(new FundBackerTransaction()) + ->setTransactionType(FundBackerTransaction::TYPE_STATUS) + ->setNewValue(FundBacker::STATUS_PURCHASED); + } else { + $amount = $xaction->getMetadataValue( + FundInitiativeTransaction::PROPERTY_AMOUNT); + $subx[] = id(new FundBackerTransaction()) + ->setTransactionType(FundBackerTransaction::TYPE_STATUS) + ->setNewValue($amount); + } + + $editor = id(new FundBackerEditor()) + ->setActor($this->requireActor()) + ->setContentSource($this->getContentSource()) + ->setContinueOnMissingFields(true) + ->setContinueOnNoEffect(true); + + $editor->applyTransactions($backer, $subx); 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/phortune/FundBackerProduct.php b/src/applications/fund/phortune/FundBackerProduct.php index 3ffd149667..cc13200639 100644 --- a/src/applications/fund/phortune/FundBackerProduct.php +++ b/src/applications/fund/phortune/FundBackerProduct.php @@ -1,133 +1,148 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function getRef() { return $this->getInitiativePHID(); } public function getName(PhortuneProduct $product) { $initiative = $this->getInitiative(); return pht( 'Fund %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!')); } - // Load the actual backing user --they may not be the curent viewer if this + // Load the actual backing user -- they may not be the curent viewer if this // product purchase is completing from a background worker or a merchant // action. $actor = id(new PhabricatorPeopleQuery()) ->setViewer($viewer) ->withPHIDs(array($backer->getBackerPHID())) ->executeOne(); - $xactions = array(); - $xactions[] = id(new FundBackerTransaction()) - ->setTransactionType(FundBackerTransaction::TYPE_STATUS) - ->setNewValue(FundBacker::STATUS_PURCHASED); - - $editor = id(new FundBackerEditor()) - ->setActor($actor) - ->setContentSource($this->getContentSource()); - - $editor->applyTransactions($backer, $xactions); - $xactions = array(); $xactions[] = id(new FundInitiativeTransaction()) ->setTransactionType(FundInitiativeTransaction::TYPE_BACKER) + ->setMetadataValue( + FundInitiativeTransaction::PROPERTY_AMOUNT, + $backer->getAmountAsCurrency()->serializeForStorage()) ->setNewValue($backer->getPHID()); $editor = id(new FundInitiativeEditor()) ->setActor($actor) ->setContentSource($this->getContentSource()); $editor->applyTransactions($this->getInitiative(), $xactions); - - return; } public function didRefundProduct( PhortuneProduct $product, - PhortunePurchase $purchase) { + PhortunePurchase $purchase, + PhortuneCurrency $amount) { $viewer = $this->getViewer(); - // TODO: Undonate. + + $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 FundInitiativeTransaction()) + ->setTransactionType(FundInitiativeTransaction::TYPE_REFUND) + ->setMetadataValue( + FundInitiativeTransaction::PROPERTY_AMOUNT, + $amount->serializeForStorage()) + ->setMetadataValue( + FundInitiativeTransaction::PROPERTY_BACKER, + $backer->getBackerPHID()) + ->setNewValue($backer->getPHID()); + + $editor = id(new FundInitiativeEditor()) + ->setActor($viewer) + ->setContentSource($this->getContentSource()); + + $editor->applyTransactions($this->getInitiative(), $xactions); } } diff --git a/src/applications/fund/storage/FundBackerTransaction.php b/src/applications/fund/storage/FundBackerTransaction.php index 28aed38d29..555c7d2966 100644 --- a/src/applications/fund/storage/FundBackerTransaction.php +++ b/src/applications/fund/storage/FundBackerTransaction.php @@ -1,20 +1,21 @@ getOldValue(); $new = $this->getNewValue(); $type = $this->getTransactionType(); switch ($type) { case FundInitiativeTransaction::TYPE_MERCHANT: if ($old) { $phids[] = $old; } if ($new) { $phids[] = $new; } break; + case FundInitiativeTransaction::TYPE_REFUND: + $phids[] = $this->getMetadataValue(self::PROPERTY_BACKER); + break; } return $phids; } public function getTitle() { $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 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_RISKS: return pht( '%s edited the risks for this initiative.', $this->renderHandleLink($author_phid)); 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: + $amount = $this->getMetadataValue(self::PROPERTY_AMOUNT); + $amount = PhortuneCurrency::newFromString($amount); return pht( - '%s backed this initiative.', - $this->renderHandleLink($author_phid)); + '%s backed this initiative with %s.', + $this->renderHandleLink($author_phid), + $amount->formatForDisplay()); + case FundInitiativeTransaction::TYPE_REFUND: + $amount = $this->getMetadataValue(self::PROPERTY_AMOUNT); + $amount = PhortuneCurrency::newFromString($amount); + + $backer_phid = $this->getMetadataValue(self::PROPERTY_BACKER); + + return pht( + '%s refunded %s to %s.', + $this->renderHandleLink($author_phid), + $amount->formatForDisplay(), + $this->renderHandleLink($backer_phid)); case FundInitiativeTransaction::TYPE_MERCHANT: if ($old === null) { return pht( '%s set this initiative to pay to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($new)); } else { return pht( '%s changed the merchant receiving funds from this '. 'initiative from %s to %s.', $this->renderHandleLink($author_phid), $this->renderHandleLink($old), $this->renderHandleLink($new)); } } 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: case FundInitiativeTransaction::TYPE_RISKS: return ($old === null); } return parent::shouldHide(); } public function hasChangeDetails() { switch ($this->getTransactionType()) { case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_RISKS: 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/phortune/controller/PhortuneCartCancelController.php b/src/applications/phortune/controller/PhortuneCartCancelController.php index aa726dbe5e..49561ba254 100644 --- a/src/applications/phortune/controller/PhortuneCartCancelController.php +++ b/src/applications/phortune/controller/PhortuneCartCancelController.php @@ -1,207 +1,208 @@ id = $data['id']; $this->action = $data['action']; } 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(); } switch ($this->action) { case 'cancel': // You must be able to edit the account to cancel an order. PhabricatorPolicyFilter::requireCapability( $viewer, $cart->getAccount(), PhabricatorPolicyCapability::CAN_EDIT); $is_refund = false; break; case 'refund': // You must be able to control the merchant to refund an order. PhabricatorPolicyFilter::requireCapability( $viewer, $cart->getMerchant(), PhabricatorPolicyCapability::CAN_EDIT); $is_refund = true; break; default: return new Aphront404Response(); } $cancel_uri = $cart->getDetailURI(); $merchant = $cart->getMerchant(); try { if ($is_refund) { $title = pht('Unable to Refund Order'); $cart->assertCanRefundOrder(); } else { $title = pht('Unable to Cancel Order'); $cart->assertCanCancelOrder(); } } catch (Exception $ex) { return $this->newDialog() ->setTitle($title) ->appendChild($ex->getMessage()) ->addCancelButton($cancel_uri); } $charges = id(new PhortuneChargeQuery()) ->setViewer($viewer) ->withCartPHIDs(array($cart->getPHID())) ->withStatuses( array( PhortuneCharge::STATUS_HOLD, PhortuneCharge::STATUS_CHARGED, )) ->execute(); $amounts = mpull($charges, 'getAmountAsCurrency'); $maximum = PhortuneCurrency::newFromList($amounts); $v_refund = $maximum->formatForDisplay(); $errors = array(); $e_refund = true; if ($request->isFormPost()) { if ($is_refund) { try { $refund = PhortuneCurrency::newFromUserInput( $viewer, $request->getStr('refund')); $refund->assertInRange('0.00 USD', $maximum->formatForDisplay()); } catch (Exception $ex) { - $errors[] = $ex; + $errors[] = $ex->getMessage(); $e_refund = pht('Invalid'); } } else { $refund = $maximum; } if (!$errors) { $charges = msort($charges, 'getID'); $charges = array_reverse($charges); if ($charges) { $providers = id(new PhortunePaymentProviderConfigQuery()) ->setViewer($viewer) ->withPHIDs(mpull($charges, 'getProviderPHID')) ->execute(); $providers = mpull($providers, null, 'getPHID'); } else { $providers = array(); } foreach ($charges as $charge) { $refundable = $charge->getAmountRefundableAsCurrency(); if (!$refundable->isPositive()) { // This charge is a refund, or has already been fully refunded. continue; } if ($refund->isGreaterThan($refundable)) { $refund_amount = $refundable; } else { $refund_amount = $refund; } $provider_config = idx($providers, $charge->getProviderPHID()); if (!$provider_config) { throw new Exception(pht('Unable to load provider for charge!')); } $provider = $provider_config->buildProvider(); $refund_charge = $cart->willRefundCharge( $viewer, $provider, $charge, $refund_amount); $refunded = false; try { $provider->refundCharge($charge, $refund_charge); $refunded = true; } catch (Exception $ex) { phlog($ex); $cart->didFailRefund($charge, $refund_charge); } if ($refunded) { $cart->didRefundCharge($charge, $refund_charge); $refund = $refund->subtract($refund_amount); } if (!$refund->isPositive()) { break; } } if ($refund->isPositive()) { throw new Exception(pht('Unable to refund some charges!')); } // TODO: If every HOLD and CHARGING transaction has been fully refunded // and we're in a HOLD, REVIEW, PURCHASING or CHARGED cart state we // probably need to kick the cart back to READY here (or maybe kill // it if it was in REVIEW)? return id(new AphrontRedirectResponse())->setURI($cancel_uri); } } if ($is_refund) { $title = pht('Refund Order?'); $body = pht( 'Really refund this order?'); $button = pht('Refund Order'); $cancel_text = pht('Cancel'); $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild( id(new AphrontFormTextControl()) ->setName('refund') ->setLabel(pht('Amount')) ->setError($e_refund) ->setValue($v_refund)); $form = $form->buildLayoutView(); } else { $title = pht('Cancel Order?'); $body = pht( 'Really cancel this order? Any payment will be refunded.'); $button = pht('Cancel Order'); // Don't give the user a "Cancel" button in response to a "Cancel?" // prompt, as it's confusing. $cancel_text = pht('Do Not Cancel Order'); $form = null; } return $this->newDialog() ->setTitle($title) + ->setErrors($errors) ->appendChild($body) ->appendChild($form) ->addSubmitButton($button) ->addCancelButton($cancel_uri, $cancel_text); } } diff --git a/src/applications/phortune/product/PhortuneProductImplementation.php b/src/applications/phortune/product/PhortuneProductImplementation.php index 14aa4df398..56138befd4 100644 --- a/src/applications/phortune/product/PhortuneProductImplementation.php +++ b/src/applications/phortune/product/PhortuneProductImplementation.php @@ -1,37 +1,38 @@ getName($product); } public function didPurchaseProduct( PhortuneProduct $product, PhortunePurchase $purchase) { return; } public function didRefundProduct( PhortuneProduct $product, - PhortunePurchase $purchase) { + PhortunePurchase $purchase, + PhortuneCurrency $amount) { return; } } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index 1181d301ce..e0b1990711 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -1,588 +1,589 @@ setAuthorPHID($actor->getPHID()) ->setStatus(self::STATUS_BUILDING) ->setAccountPHID($account->getPHID()) ->setMerchantPHID($merchant->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 static function getStatusNameMap() { return array( self::STATUS_BUILDING => pht('Building'), self::STATUS_READY => pht('Ready'), self::STATUS_PURCHASING => pht('Purchasing'), self::STATUS_CHARGED => pht('Charged'), self::STATUS_HOLD => pht('Hold'), self::STATUS_REVIEW => pht('Review'), self::STATUS_PURCHASED => pht('Purchased'), ); } public static function getNameForStatus($status) { return idx(self::getStatusNameMap(), $status, $status); } public function activateCart() { $this->setStatus(self::STATUS_READY)->save(); return $this; } public function willApplyCharge( PhabricatorUser $actor, PhortunePaymentProvider $provider, PhortunePaymentMethod $method = null) { $account = $this->getAccount(); $charge = PhortuneCharge::initializeNewCharge() ->setAccountPHID($account->getPHID()) ->setCartPHID($this->getPHID()) ->setAuthorPHID($actor->getPHID()) ->setMerchantPHID($this->getMerchant()->getPHID()) ->setProviderPHID($provider->getProviderConfig()->getPHID()) ->setAmountAsCurrency($this->getTotalPriceAsCurrency()); if ($method) { $charge->setPaymentMethodPHID($method->getPHID()); } $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if ($copy->getStatus() !== self::STATUS_READY) { throw new Exception( pht( 'Cart has wrong status ("%s") to call willApplyCharge(), '. 'expected "%s".', $copy->getStatus(), self::STATUS_READY)); } $charge->save(); $this->setStatus(PhortuneCart::STATUS_PURCHASING)->save(); $this->endReadLocking(); $this->saveTransaction(); return $charge; } public function didHoldCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_HOLD); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if ($copy->getStatus() !== self::STATUS_PURCHASING) { throw new Exception( pht( 'Cart has wrong status ("%s") to call didHoldCharge(), '. 'expected "%s".', $copy->getStatus(), self::STATUS_PURCHASING)); } $charge->save(); $this->setStatus(self::STATUS_HOLD)->save(); $this->endReadLocking(); $this->saveTransaction(); } public function didApplyCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_CHARGED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if (($copy->getStatus() !== self::STATUS_PURCHASING) && ($copy->getStatus() !== self::STATUS_HOLD)) { throw new Exception( pht( 'Cart has wrong status ("%s") to call didApplyCharge().', $copy->getStatus())); } $charge->save(); $this->setStatus(self::STATUS_CHARGED)->save(); $this->endReadLocking(); $this->saveTransaction(); // TODO: Perform purchase review. Here, we would apply rules to determine // whether the charge needs manual review (maybe making the decision via // Herald, configuration, or by examining provider fraud data). For now, // always require review. $needs_review = true; if ($needs_review) { $this->willReviewCart(); } else { $this->didReviewCart(); } return $this; } public function willReviewCart() { $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if (($copy->getStatus() !== self::STATUS_CHARGED)) { throw new Exception( pht( 'Cart has wrong status ("%s") to call willReviewCart()!', $copy->getStatus())); } $this->setStatus(self::STATUS_REVIEW)->save(); $this->endReadLocking(); $this->saveTransaction(); // TODO: Notify merchant to review order. return $this; } public function didReviewCart() { $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if (($copy->getStatus() !== self::STATUS_CHARGED) && ($copy->getStatus() !== self::STATUS_REVIEW)) { throw new Exception( pht( 'Cart has wrong status ("%s") to call didReviewCart()!', $copy->getStatus())); } foreach ($this->purchases as $purchase) { $purchase->getProduct()->didPurchaseProduct($purchase); } $this->setStatus(self::STATUS_PURCHASED)->save(); $this->endReadLocking(); $this->saveTransaction(); return $this; } public function didFailCharge(PhortuneCharge $charge) { $charge->setStatus(PhortuneCharge::STATUS_FAILED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $this; $copy->reload(); if (($copy->getStatus() !== self::STATUS_PURCHASING) && ($copy->getStatus() !== self::STATUS_HOLD)) { throw new Exception( pht( 'Cart has wrong status ("%s") to call didFailCharge().', $copy->getStatus())); } $charge->save(); // Move the cart back into STATUS_READY so the user can try // making the purchase again. $this->setStatus(self::STATUS_READY)->save(); $this->endReadLocking(); $this->saveTransaction(); return $this; } public function willRefundCharge( PhabricatorUser $actor, PhortunePaymentProvider $provider, PhortuneCharge $charge, PhortuneCurrency $amount) { if (!$amount->isPositive()) { throw new Exception( pht('Trying to refund nonpositive amount of money!')); } if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) { throw new Exception( pht('Trying to refund more money than remaining on charge!')); } if ($charge->getRefundedChargePHID()) { throw new Exception( pht('Trying to refund a refund!')); } if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) && ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) { throw new Exception( pht('Trying to refund an uncharged charge!')); } $refund_charge = PhortuneCharge::initializeNewCharge() ->setAccountPHID($this->getAccount()->getPHID()) ->setCartPHID($this->getPHID()) ->setAuthorPHID($actor->getPHID()) ->setMerchantPHID($this->getMerchant()->getPHID()) ->setProviderPHID($provider->getProviderConfig()->getPHID()) ->setPaymentMethodPHID($charge->getPaymentMethodPHID()) ->setRefundedChargePHID($charge->getPHID()) ->setAmountAsCurrency($amount->negate()); $charge->openTransaction(); $charge->beginReadLocking(); $copy = clone $charge; $copy->reload(); if ($copy->getRefundingPHID() !== null) { throw new Exception( pht('Trying to refund a charge which is already refunding!')); } $refund_charge->save(); $charge->setRefundingPHID($refund_charge->getPHID()); $charge->save(); $charge->endReadLocking(); $charge->saveTransaction(); return $refund_charge; } public function didRefundCharge( PhortuneCharge $charge, PhortuneCharge $refund) { $refund->setStatus(PhortuneCharge::STATUS_CHARGED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $charge; $copy->reload(); if ($charge->getRefundingPHID() !== $refund->getPHID()) { throw new Exception( pht('Charge is in the wrong refunding state!')); } $charge->setRefundingPHID(null); // NOTE: There's some trickiness here to get the signs right. Both // these values are positive but the refund has a negative value. $total_refunded = $charge ->getAmountRefundedAsCurrency() ->add($refund->getAmountAsCurrency()->negate()); $charge->setAmountRefundedAsCurrency($total_refunded); $charge->save(); $refund->save(); $this->endReadLocking(); $this->saveTransaction(); + $amount = $refund->getAmountAsCurrency()->negate(); foreach ($this->purchases as $purchase) { - $purchase->getProduct()->didRefundProduct($purchase); + $purchase->getProduct()->didRefundProduct($purchase, $amount); } return $this; } public function didFailRefund( PhortuneCharge $charge, PhortuneCharge $refund) { $refund->setStatus(PhortuneCharge::STATUS_FAILED); $this->openTransaction(); $this->beginReadLocking(); $copy = clone $charge; $copy->reload(); if ($charge->getRefundingPHID() !== $refund->getPHID()) { throw new Exception( pht('Charge is in the wrong refunding state!')); } $charge->setRefundingPHID(null); $charge->save(); $refund->save(); $this->endReadLocking(); $this->saveTransaction(); } public function getName() { return $this->getImplementation()->getName($this); } public function getDoneURI() { return $this->getImplementation()->getDoneURI($this); } public function getDoneActionName() { return $this->getImplementation()->getDoneActionName($this); } public function getCancelURI() { return $this->getImplementation()->getCancelURI($this); } public function getDetailURI() { return '/phortune/cart/'.$this->getID().'/'; } public function getCheckoutURI() { return '/phortune/cart/'.$this->getID().'/checkout/'; } public function canCancelOrder() { try { $this->assertCanCancelOrder(); return true; } catch (Exception $ex) { return false; } } public function canRefundOrder() { try { $this->assertCanRefundOrder(); return true; } catch (Exception $ex) { return false; } } public function assertCanCancelOrder() { switch ($this->getStatus()) { case self::STATUS_BUILDING: throw new Exception( pht( 'This order can not be cancelled because the application has not '. 'finished building it yet.')); case self::STATUS_READY: throw new Exception( pht( 'This order can not be cancelled because it has not been placed.')); } return $this->getImplementation()->assertCanCancelOrder($this); } public function assertCanRefundOrder() { switch ($this->getStatus()) { case self::STATUS_BUILDING: throw new Exception( pht( 'This order can not be refunded because the application has not '. 'finished building it yet.')); case self::STATUS_READY: throw new Exception( pht( 'This order can not be refunded because it has not been placed.')); } return $this->getImplementation()->assertCanRefundOrder($this); } 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', 'cartClass' => 'text128', ), self::CONFIG_KEY_SCHEMA => array( 'key_account' => array( 'columns' => array('accountPHID'), ), 'key_merchant' => array( 'columns' => array('merchantPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhortuneCartPHIDType::TYPECONST); } 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 attachMerchant(PhortuneMerchant $merchant) { $this->merchant = $merchant; return $this; } public function getMerchant() { return $this->assertAttached($this->merchant); } public function attachImplementation( PhortuneCartImplementation $implementation) { $this->implementation = $implementation; return $this; } public function getImplementation() { return $this->assertAttached($this->implementation); } public function getTotalPriceAsCurrency() { $prices = array(); foreach ($this->getPurchases() as $purchase) { $prices[] = $purchase->getTotalPriceAsCurrency(); } return PhortuneCurrency::newFromList($prices); } public function setMetadataValue($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadataValue($key, $default = null) { return idx($this->metadata, $key, $default); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { // NOTE: Both view and edit use the account's edit policy. We punch a hole // through this for merchants, below. return $this ->getAccount() ->getPolicy(PhabricatorPolicyCapability::CAN_EDIT); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->getAccount()->hasAutomaticCapability($capability, $viewer)) { return true; } // If the viewer controls the merchant this order was placed with, they // can view the order. if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { $can_admin = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getMerchant(), PhabricatorPolicyCapability::CAN_EDIT); if ($can_admin) { return true; } } return false; } public function describeAutomaticCapability($capability) { return array( pht('Orders inherit the policies of the associated account.'), pht('The merchant you placed an order with can review and manage it.'), ); } } diff --git a/src/applications/phortune/storage/PhortuneProduct.php b/src/applications/phortune/storage/PhortuneProduct.php index c984cf86a7..a7d952ed89 100644 --- a/src/applications/phortune/storage/PhortuneProduct.php +++ b/src/applications/phortune/storage/PhortuneProduct.php @@ -1,107 +1,112 @@ 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( PhortuneProductPHIDType::TYPECONST); } 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); } - public function didRefundProduct(PhortunePurchase $purchase) { - return $this->getImplementation()->didRefundProduct($this, $purchase); + public function didRefundProduct( + PhortunePurchase $purchase, + PhortuneCurrency $amount) { + return $this->getImplementation()->didRefundProduct( + $this, + $purchase, + $amount); } /* -( 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; } }