@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+436 -18
+6
src/__phutil_library_map__.php
··· 2817 2817 'PhortuneAccountTransaction' => 'applications/phortune/storage/PhortuneAccountTransaction.php', 2818 2818 'PhortuneAccountTransactionQuery' => 'applications/phortune/query/PhortuneAccountTransactionQuery.php', 2819 2819 'PhortuneAccountViewController' => 'applications/phortune/controller/PhortuneAccountViewController.php', 2820 + 'PhortuneAdHocCart' => 'applications/phortune/cart/PhortuneAdHocCart.php', 2821 + 'PhortuneAdHocProduct' => 'applications/phortune/product/PhortuneAdHocProduct.php', 2820 2822 'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php', 2821 2823 'PhortuneCartAcceptController' => 'applications/phortune/controller/PhortuneCartAcceptController.php', 2822 2824 'PhortuneCartCancelController' => 'applications/phortune/controller/PhortuneCartCancelController.php', ··· 2856 2858 'PhortuneMerchantEditController' => 'applications/phortune/controller/PhortuneMerchantEditController.php', 2857 2859 'PhortuneMerchantEditor' => 'applications/phortune/editor/PhortuneMerchantEditor.php', 2858 2860 'PhortuneMerchantHasMemberEdgeType' => 'applications/phortune/edge/PhortuneMerchantHasMemberEdgeType.php', 2861 + 'PhortuneMerchantInvoiceCreateController' => 'applications/phortune/controller/PhortuneMerchantInvoiceCreateController.php', 2859 2862 'PhortuneMerchantListController' => 'applications/phortune/controller/PhortuneMerchantListController.php', 2860 2863 'PhortuneMerchantPHIDType' => 'applications/phortune/phid/PhortuneMerchantPHIDType.php', 2861 2864 'PhortuneMerchantQuery' => 'applications/phortune/query/PhortuneMerchantQuery.php', ··· 6265 6268 'PhortuneAccountTransaction' => 'PhabricatorApplicationTransaction', 6266 6269 'PhortuneAccountTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 6267 6270 'PhortuneAccountViewController' => 'PhortuneController', 6271 + 'PhortuneAdHocCart' => 'PhortuneCartImplementation', 6272 + 'PhortuneAdHocProduct' => 'PhortuneProductImplementation', 6268 6273 'PhortuneCart' => array( 6269 6274 'PhortuneDAO', 6270 6275 'PhabricatorApplicationTransactionInterface', ··· 6312 6317 'PhortuneMerchantEditController' => 'PhortuneMerchantController', 6313 6318 'PhortuneMerchantEditor' => 'PhabricatorApplicationTransactionEditor', 6314 6319 'PhortuneMerchantHasMemberEdgeType' => 'PhabricatorEdgeType', 6320 + 'PhortuneMerchantInvoiceCreateController' => 'PhortuneMerchantController', 6315 6321 'PhortuneMerchantListController' => 'PhortuneMerchantController', 6316 6322 'PhortuneMerchantPHIDType' => 'PhabricatorPHIDType', 6317 6323 'PhortuneMerchantQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
+18 -13
src/applications/phortune/application/PhabricatorPhortuneApplication.php
··· 84 84 'edit/(?:(?P<id>\d+)/)?' => 'PhortuneMerchantEditController', 85 85 'orders/(?P<merchantID>\d+)/(?:query/(?P<queryKey>[^/]+)/)?' 86 86 => 'PhortuneCartListController', 87 - '(?P<merchantID>\d+)/cart/(?P<id>\d+)/' => array( 88 - '' => 'PhortuneCartViewController', 89 - '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController', 90 - 'update/' => 'PhortuneCartUpdateController', 91 - 'accept/' => 'PhortuneCartAcceptController', 92 - ), 93 - '(?P<merchantID>\d+)/subscription/' => array( 94 - '(?:query/(?P<queryKey>[^/]+)/)?' 95 - => 'PhortuneSubscriptionListController', 96 - 'view/(?P<id>\d+)/' 97 - => 'PhortuneSubscriptionViewController', 98 - 'order/(?P<subscriptionID>\d+)/' 99 - => 'PhortuneCartListController', 87 + '(?P<merchantID>\d+)/' => array( 88 + 'cart/(?P<id>\d+)/' => array( 89 + '' => 'PhortuneCartViewController', 90 + '(?P<action>cancel|refund)/' => 'PhortuneCartCancelController', 91 + 'update/' => 'PhortuneCartUpdateController', 92 + 'accept/' => 'PhortuneCartAcceptController', 93 + ), 94 + 'subscription/' => array( 95 + '(?:query/(?P<queryKey>[^/]+)/)?' 96 + => 'PhortuneSubscriptionListController', 97 + 'view/(?P<id>\d+)/' 98 + => 'PhortuneSubscriptionViewController', 99 + 'order/(?P<subscriptionID>\d+)/' 100 + => 'PhortuneCartListController', 101 + ), 102 + 'invoice/' => array( 103 + 'new/' => 'PhortuneMerchantInvoiceCreateController', 104 + ), 100 105 ), 101 106 '(?P<id>\d+)/' => 'PhortuneMerchantViewController', 102 107 ),
+39
src/applications/phortune/cart/PhortuneAdHocCart.php
··· 1 + <?php 2 + 3 + final class PhortuneAdHocCart extends PhortuneCartImplementation { 4 + 5 + public function loadImplementationsForCarts( 6 + PhabricatorUser $viewer, 7 + array $carts) { 8 + 9 + $results = array(); 10 + foreach ($carts as $key => $cart) { 11 + $results[$key] = new PhortuneAdHocCart(); 12 + } 13 + 14 + return $results; 15 + } 16 + 17 + public function getName(PhortuneCart $cart) { 18 + return $cart->getMetadataValue('adhoc.title'); 19 + } 20 + 21 + public function getDescription(PhortuneCart $cart) { 22 + return $cart->getMetadataValue('adhoc.description'); 23 + } 24 + 25 + public function getCancelURI(PhortuneCart $cart) { 26 + return null; 27 + } 28 + 29 + public function getDoneURI(PhortuneCart $cart) { 30 + return null; 31 + } 32 + 33 + public function willCreateCart( 34 + PhabricatorUser $viewer, 35 + PhortuneCart $cart) { 36 + return; 37 + } 38 + 39 + }
+4
src/applications/phortune/cart/PhortuneCartImplementation.php
··· 16 16 abstract public function getCancelURI(PhortuneCart $cart); 17 17 abstract public function getDoneURI(PhortuneCart $cart); 18 18 19 + public function getDescription(PhortuneCart $cart) { 20 + return null; 21 + } 22 + 19 23 public function getDoneActionName(PhortuneCart $cart) { 20 24 return pht('Return to Application'); 21 25 }
+3
src/applications/phortune/controller/PhortuneCartCheckoutController.php
··· 209 209 ->appendChild($form) 210 210 ->appendChild($provider_form); 211 211 212 + $description_box = $this->renderCartDescription($cart); 213 + 212 214 $crumbs = $this->buildApplicationCrumbs(); 213 215 $crumbs->addTextCrumb(pht('Checkout')); 214 216 $crumbs->addTextCrumb($title); ··· 217 219 array( 218 220 $crumbs, 219 221 $cart_box, 222 + $description_box, 220 223 $payment_box, 221 224 ), 222 225 array(
+22
src/applications/phortune/controller/PhortuneCartController.php
··· 42 42 return $table; 43 43 } 44 44 45 + protected function renderCartDescription(PhortuneCart $cart) { 46 + $description = $cart->getDescription(); 47 + if (!strlen($description)) { 48 + return null; 49 + } 50 + 51 + $output = PhabricatorMarkupEngine::renderOneObject( 52 + id(new PhabricatorMarkupOneOff()) 53 + ->setPreserveLinebreaks(true) 54 + ->setContent($description), 55 + 'default', 56 + $this->getViewer()); 57 + 58 + $box = id(new PHUIBoxView()) 59 + ->addMargin(PHUI::MARGIN_LARGE) 60 + ->appendChild($output); 61 + 62 + return id(new PHUIObjectBoxView()) 63 + ->setHeaderText(pht('Description')) 64 + ->appendChild($box); 65 + } 66 + 45 67 }
+36 -4
src/applications/phortune/controller/PhortuneCartViewController.php
··· 15 15 16 16 $authority = $this->loadMerchantAuthority(); 17 17 18 - $cart = id(new PhortuneCartQuery()) 18 + $query = id(new PhortuneCartQuery()) 19 19 ->setViewer($viewer) 20 20 ->withIDs(array($this->id)) 21 - ->needPurchases(true) 22 - ->executeOne(); 21 + ->needPurchases(true); 22 + 23 + if ($authority) { 24 + $query->withMerchantPHIDs(array($authority->getPHID())); 25 + } 26 + 27 + $cart = $query->executeOne(); 23 28 if (!$cart) { 24 29 return new Aphront404Response(); 25 30 } ··· 35 40 $error_view = null; 36 41 $resume_uri = null; 37 42 switch ($cart->getStatus()) { 43 + case PhortuneCart::STATUS_READY: 44 + if ($authority && $request->getStr('invoice')) { 45 + // We arrived here by following the ad-hoc invoice workflow, and 46 + // are acting with merchant authority. 47 + 48 + $checkout_uri = PhabricatorEnv::getURI($cart->getCheckoutURI()); 49 + 50 + $invoice_message = array( 51 + pht( 52 + 'Manual invoices do not automatically notify recipients yet. '. 53 + 'Send the payer this checkout link:'), 54 + ' ', 55 + phutil_tag( 56 + 'a', 57 + array( 58 + 'href' => $checkout_uri, 59 + ), 60 + $checkout_uri), 61 + ); 62 + 63 + $error_view = id(new PHUIInfoView()) 64 + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) 65 + ->setErrors(array($invoice_message)); 66 + } 67 + break; 38 68 case PhortuneCart::STATUS_PURCHASING: 39 69 if ($can_edit) { 40 70 $resume_uri = $cart->getMetadataValue('provider.checkoutURI'); ··· 86 116 $error_view = id(new PHUIInfoView()) 87 117 ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) 88 118 ->appendChild(pht('This purchase has been completed.')); 89 - 90 119 break; 91 120 } 92 121 ··· 126 155 $cart_box->setInfoView($error_view); 127 156 } 128 157 158 + $description = $this->renderCartDescription($cart); 159 + 129 160 $charges = id(new PhortuneChargeQuery()) 130 161 ->setViewer($viewer) 131 162 ->withCartPHIDs(array($cart->getPHID())) ··· 171 202 array( 172 203 $crumbs, 173 204 $cart_box, 205 + $description, 174 206 $charges, 175 207 $timeline, 176 208 ),
+245
src/applications/phortune/controller/PhortuneMerchantInvoiceCreateController.php
··· 1 + <?php 2 + 3 + final class PhortuneMerchantInvoiceCreateController 4 + extends PhortuneMerchantController { 5 + 6 + public function handleRequest(AphrontRequest $request) { 7 + $viewer = $request->getUser(); 8 + 9 + $merchant = $this->loadMerchantAuthority(); 10 + if (!$merchant) { 11 + return new Aphront404Response(); 12 + } 13 + 14 + $merchant_id = $merchant->getID(); 15 + $cancel_uri = $this->getApplicationURI("/merchant/{$merchant_id}/"); 16 + 17 + // Load the user to invoice, or prompt the viewer to select one. 18 + $target_user = null; 19 + $user_phid = head($request->getArr('userPHID')); 20 + if (!$user_phid) { 21 + $user_phid = $request->getStr('userPHID'); 22 + } 23 + if ($user_phid) { 24 + $target_user = id(new PhabricatorPeopleQuery()) 25 + ->setViewer($viewer) 26 + ->withPHIDs(array($user_phid)) 27 + ->executeOne(); 28 + } 29 + 30 + if (!$target_user) { 31 + $form = id(new AphrontFormView()) 32 + ->setUser($viewer) 33 + ->appendRemarkupInstructions(pht('Choose a user to invoice.')) 34 + ->appendControl( 35 + id(new AphrontFormTokenizerControl()) 36 + ->setLabel(pht('User')) 37 + ->setDatasource(new PhabricatorPeopleDatasource()) 38 + ->setName('userPHID') 39 + ->setLimit(1)); 40 + 41 + return $this->newDialog() 42 + ->setTitle(pht('Choose User')) 43 + ->appendForm($form) 44 + ->addCancelButton($cancel_uri) 45 + ->addSubmitButton(pht('Continue')); 46 + } 47 + 48 + // Load the account to invoice, or prompt the viewer to select one. 49 + $target_account = null; 50 + $account_phid = $request->getStr('accountPHID'); 51 + if ($account_phid) { 52 + $target_account = id(new PhortuneAccountQuery()) 53 + ->setViewer($viewer) 54 + ->withPHIDs(array($account_phid)) 55 + ->withMemberPHIDs(array($target_user->getPHID())) 56 + ->executeOne(); 57 + } 58 + 59 + if (!$target_account) { 60 + $accounts = PhortuneAccountQuery::loadAccountsForUser( 61 + $target_user, 62 + PhabricatorContentSource::newFromRequest($request)); 63 + 64 + $form = id(new AphrontFormView()) 65 + ->setUser($viewer) 66 + ->addHiddenInput('userPHID', $target_user->getPHID()) 67 + ->appendRemarkupInstructions(pht('Choose which account to invoice.')) 68 + ->appendControl( 69 + id(new AphrontFormMarkupControl()) 70 + ->setLabel(pht('User')) 71 + ->setValue($viewer->renderHandle($target_user->getPHID()))) 72 + ->appendControl( 73 + id(new AphrontFormSelectControl()) 74 + ->setLabel(pht('Account')) 75 + ->setName('accountPHID') 76 + ->setValue($account_phid) 77 + ->setOptions(mpull($accounts, 'getName', 'getPHID'))); 78 + 79 + return $this->newDialog() 80 + ->setTitle(pht('Choose Account')) 81 + ->appendForm($form) 82 + ->addCancelButton($cancel_uri) 83 + ->addSubmitButton(pht('Continue')); 84 + } 85 + 86 + 87 + // Now we build the actual invoice. 88 + $title = pht('New Invoice'); 89 + 90 + $crumbs = $this->buildApplicationCrumbs(); 91 + $crumbs->addTextCrumb($merchant->getName()); 92 + 93 + $v_title = $request->getStr('title'); 94 + $e_title = true; 95 + 96 + $v_name = $request->getStr('name'); 97 + $e_name = true; 98 + 99 + $v_cost = $request->getStr('cost'); 100 + $e_cost = true; 101 + 102 + $v_desc = $request->getStr('description'); 103 + 104 + $v_quantity = 1; 105 + $e_quantity = null; 106 + 107 + $errors = array(); 108 + if ($request->isFormPost() && $request->getStr('invoice')) { 109 + $v_quantity = $request->getStr('quantity'); 110 + 111 + $e_title = null; 112 + $e_name = null; 113 + $e_cost = null; 114 + $e_quantity = null; 115 + 116 + if (!strlen($v_title)) { 117 + $e_title = pht('Required'); 118 + $errors[] = pht('You must title this invoice.'); 119 + } 120 + 121 + if (!strlen($v_name)) { 122 + $e_name = pht('Required'); 123 + $errors[] = pht('You must provide a name for this purchase.'); 124 + } 125 + 126 + if (!strlen($v_cost)) { 127 + $e_cost = pht('Required'); 128 + $errors[] = pht('You must provide a cost for this purchase.'); 129 + } else { 130 + try { 131 + $v_currency = PhortuneCurrency::newFromUserInput( 132 + $viewer, 133 + $v_cost); 134 + } catch (Exception $ex) { 135 + $errors[] = $ex->getMessage(); 136 + $e_cost = pht('Invalid'); 137 + } 138 + } 139 + 140 + if ((int)$v_quantity <= 0) { 141 + $e_quantity = pht('Invalid'); 142 + $errors[] = pht('Quantity must be a positive integer.'); 143 + } 144 + 145 + if (!$errors) { 146 + $unique = Filesystem::readRandomCharacters(16); 147 + 148 + $product = id(new PhortuneProductQuery()) 149 + ->setViewer($target_user) 150 + ->withClassAndRef('PhortuneAdHocProduct', $unique) 151 + ->executeOne(); 152 + 153 + $cart_implementation = new PhortuneAdHocCart(); 154 + 155 + $cart = $target_account->newCart( 156 + $target_user, 157 + $cart_implementation, 158 + $merchant); 159 + 160 + $cart 161 + ->setMetadataValue('adhoc.title', $v_title) 162 + ->setMetadataValue('adhoc.description', $v_desc); 163 + 164 + $purchase = $cart->newPurchase($target_user, $product) 165 + ->setBasePriceAsCurrency($v_currency) 166 + ->setQuantity((int)$v_quantity) 167 + ->setMetadataValue('adhoc.name', $v_name) 168 + ->save(); 169 + 170 + // TODO: Actually mark these as invoices. Right now, there's no easy 171 + // way to do that. 172 + 173 + $cart->activateCart(); 174 + $cart_id = $cart->getID(); 175 + 176 + $uri = "/merchant/{$merchant_id}/cart/{$cart_id}/?invoice=true"; 177 + $uri = $this->getApplicationURI($uri); 178 + 179 + return id(new AphrontRedirectResponse())->setURI($uri); 180 + } 181 + } 182 + 183 + $form = id(new AphrontFormView()) 184 + ->setUser($viewer) 185 + ->addHiddenInput('userPHID', $target_user->getPHID()) 186 + ->addHiddenInput('accountPHID', $target_account->getPHID()) 187 + ->addHiddenInput('invoice', true) 188 + ->appendControl( 189 + id(new AphrontFormMarkupControl()) 190 + ->setLabel(pht('User')) 191 + ->setValue($viewer->renderHandle($target_user->getPHID()))) 192 + ->appendControl( 193 + id(new AphrontFormMarkupControl()) 194 + ->setLabel(pht('Account')) 195 + ->setValue($viewer->renderHandle($target_account->getPHID()))) 196 + ->appendChild( 197 + id(new AphrontFormTextControl()) 198 + ->setLabel(pht('Invoice Title')) 199 + ->setName('title') 200 + ->setValue($v_title) 201 + ->setError($e_title)) 202 + ->appendChild( 203 + id(new AphrontFormTextControl()) 204 + ->setLabel(pht('Purchase Name')) 205 + ->setName('name') 206 + ->setValue($v_name) 207 + ->setError($e_name)) 208 + ->appendChild( 209 + id(new AphrontFormTextControl()) 210 + ->setLabel(pht('Purchase Cost')) 211 + ->setName('cost') 212 + ->setValue($v_cost) 213 + ->setError($e_cost)) 214 + ->appendChild( 215 + id(new AphrontFormTextControl()) 216 + ->setLabel(pht('Quantity')) 217 + ->setName('quantity') 218 + ->setValue($v_quantity) 219 + ->setError($e_quantity)) 220 + ->appendChild( 221 + id(new AphrontFormTextAreaControl()) 222 + ->setLabel(pht('Invoice Description')) 223 + ->setName('description') 224 + ->setValue($v_desc)) 225 + ->appendChild( 226 + id(new AphrontFormSubmitControl()) 227 + ->addCancelButton($cancel_uri) 228 + ->setValue(pht('Send Invoice'))); 229 + 230 + $box = id(new PHUIObjectBoxView()) 231 + ->setHeaderText(pht('New Invoice')) 232 + ->setFormErrors($errors) 233 + ->appendChild($form); 234 + 235 + return $this->buildApplicationPage( 236 + array( 237 + $crumbs, 238 + $box, 239 + ), 240 + array( 241 + 'title' => $title, 242 + )); 243 + } 244 + 245 + }
+9
src/applications/phortune/controller/PhortuneMerchantViewController.php
··· 192 192 ->setDisabled(!$can_edit) 193 193 ->setWorkflow(!$can_edit)); 194 194 195 + 196 + $view->addAction( 197 + id(new PhabricatorActionView()) 198 + ->setName(pht('New Invoice')) 199 + ->setIcon('fa-fax') 200 + ->setHref($this->getApplicationURI("merchant/{$id}/invoice/new/")) 201 + ->setDisabled(!$can_edit) 202 + ->setWorkflow(!$can_edit)); 203 + 195 204 return $view; 196 205 } 197 206
+42
src/applications/phortune/product/PhortuneAdHocProduct.php
··· 1 + <?php 2 + 3 + final class PhortuneAdHocProduct extends PhortuneProductImplementation { 4 + 5 + private $ref; 6 + 7 + public function loadImplementationsForRefs( 8 + PhabricatorUser $viewer, 9 + array $refs) { 10 + 11 + $results = array(); 12 + foreach ($refs as $key => $ref) { 13 + $product = new PhortuneAdHocProduct(); 14 + $product->ref = $ref; 15 + $results[$key] = $product; 16 + } 17 + 18 + return $results; 19 + } 20 + 21 + public function getRef() { 22 + return $this->ref; 23 + } 24 + 25 + public function getName(PhortuneProduct $product) { 26 + return pht('Ad-Hoc Product'); 27 + } 28 + 29 + public function getPurchaseName( 30 + PhortuneProduct $product, 31 + PhortunePurchase $purchase) { 32 + 33 + return coalesce( 34 + $purchase->getMetadataValue('adhoc.name'), 35 + $this->getName($product)); 36 + } 37 + 38 + public function getPriceAsCurrency(PhortuneProduct $product) { 39 + return PhortuneCurrency::newEmptyCurrency(); 40 + } 41 + 42 + }
+4
src/applications/phortune/storage/PhortuneCart.php
··· 453 453 return $this->getImplementation()->getCancelURI($this); 454 454 } 455 455 456 + public function getDescription() { 457 + return $this->getImplementation()->getDescription($this); 458 + } 459 + 456 460 public function getDetailURI(PhortuneMerchant $authority = null) { 457 461 if ($authority) { 458 462 $prefix = 'merchant/'.$authority->getID().'/';
+8 -1
src/applications/phortune/storage/PhortunePurchase.php
··· 92 92 } 93 93 94 94 public function getTotalPriceAsCurrency() { 95 - return $this->getBasePriceAsCurrency(); 95 + $base = $this->getBasePriceAsCurrency(); 96 + 97 + $price = PhortuneCurrency::newEmptyCurrency(); 98 + for ($ii = 0; $ii < $this->getQuantity(); $ii++) { 99 + $price = $price->add($base); 100 + } 101 + 102 + return $price; 96 103 } 97 104 98 105 public function getMetadataValue($key, $default = null) {