3 final class PhortuneCart
extends PhortuneDAO
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;
23 protected $metadata = array();
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())
41 ->attachAccount($account)
42 ->setMerchantPHID($merchant->getPHID())
43 ->attachMerchant($merchant);
45 $cart->account
= $account;
46 $cart->purchases
= array();
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())
60 $this->purchases
[] = $purchase;
65 public static function getStatusNameMap() {
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();
88 if ($copy->getStatus() !== self
::STATUS_BUILDING
) {
91 'Cart has wrong status ("%s") to call %s.',
93 'willApplyCharge()'));
96 $this->setStatus(self
::STATUS_READY
)->save();
98 $this->endReadLocking();
99 $this->saveTransaction();
101 $this->recordCartTransaction(PhortuneCartTransaction
::TYPE_CREATED
);
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());
122 if (!$method->isActive()) {
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();
138 if ($copy->getStatus() !== self
::STATUS_READY
) {
141 'Cart has wrong status ("%s") to call %s, expected "%s".',
144 self
::STATUS_READY
));
148 $this->setStatus(self
::STATUS_PURCHASING
)->save();
150 $this->endReadLocking();
151 $this->saveTransaction();
156 public function didHoldCharge(PhortuneCharge
$charge) {
157 $charge->setStatus(PhortuneCharge
::STATUS_HOLD
);
159 $this->openTransaction();
160 $this->beginReadLocking();
165 if ($copy->getStatus() !== self
::STATUS_PURCHASING
) {
168 'Cart has wrong status ("%s") to call %s, expected "%s".',
171 self
::STATUS_PURCHASING
));
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();
192 if (($copy->getStatus() !== self
::STATUS_PURCHASING
) &&
193 ($copy->getStatus() !== self
::STATUS_HOLD
)) {
196 'Cart has wrong status ("%s") to call %s.',
198 'didApplyCharge()'));
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;
214 $this->willReviewCart();
216 $this->didReviewCart();
222 public function willReviewCart() {
223 $this->openTransaction();
224 $this->beginReadLocking();
229 if (($copy->getStatus() !== self
::STATUS_CHARGED
)) {
232 'Cart has wrong status ("%s") to call %s!',
234 'willReviewCart()'));
237 $this->setStatus(self
::STATUS_REVIEW
)->save();
239 $this->endReadLocking();
240 $this->saveTransaction();
242 $this->recordCartTransaction(PhortuneCartTransaction
::TYPE_REVIEW
);
247 public function didReviewCart() {
248 $this->openTransaction();
249 $this->beginReadLocking();
254 if (($copy->getStatus() !== self
::STATUS_CHARGED
) &&
255 ($copy->getStatus() !== self
::STATUS_REVIEW
)) {
258 'Cart has wrong status ("%s") to call %s!',
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
);
277 public function didFailCharge(PhortuneCharge
$charge) {
278 $charge->setStatus(PhortuneCharge
::STATUS_FAILED
);
280 $this->openTransaction();
281 $this->beginReadLocking();
286 if (($copy->getStatus() !== self
::STATUS_PURCHASING
) &&
287 ($copy->getStatus() !== self
::STATUS_HOLD
)) {
290 'Cart has wrong status ("%s") to call %s.',
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();
308 public function willRefundCharge(
309 PhabricatorUser
$actor,
310 PhortunePaymentProvider
$provider,
311 PhortuneCharge
$charge,
312 PhortuneCurrency
$amount) {
314 if (!$amount->isPositive()) {
316 pht('Trying to refund non-positive amount of money!'));
319 if ($amount->isGreaterThan($charge->getAmountRefundableAsCurrency())) {
321 pht('Trying to refund more money than remaining on charge!'));
324 if ($charge->getRefundedChargePHID()) {
326 pht('Trying to refund a refund!'));
329 if (($charge->getStatus() !== PhortuneCharge
::STATUS_CHARGED
) &&
330 ($charge->getStatus() !== PhortuneCharge
::STATUS_HOLD
)) {
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;
351 if ($copy->getRefundingPHID() !== null) {
353 pht('Trying to refund a charge which is already refunding!'));
356 $refund_charge->save();
357 $charge->setRefundingPHID($refund_charge->getPHID());
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;
378 if ($charge->getRefundingPHID() !== $refund->getPHID()) {
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);
395 $this->endReadLocking();
396 $this->saveTransaction();
398 $amount = $refund->getAmountAsCurrency()->negate();
399 foreach ($this->purchases
as $purchase) {
400 $purchase->getProduct()->didRefundProduct($purchase, $amount);
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;
418 if ($charge->getRefundingPHID() !== $refund->getPHID()) {
420 pht('Charge is in the wrong refunding state!'));
423 $charge->setRefundingPHID(null);
427 $this->endReadLocking();
428 $this->saveTransaction();
431 private function recordCartTransaction($type) {
432 $omnipotent_user = PhabricatorUser
::getOmnipotentUser();
433 $phortune_phid = id(new PhabricatorPhortuneApplication())->getPHID();
437 $xactions[] = id(new PhortuneCartTransaction())
438 ->setTransactionType($type)
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() {
476 '/phortune/cart/%d/',
480 public function getCheckoutURI() {
481 return '/phortune/cart/'.$this->getID().'/checkout/';
484 public function canCancelOrder() {
486 $this->assertCanCancelOrder();
488 } catch (Exception
$ex) {
493 public function canRefundOrder() {
495 $this->assertCanRefundOrder();
497 } catch (Exception
$ex) {
502 public function canVoidOrder() {
504 $this->assertCanVoidOrder();
506 } catch (Exception
$ex) {
511 public function assertCanCancelOrder() {
512 switch ($this->getStatus()) {
513 case self
::STATUS_BUILDING
:
516 'This order can not be cancelled because the application has not '.
517 'finished building it yet.'));
518 case self
::STATUS_READY
:
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
:
532 'This order can not be refunded because the application has not '.
533 'finished building it yet.'));
534 case self
::STATUS_READY
:
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()) {
547 'This order can not be voided because it is not an invoice.'));
550 switch ($this->getStatus()) {
551 case self
::STATUS_READY
:
556 'This order can not be voided because it is not ready for '.
564 protected function getConfiguration() {
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;
609 public function getPurchases() {
610 return $this->assertAttached($this->purchases
);
613 public function attachAccount(PhortuneAccount
$account) {
614 $this->account
= $account;
618 public function getAccount() {
619 return $this->assertAttached($this->account
);
622 public function attachMerchant(PhortuneMerchant
$merchant) {
623 $this->merchant
= $merchant;
627 public function getMerchant() {
628 return $this->assertAttached($this->merchant
);
631 public function attachImplementation(
632 PhortuneCartImplementation
$implementation) {
633 $this->implementation
= $implementation;
637 public function getImplementation() {
638 return $this->assertAttached($this->implementation
);
641 public function getTotalPriceAsCurrency() {
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;
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() {
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()));
704 /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
707 public function getExtendedPolicy($capability, PhabricatorUser
$viewer) {
708 if ($this->hasAutomaticCapability($capability, $viewer)) {
715 PhabricatorPolicyCapability
::CAN_EDIT
,