1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-19 05:12:41 +01:00

Add more structure to Phortune product purchasing flow

Summary:
Ref T2787. When a user purchases a product in Phortune, transition the cart through a purchased state and invoke product callbacks so applications can respond to the workflow.

Also shore up some stuff like preventing negative amounts of funding.

Test Plan: Backed an initiative and saw it show up on the initiative after completing the purcahsing workflow.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T2787

Differential Revision: https://secure.phabricator.com/D10635
This commit is contained in:
epriestley 2014-10-06 10:36:43 -07:00
parent e9615b74a5
commit 35dc510e18
16 changed files with 352 additions and 11 deletions

View file

@ -47,6 +47,7 @@ final class FundInitiativeBackController
$currency = PhortuneCurrency::newFromUserInput( $currency = PhortuneCurrency::newFromUserInput(
$viewer, $viewer,
$v_amount); $v_amount);
$currency->assertInRange('1.00 USD', null);
} catch (Exception $ex) { } catch (Exception $ex) {
$errors[] = $ex->getMessage(); $errors[] = $ex->getMessage();
$e_amount = pht('Invalid'); $e_amount = pht('Invalid');
@ -72,7 +73,10 @@ final class FundInitiativeBackController
$cart = $account->newCart($viewer); $cart = $account->newCart($viewer);
$purchase = $cart->newPurchase($viewer, $product); $purchase = $cart->newPurchase($viewer, $product);
$purchase->setBasePriceAsCurrency($currency)->save(); $purchase
->setBasePriceAsCurrency($currency)
->setMetadataValue('backerPHID', $backer->getPHID())
->save();
$xactions = array(); $xactions = array();
@ -86,6 +90,8 @@ final class FundInitiativeBackController
$editor->applyTransactions($backer, $xactions); $editor->applyTransactions($backer, $xactions);
$cart->activateCart();
return id(new AphrontRedirectResponse()) return id(new AphrontRedirectResponse())
->setURI($cart->getCheckoutURI()); ->setURI($cart->getCheckoutURI());
} }

View file

@ -17,6 +17,7 @@ final class FundInitiativeEditor
$types[] = FundInitiativeTransaction::TYPE_NAME; $types[] = FundInitiativeTransaction::TYPE_NAME;
$types[] = FundInitiativeTransaction::TYPE_DESCRIPTION; $types[] = FundInitiativeTransaction::TYPE_DESCRIPTION;
$types[] = FundInitiativeTransaction::TYPE_STATUS; $types[] = FundInitiativeTransaction::TYPE_STATUS;
$types[] = FundInitiativeTransaction::TYPE_BACKER;
$types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
$types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
@ -33,6 +34,8 @@ final class FundInitiativeEditor
return $object->getDescription(); return $object->getDescription();
case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_STATUS:
return $object->getStatus(); return $object->getStatus();
case FundInitiativeTransaction::TYPE_BACKER:
return null;
} }
return parent::getCustomTransactionOldValue($object, $xaction); return parent::getCustomTransactionOldValue($object, $xaction);
@ -46,6 +49,7 @@ final class FundInitiativeEditor
case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_NAME:
case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_DESCRIPTION:
case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_STATUS:
case FundInitiativeTransaction::TYPE_BACKER:
return $xaction->getNewValue(); return $xaction->getNewValue();
} }
@ -66,6 +70,9 @@ final class FundInitiativeEditor
case FundInitiativeTransaction::TYPE_STATUS: case FundInitiativeTransaction::TYPE_STATUS:
$object->setStatus($xaction->getNewValue()); $object->setStatus($xaction->getNewValue());
return; return;
case FundInitiativeTransaction::TYPE_BACKER:
// TODO: Calculate total funding / backers / etc.
return;
case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_SUBSCRIBERS:
case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_EDGE:
return; return;
@ -82,6 +89,9 @@ final class FundInitiativeEditor
case FundInitiativeTransaction::TYPE_NAME: case FundInitiativeTransaction::TYPE_NAME:
case FundInitiativeTransaction::TYPE_DESCRIPTION: case FundInitiativeTransaction::TYPE_DESCRIPTION:
case FundInitiativeTransaction::TYPE_STATUS: 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_SUBSCRIBERS:
case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_EDGE:
return; return;

View file

