Correct a parameter order swap in "diffusion.historyquery" for Mercurial
[phabricator.git] / src / applications / phortune / provider / PhortunePayPalPaymentProvider.php
blob262606ca629095e3413d91b36460843fac3eb8e3
1 <?php
3 final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider {
5 const PAYPAL_API_USERNAME = 'paypal.api-username';
6 const PAYPAL_API_PASSWORD = 'paypal.api-password';
7 const PAYPAL_API_SIGNATURE = 'paypal.api-signature';
8 const PAYPAL_MODE = 'paypal.mode';
10 public function isAcceptingLivePayments() {
11 $mode = $this->getProviderConfig()->getMetadataValue(self::PAYPAL_MODE);
12 return ($mode === 'live');
15 public function getName() {
16 return pht('PayPal');
19 public function getConfigureName() {
20 return pht('Add PayPal Payments Account');
23 public function getConfigureDescription() {
24 return pht(
25 'Allows you to accept various payment instruments with a paypal.com '.
26 'account.');
29 public function getConfigureProvidesDescription() {
30 return pht('This merchant accepts payments via PayPal.');
33 public function getConfigureInstructions() {
34 return pht(
35 "To configure PayPal, register or log into an existing account on ".
36 "[[https://paypal.com | paypal.com]] (for live payments) or ".
37 "[[https://sandbox.paypal.com | sandbox.paypal.com]] (for test ".
38 "payments). Once logged in:\n\n".
39 " - Navigate to {nav Tools > API Access}.\n".
40 " - Choose **View API Signature**.\n".
41 " - Copy the **API Username**, **API Password** and **Signature** ".
42 " into the fields above.\n\n".
43 "You can select whether the provider operates in test mode or ".
44 "accepts live payments using the **Mode** dropdown above.\n\n".
45 "You can either use `sandbox.paypal.com` to retrieve live credentials, ".
46 "or `paypal.com` to retrieve live credentials.");
49 public function getAllConfigurableProperties() {
50 return array(
51 self::PAYPAL_API_USERNAME,
52 self::PAYPAL_API_PASSWORD,
53 self::PAYPAL_API_SIGNATURE,
54 self::PAYPAL_MODE,
58 public function getAllConfigurableSecretProperties() {
59 return array(
60 self::PAYPAL_API_PASSWORD,
61 self::PAYPAL_API_SIGNATURE,
65 public function processEditForm(
66 AphrontRequest $request,
67 array $values) {
69 $errors = array();
70 $issues = array();
72 if (!strlen($values[self::PAYPAL_API_USERNAME])) {
73 $errors[] = pht('PayPal API Username is required.');
74 $issues[self::PAYPAL_API_USERNAME] = pht('Required');
77 if (!strlen($values[self::PAYPAL_API_PASSWORD])) {
78 $errors[] = pht('PayPal API Password is required.');
79 $issues[self::PAYPAL_API_PASSWORD] = pht('Required');
82 if (!strlen($values[self::PAYPAL_API_SIGNATURE])) {
83 $errors[] = pht('PayPal API Signature is required.');
84 $issues[self::PAYPAL_API_SIGNATURE] = pht('Required');
87 if (!strlen($values[self::PAYPAL_MODE])) {
88 $errors[] = pht('Mode is required.');
89 $issues[self::PAYPAL_MODE] = pht('Required');
92 return array($errors, $issues, $values);
95 public function extendEditForm(
96 AphrontRequest $request,
97 AphrontFormView $form,
98 array $values,
99 array $issues) {
101 $form
102 ->appendChild(
103 id(new AphrontFormTextControl())
104 ->setName(self::PAYPAL_API_USERNAME)
105 ->setValue($values[self::PAYPAL_API_USERNAME])
106 ->setError(idx($issues, self::PAYPAL_API_USERNAME, true))
107 ->setLabel(pht('Paypal API Username')))
108 ->appendChild(
109 id(new AphrontFormTextControl())
110 ->setName(self::PAYPAL_API_PASSWORD)
111 ->setValue($values[self::PAYPAL_API_PASSWORD])
112 ->setError(idx($issues, self::PAYPAL_API_PASSWORD, true))
113 ->setLabel(pht('Paypal API Password')))
114 ->appendChild(
115 id(new AphrontFormTextControl())
116 ->setName(self::PAYPAL_API_SIGNATURE)
117 ->setValue($values[self::PAYPAL_API_SIGNATURE])
118 ->setError(idx($issues, self::PAYPAL_API_SIGNATURE, true))
119 ->setLabel(pht('Paypal API Signature')))
120 ->appendChild(
121 id(new AphrontFormSelectControl())
122 ->setName(self::PAYPAL_MODE)
123 ->setValue($values[self::PAYPAL_MODE])
124 ->setError(idx($issues, self::PAYPAL_MODE))
125 ->setLabel(pht('Mode'))
126 ->setOptions(
127 array(
128 'test' => pht('Test Mode'),
129 'live' => pht('Live Mode'),
130 )));
132 return;
135 public function canRunConfigurationTest() {
136 return true;
139 public function runConfigurationTest() {
140 $result = $this
141 ->newPaypalAPICall()
142 ->setRawPayPalQuery('GetBalance', array())
143 ->resolve();
146 public function getPaymentMethodDescription() {
147 return pht('Credit Card or PayPal Account');
150 public function getPaymentMethodIcon() {
151 return 'PayPal';
154 public function getPaymentMethodProviderDescription() {
155 return 'PayPal';
158 protected function executeCharge(
159 PhortunePaymentMethod $payment_method,
160 PhortuneCharge $charge) {
161 throw new Exception('!');
164 protected function executeRefund(
165 PhortuneCharge $charge,
166 PhortuneCharge $refund) {
168 $transaction_id = $charge->getMetadataValue('paypal.transactionID');
169 if (!$transaction_id) {
170 throw new Exception(pht('Charge has no transaction ID!'));
173 $refund_amount = $refund->getAmountAsCurrency()->negate();
174 $refund_currency = $refund_amount->getCurrency();
175 $refund_value = $refund_amount->formatBareValue();
177 $params = array(
178 'TRANSACTIONID' => $transaction_id,
179 'REFUNDTYPE' => 'Partial',
180 'AMT' => $refund_value,
181 'CURRENCYCODE' => $refund_currency,
184 $result = $this
185 ->newPaypalAPICall()
186 ->setRawPayPalQuery('RefundTransaction', $params)
187 ->resolve();
189 $charge->setMetadataValue(
190 'paypal.refundID',
191 $result['REFUNDTRANSACTIONID']);
194 public function updateCharge(PhortuneCharge $charge) {
195 $transaction_id = $charge->getMetadataValue('paypal.transactionID');
196 if (!$transaction_id) {
197 throw new Exception(pht('Charge has no transaction ID!'));
200 $params = array(
201 'TRANSACTIONID' => $transaction_id,
204 $result = $this
205 ->newPaypalAPICall()
206 ->setRawPayPalQuery('GetTransactionDetails', $params)
207 ->resolve();
209 $is_charge = false;
210 $is_fail = false;
211 switch ($result['PAYMENTSTATUS']) {
212 case 'Processed':
213 case 'Completed':
214 case 'Completed-Funds-Held':
215 $is_charge = true;
216 break;
217 case 'Partially-Refunded':
218 case 'Refunded':
219 case 'Reversed':
220 case 'Canceled-Reversal':
221 // TODO: Handle these.
222 return;
223 case 'In-Progress':
224 case 'Pending':
225 // TODO: Also handle these better?
226 return;
227 case 'Denied':
228 case 'Expired':
229 case 'Failed':
230 case 'None':
231 case 'Voided':
232 default:
233 $is_fail = true;
234 break;
237 if ($charge->getStatus() == PhortuneCharge::STATUS_HOLD) {
238 $cart = $charge->getCart();
240 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
241 if ($is_charge) {
242 $cart->didApplyCharge($charge);
243 } else if ($is_fail) {
244 $cart->didFailCharge($charge);
246 unset($unguarded);
250 private function getPaypalAPIUsername() {
251 return $this
252 ->getProviderConfig()
253 ->getMetadataValue(self::PAYPAL_API_USERNAME);
256 private function getPaypalAPIPassword() {
257 return $this
258 ->getProviderConfig()
259 ->getMetadataValue(self::PAYPAL_API_PASSWORD);
262 private function getPaypalAPISignature() {
263 return $this
264 ->getProviderConfig()
265 ->getMetadataValue(self::PAYPAL_API_SIGNATURE);
268 /* -( One-Time Payments )-------------------------------------------------- */
270 public function canProcessOneTimePayments() {
271 return true;
274 /* -( Controllers )-------------------------------------------------------- */
277 public function canRespondToControllerAction($action) {
278 switch ($action) {
279 case 'checkout':
280 case 'charge':
281 case 'cancel':
282 return true;
284 return parent::canRespondToControllerAction();
287 public function processControllerRequest(
288 PhortuneProviderActionController $controller,
289 AphrontRequest $request) {
291 $viewer = $request->getUser();
293 $cart = $controller->loadCart($request->getInt('cartID'));
294 if (!$cart) {
295 return new Aphront404Response();
298 $charge = $controller->loadActiveCharge($cart);
299 switch ($controller->getAction()) {
300 case 'checkout':
301 if ($charge) {
302 throw new Exception(pht('Cart is already charging!'));
304 break;
305 case 'charge':
306 case 'cancel':
307 if (!$charge) {
308 throw new Exception(pht('Cart is not charging yet!'));
310 break;
313 switch ($controller->getAction()) {
314 case 'checkout':
315 $return_uri = $this->getControllerURI(
316 'charge',
317 array(
318 'cartID' => $cart->getID(),
321 $cancel_uri = $this->getControllerURI(
322 'cancel',
323 array(
324 'cartID' => $cart->getID(),
327 $price = $cart->getTotalPriceAsCurrency();
329 $charge = $cart->willApplyCharge($viewer, $this);
331 $params = array(
332 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(),
333 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(),
334 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale',
335 'PAYMENTREQUEST_0_CUSTOM' => $charge->getPHID(),
336 'PAYMENTREQUEST_0_DESC' => $cart->getName(),
338 'RETURNURL' => $return_uri,
339 'CANCELURL' => $cancel_uri,
341 // TODO: This should be cart-dependent if we eventually support
342 // physical goods.
343 'NOSHIPPING' => '1',
346 $result = $this
347 ->newPaypalAPICall()
348 ->setRawPayPalQuery('SetExpressCheckout', $params)
349 ->resolve();
351 $params = array(
352 'cmd' => '_express-checkout',
353 'token' => $result['TOKEN'],
356 $uri = new PhutilURI(
357 'https://www.sandbox.paypal.com/cgi-bin/webscr',
358 $params);
360 $cart->setMetadataValue('provider.checkoutURI', (string)$uri);
361 $cart->save();
363 $charge->setMetadataValue('paypal.token', $result['TOKEN']);
364 $charge->save();
366 return id(new AphrontRedirectResponse())
367 ->setIsExternal(true)
368 ->setURI($uri);
369 case 'charge':
370 if ($cart->getStatus() !== PhortuneCart::STATUS_PURCHASING) {
371 return id(new AphrontRedirectResponse())
372 ->setURI($cart->getCheckoutURI());
375 $token = $request->getStr('token');
377 $params = array(
378 'TOKEN' => $token,
381 $result = $this
382 ->newPaypalAPICall()
383 ->setRawPayPalQuery('GetExpressCheckoutDetails', $params)
384 ->resolve();
386 if ($result['CUSTOM'] !== $charge->getPHID()) {
387 throw new Exception(
388 pht('Paypal checkout does not match Phortune charge!'));
391 if ($result['CHECKOUTSTATUS'] !== 'PaymentActionNotInitiated') {
392 return $controller->newDialog()
393 ->setTitle(pht('Payment Already Processed'))
394 ->appendParagraph(
395 pht(
396 'The payment response for this charge attempt has already '.
397 'been processed.'))
398 ->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
401 $price = $cart->getTotalPriceAsCurrency();
403 $params = array(
404 'TOKEN' => $token,
405 'PAYERID' => $result['PAYERID'],
407 'PAYMENTREQUEST_0_AMT' => $price->formatBareValue(),
408 'PAYMENTREQUEST_0_CURRENCYCODE' => $price->getCurrency(),
409 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale',
412 $result = $this
413 ->newPaypalAPICall()
414 ->setRawPayPalQuery('DoExpressCheckoutPayment', $params)
415 ->resolve();
417 $transaction_id = $result['PAYMENTINFO_0_TRANSACTIONID'];
419 $success = false;
420 $hold = false;
421 switch ($result['PAYMENTINFO_0_PAYMENTSTATUS']) {
422 case 'Processed':
423 case 'Completed':
424 case 'Completed-Funds-Held':
425 $success = true;
426 break;
427 case 'In-Progress':
428 case 'Pending':
429 // TODO: We can capture more information about this stuff.
430 $hold = true;
431 break;
432 case 'Denied':
433 case 'Expired':
434 case 'Failed':
435 case 'Partially-Refunded':
436 case 'Canceled-Reversal':
437 case 'None':
438 case 'Refunded':
439 case 'Reversed':
440 case 'Voided':
441 default:
442 // These are all failure states.
443 break;
446 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
448 $charge->setMetadataValue('paypal.transactionID', $transaction_id);
449 $charge->save();
451 if ($success) {
452 $cart->didApplyCharge($charge);
453 $response = id(new AphrontRedirectResponse())->setURI(
454 $cart->getCheckoutURI());
455 } else if ($hold) {
456 $cart->didHoldCharge($charge);
458 $response = $controller
459 ->newDialog()
460 ->setTitle(pht('Charge On Hold'))
461 ->appendParagraph(
462 pht('Your charge is on hold, for reasons?'))
463 ->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
464 } else {
465 $cart->didFailCharge($charge);
467 $response = $controller
468 ->newDialog()
469 ->setTitle(pht('Charge Failed'))
470 ->addCancelButton($cart->getCheckoutURI(), pht('Continue'));
472 unset($unguarded);
474 return $response;
475 case 'cancel':
476 if ($cart->getStatus() === PhortuneCart::STATUS_PURCHASING) {
477 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
478 // TODO: Since the user cancelled this, we could conceivably just
479 // throw it away or make it more clear that it's a user cancel.
480 $cart->didFailCharge($charge);
481 unset($unguarded);
484 return id(new AphrontRedirectResponse())
485 ->setURI($cart->getCheckoutURI());
488 throw new Exception(
489 pht('Unsupported action "%s".', $controller->getAction()));
492 private function newPaypalAPICall() {
493 if ($this->isAcceptingLivePayments()) {
494 $host = 'https://api-3t.paypal.com/nvp';
495 } else {
496 $host = 'https://api-3t.sandbox.paypal.com/nvp';
499 return id(new PhutilPayPalAPIFuture())
500 ->setHost($host)
501 ->setAPIUsername($this->getPaypalAPIUsername())
502 ->setAPIPassword($this->getPaypalAPIPassword())
503 ->setAPISignature($this->getPaypalAPISignature());