Remove all "FileHasObject" edge reads and writes
[phabricator.git] / src / applications / phortune / storage / PhortuneCart.php
blob91171037c6d98aa64c773b3b02f5cde26bbcc843
1 <?php
3 final class PhortuneCart extends PhortuneDAO
4 implements
5 PhabricatorApplicationTransactionInterface,
6 PhabricatorPolicyInterface,
7 PhabricatorExtendedPolicyInterface {
9 const STATUS_BUILDING = 'cart:building';
10 const STATUS_READY = 'cart:ready';
11 const STATUS_PURCHASING = 'cart:purchasing';
12 const STATUS_CHARGED = 'cart:charged';
13 const STATUS_HOLD = 'cart:hold';
14 const STATUS_REVIEW = 'cart:review';
15 const STATUS_PURCHASED = 'cart:purchased';
17 protected $accountPHID;
18 protected $authorPHID;
19 protected $merchantPHID;
20 protected $subscriptionPHID;
21 protected $cartClass;
22 protected $status;
23 protected $metadata = array();
24 protected $mailKey;
25 protected $isInvoice;
27 private $account = self::ATTACHABLE;
28 private $purchases = self::ATTACHABLE;
29 private $implementation = self::ATTACHABLE;
30 private $merchant = self::ATTACHABLE;
32 public static function initializeNewCart(
33 PhabricatorUser $actor,
34 PhortuneAccount $account,
35 PhortuneMerchant $merchant) {
36 $cart = id(new PhortuneCart())
37 ->setAuthorPHID($actor->getPHID())
38 ->setStatus(self::STATUS_BUILDING)
39 ->setAccountPHID($account->getPHID())
40 ->setIsInvoice(0)
41 ->attachAccount($account)
42 ->setMerchantPHID($merchant->getPHID())
43 ->attachMerchant($merchant);
45 $cart->account = $account;
46 $cart->purchases = array();
48 return $cart;
51 public function newPurchase(
52 PhabricatorUser $actor,
53 PhortuneProduct $product) {
55 $purchase = PhortunePurchase::initializeNewPurchase($actor, $product)
56 ->setAccountPHID($this->getAccount()->getPHID())
57 ->setCartPHID($this->getPHID())
58 ->save();
60 $this->purchases[] = $purchase;
62 return $purchase;
65 public static function getStatusNameMap() {
66 return array(
67 self::STATUS_BUILDING => pht('Building'),
68 self::STATUS_READY => pht('Ready'),
69 self::STATUS_PURCHASING => pht('Purchasing'),
70 self::STATUS_CHARGED => pht('Charged'),
71 self::STATUS_HOLD => pht('Hold'),
72 self::STATUS_REVIEW => pht('Review'),
73 self::STATUS_PURCHASED => pht('Purchased'),
77 public static function getNameForStatus($status) {
78 return idx(self::getStatusNameMap(), $status, $status);
81 public function activateCart() {
82 $this->openTransaction();
83 $this->beginReadLocking();
85 $copy = clone $this;
86 $copy->reload();
88 if ($copy->getStatus() !== self::STATUS_BUILDING) {
89 throw new Exception(
90 pht(
91 'Cart has wrong status ("%s") to call %s.',
92 $copy->getStatus(),
93 'willApplyCharge()'));
96 $this->setStatus(self::STATUS_READY)->save();
98 $this->endReadLocking();
99 $this->saveTransaction();
101 $this->recordCartTransaction(PhortuneCartTransaction::TYPE_CREATED);
103 return $this;
106 public function willApplyCharge(
107 PhabricatorUser $actor,
108 PhortunePaymentProvider $provider,
109 PhortunePaymentMethod $method = null) {
111 $account = $this->getAccount();
113 $charge = PhortuneCharge::initializeNewCharge()
114 ->setAccountPHID($account->getPHID())
115 ->setCartPHID($this->getPHID())
116 ->setAuthorPHID($actor->getPHID())
117 ->setMerchantPHID($this->getMerchant()->getPHID())
118 ->setProviderPHID($provider->getProviderConfig()->getPHID())
119 ->setAmountAsCurrency($this->getTotalPriceAsCurrency());
121 if ($method) {
122 if (!$method->isActive()) {
123 throw new Exception(
124 pht(
125 'Attempting to apply a charge using an inactive '.
126 'payment method ("%s")!',
127 $method->getPHID()));
129 $charge->setPaymentMethodPHID($method->getPHID());
132 $this->openTransaction();
133 $this->beginReadLocking();
135 $copy = clone $this;
136 $copy->reload();
138 if ($copy->getStatus() !== self::STATUS_READY) {
139 throw new Exception(
140 pht(
141 'Cart has wrong status ("%s") to call %s, expected "%s".',
142 $copy->getStatus(),
143 'willApplyCharge()',
144 self::STATUS_READY));
147 $charge->save();
148 $this->setStatus(self::STATUS_PURCHASING)->save();
150 $this->endReadLocking();
151 $this->saveTransaction();
153 return $charge;
156 public function didHoldCharge(PhortuneCharge $charge) {
157 $charge->setStatus(PhortuneCharge::STATUS_HOLD);
159 $this->openTransaction();
160 $this->beginReadLocking();
162 $copy = clone $this;
163 $copy->reload();
165 if ($copy->getStatus() !== self::STATUS_PURCHASING) {
166 throw new Exception(
167 pht(
168 'Cart has wrong status ("%s") to call %s, expected "%s".',
169 $copy->getStatus(),
170 'didHoldCharge()',
171 self::STATUS_PURCHASING));
174 $charge->save();
175 $this->setStatus(self::STATUS_HOLD)->save();
177 $this->endReadLocking();
178 $this->saveTransaction();
180 $this->recordCartTransaction(PhortuneCartTransaction::TYPE_HOLD);
183 public function didApplyCharge(PhortuneCharge $charge) {
184 $charge->setStatus(PhortuneCharge::STATUS_CHARGED);
186 $this->openTransaction();
187 $this->beginReadLocking();
189 $copy = clone $this;
190 $copy->reload();
192 if (($copy->getStatus() !== self::STATUS_PURCHASING) &&
193 ($copy->getStatus() !== self::STATUS_HOLD)) {
194 throw new Exception(
195 pht(
196 'Cart has wrong status ("%s") to call %s.',
197 $copy->getStatus(),
198 'didApplyCharge()'));
201 $charge->save();
202 $this->setStatus(self::STATUS_CHARGED)->save();
204 $this->endReadLocking();
205 $this->saveTransaction();
207 // TODO: Perform purchase review. Here, we would apply rules to determine
208 // whether the charge needs manual review (maybe making the decision via
209 // Herald, configuration, or by examining provider fraud data). For now,
210 // don't require review.
211 $needs_review = false;
213 if ($needs_review) {
214 $this->willReviewCart();
215 } else {
216 $this->didReviewCart();
219 return $this;
222 public function willReviewCart() {
223 $this->openTransaction();
224 $this->beginReadLocking();
226 $copy = clone $this;
227 $copy->reload();
229 if (($copy->getStatus() !== self::STATUS_CHARGED)) {
230 throw new Exception(
231 pht(
232 'Cart has wrong status ("%s") to call %s!',
233 $copy->getStatus(),
234 'willReviewCart()'));
237 $this->setStatus(self::STATUS_REVIEW)->save();
239 $this->endReadLocking();
240 $this->saveTransaction();
242 $this->recordCartTransaction(PhortuneCartTransaction::TYPE_REVIEW);
244 return $this;
247 public function didReviewCart() {
248 $this->openTransaction();
249 $this->beginReadLocking();
251 $copy = clone $this;
252 $copy->reload();
254 if (($copy->getStatus() !== self::STATUS_CHARGED) &&
255 ($copy->getStatus() !== self::STATUS_REVIEW)) {
256 throw new Exception(
257 pht(
258 'Cart has wrong status ("%s") to call %s!',
259 $copy->getStatus(),
260 'didReviewCart()'));
263 foreach ($this->purchases as $purchase) {
264 $purchase->getProduct()->didPurchaseProduct($purchase);
267 $this->setStatus(self::STATUS_PURCHASED)->save();
269 $this->endReadLocking();
270 $this->saveTransaction();
272 $this->recordCartTransaction(PhortuneCartTransaction::TYPE_PURCHASED);
274 return $this;
277 public function didFailCharge(PhortuneCharge $charge) {
278 $charge->setStatus(PhortuneCharge::STATUS_FAILED);
280 $this->openTransaction();
281 $this->beginReadLocking();
283 $copy = clone $this;
284 $copy->reload();
286 if (($copy->getStatus() !== self::STATUS_PURCHASING) &&
287 ($copy->getStatus() !== self::STATUS_HOLD)) {
288 throw new Exception(
289 pht(
290 'Cart has wrong status ("%s") to call %s.',
291 $copy->getStatus(),
292 'didFailCharge()'));
295 $charge->save();
297 // Move the cart back into STATUS_READY so the user can try
298 // making the purchase again.
299 $this->setStatus(self::STATUS_READY)->save();
301 $this->endReadLocking();
302 $this->saveTransaction();
304 return $this;
308 public function willRefundCharge(
309 PhabricatorUser $actor,
310 PhortunePaymentProvider $provider,
311 PhortuneCharge $charge,
312 PhortuneCurrency $amount) {
314 if (!$amount->isPositive()) {
315 throw new Exception(
316 pht('Trying to refund non-positive amount of money!'));
319 if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) {
320 throw new Exception(
321 pht('Trying to refund more money than remaining on charge!'));
324 if ($charge->getRefundedChargePHID()) {
325 throw new Exception(
326 pht('Trying to refund a refund!'));
329 if (($charge->getStatus() !== PhortuneCharge::STATUS_CHARGED) &&
330 ($charge->getStatus() !== PhortuneCharge::STATUS_HOLD)) {
331 throw new Exception(
332 pht('Trying to refund an uncharged charge!'));
335 $refund_charge = PhortuneCharge::initializeNewCharge()
336 ->setAccountPHID($this->getAccount()->getPHID())
337 ->setCartPHID($this->getPHID())
338 ->setAuthorPHID($actor->getPHID())
339 ->setMerchantPHID($this->getMerchant()->getPHID())
340 ->setProviderPHID($provider->getProviderConfig()->getPHID())
341 ->setPaymentMethodPHID($charge->getPaymentMethodPHID())
342 ->setRefundedChargePHID($charge->getPHID())
343 ->setAmountAsCurrency($amount->negate());
345 $charge->openTransaction();
346 $charge->beginReadLocking();
348 $copy = clone $charge;
349 $copy->reload();
351 if ($copy->getRefundingPHID() !== null) {
352 throw new Exception(
353 pht('Trying to refund a charge which is already refunding!'));
356 $refund_charge->save();
357 $charge->setRefundingPHID($refund_charge->getPHID());
358 $charge->save();
360 $charge->endReadLocking();
361 $charge->saveTransaction();
363 return $refund_charge;
366 public function didRefundCharge(
367 PhortuneCharge $charge,
368 PhortuneCharge $refund) {
370 $refund->setStatus(PhortuneCharge::STATUS_CHARGED);
372 $this->openTransaction();
373 $this->beginReadLocking();
375 $copy = clone $charge;
376 $copy->reload();
378 if ($charge->getRefundingPHID() !== $refund->getPHID()) {
379 throw new Exception(
380 pht('Charge is in the wrong refunding state!'));
383 $charge->setRefundingPHID(null);
385 // NOTE: There's some trickiness here to get the signs right. Both
386 // these values are positive but the refund has a negative value.
387 $total_refunded = $charge
388 ->getAmountRefundedAsCurrency()
389 ->add($refund->getAmountAsCurrency()->negate());
391 $charge->setAmountRefundedAsCurrency($total_refunded);
392 $charge->save();
393 $refund->save();
395 $this->endReadLocking();
396 $this->saveTransaction();
398 $amount = $refund->getAmountAsCurrency()->negate();
399 foreach ($this->purchases as $purchase) {
400 $purchase->getProduct()->didRefundProduct($purchase, $amount);
403 return $this;
406 public function didFailRefund(
407 PhortuneCharge $charge,
408 PhortuneCharge $refund) {
410 $refund->setStatus(PhortuneCharge::STATUS_FAILED);
412 $this->openTransaction();
413 $this->beginReadLocking();
415 $copy = clone $charge;
416 $copy->reload();
418 if ($charge->getRefundingPHID() !== $refund->getPHID()) {
419 throw new Exception(
420 pht('Charge is in the wrong refunding state!'));
423 $charge->setRefundingPHID(null);
424 $charge->save();
425 $refund->save();
427 $this->endReadLocking();
428 $this->saveTransaction();
431 private function recordCartTransaction($type) {
432 $omnipotent_user = PhabricatorUser::getOmnipotentUser();
433 $phortune_phid = id(new PhabricatorPhortuneApplication())->getPHID();
435 $xactions = array();
437 $xactions[] = id(new PhortuneCartTransaction())
438 ->setTransactionType($type)
439 ->setNewValue(true);
441 $content_source = PhabricatorContentSource::newForSource(
442 PhabricatorPhortuneContentSource::SOURCECONST);
444 $editor = id(new PhortuneCartEditor())
445 ->setActor($omnipotent_user)
446 ->setActingAsPHID($phortune_phid)
447 ->setContentSource($content_source)
448 ->setContinueOnMissingFields(true)
449 ->setContinueOnNoEffect(true);
451 $editor->applyTransactions($this, $xactions);
454 public function getName() {
455 return $this->getImplementation()->getName($this);
458 public function getDoneURI() {
459 return $this->getImplementation()->getDoneURI($this);
462 public function getDoneActionName() {
463 return $this->getImplementation()->getDoneActionName($this);
466 public function getCancelURI() {
467 return $this->getImplementation()->getCancelURI($this);
470 public function getDescription() {
471 return $this->getImplementation()->getDescription($this);
474 public function getDetailURI() {
475 return urisprintf(
476 '/phortune/cart/%d/',
477 $this->getID());
480 public function getCheckoutURI() {
481 return '/phortune/cart/'.$this->getID().'/checkout/';
484 public function canCancelOrder() {
485 try {
486 $this->assertCanCancelOrder();
487 return true;
488 } catch (Exception $ex) {
489 return false;
493 public function canRefundOrder() {
494 try {
495 $this->assertCanRefundOrder();
496 return true;
497 } catch (Exception $ex) {
498 return false;
502 public function canVoidOrder() {
503 try {
504 $this->assertCanVoidOrder();
505 return true;
506 } catch (Exception $ex) {
507 return false;
511 public function assertCanCancelOrder() {
512 switch ($this->getStatus()) {
513 case self::STATUS_BUILDING:
514 throw new Exception(
515 pht(
516 'This order can not be cancelled because the application has not '.
517 'finished building it yet.'));
518 case self::STATUS_READY:
519 throw new Exception(
520 pht(
521 'This order can not be cancelled because it has not been placed.'));
524 return $this->getImplementation()->assertCanCancelOrder($this);
527 public function assertCanRefundOrder() {
528 switch ($this->getStatus()) {
529 case self::STATUS_BUILDING:
530 throw new Exception(
531 pht(
532 'This order can not be refunded because the application has not '.
533 'finished building it yet.'));
534 case self::STATUS_READY:
535 throw new Exception(
536 pht(
537 'This order can not be refunded because it has not been placed.'));
540 return $this->getImplementation()->assertCanRefundOrder($this);
543 public function assertCanVoidOrder() {
544 if (!$this->getIsInvoice()) {
545 throw new Exception(
546 pht(
547 'This order can not be voided because it is not an invoice.'));
550 switch ($this->getStatus()) {
551 case self::STATUS_READY:
552 break;
553 default:
554 throw new Exception(
555 pht(
556 'This order can not be voided because it is not ready for '.
557 'payment.'));
560 return null;
564 protected function getConfiguration() {
565 return array(
566 self::CONFIG_AUX_PHID => true,
567 self::CONFIG_SERIALIZATION => array(
568 'metadata' => self::SERIALIZATION_JSON,
570 self::CONFIG_COLUMN_SCHEMA => array(
571 'status' => 'text32',
572 'cartClass' => 'text128',
573 'mailKey' => 'bytes20',
574 'subscriptionPHID' => 'phid?',
575 'isInvoice' => 'bool',
577 self::CONFIG_KEY_SCHEMA => array(
578 'key_account' => array(
579 'columns' => array('accountPHID'),
581 'key_merchant' => array(
582 'columns' => array('merchantPHID'),
584 'key_subscription' => array(
585 'columns' => array('subscriptionPHID'),
588 ) + parent::getConfiguration();
591 public function generatePHID() {
592 return PhabricatorPHID::generateNewPHID(
593 PhortuneCartPHIDType::TYPECONST);
596 public function save() {
597 if (!$this->getMailKey()) {
598 $this->setMailKey(Filesystem::readRandomCharacters(20));
600 return parent::save();
603 public function attachPurchases(array $purchases) {
604 assert_instances_of($purchases, 'PhortunePurchase');
605 $this->purchases = $purchases;
606 return $this;
609 public function getPurchases() {
610 return $this->assertAttached($this->purchases);
613 public function attachAccount(PhortuneAccount $account) {
614 $this->account = $account;
615 return $this;
618 public function getAccount() {
619 return $this->assertAttached($this->account);
622 public function attachMerchant(PhortuneMerchant $merchant) {
623 $this->merchant = $merchant;
624 return $this;
627 public function getMerchant() {
628 return $this->assertAttached($this->merchant);
631 public function attachImplementation(
632 PhortuneCartImplementation $implementation) {
633 $this->implementation = $implementation;
634 return $this;
637 public function getImplementation() {
638 return $this->assertAttached($this->implementation);
641 public function getTotalPriceAsCurrency() {
642 $prices = array();
643 foreach ($this->getPurchases() as $purchase) {
644 $prices[] = $purchase->getTotalPriceAsCurrency();
647 return PhortuneCurrency::newFromList($prices);
650 public function setMetadataValue($key, $value) {
651 $this->metadata[$key] = $value;
652 return $this;
655 public function getMetadataValue($key, $default = null) {
656 return idx($this->metadata, $key, $default);
659 public function getObjectName() {
660 return pht('Order %d', $this->getID());
664 /* -( PhabricatorApplicationTransactionInterface )------------------------- */
667 public function getApplicationTransactionEditor() {
668 return new PhortuneCartEditor();
671 public function getApplicationTransactionTemplate() {
672 return new PhortuneCartTransaction();
676 /* -( PhabricatorPolicyInterface )----------------------------------------- */
679 public function getCapabilities() {
680 return array(
681 PhabricatorPolicyCapability::CAN_VIEW,
682 PhabricatorPolicyCapability::CAN_EDIT,
686 public function getPolicy($capability) {
687 return PhabricatorPolicies::getMostOpenPolicy();
690 public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
691 if ($capability === PhabricatorPolicyCapability::CAN_VIEW) {
692 $any_edit = PhortuneMerchantQuery::canViewersEditMerchants(
693 array($viewer->getPHID()),
694 array($this->getMerchantPHID()));
695 if ($any_edit) {
696 return true;
700 return false;
704 /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
707 public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
708 if ($this->hasAutomaticCapability($capability, $viewer)) {
709 return array();
712 return array(
713 array(
714 $this->getAccount(),
715 PhabricatorPolicyCapability::CAN_EDIT,