1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-21 13:00:56 +01:00

Support basic ad-hoc invoices in Phortune

Summary:
This allows a merchant to send a user an invoice for something arbitrary, like services rendered.

Two major missing parts:

  - These don't actually get marked as invoices. I'll fix that in the next diff, but it's not entirely trivial because `subscriptionPHID` is currently overloaded to also mean "is invoice".
  - We don't send email automatically. I don't plan to fix that for now, since all our invoicing needs are covered by personal email.

Test Plan:
Merchants have a new "new invoice" option:

{F376999}

This leads to selecting a user and account, and then you can generate the invoice (only one actual "purchase" / line item for the moment). You can add a longer-form remarkup description to contextualize the billable items:

{F377001}

This sends the invoice and takes you to the merchant order overview screen:

{F377002}

For now, you copy/paste that link into a nice personal enterprisey business-to-business email; the recipient sees this:

{F377003}

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Differential Revision: https://secure.phabricator.com/D12478
This commit is contained in:
epriestley 2015-04-20 10:05:22 -07:00
parent e0c95bca86
commit 0195e751c6
12 changed files with 436 additions and 18 deletions

View file

@ -2817,6 +2817,8 @@ phutil_register_library_map(array(
'PhortuneAccountTransaction' => 'applications/phortune/storage/PhortuneAccountTransaction.php',
'PhortuneAccountTransactionQuery' => 'applications/phortune/query/PhortuneAccountTransactionQuery.php',
'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php',
'PhortuneAdHocCart' => 'applications/phortune/cart/PhortuneAdHocCart.php',
'PhortuneAdHocProduct' => 'applications/phortune/product/PhortuneAdHocProduct.php',
'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php',
'PhortuneCartAcceptController' => 'applications/phortune/controller/PhortuneCartAcceptController.php',
'PhortuneCartCancelController' => 'applications/phortune/controller/PhortuneCartCancelController.php',
@ -2856,6 +2858,7 @@ phutil_register_library_map(array(
'PhortuneMerchantEditController' => 'applications/phortune/controller/PhortuneMerchantEditController.php',
'PhortuneMerchantEditor' => 'applications/phortune/editor/PhortuneMerchantEditor.php',
'PhortuneMerchantHasMemberEdgeType' => 'applications/phortune/edge/PhortuneMerchantHasMemberEdgeType.php',
'PhortuneMerchantInvoiceCreateController' => 'applications/phortune/controller/PhortuneMerchantInvoiceCreateController.php',
'PhortuneMerchantListController' => 'applications/phortune/controller/PhortuneMerchantListController.php',
'PhortuneMerchantPHIDType' => 'applications/phortune/phid/PhortuneMerchantPHIDType.php',
'PhortuneMerchantQuery' => 'applications/phortune/query/PhortuneMerchantQuery.php',
@ -6265,6 +6268,8 @@ phutil_register_library_map(array(
'PhortuneAccountTransaction' => 'PhabricatorApplicationTransaction',
'PhortuneAccountTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'PhortuneAccountViewController' => 'PhortuneController',
'PhortuneAdHocCart' => 'PhortuneCartImplementation',
'PhortuneAdHocProduct' => 'PhortuneProductImplementation',
'PhortuneCart' => array(
'PhortuneDAO',
'PhabricatorApplicationTransactionInterface',
@ -6312,6 +6317,7 @@ phutil_register_library_map(array(
'PhortuneMerchantEditController' => 'PhortuneMerchantController',
'PhortuneMerchantEditor' => 'PhabricatorApplicationTransactionEditor',
'PhortuneMerchantHasMemberEdgeType' => 'PhabricatorEdgeType',
'PhortuneMerchantInvoiceCreateController' => 'PhortuneMerchantController',
'PhortuneMerchantListController' => 'PhortuneMerchantController',
'PhortuneMerchantPHIDType' => 'PhabricatorPHIDType',
'PhortuneMerchantQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',

View file

@ -84,19 +84,24 @@ final class PhabricatorPhortuneApplication extends PhabricatorApplication {
'edit/(?:(?P<id>\d+)/)?' => 'PhortuneMerchantEditController',
'orders/(?P<merchantID>\d+)/(?:query/(?P<queryKey>[^/]+)/)?'
=> 'PhortuneCartListController',
'(?P<merchantID>\d+)/cart/(?P<id>\d+)/' => array(
'' => 'PhortuneCartViewController',
'(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
'update/' => 'PhortuneCartUpdateController',
'accept/' => 'PhortuneCartAcceptController',
),
'(?P<merchantID>\d+)/subscription/' => array(
'(?:query/(?P<queryKey>[^/]+)/)?'
=> 'PhortuneSubscriptionListController',
'view/(?P<id>\d+)/'
=> 'PhortuneSubscriptionViewController',
'order/(?P<subscriptionID>\d+)/'
=> 'PhortuneCartListController',
'(?P<merchantID>\d+)/' => array(
'cart/(?P<id>\d+)/' => array(
'' => 'PhortuneCartViewController',
'(?P<action>cancel|refund)/' => 'PhortuneCartCancelController',
'update/' => 'PhortuneCartUpdateController',
'accept/' => 'PhortuneCartAcceptController',
),
'subscription/' => array(
'(?:query/(?P<queryKey>[^/]+)/)?'
=> 'PhortuneSubscriptionListController',
'view/(?P<id>\d+)/'
=> 'PhortuneSubscriptionViewController',
'order/(?P<subscriptionID>\d+)/'
=> 'PhortuneCartListController',
),
'invoice/' => array(
'new/' => 'PhortuneMerchantInvoiceCreateController',
),
),
'(?P<id>\d+)/' => 'PhortuneMerchantViewController',
),

View file

@ -0,0 +1,39 @@
<?php
final class PhortuneAdHocCart extends PhortuneCartImplementation {
public function loadImplementationsForCarts(
PhabricatorUser $viewer,
array $carts) {
$results = array();
foreach ($carts as $key => $cart) {
$results[$key] = new PhortuneAdHocCart();
}
return $results;
}
public function getName(PhortuneCart $cart) {
return $cart->getMetadataValue('adhoc.title');
}
public function getDescription(PhortuneCart $cart) {
return $cart->getMetadataValue('adhoc.description');
}
public function getCancelURI(PhortuneCart $cart) {
return null;
}
public function getDoneURI(PhortuneCart $cart) {
return null;
}
public function willCreateCart(
PhabricatorUser $viewer,
PhortuneCart $cart) {
return;
}
}

View file

@ -16,6 +16,10 @@ abstract class PhortuneCartImplementation {
abstract public function getCancelURI(PhortuneCart $cart);
abstract public function getDoneURI(PhortuneCart $cart);
public function getDescription(PhortuneCart $cart) {
return null;
}
public function getDoneActionName(PhortuneCart $cart) {
return pht('Return to Application');
}

View file

@ -209,6 +209,8 @@ final class PhortuneCartCheckoutController
->appendChild($form)
->appendChild($provider_form);
$description_box = $this->renderCartDescription($cart);
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(pht('Checkout'));
$crumbs->addTextCrumb($title);
@ -217,6 +219,7 @@ final class PhortuneCartCheckoutController
array(
$crumbs,
$cart_box,
$description_box,
$payment_box,
),
array(

View file

@ -42,4 +42,26 @@ abstract class PhortuneCartController
return $table;
}
protected function renderCartDescription(PhortuneCart $cart) {
$description = $cart->getDescription();
if (!strlen($description)) {
return null;
}
$output = PhabricatorMarkupEngine::renderOneObject(
id(new PhabricatorMarkupOneOff())
->setPreserveLinebreaks(true)
->setContent($description),
'default',
$this->getViewer());
$box = id(new PHUIBoxView())
->addMargin(PHUI::MARGIN_LARGE)
->appendChild($output);
return id(new PHUIObjectBoxView())
->setHeaderText(pht('Description'))
->appendChild($box);
}
}

View file

@ -15,11 +15,16 @@ final class PhortuneCartViewController
$authority = $this->loadMerchantAuthority();
$cart = id(new PhortuneCartQuery())
$query = id(new PhortuneCartQuery())
->setViewer($viewer)
->withIDs(array($this->id))
->needPurchases(true)
->executeOne();
->needPurchases(true);
if ($authority) {
$query->withMerchantPHIDs(array($authority->getPHID()));
}
$cart = $query->executeOne();
if (!$cart) {
return new Aphront404Response();
}
@ -35,6 +40,31 @@ final class PhortuneCartViewController
$error_view = null;
$resume_uri = null;
switch ($cart->getStatus()) {
case PhortuneCart::STATUS_READY:
if ($authority && $request->getStr('invoice')) {
// We arrived here by following the ad-hoc invoice workflow, and
// are acting with merchant authority.
$checkout_uri = PhabricatorEnv::getURI($cart->getCheckoutURI());
$invoice_message = array(
pht(
'Manual invoices do not automatically notify recipients yet. '.
'Send the payer this checkout link:'),
' ',
phutil_tag(
'a',
array(
'href' => $checkout_uri,
),
$checkout_uri),
);
$error_view = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setErrors(array($invoice_message));
}
break;
case PhortuneCart::STATUS_PURCHASING:
if ($can_edit) {
$resume_uri = $cart->getMetadataValue('provider.checkoutURI');
@ -86,7 +116,6 @@ final class PhortuneCartViewController
$error_view = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_NOTICE)
->appendChild(pht('This purchase has been completed.'));
break;
}
@ -126,6 +155,8 @@ final class PhortuneCartViewController
$cart_box->setInfoView($error_view);
}
$description = $this->renderCartDescription($cart);
$charges = id(new PhortuneChargeQuery())
->setViewer($viewer)
->withCartPHIDs(array($cart->getPHID()))
@ -171,6 +202,7 @@ final class PhortuneCartViewController
array(
$crumbs,
$cart_box,
$description,
$charges,
$timeline,
),

View file

@ -0,0 +1,245 @@
<?php
final class PhortuneMerchantInvoiceCreateController
extends PhortuneMerchantController {
public function handleRequest(AphrontRequest $request) {
$viewer = $request->getUser();
$merchant = $this->loadMerchantAuthority();
if (!$merchant) {
return new Aphront404Response();
}
$merchant_id = $merchant->getID();
$cancel_uri = $this->getApplicationURI("/merchant/{$merchant_id}/");
// Load the user to invoice, or prompt the viewer to select one.
$target_user = null;
$user_phid = head($request->getArr('userPHID'));
if (!$user_phid) {
$user_phid = $request->getStr('userPHID');
}
if ($user_phid) {
$target_user = id(new PhabricatorPeopleQuery())
->setViewer($viewer)
->withPHIDs(array($user_phid))
->executeOne();
}
if (!$target_user) {
$form = id(new AphrontFormView())
->setUser($viewer)
->appendRemarkupInstructions(pht('Choose a user to invoice.'))
->appendControl(
id(new AphrontFormTokenizerControl())
->setLabel(pht('User'))
->setDatasource(new PhabricatorPeopleDatasource())
->setName('userPHID')
->setLimit(1));
return $this->newDialog()
->setTitle(pht('Choose User'))
->appendForm($form)
->addCancelButton($cancel_uri)
->addSubmitButton(pht('Continue'));
}
// Load the account to invoice, or prompt the viewer to select one.
$target_account = null;
$account_phid = $request->getStr('accountPHID');
if ($account_phid) {
$target_account = id(new PhortuneAccountQuery())
->setViewer($viewer)
->withPHIDs(array($account_phid))
->withMemberPHIDs(array($target_user->getPHID()))
->executeOne();
}
if (!$target_account) {
$accounts = PhortuneAccountQuery::loadAccountsForUser(
$target_user,
PhabricatorContentSource::newFromRequest($request));
$form = id(new AphrontFormView())
->setUser($viewer)
->addHiddenInput('userPHID', $target_user->getPHID())
->appendRemarkupInstructions(pht('Choose which account to invoice.'))
->appendControl(
id(new AphrontFormMarkupControl())
->setLabel(pht('User'))
->setValue($viewer->renderHandle($target_user->getPHID())))
->appendControl(
id(new AphrontFormSelectControl())
->setLabel(pht('Account'))
->setName('accountPHID')
->setValue($account_phid)
->setOptions(mpull($accounts, 'getName', 'getPHID')));
return $this->newDialog()
->setTitle(pht('Choose Account'))
->appendForm($form)
->addCancelButton($cancel_uri)
->addSubmitButton(pht('Continue'));
}
// Now we build the actual invoice.
$title = pht('New Invoice');
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb($merchant->getName());
$v_title = $request->getStr('title');
$e_title = true;
$v_name = $request->getStr('name');
$e_name = true;
$v_cost = $request->getStr('cost');
$e_cost = true;
$v_desc = $request->getStr('description');
$v_quantity = 1;
$e_quantity = null;
$errors = array();
if ($request->isFormPost() && $request->getStr('invoice')) {
$v_quantity = $request->getStr('quantity');
$e_title = null;
$e_name = null;
$e_cost = null;
$e_quantity = null;
if (!strlen($v_title)) {
$e_title = pht('Required');
$errors[] = pht('You must title this invoice.');
}
if (!strlen($v_name)) {
$e_name = pht('Required');
$errors[] = pht('You must provide a name for this purchase.');
}
if (!strlen($v_cost)) {
$e_cost = pht('Required');
$errors[] = pht('You must provide a cost for this purchase.');
} else {
try {
$v_currency = PhortuneCurrency::newFromUserInput(
$viewer,
$v_cost);
} catch (Exception $ex) {
$errors[] = $ex->getMessage();
$e_cost = pht('Invalid');
}
}
if ((int)$v_quantity <= 0) {
$e_quantity = pht('Invalid');
$errors[] = pht('Quantity must be a positive integer.');
}
if (!$errors) {
$unique = Filesystem::readRandomCharacters(16);
$product = id(new PhortuneProductQuery())
->setViewer($target_user)
->withClassAndRef('PhortuneAdHocProduct', $unique)
->executeOne();
$cart_implementation = new PhortuneAdHocCart();
$cart = $target_account->newCart(
$target_user,
$cart_implementation,
$merchant);
$cart
->setMetadataValue('adhoc.title', $v_title)
->setMetadataValue('adhoc.description', $v_desc);
$purchase = $cart->newPurchase($target_user, $product)
->setBasePriceAsCurrency($v_currency)
->setQuantity((int)$v_quantity)
->setMetadataValue('adhoc.name', $v_name)
->save();
// TODO: Actually mark these as invoices. Right now, there's no easy
// way to do that.
$cart->activateCart();
$cart_id = $cart->getID();
$uri = "/merchant/{$merchant_id}/cart/{$cart_id}/?invoice=true";
$uri = $this->getApplicationURI($uri);
return id(new AphrontRedirectResponse())->setURI($uri);
}
}
$form = id(new AphrontFormView())
->setUser($viewer)
->addHiddenInput('userPHID', $target_user->getPHID())
->addHiddenInput('accountPHID', $target_account->getPHID())
->addHiddenInput('invoice', true)
->appendControl(
id(new AphrontFormMarkupControl())
->setLabel(pht('User'))
->setValue($viewer->renderHandle($target_user->getPHID())))
->appendControl(
id(new AphrontFormMarkupControl())
->setLabel(pht('Account'))
->setValue($viewer->renderHandle($target_account->getPHID())))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Invoice Title'))
->setName('title')
->setValue($v_title)
->setError($e_title))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Purchase Name'))
->setName('name')
->setValue($v_name)
->setError($e_name))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Purchase Cost'))
->setName('cost')
->setValue($v_cost)
->setError($e_cost))
->appendChild(
id(new AphrontFormTextControl())
->setLabel(pht('Quantity'))
->setName('quantity')
->setValue($v_quantity)
->setError($e_quantity))
->appendChild(
id(new AphrontFormTextAreaControl())
->setLabel(pht('Invoice Description'))
->setName('description')
->setValue($v_desc))
->appendChild(
id(new AphrontFormSubmitControl())
->addCancelButton($cancel_uri)
->setValue(pht('Send Invoice')));
$box = id(new PHUIObjectBoxView())
->setHeaderText(pht('New Invoice'))
->setFormErrors($errors)
->appendChild($form);
return $this->buildApplicationPage(
array(
$crumbs,
$box,
),
array(
'title' => $title,
));
}
}

View file

@ -192,6 +192,15 @@ final class PhortuneMerchantViewController
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
$view->addAction(
id(new PhabricatorActionView())
->setName(pht('New Invoice'))
->setIcon('fa-fax')
->setHref($this->getApplicationURI("merchant/{$id}/invoice/new/"))
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
return $view;
}

View file

@ -0,0 +1,42 @@
<?php
final class PhortuneAdHocProduct extends PhortuneProductImplementation {
private $ref;
public function loadImplementationsForRefs(
PhabricatorUser $viewer,
array $refs) {
$results = array();
foreach ($refs as $key => $ref) {
$product = new PhortuneAdHocProduct();
$product->ref = $ref;
$results[$key] = $product;
}
return $results;
}
public function getRef() {
return $this->ref;
}
public function getName(PhortuneProduct $product) {
return pht('Ad-Hoc Product');
}
public function getPurchaseName(
PhortuneProduct $product,
PhortunePurchase $purchase) {
return coalesce(
$purchase->getMetadataValue('adhoc.name'),
$this->getName($product));
}
public function getPriceAsCurrency(PhortuneProduct $product) {
return PhortuneCurrency::newEmptyCurrency();
}
}

View file

@ -453,6 +453,10 @@ final class PhortuneCart extends PhortuneDAO
return $this->getImplementation()->getCancelURI($this);
}
public function getDescription() {
return $this->getImplementation()->getDescription($this);
}
public function getDetailURI(PhortuneMerchant $authority = null) {
if ($authority) {
$prefix = 'merchant/'.$authority->getID().'/';

View file

@ -92,7 +92,14 @@ final class PhortunePurchase extends PhortuneDAO
}
public function getTotalPriceAsCurrency() {
return $this->getBasePriceAsCurrency();
$base = $this->getBasePriceAsCurrency();
$price = PhortuneCurrency::newEmptyCurrency();
for ($ii = 0; $ii < $this->getQuantity(); $ii++) {
$price = $price->add($base);
}
return $price;
}
public function getMetadataValue($key, $default = null) {