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 @@ -47,6 +47,7 @@ final class FundInitiativeBackController $currency = PhortuneCurrency::newFromUserInput( $viewer, $v_amount); + $currency->assertInRange('1.00 USD', null); } catch (Exception $ex) { $errors[] = $ex->getMessage(); $e_amount = pht('Invalid'); @@ -72,7 +73,10 @@ final class FundInitiativeBackController $cart = $account->newCart($viewer); $purchase = $cart->newPurchase($viewer, $product); - $purchase->setBasePriceAsCurrency($currency)->save(); + $purchase + ->setBasePriceAsCurrency($currency) + ->setMetadataValue('backerPHID', $backer->getPHID()) + ->save(); $xactions = array(); @@ -86,6 +90,8 @@ final class FundInitiativeBackController $editor->applyTransactions($backer, $xactions); + $cart->activateCart(); + return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } 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 @@ -17,6 +17,7 @@ final class FundInitiativeEditor $types[] = FundInitiativeTransaction::TYPE_NAME; $types[] = FundInitiativeTransaction::TYPE_DESCRIPTION; $types[] = FundInitiativeTransaction::TYPE_STATUS; + $types[] = FundInitiativeTransaction::TYPE_BACKER; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; @@ -33,6 +34,8 @@ final class FundInitiativeEditor return $object->getDescription(); case FundInitiativeTransaction::TYPE_STATUS: return $object->getStatus(); + case FundInitiativeTransaction::TYPE_BACKER: + return null; } return parent::getCustomTransactionOldValue($object, $xaction); @@ -46,6 +49,7 @@ final class FundInitiativeEditor case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_STATUS: + case FundInitiativeTransaction::TYPE_BACKER: return $xaction->getNewValue(); } @@ -66,6 +70,9 @@ final class FundInitiativeEditor 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; @@ -82,6 +89,9 @@ final class FundInitiativeEditor 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; 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 @@ -4,13 +4,27 @@ final class FundBackerProduct extends PhortuneProductImplementation { private $initiativePHID; private $initiative; + private $viewer; + + public function setViewer(PhabricatorUser $viewer) { + $this->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) { @@ -48,6 +62,7 @@ final class FundBackerProduct extends PhortuneProductImplementation { $objects = array(); foreach ($refs as $ref) { $object = id(new FundBackerProduct()) + ->setViewer($viewer) ->setInitiativePHID($ref); $initiative = idx($initiatives, $ref); @@ -61,4 +76,43 @@ final class FundBackerProduct extends PhortuneProductImplementation { 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 @@ -5,6 +5,7 @@ final class FundBackerQuery private $ids; private $phids; + private $statuses; private $initiativePHIDs; private $backerPHIDs; @@ -19,6 +20,11 @@ final class FundBackerQuery return $this; } + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + public function withInitiativePHIDs(array $phids) { $this->initiativePHIDs = $phids; return $this; @@ -95,6 +101,13 @@ final class FundBackerQuery $this->backerPHIDs); } + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn_r, + 'status IN (%Ls)', + $this->statuses); + } + return $this->formatWhereClause($where); } 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 @@ -35,6 +35,8 @@ final class FundBackerSearchEngine public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new FundBackerQuery()); + $query->withStatuses(array(FundBacker::STATUS_PURCHASED)); + if ($this->getInitiative()) { $query->withInitiativePHIDs( array( @@ -128,7 +130,7 @@ final class FundBackerSearchEngine foreach ($backers as $backer) { $backer_handle = $handles[$backer->getBackerPHID()]; - $currency = $backer->getAmount(); + $currency = $backer->getAmountAsCurrency(); $header = pht( '%s for %s', 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 @@ -15,6 +15,7 @@ final class FundBacker extends FundDAO const STATUS_NEW = 'new'; const STATUS_IN_CART = 'in-cart'; + const STATUS_PURCHASED = 'purchased'; public static function initializeNewBacker(PhabricatorUser $actor) { return id(new FundBacker()) 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 @@ -6,6 +6,7 @@ final class FundInitiativeTransaction const TYPE_NAME = 'fund:name'; const TYPE_DESCRIPTION = 'fund:description'; const TYPE_STATUS = 'fund:status'; + const TYPE_BACKER = 'fund:backer'; public function getApplicationName() { return 'fund'; @@ -57,6 +58,10 @@ final class FundInitiativeTransaction $this->renderHandleLink($author_phid)); } break; + case FundInitiativeTransaction::TYPE_BACKER: + return pht( + '%s backed this initiative.', + $this->renderHandleLink($author_phid)); } return parent::getTitle(); @@ -104,6 +109,11 @@ final class FundInitiativeTransaction $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); 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 @@ -14,6 +14,7 @@ final class PhabricatorContentSource { const SOURCE_LEGACY = 'legacy'; const SOURCE_DAEMON = 'daemon'; const SOURCE_LIPSUM = 'lipsum'; + const SOURCE_PHORTUNE = 'phortune'; private $source; private $params = array(); @@ -77,6 +78,7 @@ final class PhabricatorContentSource { self::SOURCE_DAEMON => pht('Daemons'), self::SOURCE_LIPSUM => pht('Lipsum'), self::SOURCE_UNKNOWN => pht('Old World'), + self::SOURCE_PHORTUNE => pht('Phortune'), ); } 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 @@ -22,6 +22,42 @@ final class PhortuneCartCheckoutController 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().'/'); @@ -71,12 +107,10 @@ final class PhortuneCartCheckoutController $provider->applyCharge($method, $charge); - $cart->setStatus(PhortuneCart::STATUS_PURCHASED); - $cart->save(); + $cart->didApplyCharge($charge); - $view_uri = $this->getApplicationURI('cart/'.$cart->getID().'/'); - - return id(new AphrontRedirectResponse())->setURI($view_uri); + $done_uri = $cart->getDoneURI(); + return id(new AphrontRedirectResponse())->setURI($done_uri); } } 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 @@ -125,6 +125,54 @@ final class PhortuneCurrency extends Phobject { 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 @@ -86,4 +86,60 @@ final class PhortuneCurrencyTestCase extends PhabricatorTestCase { } } + 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 @@ -10,4 +10,22 @@ abstract class PhortuneProductImplementation { abstract public function getName(PhortuneProduct $product); abstract public function getPriceAsCurrency(PhortuneProduct $product); + protected function getContentSource() { + return PhabricatorContentSource::newForSource( + PhabricatorContentSource::SOURCE_PHORTUNE, + array()); + } + + public function getPurchaseName( + PhortuneProduct $product, + PhortunePurchase $purchase) { + return $this->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 @@ -54,6 +54,23 @@ final class PhortunePurchaseQuery $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; } 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 @@ -3,8 +3,10 @@ final class PhortuneCart extends PhortuneDAO implements PhabricatorPolicyInterface { + const STATUS_BUILDING = 'cart:building'; const STATUS_READY = 'cart:ready'; const STATUS_PURCHASING = 'cart:purchasing'; + const STATUS_CHARGED = 'cart:charged'; const STATUS_PURCHASED = 'cart:purchased'; protected $accountPHID; @@ -20,7 +22,7 @@ final class PhortuneCart extends PhortuneDAO PhortuneAccount $account) { $cart = id(new PhortuneCart()) ->setAuthorPHID($actor->getPHID()) - ->setStatus(self::STATUS_READY) + ->setStatus(self::STATUS_BUILDING) ->setAccountPHID($account->getPHID()); $cart->account = $account; @@ -43,6 +45,47 @@ final class PhortuneCart extends PhortuneDAO 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/'; } 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 @@ -70,6 +70,14 @@ final class PhortuneProduct extends PhortuneDAO 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 )----------------------------------------- */ 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,7 +1,7 @@ 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 )----------------------------------------- */