diff --git a/src/applications/fund/phortune/FundBackerCart.php b/src/applications/fund/phortune/FundBackerCart.php index 239b50baa7..3dc25d4bbf 100644 --- a/src/applications/fund/phortune/FundBackerCart.php +++ b/src/applications/fund/phortune/FundBackerCart.php @@ -1,84 +1,88 @@ 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 getName(PhortuneCart $cart) { return pht('Fund Initiative'); } public function willCreateCart( PhabricatorUser $viewer, PhortuneCart $cart) { $initiative = $this->getInitiative(); if (!$initiative) { throw new Exception( pht('Call setInitiative() before building a cart!')); } $cart->setMetadataValue('initiativePHID', $initiative->getPHID()); } public function loadImplementationsForCarts( PhabricatorUser $viewer, array $carts) { $phids = array(); foreach ($carts as $cart) { $phids[] = $cart->getMetadataValue('initiativePHID'); } $initiatives = id(new FundInitiativeQuery()) ->setViewer($viewer) ->withPHIDs($phids) ->execute(); $initiatives = mpull($initiatives, null, 'getPHID'); $objects = array(); foreach ($carts as $key => $cart) { $initiative_phid = $cart->getMetadataValue('initiativePHID'); $object = id(new FundBackerCart()) ->setInitiativePHID($initiative_phid); $initiative = idx($initiatives, $initiative_phid); if ($initiative) { $object->setInitiative($initiative); } $objects[$key] = $object; } return $objects; } public function getCancelURI(PhortuneCart $cart) { return '/'.$this->getInitiative()->getMonogram(); } public function getDoneURI(PhortuneCart $cart) { return '/'.$this->getInitiative()->getMonogram(); } + public function getDoneActionName(PhortuneCart $cart) { + return pht('Return to Initiative'); + } + } diff --git a/src/applications/phortune/cart/PhortuneCartImplementation.php b/src/applications/phortune/cart/PhortuneCartImplementation.php index cf2cf28e09..09c0187f49 100644 --- a/src/applications/phortune/cart/PhortuneCartImplementation.php +++ b/src/applications/phortune/cart/PhortuneCartImplementation.php @@ -1,38 +1,42 @@ getStatus()) { case PhortuneCart::STATUS_PURCHASED: throw new Exception( pht( 'This order can not be cancelled because it has already been '. 'completed.')); break; } } public function assertCanRefundOrder(PhortuneCart $cart) { return; } abstract public function willCreateCart( PhabricatorUser $viewer, PhortuneCart $cart); } diff --git a/src/applications/phortune/controller/PhortuneCartCheckoutController.php b/src/applications/phortune/controller/PhortuneCartCheckoutController.php index d5cede309b..8d02ff3759 100644 --- a/src/applications/phortune/controller/PhortuneCartCheckoutController.php +++ b/src/applications/phortune/controller/PhortuneCartCheckoutController.php @@ -1,226 +1,226 @@ id = $data['id']; } 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(); } $cancel_uri = $cart->getCancelURI(); $merchant = $cart->getMerchant(); 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: case PhortuneCart::STATUS_PURCHASING: case PhortuneCart::STATUS_HOLD: case PhortuneCart::STATUS_PURCHASED: // For these states, kick the user to the order page to give them // information and options. 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().'/'); $methods = id(new PhortunePaymentMethodQuery()) ->setViewer($viewer) ->withAccountPHIDs(array($account->getPHID())) ->withMerchantPHIDs(array($merchant->getPHID())) ->withStatuses(array(PhortunePaymentMethod::STATUS_ACTIVE)) ->execute(); $e_method = null; $errors = array(); if ($request->isFormPost()) { // Require CAN_EDIT on the cart to actually make purchases. PhabricatorPolicyFilter::requireCapability( $viewer, $cart, PhabricatorPolicyCapability::CAN_EDIT); $method_id = $request->getInt('paymentMethodID'); $method = idx($methods, $method_id); if (!$method) { $e_method = pht('Required'); $errors[] = pht('You must choose a payment method.'); } if (!$errors) { $provider = $method->buildPaymentProvider(); $charge = $cart->willApplyCharge($viewer, $provider, $method); try { $provider->applyCharge($method, $charge); } catch (Exception $ex) { $cart->didFailCharge($charge); return $this->newDialog() ->setTitle(pht('Charge Failed')) ->appendParagraph( pht( 'Unable to make payment: %s', $ex->getMessage())) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } $cart->didApplyCharge($charge); - $done_uri = $cart->getDoneURI(); + $done_uri = $cart->getCheckoutURI(); return id(new AphrontRedirectResponse())->setURI($done_uri); } } $cart_table = $this->buildCartContentTable($cart); $cart_box = id(new PHUIObjectBoxView()) ->setFormErrors($errors) ->setHeaderText(pht('Cart Contents')) ->appendChild($cart_table); $title = pht('Buy Stuff'); if (!$methods) { $method_control = id(new AphrontFormStaticControl()) ->setLabel(pht('Payment Method')) ->setValue( phutil_tag('em', array(), pht('No payment methods configured.'))); } else { $method_control = id(new AphrontFormRadioButtonControl()) ->setLabel(pht('Payment Method')) ->setName('paymentMethodID') ->setValue($request->getInt('paymentMethodID')); foreach ($methods as $method) { $method_control->addButton( $method->getID(), $method->getFullDisplayName(), $method->getDescription()); } } $method_control->setError($e_method); $account_id = $account->getID(); $payment_method_uri = $this->getApplicationURI("{$account_id}/card/new/"); $payment_method_uri = new PhutilURI($payment_method_uri); $payment_method_uri->setQueryParams( array( 'merchantID' => $merchant->getID(), 'cartID' => $cart->getID(), )); $form = id(new AphrontFormView()) ->setUser($viewer) ->appendChild($method_control); $add_providers = $this->loadCreatePaymentMethodProvidersForMerchant( $merchant); if ($add_providers) { $new_method = javelin_tag( 'a', array( 'class' => 'button grey', 'href' => $payment_method_uri, 'sigil' => 'workflow', ), pht('Add New Payment Method')); $form->appendChild( id(new AphrontFormMarkupControl()) ->setValue($new_method)); } if ($methods || $add_providers) { $submit = id(new AphrontFormSubmitControl()) ->setValue(pht('Submit Payment')) ->setDisabled(!$methods); if ($cart->getCancelURI() !== null) { $submit->addCancelButton($cart->getCancelURI()); } $form->appendChild($submit); } $provider_form = null; $pay_providers = $this->loadOneTimePaymentProvidersForMerchant($merchant); if ($pay_providers) { $one_time_options = array(); foreach ($pay_providers as $provider) { $one_time_options[] = $provider->renderOneTimePaymentButton( $account, $cart, $viewer); } $one_time_options = phutil_tag( 'div', array( 'class' => 'phortune-payment-onetime-list', ), $one_time_options); $provider_form = new PHUIFormLayoutView(); $provider_form->appendChild( id(new AphrontFormMarkupControl()) ->setLabel('Pay With') ->setValue($one_time_options)); } $payment_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Choose Payment Method')) ->appendChild($form) ->appendChild($provider_form); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb($title); return $this->buildApplicationPage( array( $crumbs, $cart_box, $payment_box, ), array( 'title' => $title, )); } } diff --git a/src/applications/phortune/controller/PhortuneCartViewController.php b/src/applications/phortune/controller/PhortuneCartViewController.php index b909a996f5..017ec45fa2 100644 --- a/src/applications/phortune/controller/PhortuneCartViewController.php +++ b/src/applications/phortune/controller/PhortuneCartViewController.php @@ -1,210 +1,235 @@ id = $data['id']; } 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(); } $can_admin = PhabricatorPolicyFilter::hasCapability( $viewer, $cart->getMerchant(), PhabricatorPolicyCapability::CAN_EDIT); $cart_table = $this->buildCartContentTable($cart); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $cart, PhabricatorPolicyCapability::CAN_EDIT); $errors = array(); + $error_view = null; $resume_uri = null; switch ($cart->getStatus()) { case PhortuneCart::STATUS_PURCHASING: if ($can_edit) { $resume_uri = $cart->getMetadataValue('provider.checkoutURI'); if ($resume_uri) { $errors[] = pht( 'The checkout process has been started, but not yet completed. '. 'You can continue checking out by clicking %s, or cancel the '. 'order, or contact the merchant for assistance.', phutil_tag('strong', array(), pht('Continue Checkout'))); } else { $errors[] = pht( 'The checkout process has been started, but an error occurred. '. 'You can cancel the order or contact the merchant for '. 'assistance.'); } } break; case PhortuneCart::STATUS_CHARGED: if ($can_edit) { $errors[] = pht( 'You have been charged, but processing could not be completed. '. 'You can cancel your order, or contact the merchant for '. 'assistance.'); } break; case PhortuneCart::STATUS_HOLD: if ($can_edit) { $errors[] = pht( 'Payment for this order is on hold. You can click %s to check '. 'for updates, cancel the order, or contact the merchant for '. 'assistance.', phutil_tag('strong', array(), pht('Update Status'))); } break; + case PhortuneCart::STATUS_PURCHASED: + $error_view = id(new AphrontErrorView()) + ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) + ->appendChild(pht('This purchase has been completed.')); + + break; } $properties = $this->buildPropertyListView($cart); $actions = $this->buildActionListView( $cart, $can_edit, $can_admin, $resume_uri); $properties->setActionList($actions); $header = id(new PHUIHeaderView()) ->setUser($viewer) ->setHeader(pht('Order Detail')); + if ($cart->getStatus() == PhortuneCart::STATUS_PURCHASED) { + $done_uri = $cart->getDoneURI(); + if ($done_uri) { + $header->addActionLink( + id(new PHUIButtonView()) + ->setTag('a') + ->setHref($done_uri) + ->setIcon(id(new PHUIIconView()) + ->setIconFont('fa-check-square green')) + ->setText($cart->getDoneActionName())); + } + } + $cart_box = id(new PHUIObjectBoxView()) ->setHeader($header) - ->setFormErrors($errors) ->appendChild($properties) ->appendChild($cart_table); + if ($errors) { + $cart_box->setFormErrors($errors); + } else if ($error_view) { + $cart_box->setErrorView($error_view); + } + $charges = id(new PhortuneChargeQuery()) ->setViewer($viewer) ->withCartPHIDs(array($cart->getPHID())) ->needCarts(true) ->execute(); $charges_table = $this->buildChargesTable($charges, false); $account = $cart->getAccount(); $crumbs = $this->buildApplicationCrumbs(); $this->addAccountCrumb($crumbs, $cart->getAccount()); $crumbs->addTextCrumb(pht('Cart %d', $cart->getID())); return $this->buildApplicationPage( array( $crumbs, $cart_box, $charges_table, ), array( 'title' => pht('Cart'), )); } private function buildPropertyListView(PhortuneCart $cart) { $viewer = $this->getRequest()->getUser(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($cart); $handles = $this->loadViewerHandles( array( $cart->getAccountPHID(), $cart->getAuthorPHID(), $cart->getMerchantPHID(), )); $view->addProperty( pht('Order Name'), $cart->getName()); $view->addProperty( pht('Account'), $handles[$cart->getAccountPHID()]->renderLink()); $view->addProperty( pht('Authorized By'), $handles[$cart->getAuthorPHID()]->renderLink()); $view->addProperty( pht('Merchant'), $handles[$cart->getMerchantPHID()]->renderLink()); $view->addProperty( pht('Status'), PhortuneCart::getNameForStatus($cart->getStatus())); $view->addProperty( pht('Updated'), phabricator_datetime($cart->getDateModified(), $viewer)); return $view; } private function buildActionListView( PhortuneCart $cart, $can_edit, $can_admin, $resume_uri) { $viewer = $this->getRequest()->getUser(); $id = $cart->getID(); $view = id(new PhabricatorActionListView()) ->setUser($viewer) ->setObject($cart); $can_cancel = ($can_edit && $cart->canCancelOrder()); $cancel_uri = $this->getApplicationURI("cart/{$id}/cancel/"); $refund_uri = $this->getApplicationURI("cart/{$id}/refund/"); $update_uri = $this->getApplicationURI("cart/{$id}/update/"); $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Cancel Order')) ->setIcon('fa-times') ->setDisabled(!$can_cancel) ->setWorkflow(true) ->setHref($cancel_uri)); if ($can_admin) { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Refund Order')) ->setIcon('fa-reply') ->setWorkflow(true) ->setHref($refund_uri)); } $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Update Status')) ->setIcon('fa-refresh') ->setHref($update_uri)); if ($can_edit && $resume_uri) { $view->addAction( id(new PhabricatorActionView()) ->setName(pht('Continue Checkout')) ->setIcon('fa-shopping-cart') ->setHref($resume_uri)); } return $view; } } diff --git a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php index 258d8875f2..bc19602901 100644 --- a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php @@ -1,506 +1,506 @@ getProviderConfig()->getMetadataValue(self::PAYPAL_MODE); return ($mode === 'live'); } public function getName() { return pht('PayPal'); } public function getConfigureName() { return pht('Add PayPal Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept various payment instruments with a paypal.com '. 'account.'); } public function getConfigureProvidesDescription() { return pht( 'This merchant accepts payments via PayPal.'); } public function getConfigureInstructions() { return pht( "To configure PayPal, register or log into an existing account on ". "[[https://paypal.com | paypal.com]] (for live payments) or ". "[[https://sandbox.paypal.com | sandbox.paypal.com]] (for test ". "payments). Once logged in:\n\n". " - Navigate to {nav Tools > API Access}.\n". " - Choose **View API Signature**.\n". " - Copy the **API Username**, **API Password** and **Signature** ". " into the fields above.\n\n". "You can select whether the provider operates in test mode or ". "accepts live payments using the **Mode** dropdown above.\n\n". "You can either use `sandbox.paypal.com` to retrieve live credentials, ". "or `paypal.com` to retrieve live credentials."); } public function getAllConfigurableProperties() { return array( self::PAYPAL_API_USERNAME, self::PAYPAL_API_PASSWORD, self::PAYPAL_API_SIGNATURE, self::PAYPAL_MODE, ); } public function getAllConfigurableSecretProperties() { return array( self::PAYPAL_API_PASSWORD, self::PAYPAL_API_SIGNATURE, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::PAYPAL_API_USERNAME])) { $errors[] = pht('PayPal API Username is required.'); $issues[self::PAYPAL_API_USERNAME] = pht('Required'); } if (!strlen($values[self::PAYPAL_API_PASSWORD])) { $errors[] = pht('PayPal API Password is required.'); $issues[self::PAYPAL_API_PASSWORD] = pht('Required'); } if (!strlen($values[self::PAYPAL_API_SIGNATURE])) { $errors[] = pht('PayPal API Signature is required.'); $issues[self::PAYPAL_API_SIGNATURE] = pht('Required'); } if (!strlen($values[self::PAYPAL_MODE])) { $errors[] = pht('Mode is required.'); $issues[self::PAYPAL_MODE] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_USERNAME) ->setValue($values[self::PAYPAL_API_USERNAME]) ->setError(idx($issues, self::PAYPAL_API_USERNAME, true)) ->setLabel(pht('Paypal API Username'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_PASSWORD) ->setValue($values[self::PAYPAL_API_PASSWORD]) ->setError(idx($issues, self::PAYPAL_API_PASSWORD, true)) ->setLabel(pht('Paypal API Password'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::PAYPAL_API_SIGNATURE) ->setValue($values[self::PAYPAL_API_SIGNATURE]) ->setError(idx($issues, self::PAYPAL_API_SIGNATURE, true)) ->setLabel(pht('Paypal API Signature'))) ->appendChild( id(new AphrontFormSelectControl()) ->setName(self::PAYPAL_MODE) ->setValue($values[self::PAYPAL_MODE]) ->setError(idx($issues, self::PAYPAL_MODE)) ->setLabel(pht('Mode')) ->setOptions( array( 'test' => pht('Test Mode'), 'live' => pht('Live Mode'), ))); return; } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('GetBalance', array()) ->resolve(); } public function getPaymentMethodDescription() { return pht('Credit Card or PayPal Account'); } public function getPaymentMethodIcon() { return 'PayPal'; } public function getPaymentMethodProviderDescription() { return 'PayPal'; } protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { throw new Exception('!'); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { $transaction_id = $charge->getMetadataValue('paypal.transactionID'); if (!$transaction_id) { throw new Exception(pht('Charge has no transaction ID!')); } $refund_amount = $refund->getAmountAsCurrency()->negate(); $refund_currency = $refund_amount->getCurrency(); $refund_value = $refund_amount->formatBareValue(); $params = array( 'TRANSACTIONID' => $transaction_id, 'REFUNDTYPE' => 'Partial', 'AMT' => $refund_value, 'CURRENCYCODE' => $refund_currency, ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('RefundTransaction', $params) ->resolve(); $charge->setMetadataValue( 'paypal.refundID', $result['REFUNDTRANSACTIONID']); } public function updateCharge(PhortuneCharge $charge) { $transaction_id = $charge->getMetadataValue('paypal.transactionID'); if (!$transaction_id) { throw new Exception(pht('Charge has no transaction ID!')); } $params = array( 'TRANSACTIONID' => $transaction_id, ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('GetTransactionDetails', $params) ->resolve(); $is_charge = false; $is_fail = false; switch ($result['PAYMENTSTATUS']) { case 'Processed': case 'Completed': case 'Completed-Funds-Held': $is_charge = true; break; case 'Partially-Refunded': case 'Refunded': case 'Reversed': case 'Canceled-Reversal': // TODO: Handle these. return; case 'In-Progress': case 'Pending': // TODO: Also handle these better? return; case 'Denied': case 'Expired': case 'Failed': case 'None': case 'Voided': default: $is_fail = true; break; } if ($charge->getStatus() == PhortuneCharge::STATUS_HOLD) { $cart = $charge->getCart(); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($is_charge) { $cart->didApplyCharge($charge); } else if ($is_fail) { $cart->didFailCharge($charge); } unset($unguarded); } } private function getPaypalAPIUsername() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_USERNAME); } private function getPaypalAPIPassword() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_PASSWORD); } private function getPaypalAPISignature() { return $this ->getProviderConfig() ->getMetadataValue(self::PAYPAL_API_SIGNATURE); } /* -( One-Time Payments )-------------------------------------------------- */ public function canProcessOneTimePayments() { return true; } /* -( Controllers )-------------------------------------------------------- */ public function canRespondToControllerAction($action) { switch ($action) { case 'checkout': case 'charge': case 'cancel': return true; } return parent::canRespondToControllerAction(); } public function processControllerRequest( PhortuneProviderActionController $controller, AphrontRequest $request) { $viewer = $request->getUser(); $cart = $controller->loadCart($request->getInt('cartID')); if (!$cart) { return new Aphront404Response(); } $charge = $controller->loadActiveCharge($cart); switch ($controller->getAction()) { case 'checkout': if ($charge) { throw new Exception(pht('Cart is already charging!')); } break; case 'charge': case 'cancel': if (!$charge) { throw new Exception(pht('Cart is not charging yet!')); } break; } switch ($controller->getAction()) { case 'checkout': $return_uri = $this->getControllerURI( 'charge', array( 'cartID' => $cart->getID(), )); $cancel_uri = $this->getControllerURI( 'cancel', array( 'cartID' => $cart->getID(), )); $price = $cart->getTotalPriceAsCurrency(); $charge = $cart->willApplyCharge($viewer, $this); $params = array( 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(), 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'PAYMENTREQUEST_0_CUSTOM' => $charge->getPHID(), 'PAYMENTREQUEST_0_DESC' => $cart->getName(), 'RETURNURL' => $return_uri, 'CANCELURL' => $cancel_uri, // TODO: This should be cart-dependent if we eventually support // physical goods. 'NOSHIPPING' => '1', ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('SetExpressCheckout', $params) ->resolve(); $uri = new PhutilURI('https://www.sandbox.paypal.com/cgi-bin/webscr'); $uri->setQueryParams( array( 'cmd' => '_express-checkout', 'token' => $result['TOKEN'], )); $cart->setMetadataValue('provider.checkoutURI', (string)$uri); $cart->save(); $charge->setMetadataValue('paypal.token', $result['TOKEN']); $charge->save(); return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($uri); case 'charge': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } $token = $request->getStr('token'); $params = array( 'TOKEN' => $token, ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('GetExpressCheckoutDetails', $params) ->resolve(); if ($result['CUSTOM'] !== $charge->getPHID()) { throw new Exception( pht('Paypal checkout does not match Phortune charge!')); } if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') { return $controller->newDialog() ->setTitle(pht('Payment Already Processed')) ->appendParagraph( pht( 'The payment response for this charge attempt has already '. 'been processed.')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } $price = $cart->getTotalPriceAsCurrency(); $params = array( 'TOKEN' => $token, 'PAYERID' => $result['PAYERID'], 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(), 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(), 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', ); $result = $this ->newPaypalAPICall() ->setRawPayPalQuery('DoExpressCheckoutPayment', $params) ->resolve(); $transaction_id = $result['PAYMENTINFO_0_TRANSACTIONID']; $success = false; $hold = false; switch ($result['PAYMENTINFO_0_PAYMENTSTATUS']) { case 'Processed': case 'Completed': case 'Completed-Funds-Held': $success = true; break; case 'In-Progress': case 'Pending': // TODO: We can capture more information about this stuff. $hold = true; break; case 'Denied': case 'Expired': case 'Failed': case 'Partially-Refunded': case 'Canceled-Reversal': case 'None': case 'Refunded': case 'Reversed': case 'Voided': default: // These are all failure states. break; } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $charge->setMetadataValue('paypal.transactionID', $transaction_id); $charge->save(); if ($success) { $cart->didApplyCharge($charge); $response = id(new AphrontRedirectResponse())->setURI( - $cart->getDoneURI()); + $cart->getCheckoutURI()); } else if ($hold) { $cart->didHoldCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge On Hold')) ->appendParagraph( pht('Your charge is on hold, for reasons?')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } else { $cart->didFailCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge Failed')) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); } unset($unguarded); return $response; case 'cancel': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); // TODO: Since the user cancelled this, we could conceivably just // throw it away or make it more clear that it's a user cancel. $cart->didFailCharge($charge); unset($unguarded); } return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } throw new Exception( pht('Unsupported action "%s".', $controller->getAction())); } private function newPaypalAPICall() { if ($this->isAcceptingLivePayments()) { $host = 'https://api-3t.paypal.com/nvp'; } else { $host = 'https://api-3t.sandbox.paypal.com/nvp'; } return id(new PhutilPayPalAPIFuture()) ->setHost($host) ->setAPIUsername($this->getPaypalAPIUsername()) ->setAPIPassword($this->getPaypalAPIPassword()) ->setAPISignature($this->getPaypalAPISignature()); } } diff --git a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php index 0bc54fe193..b520443d8c 100644 --- a/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneWePayPaymentProvider.php @@ -1,405 +1,405 @@ getWePayAccessToken()); } public function getName() { return pht('WePay'); } public function getConfigureName() { return pht('Add WePay Payments Account'); } public function getConfigureDescription() { return pht( 'Allows you to accept credit or debit card payments with a '. 'wepay.com account.'); } public function getConfigureProvidesDescription() { return pht('This merchant accepts credit and debit cards via WePay.'); } public function getConfigureInstructions() { return pht( "To configure WePay, register or log in to an existing account on ". "[[https://wepay.com | wepay.com]] (for live payments) or ". "[[https://stage.wepay.com | stage.wepay.com]] (for testing). ". "Once logged in:\n\n". " - Create an API application if you don't already have one.\n". " - Click the API application name to go to the detail page.\n". " - Copy **Client ID**, **Client Secret**, **Access Token** and ". " **AccountID** from that page to the fields above.\n\n". "You can either use `stage.wepay.com` to retrieve test credentials, ". "or `wepay.com` to retrieve live credentials for accepting live ". "payments."); } public function canRunConfigurationTest() { return true; } public function runConfigurationTest() { $this->loadWePayAPILibraries(); WePay::useStaging( $this->getWePayClientID(), $this->getWePayClientSecret()); $wepay = new WePay($this->getWePayAccessToken()); $params = array( 'client_id' => $this->getWePayClientID(), 'client_secret' => $this->getWePayClientSecret(), ); $wepay->request('app', $params); } public function getAllConfigurableProperties() { return array( self::WEPAY_CLIENT_ID, self::WEPAY_CLIENT_SECRET, self::WEPAY_ACCESS_TOKEN, self::WEPAY_ACCOUNT_ID, ); } public function getAllConfigurableSecretProperties() { return array( self::WEPAY_CLIENT_SECRET, ); } public function processEditForm( AphrontRequest $request, array $values) { $errors = array(); $issues = array(); if (!strlen($values[self::WEPAY_CLIENT_ID])) { $errors[] = pht('WePay Client ID is required.'); $issues[self::WEPAY_CLIENT_ID] = pht('Required'); } if (!strlen($values[self::WEPAY_CLIENT_SECRET])) { $errors[] = pht('WePay Client Secret is required.'); $issues[self::WEPAY_CLIENT_SECRET] = pht('Required'); } if (!strlen($values[self::WEPAY_ACCESS_TOKEN])) { $errors[] = pht('WePay Access Token is required.'); $issues[self::WEPAY_ACCESS_TOKEN] = pht('Required'); } if (!strlen($values[self::WEPAY_ACCOUNT_ID])) { $errors[] = pht('WePay Account ID is required.'); $issues[self::WEPAY_ACCOUNT_ID] = pht('Required'); } return array($errors, $issues, $values); } public function extendEditForm( AphrontRequest $request, AphrontFormView $form, array $values, array $issues) { $form ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_CLIENT_ID) ->setValue($values[self::WEPAY_CLIENT_ID]) ->setError(idx($issues, self::WEPAY_CLIENT_ID, true)) ->setLabel(pht('WePay Client ID'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_CLIENT_SECRET) ->setValue($values[self::WEPAY_CLIENT_SECRET]) ->setError(idx($issues, self::WEPAY_CLIENT_SECRET, true)) ->setLabel(pht('WePay Client Secret'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_ACCESS_TOKEN) ->setValue($values[self::WEPAY_ACCESS_TOKEN]) ->setError(idx($issues, self::WEPAY_ACCESS_TOKEN, true)) ->setLabel(pht('WePay Access Token'))) ->appendChild( id(new AphrontFormTextControl()) ->setName(self::WEPAY_ACCOUNT_ID) ->setValue($values[self::WEPAY_ACCOUNT_ID]) ->setError(idx($issues, self::WEPAY_ACCOUNT_ID, true)) ->setLabel(pht('WePay Account ID'))); } public function getPaymentMethodDescription() { return pht('Credit or Debit Card'); } public function getPaymentMethodIcon() { return 'WePay'; } public function getPaymentMethodProviderDescription() { return 'WePay'; } protected function executeCharge( PhortunePaymentMethod $payment_method, PhortuneCharge $charge) { throw new Exception('!'); } private function getWePayClientID() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_CLIENT_ID); } private function getWePayClientSecret() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_CLIENT_SECRET); } private function getWePayAccessToken() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_ACCESS_TOKEN); } private function getWePayAccountID() { return $this ->getProviderConfig() ->getMetadataValue(self::WEPAY_ACCOUNT_ID); } protected function executeRefund( PhortuneCharge $charge, PhortuneCharge $refund) { $wepay = $this->loadWePayAPILibraries(); $checkout_id = $this->getWePayCheckoutID($charge); $params = array( 'checkout_id' => $checkout_id, 'refund_reason' => pht('Refund'), 'amount' => $refund->getAmountAsCurrency()->negate()->formatBareValue(), ); $wepay->request('checkout/refund', $params); } public function updateCharge(PhortuneCharge $charge) { $wepay = $this->loadWePayAPILibraries(); $params = array( 'checkout_id' => $this->getWePayCheckoutID($charge), ); $wepay_checkout = $wepay->request('checkout', $params); // TODO: Deal with disputes / chargebacks / surprising refunds. } /* -( One-Time Payments )-------------------------------------------------- */ public function canProcessOneTimePayments() { return true; } /* -( Controllers )-------------------------------------------------------- */ public function canRespondToControllerAction($action) { switch ($action) { case 'checkout': case 'charge': case 'cancel': return true; } return parent::canRespondToControllerAction(); } /** * @phutil-external-symbol class WePay */ public function processControllerRequest( PhortuneProviderActionController $controller, AphrontRequest $request) { $wepay = $this->loadWePayAPILibraries(); $viewer = $request->getUser(); $cart = $controller->loadCart($request->getInt('cartID')); if (!$cart) { return new Aphront404Response(); } $charge = $controller->loadActiveCharge($cart); switch ($controller->getAction()) { case 'checkout': if ($charge) { throw new Exception(pht('Cart is already charging!')); } break; case 'charge': case 'cancel': if (!$charge) { throw new Exception(pht('Cart is not charging yet!')); } break; } switch ($controller->getAction()) { case 'checkout': $return_uri = $this->getControllerURI( 'charge', array( 'cartID' => $cart->getID(), )); $cancel_uri = $this->getControllerURI( 'cancel', array( 'cartID' => $cart->getID(), )); $price = $cart->getTotalPriceAsCurrency(); $params = array( 'account_id' => $this->getWePayAccountID(), 'short_description' => $cart->getName(), 'type' => 'SERVICE', 'amount' => $price->formatBareValue(), 'long_description' => $cart->getName(), 'reference_id' => $cart->getPHID(), 'app_fee' => 0, 'fee_payer' => 'Payee', 'redirect_uri' => $return_uri, 'fallback_uri' => $cancel_uri, // NOTE: If we don't `auto_capture`, we might get a result back in // either an "authorized" or a "reserved" state. We can't capture // an "authorized" result, so just autocapture. 'auto_capture' => true, 'require_shipping' => 0, 'shipping_fee' => 0, 'charge_tax' => 0, 'mode' => 'regular', // TODO: We could accept bank accounts but the hold/capture rules // are not quite clear. Just accept credit cards for now. 'funding_sources' => 'cc', ); $charge = $cart->willApplyCharge($viewer, $this); $result = $wepay->request('checkout/create', $params); $cart->setMetadataValue('provider.checkoutURI', $result->checkout_uri); $cart->save(); $charge->setMetadataValue('wepay.checkoutID', $result->checkout_id); $charge->save(); $uri = new PhutilURI($result->checkout_uri); return id(new AphrontRedirectResponse()) ->setIsExternal(true) ->setURI($uri); case 'charge': if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) { return id(new AphrontRedirectResponse()) ->setURI($cart->getCheckoutURI()); } $checkout_id = $request->getInt('checkout_id'); $params = array( 'checkout_id' => $checkout_id, ); $checkout = $wepay->request('checkout', $params); if ($checkout->reference_id != $cart->getPHID()) { throw new Exception( pht('Checkout reference ID does not match cart PHID!')); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); switch ($checkout->state) { case 'authorized': case 'reserved': case 'captured': // TODO: Are these all really "done" states, and not "hold" // states? Cards and bank accounts both come back as "authorized" // on the staging environment. Figure out what happens in // production? $cart->didApplyCharge($charge); $response = id(new AphrontRedirectResponse())->setURI( - $cart->getDoneURI()); + $cart->getCheckoutURI()); break; default: // It's not clear if we can ever get here on the web workflow, // WePay doesn't seem to return back to us after a failure (the // workflow dead-ends instead). $cart->didFailCharge($charge); $response = $controller ->newDialog() ->setTitle(pht('Charge Failed')) ->appendParagraph( pht( 'Unable to make payment (checkout state is "%s").', $checkout->state)) ->addCancelButton($cart->getCheckoutURI(), pht('Continue')); break; } unset($unguarded); return $response; case 'cancel': // TODO: I don't know how it's possible to cancel out of a WePay // charge workflow. throw new Exception( pht('How did you get here? WePay has no cancel flow in its UI...?')); break; } throw new Exception( pht('Unsupported action "%s".', $controller->getAction())); } private function loadWePayAPILibraries() { $root = dirname(phutil_get_library_root('phabricator')); require_once $root.'/externals/wepay/wepay.php'; WePay::useStaging( $this->getWePayClientID(), $this->getWePayClientSecret()); return new WePay($this->getWePayAccessToken()); } private function getWePayCheckoutID(PhortuneCharge $charge) { $checkout_id = $charge->getMetadataValue('wepay.checkoutID'); if ($checkout_id === null) { throw new Exception(pht('No WePay Checkout ID present on charge!')); } return $checkout_id; } } diff --git a/src/applications/phortune/storage/PhortuneCart.php b/src/applications/phortune/storage/PhortuneCart.php index 4b0225c68c..76aae18b62 100644 --- a/src/applications/phortune/storage/PhortuneCart.php +++ b/src/applications/phortune/storage/PhortuneCart.php @@ -1,525 +1,529 @@ 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_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(); foreach ($this->purchases as $purchase) { $purchase->getProduct()->didPurchaseProduct($purchase); } $this->setStatus(self::STATUS_PURCHASED)->save(); 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(); foreach ($this->purchases as $purchase) { $purchase->getProduct()->didRefundProduct($purchase); } 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/view/__tests__/PhabricatorUnitsTestCase.php b/src/view/__tests__/PhabricatorUnitsTestCase.php index 4671532a3b..bd0f535008 100644 --- a/src/view/__tests__/PhabricatorUnitsTestCase.php +++ b/src/view/__tests__/PhabricatorUnitsTestCase.php @@ -1,130 +1,130 @@ '1 B', - 1000 => '1 KB', - 1000000 => '1 MB', - 10000000 => '10 MB', - 100000000 => '100 MB', - 1000000000 => '1 GB', - 999 => '999 B', + 1 => '1 B', + 1024 => '1 KB', + 1024 * 1024 => '1 MB', + 10 * 1024 * 1024 => '10 MB', + 100 * 1024 * 1024 => '100 MB', + 1024 * 1024 * 1024 => '1 GB', + 999 => '999 B', ); foreach ($tests as $input => $expect) { $this->assertEqual( $expect, phutil_format_bytes($input), 'phutil_format_bytes('.$input.')'); } } public function testByteParsing() { $tests = array( '1' => 1, - '1k' => 1000, - '1K' => 1000, - '1kB' => 1000, - '1Kb' => 1000, - '1KB' => 1000, - '1MB' => 1000000, - '1GB' => 1000000000, - '1.5M' => 1500000, + '1k' => 1024, + '1K' => 1024, + '1kB' => 1024, + '1Kb' => 1024, + '1KB' => 1024, + '1MB' => 1024 * 1024, + '1GB' => 1024 * 1024 * 1024, + '1.5M' => (int)(1024 * 1024 * 1.5), '1 000' => 1000, - '1,234.56 KB' => 1234560, + '1,234.56 KB' => (int)(1024 * 1234.56), ); foreach ($tests as $input => $expect) { $this->assertEqual( $expect, phutil_parse_bytes($input), 'phutil_parse_bytes('.$input.')'); } $this->tryTestCases( array('string' => 'string'), array(false), 'phutil_parse_bytes'); } public function testDetailedDurationFormatting() { $expected_zero = 'now'; $tests = array ( 12095939 => '19 w, 6 d', -12095939 => '19 w, 6 d ago', 3380521 => '5 w, 4 d', -3380521 => '5 w, 4 d ago', 0 => $expected_zero, ); foreach ($tests as $duration => $expect) { $this->assertEqual( $expect, phutil_format_relative_time_detailed($duration), 'phutil_format_relative_time_detailed('.$duration.')'); } $tests = array( 3380521 => array( -1 => '5 w', 0 => '5 w', 1 => '5 w', 2 => '5 w, 4 d', 3 => '5 w, 4 d, 3 h', 4 => '5 w, 4 d, 3 h, 2 m', 5 => '5 w, 4 d, 3 h, 2 m, 1 s', 6 => '5 w, 4 d, 3 h, 2 m, 1 s', ), -3380521 => array( -1 => '5 w ago', 0 => '5 w ago', 1 => '5 w ago', 2 => '5 w, 4 d ago', 3 => '5 w, 4 d, 3 h ago', 4 => '5 w, 4 d, 3 h, 2 m ago', 5 => '5 w, 4 d, 3 h, 2 m, 1 s ago', 6 => '5 w, 4 d, 3 h, 2 m, 1 s ago', ), 0 => array( -1 => $expected_zero, 0 => $expected_zero, 1 => $expected_zero, 2 => $expected_zero, 3 => $expected_zero, 4 => $expected_zero, 5 => $expected_zero, 6 => $expected_zero, ), ); foreach ($tests as $duration => $sub_tests) { if (is_array($sub_tests)) { foreach ($sub_tests as $levels => $expect) { $this->assertEqual( $expect, phutil_format_relative_time_detailed($duration, $levels), 'phutil_format_relative_time_detailed('.$duration.', '.$levels.')'); } } else { $expect = $sub_tests; $this->assertEqual( $expect, phutil_format_relative_time_detailed($duration), 'phutil_format_relative_time_detailed('.$duration.')'); } } } } diff --git a/src/view/phui/PHUIObjectBoxView.php b/src/view/phui/PHUIObjectBoxView.php index fdc7a35869..da7372d203 100644 --- a/src/view/phui/PHUIObjectBoxView.php +++ b/src/view/phui/PHUIObjectBoxView.php @@ -1,285 +1,285 @@ sigils[] = $sigil; return $this; } public function setMetadata(array $metadata) { $this->metadata = $metadata; return $this; } public function addPropertyList( PHUIPropertyListView $property_list, $tab = null) { if (!($tab instanceof PHUIListItemView) && ($tab !== null)) { assert_stringlike($tab); $tab = id(new PHUIListItemView())->setName($tab); } if ($tab) { if ($tab->getKey()) { $key = $tab->getKey(); } else { $key = 'tab.default.'.spl_object_hash($tab); $tab->setKey($key); } } else { $key = 'tab.default'; } if ($tab) { if (empty($this->tabs[$key])) { $tab->addSigil('phui-object-box-tab'); $tab->setMetadata( array( 'tabKey' => $key, )); if (!$tab->getHref()) { $tab->setHref('#'); } if (!$tab->getType()) { $tab->setType(PHUIListItemView::TYPE_LINK); } $this->tabs[$key] = $tab; } } $this->propertyLists[$key][] = $property_list; return $this; } public function setHeaderText($text) { $this->headerText = $text; return $this; } public function setHeaderColor($color) { $this->headerColor = $color; return $this; } public function setFormErrors(array $errors, $title = null) { - if (nonempty($errors)) { + if ($errors) { $this->formErrors = id(new AphrontErrorView()) ->setTitle($title) ->setErrors($errors); } return $this; } public function setFormSaved($saved, $text = null) { if (!$text) { $text = pht('Changes saved.'); } if ($saved) { $save = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) ->appendChild($text); $this->formSaved = $save; } return $this; } public function setErrorView(AphrontErrorView $view) { $this->errorView = $view; return $this; } public function setForm($form) { $this->form = $form; return $this; } public function setID($id) { $this->id = $id; return $this; } public function setHeader($header) { $this->header = $header; return $this; } public function setFlush($flush) { $this->flush = $flush; return $this; } public function setValidationException( PhabricatorApplicationTransactionValidationException $ex = null) { $this->validationException = $ex; return $this; } public function render() { require_celerity_resource('phui-object-box-css'); if ($this->headerColor) { $header_color = $this->headerColor; } else { $header_color = PHUIActionHeaderView::HEADER_LIGHTBLUE; } if ($this->header) { $header = $this->header; $header->setHeaderColor($header_color); } else { $header = id(new PHUIHeaderView()) ->setHeader($this->headerText) ->setHeaderColor($header_color); } $ex = $this->validationException; $exception_errors = null; if ($ex) { $messages = array(); foreach ($ex->getErrors() as $error) { $messages[] = $error->getMessage(); } if ($messages) { $exception_errors = id(new AphrontErrorView()) ->setErrors($messages); } } $tab_lists = array(); $property_lists = array(); $tab_map = array(); $default_key = 'tab.default'; // Find the selected tab, or select the first tab if none are selected. if ($this->tabs) { $selected_tab = null; foreach ($this->tabs as $key => $tab) { if ($tab->getSelected()) { $selected_tab = $key; break; } } if ($selected_tab === null) { head($this->tabs)->setSelected(true); $selected_tab = head_key($this->tabs); } } foreach ($this->propertyLists as $key => $list) { $group = new PHUIPropertyGroupView(); $i = 0; foreach ($list as $item) { $group->addPropertyList($item); if ($i > 0) { $item->addClass('phui-property-list-section-noninitial'); } $i++; } if ($this->tabs && $key != $default_key) { $tab_id = celerity_generate_unique_node_id(); $tab_map[$key] = $tab_id; if ($key === $selected_tab) { $style = null; } else { $style = 'display: none'; } $tab_lists[] = phutil_tag( 'div', array( 'style' => $style, 'id' => $tab_id, ), $group); } else { if ($this->tabs) { $group->addClass('phui-property-group-noninitial'); } $property_lists[] = $group; } } $tabs = null; if ($this->tabs) { $tabs = id(new PHUIListView()) ->setType(PHUIListView::NAVBAR_LIST); foreach ($this->tabs as $tab) { $tabs->addMenuItem($tab); } Javelin::initBehavior('phui-object-box-tabs'); } $content = id(new PHUIBoxView()) ->appendChild( array( $header, $this->errorView, $this->formErrors, $this->formSaved, $exception_errors, $this->form, $tabs, $tab_lists, $property_lists, $this->renderChildren(), )) ->setBorder(true) ->setID($this->id) ->addMargin(PHUI::MARGIN_LARGE_TOP) ->addMargin(PHUI::MARGIN_LARGE_LEFT) ->addMargin(PHUI::MARGIN_LARGE_RIGHT) ->addClass('phui-object-box'); if ($this->tabs) { $content->addSigil('phui-object-box'); $content->setMetadata( array( 'tabMap' => $tab_map, )); } if ($this->flush) { $content->addClass('phui-object-box-flush'); } $content->addClass('phui-object-box-'.$header_color); foreach ($this->sigils as $sigil) { $content->addSigil($sigil); } if ($this->metadata !== null) { $content->setMetadata($this->metadata); } return $content; } }