@ -4,13 +4,27 @@ final class FundBackerProduct extends PhortuneProductImplementation {
private $initiativePHID; private $initiativePHID;
private $initiative; private $initiative;
private $viewer;
public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
public function getViewer() {
return $this->viewer;
}
public function getRef() { public function getRef() {
return $this->getInitiativePHID(); return $this->getInitiativePHID();
} }
public function getName(PhortuneProduct $product) { 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) { public function getPriceAsCurrency(PhortuneProduct $product) {
@ -48,6 +62,7 @@ final class FundBackerProduct extends PhortuneProductImplementation {
$objects = array(); $objects = array();
foreach ($refs as $ref) { foreach ($refs as $ref) {
$object = id(new FundBackerProduct()) $object = id(new FundBackerProduct())
->setViewer($viewer)
->setInitiativePHID($ref); ->setInitiativePHID($ref);
$initiative = idx($initiatives, $ref); $initiative = idx($initiatives, $ref);
@ -61,4 +76,43 @@ final class FundBackerProduct extends PhortuneProductImplementation {
return $objects; 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;
}
} }

View file

@ -5,6 +5,7 @@ final class FundBackerQuery
private $ids; private $ids;
private $phids; private $phids;
private $statuses;
private $initiativePHIDs; private $initiativePHIDs;
private $backerPHIDs; private $backerPHIDs;
@ -19,6 +20,11 @@ final class FundBackerQuery
return $this; return $this;
} }
public function withStatuses(array $statuses) {
$this->statuses = $statuses;
return $this;
}
public function withInitiativePHIDs(array $phids) { public function withInitiativePHIDs(array $phids) {
$this->initiativePHIDs = $phids; $this->initiativePHIDs = $phids;
return $this; return $this;
@ -95,6 +101,13 @@ final class FundBackerQuery
$this->backerPHIDs); $this->backerPHIDs);
} }
if ($this->statuses !== null) {
$where[] = qsprintf(
$conn_r,
'status IN (%Ls)',
$this->statuses);
}
return $this->formatWhereClause($where); return $this->formatWhereClause($where);
} }

View file

@ -35,6 +35,8 @@ final class FundBackerSearchEngine
public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) {
$query = id(new FundBackerQuery()); $query = id(new FundBackerQuery());
$query->withStatuses(array(FundBacker::STATUS_PURCHASED));
if ($this->getInitiative()) { if ($this->getInitiative()) {
$query->withInitiativePHIDs( $query->withInitiativePHIDs(
array( array(
@ -128,7 +130,7 @@ final class FundBackerSearchEngine
foreach ($backers as $backer) { foreach ($backers as $backer) {
$backer_handle = $handles[$backer->getBackerPHID()]; $backer_handle = $handles[$backer->getBackerPHID()];
$currency = $backer->getAmount(); $currency = $backer->getAmountAsCurrency();
$header = pht( $header = pht(
'%s for %s', '%s for %s',

View file

@ -15,6 +15,7 @@ final class FundBacker extends FundDAO
const STATUS_NEW = 'new'; const STATUS_NEW = 'new';
const STATUS_IN_CART = 'in-cart'; const STATUS_IN_CART = 'in-cart';
const STATUS_PURCHASED = 'purchased';
public static function initializeNewBacker(PhabricatorUser $actor) { public static function initializeNewBacker(PhabricatorUser $actor) {
return id(new FundBacker()) return id(new FundBacker())

View file

@ -6,6 +6,7 @@ final class FundInitiativeTransaction
const TYPE_NAME = 'fund:name'; const TYPE_NAME = 'fund:name';
const TYPE_DESCRIPTION = 'fund:description'; const TYPE_DESCRIPTION = 'fund:description';
const TYPE_STATUS = 'fund:status'; const TYPE_STATUS = 'fund:status';
const TYPE_BACKER = 'fund:backer';
public function getApplicationName() { public function getApplicationName() {
return 'fund'; return 'fund';
@ -57,6 +58,10 @@ final class FundInitiativeTransaction
$this->renderHandleLink($author_phid)); $this->renderHandleLink($author_phid));
} }
break; break;
case FundInitiativeTransaction::TYPE_BACKER:
return pht(
'%s backed this initiative.',
$this->renderHandleLink($author_phid));
} }
return parent::getTitle(); return parent::getTitle();
@ -104,6 +109,11 @@ final class FundInitiativeTransaction
$this->renderHandleLink($object_phid)); $this->renderHandleLink($object_phid));
} }
break; break;
case FundInitiativeTransaction::TYPE_BACKER:
return pht(
'%s backed %s.',
$this->renderHandleLink($author_phid),
$this->renderHandleLink($object_phid));
} }
return parent::getTitleForFeed($story); return parent::getTitleForFeed($story);

View file

@ -14,6 +14,7 @@ final class PhabricatorContentSource {
const SOURCE_LEGACY = 'legacy'; const SOURCE_LEGACY = 'legacy';
const SOURCE_DAEMON = 'daemon'; const SOURCE_DAEMON = 'daemon';
const SOURCE_LIPSUM = 'lipsum'; const SOURCE_LIPSUM = 'lipsum';
const SOURCE_PHORTUNE = 'phortune';
private $source; private $source;
private $params = array(); private $params = array();
@ -77,6 +78,7 @@ final class PhabricatorContentSource {
self::SOURCE_DAEMON => pht('Daemons'), self::SOURCE_DAEMON => pht('Daemons'),
self::SOURCE_LIPSUM => pht('Lipsum'), self::SOURCE_LIPSUM => pht('Lipsum'),
self::SOURCE_UNKNOWN => pht('Old World'), self::SOURCE_UNKNOWN => pht('Old World'),
self::SOURCE_PHORTUNE => pht('Phortune'),
); );
} }

View file

@ -22,6 +22,42 @@ final class PhortuneCartCheckoutController
return new Aphront404Response(); 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 = $cart->getAccount();
$account_uri = $this->getApplicationURI($account->getID().'/'); $account_uri = $this->getApplicationURI($account->getID().'/');
@ -71,12 +107,10 @@ final class PhortuneCartCheckoutController
$provider->applyCharge($method, $charge); $provider->applyCharge($method, $charge);
$cart->setStatus(PhortuneCart::STATUS_PURCHASED); $cart->didApplyCharge($charge);
$cart->save();
$view_uri = $this->getApplicationURI('cart/'.$cart->getID().'/'); $done_uri = $cart->getDoneURI();
return id(new AphrontRedirectResponse())->setURI($done_uri);
return id(new AphrontRedirectResponse())->setURI($view_uri);
} }
} }

View file

@ -125,6 +125,54 @@ final class PhortuneCurrency extends Phobject {
throw new Exception("Invalid currency format ('{$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;
}
} }

View file

@ -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);
}
} }

View file

@ -10,4 +10,22 @@ abstract class PhortuneProductImplementation {
abstract public function getName(PhortuneProduct $product); abstract public function getName(PhortuneProduct $product);
abstract public function getPriceAsCurrency(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;
}
} }

View file

@ -54,6 +54,23 @@ final class PhortunePurchaseQuery
$purchase->attachCart($cart); $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; return $purchases;
} }

View file

@ -3,8 +3,10 @@
final class PhortuneCart extends PhortuneDAO final class PhortuneCart extends PhortuneDAO
implements PhabricatorPolicyInterface { implements PhabricatorPolicyInterface {
const STATUS_BUILDING = 'cart:building';
const STATUS_READY = 'cart:ready'; const STATUS_READY = 'cart:ready';
const STATUS_PURCHASING = 'cart:purchasing'; const STATUS_PURCHASING = 'cart:purchasing';
const STATUS_CHARGED = 'cart:charged';
const STATUS_PURCHASED = 'cart:purchased'; const STATUS_PURCHASED = 'cart:purchased';
protected $accountPHID; protected $accountPHID;
@ -20,7 +22,7 @@ final class PhortuneCart extends PhortuneDAO
PhortuneAccount $account) { PhortuneAccount $account) {
$cart = id(new PhortuneCart()) $cart = id(new PhortuneCart())
->setAuthorPHID($actor->getPHID()) ->setAuthorPHID($actor->getPHID())
->setStatus(self::STATUS_READY) ->setStatus(self::STATUS_BUILDING)
->setAccountPHID($account->getPHID()); ->setAccountPHID($account->getPHID());
$cart->account = $account; $cart->account = $account;
@ -43,6 +45,47 @@ final class PhortuneCart extends PhortuneDAO
return $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() { public function getCheckoutURI() {
return '/phortune/cart/'.$this->getID().'/checkout/'; return '/phortune/cart/'.$this->getID().'/checkout/';
} }

View file

@ -70,6 +70,14 @@ final class PhortuneProduct extends PhortuneDAO
return $this->getImplementation()->getName($this); 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 )----------------------------------------- */ /* -( PhabricatorPolicyInterface )----------------------------------------- */

View file

@ -1,7 +1,7 @@
<?php <?php
/** /**
* A purchase represents a user buying something or a subscription to a plan. * A purchase represents a user buying something.
*/ */
final class PhortunePurchase extends PhortuneDAO final class PhortunePurchase extends PhortuneDAO
implements PhabricatorPolicyInterface { implements PhabricatorPolicyInterface {
@ -23,6 +23,7 @@ final class PhortunePurchase extends PhortuneDAO
protected $metadata = array(); protected $metadata = array();
private $cart = self::ATTACHABLE; private $cart = self::ATTACHABLE;
private $product = self::ATTACHABLE;
public static function initializeNewPurchase( public static function initializeNewPurchase(
PhabricatorUser $actor, PhabricatorUser $actor,
@ -72,14 +73,32 @@ final class PhortunePurchase extends PhortuneDAO
return $this->assertAttached($this->cart); 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() { public function getFullDisplayName() {
return pht('Goods and/or Services'); return $this->getProduct()->getPurchaseName($this);
} }
public function getTotalPriceAsCurrency() { public function getTotalPriceAsCurrency() {
return $this->getBasePriceAsCurrency(); 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 )----------------------------------------- */ /* -( PhabricatorPolicyInterface )----------------------------------------- */