From 037a1420bbe22e7b8595af42ca4d622327fa32bc Mon Sep 17 00:00:00 2001 From: smarcet Date: Sun, 1 Sep 2019 14:08:14 -0300 Subject: [PATCH] Refactoring revocation bookable rooms reservations refactored task due a need on registration new feature Change-Id: I658954d8b7132b183595a4bdeb1634e2e681ddec --- ...SummitRoomReservationRevocationCommand.php | 33 ++----- .../ISummitRoomReservationRepository.php | 12 ++- app/Models/Utils/IBaseRepository.php | 6 ++ app/Repositories/DoctrineRepository.php | 12 +++ ...octrineSummitRoomReservationRepository.php | 39 ++++++++- app/Services/Apis/IPaymentGatewayAPI.php | 34 ++++++++ .../Apis/PaymentGateways/StripeApi.php | 67 ++++++++++++++ app/Services/Model/ILocationService.php | 5 ++ app/Services/Model/SummitLocationService.php | 87 ++++++++++++++----- 9 files changed, 239 insertions(+), 56 deletions(-) diff --git a/app/Console/Commands/SummitRoomReservationRevocationCommand.php b/app/Console/Commands/SummitRoomReservationRevocationCommand.php index d533e2f5..38038a14 100644 --- a/app/Console/Commands/SummitRoomReservationRevocationCommand.php +++ b/app/Console/Commands/SummitRoomReservationRevocationCommand.php @@ -12,6 +12,7 @@ * limitations under the License. **/ use App\Models\Foundation\Summit\Repositories\ISummitRoomReservationRepository; +use App\Services\Model\ILocationService; use Illuminate\Support\Facades\Log; use libs\utils\ITransactionService; use models\summit\SummitRoomReservation; @@ -52,29 +53,22 @@ final class SummitRoomReservationRevocationCommand extends Command { /** - * @var ISummitRoomReservationRepository + * @var ILocationService */ - private $reservations_repository; + private $location_service; - /** - * @var ITransactionService - */ - private $tx_service; /** * SummitRoomReservationRevocationCommand constructor. - * @param ISummitRoomReservationRepository $reservations_repository - * @param ITransactionService $tx_service + * @param ILocationService $location_service */ public function __construct ( - ISummitRoomReservationRepository $reservations_repository, - ITransactionService $tx_service + ILocationService $location_service ) { parent::__construct(); - $this->reservations_repository = $reservations_repository; - $this->tx_service = $tx_service; + $this->location_service = $location_service; } /** @@ -96,20 +90,7 @@ final class SummitRoomReservationRevocationCommand extends Command { $start = time(); $lifetime = intval(Config::get("bookable_rooms.reservation_lifetime", 30)); Log::info(sprintf("SummitRoomReservationRevocationCommand: using lifetime of %s ", $lifetime)); - $this->tx_service->transaction(function() use($lifetime){ - $filter = new Filter(); - $filter->addFilterCondition(FilterElement::makeEqual('status', SummitRoomReservation::ReservedStatus)); - $eol = new \DateTime('now', new \DateTimeZone(SilverstripeBaseModel::DefaultTimeZone)); - - $eol->sub(new \DateInterval('PT'.$lifetime.'M')); - $filter->addFilterCondition(FilterElement::makeLowerOrEqual('created', $eol->getTimestamp() )); - $page = $this->reservations_repository->getAllByPage(new PagingInfo(1, 100), $filter); - foreach($page->getItems() as $reservation){ - Log::warning(sprintf("cancelling reservation %s create at %s", $reservation->getId(), $reservation->getCreated()->format("Y-m-d h:i:sa"))); - $reservation->cancel(); - } - }); - + $this->location_service->revokeBookableRoomsReservedOlderThanNMinutes($lifetime); $end = time(); $delta = $end - $start; $this->info(sprintf("execution call %s seconds", $delta)); diff --git a/app/Models/Foundation/Summit/Repositories/ISummitRoomReservationRepository.php b/app/Models/Foundation/Summit/Repositories/ISummitRoomReservationRepository.php index 290679c9..5d292e27 100644 --- a/app/Models/Foundation/Summit/Repositories/ISummitRoomReservationRepository.php +++ b/app/Models/Foundation/Summit/Repositories/ISummitRoomReservationRepository.php @@ -11,7 +11,6 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ - use models\summit\Summit; use models\summit\SummitRoomReservation; use models\utils\IBaseRepository; @@ -19,7 +18,6 @@ use utils\Filter; use utils\Order; use utils\PagingInfo; use utils\PagingResponse; - /** * Interface ISummitRoomReservationRepository * @package App\Models\Foundation\Summit\Repositories @@ -30,7 +28,7 @@ interface ISummitRoomReservationRepository extends IBaseRepository * @param string $payment_gateway_cart_id * @return SummitRoomReservation|null */ - public function getByPaymentGatewayCartId(string $payment_gateway_cart_id): ?SummitRoomReservation; + public function getByPaymentGatewayCartIdExclusiveLock(string $payment_gateway_cart_id): ?SummitRoomReservation; /** * @param Summit $summit @@ -40,4 +38,12 @@ interface ISummitRoomReservationRepository extends IBaseRepository * @return PagingResponse */ public function getAllBySummitByPage(Summit $summit, PagingInfo $paging_info, Filter $filter = null, Order $order = null): PagingResponse; + + /** + * @param int $minutes + * @param int $max + * @return mixed + * @throws \Exception + */ + public function getAllReservedOlderThanXMinutes(int $minutes, int $max = 100); } \ No newline at end of file diff --git a/app/Models/Utils/IBaseRepository.php b/app/Models/Utils/IBaseRepository.php index d21d1baf..0bd7994e 100644 --- a/app/Models/Utils/IBaseRepository.php +++ b/app/Models/Utils/IBaseRepository.php @@ -26,6 +26,12 @@ interface IBaseRepository */ public function getById($id); + /** + * @param int $id + * @return IEntity + */ + public function getByIdExclusiveLock($id); + /** * @param IEntity $entity * @param bool $sync diff --git a/app/Repositories/DoctrineRepository.php b/app/Repositories/DoctrineRepository.php index 53dad2d4..9bea3384 100644 --- a/app/Repositories/DoctrineRepository.php +++ b/app/Repositories/DoctrineRepository.php @@ -31,11 +31,23 @@ use Doctrine\ORM\Tools\Pagination\Paginator; abstract class DoctrineRepository extends EntityRepository implements IBaseRepository { + /** + * @param int $id + * @return IEntity|null|object + */ public function getById($id) { return $this->find($id); } + /** + * @param int $id + * @return IEntity|null|object + */ + public function getByIdExclusiveLock($id){ + return $this->find($id, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE); + } + /** * @param $entity * @param bool $sync diff --git a/app/Repositories/Summit/DoctrineSummitRoomReservationRepository.php b/app/Repositories/Summit/DoctrineSummitRoomReservationRepository.php index 2e1b72b8..62b0dae7 100644 --- a/app/Repositories/Summit/DoctrineSummitRoomReservationRepository.php +++ b/app/Repositories/Summit/DoctrineSummitRoomReservationRepository.php @@ -16,6 +16,7 @@ use App\Repositories\SilverStripeDoctrineRepository; use Doctrine\ORM\Tools\Pagination\Paginator; use models\summit\Summit; use models\summit\SummitRoomReservation; +use models\utils\SilverstripeBaseModel; use utils\DoctrineFilterMapping; use utils\DoctrineJoinFilterMapping; use utils\Filter; @@ -26,7 +27,7 @@ use utils\PagingResponse; * Class DoctrineSummitRoomReservationRepository * @package App\Repositories\Summit */ -class DoctrineSummitRoomReservationRepository +final class DoctrineSummitRoomReservationRepository extends SilverStripeDoctrineRepository implements ISummitRoomReservationRepository { @@ -156,10 +157,42 @@ class DoctrineSummitRoomReservationRepository * @param string $payment_gateway_cart_id * @return SummitRoomReservation|null */ - public function getByPaymentGatewayCartId(string $payment_gateway_cart_id):?SummitRoomReservation + public function getByPaymentGatewayCartIdExclusiveLock(string $payment_gateway_cart_id):?SummitRoomReservation { - return $this->findOneBy(["payment_gateway_cart_id" => trim($payment_gateway_cart_id)]); + $query = $this->getEntityManager() + ->createQueryBuilder() + ->select("e") + ->from($this->getBaseEntity(), "e") + ->where("e.payment_gateway_cart_id = payment_gateway_cart_id"); + + $query->setParameter("payment_gateway_cart_id", trim($payment_gateway_cart_id)); + + return $query->getQuery()->setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE)->getOneOrNullResult(); } + /** + * @param int $minutes + * @param int $max + * @return mixed + * @throws \Exception + */ + public function getAllReservedOlderThanXMinutes(int $minutes, int $max = 100) + { + $eol = new \DateTime('now', new \DateTimeZone(SilverstripeBaseModel::DefaultTimeZone)); + $eol->sub(new \DateInterval('PT' . $minutes . 'M')); + + $query = $this->getEntityManager() + ->createQueryBuilder() + ->select("e") + ->from($this->getBaseEntity(), "e") + ->where("e.created <= :eol") + ->andWhere("e.status = :status"); + + $query->setParameter("eol", $eol); + $query->setParameter("status", SummitRoomReservation::ReservedStatus); + + return $query->getQuery()->setMaxResults($max)->getResult(); + + } } \ No newline at end of file diff --git a/app/Services/Apis/IPaymentGatewayAPI.php b/app/Services/Apis/IPaymentGatewayAPI.php index 664da622..e4f50ff1 100644 --- a/app/Services/Apis/IPaymentGatewayAPI.php +++ b/app/Services/Apis/IPaymentGatewayAPI.php @@ -12,6 +12,15 @@ * limitations under the License. **/ use Illuminate\Http\Request as LaravelRequest; +use Exception; + +/** + * Class CartAlreadyPaidException + * @package App\Services\Apis + */ +class CartAlreadyPaidException extends Exception { + +} /** * Interface IPaymentGatewayAPI * @package App\Services\Apis @@ -49,4 +58,29 @@ interface IPaymentGatewayAPI * @throws \InvalidArgumentException */ public function refundPayment(string $cart_id, float $amount, string $currency): void; + + /** + * @param string $cart_id + * @return mixed|void + * @throws CartAlreadyPaidException + */ + public function abandonCart(string $cart_id); + + /** + * @param string $status + * @return bool + */ + public function canAbandon(string $status):bool; + + /** + * @param string $cart_id + * @return string + */ + public function getCartStatus(string $cart_id):string; + + /** + * @param string $status + * @return bool + */ + public function isSucceeded(string $status):bool; } \ No newline at end of file diff --git a/app/Services/Apis/PaymentGateways/StripeApi.php b/app/Services/Apis/PaymentGateways/StripeApi.php index 93c3784c..7cf8bd89 100644 --- a/app/Services/Apis/PaymentGateways/StripeApi.php +++ b/app/Services/Apis/PaymentGateways/StripeApi.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +use App\Services\Apis\CartAlreadyPaidException; use App\Services\Apis\IPaymentGatewayAPI; use Illuminate\Http\Request as LaravelRequest; use models\exceptions\ValidationException; @@ -245,4 +246,70 @@ final class StripeApi implements IPaymentGatewayAPI } $charge->refund($params); } + + /** + * @param string $cart_id + * @return mixed|void + * @throws CartAlreadyPaidException + */ + public function abandonCart(string $cart_id) + { + if(empty($this->api_key)) + throw new \InvalidArgumentException(); + + Stripe::setApiKey($this->api_key); + $intent = PaymentIntent::retrieve($cart_id); + + if(is_null($intent)) + throw new \InvalidArgumentException(); + + if(!in_array($intent->status,[ PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD, + PaymentIntent::STATUS_REQUIRES_CAPTURE, + PaymentIntent::STATUS_REQUIRES_CONFIRMATION, + PaymentIntent::STATUS_REQUIRES_ACTION + ])) + throw new CartAlreadyPaidException(sprintf("cart id %s has status %s", $cart_id, $intent->status)); + + $intent->cancel(); + } + + /** + * @param string $status + * @return bool + */ + public function canAbandon(string $status): bool + { + return in_array($status,[ + PaymentIntent::STATUS_REQUIRES_PAYMENT_METHOD, + PaymentIntent::STATUS_REQUIRES_CAPTURE, + PaymentIntent::STATUS_REQUIRES_CONFIRMATION, + PaymentIntent::STATUS_REQUIRES_ACTION + ]); + } + + /** + * @param string $status + * @return bool + */ + public function isSucceeded(string $status):bool { + return $status == PaymentIntent::STATUS_SUCCEEDED; + } + + /** + * @param string $cart_id + * @return string + */ + public function getCartStatus(string $cart_id): string + { + if(empty($this->api_key)) + throw new \InvalidArgumentException(); + + Stripe::setApiKey($this->api_key); + $intent = PaymentIntent::retrieve($cart_id); + + if(is_null($intent)) + throw new \InvalidArgumentException(); + + return $intent->status; + } } \ No newline at end of file diff --git a/app/Services/Model/ILocationService.php b/app/Services/Model/ILocationService.php index 81a5800d..349d5d4d 100644 --- a/app/Services/Model/ILocationService.php +++ b/app/Services/Model/ILocationService.php @@ -329,4 +329,9 @@ interface ILocationService * @return SummitVenueRoom */ public function removeRoomImage(Summit $summit, int $venue_id, int $room_id):SummitVenueRoom; + + /** + * @param int $minutes + */ + public function revokeBookableRoomsReservedOlderThanNMinutes(int $minutes):void; } \ No newline at end of file diff --git a/app/Services/Model/SummitLocationService.php b/app/Services/Model/SummitLocationService.php index 8cf58754..be21d955 100644 --- a/app/Services/Model/SummitLocationService.php +++ b/app/Services/Model/SummitLocationService.php @@ -11,6 +11,7 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ + use App\Events\CreatedBookableRoomReservation; use App\Events\FloorDeleted; use App\Events\FloorInserted; @@ -55,6 +56,7 @@ use models\summit\SummitRoomReservation; use models\summit\SummitVenue; use models\summit\SummitVenueFloor; use models\summit\SummitVenueRoom; + /** * Class SummitLocationService * @package App\Services\Model @@ -1710,14 +1712,14 @@ final class SummitLocationService throw new EntityNotFoundException('member not found'); } - if($owner->getReservationsCountBySummit($summit) >= $summit->getMeetingRoomBookingMaxAllowed()) - throw new ValidationException(sprintf("member %s already reached maximun quantity of reservations (%s)", $owner->getId(), $summit->getMeetingRoomBookingMaxAllowed() )); + if ($owner->getReservationsCountBySummit($summit) >= $summit->getMeetingRoomBookingMaxAllowed()) + throw new ValidationException(sprintf("member %s already reached maximun quantity of reservations (%s)", $owner->getId(), $summit->getMeetingRoomBookingMaxAllowed())); $payload['owner'] = $owner; $currency = trim($payload['currency']); - if($room->getCurrency() != $currency){ + if ($room->getCurrency() != $currency) { throw new ValidationException ( sprintf @@ -1731,7 +1733,7 @@ final class SummitLocationService $amount = intval($payload['amount']); - if($room->getTimeSlotCost() != $amount){ + if ($room->getTimeSlotCost() != $amount) { throw new ValidationException ( sprintf @@ -1751,20 +1753,20 @@ final class SummitLocationService $result = $this->payment_gateway->generatePayment ( [ - "amount" => $reservation->getAmount(), - "currency" => $reservation->getCurrency(), + "amount" => $reservation->getAmount(), + "currency" => $reservation->getCurrency(), "receipt_email" => $reservation->getOwner()->getEmail(), - "metadata" => [ - "type" => "bookable_room_reservation", + "metadata" => [ + "type" => "bookable_room_reservation", "room_id" => $room->getId(), ] ] ); - if(!isset($result['cart_id'])) + if (!isset($result['cart_id'])) throw new ValidationException("payment gateway error"); - if(!isset($result['client_token'])) + if (!isset($result['client_token'])) throw new ValidationException("payment gateway error"); $reservation->setPaymentGatewayCartId($result['cart_id']); @@ -1782,7 +1784,7 @@ final class SummitLocationService { $this->tx_service->transaction(function () use ($payload) { - $reservation = $this->reservation_repository->getByPaymentGatewayCartId($payload['cart_id']); + $reservation = $this->reservation_repository->getByPaymentGatewayCartIdExclusiveLock($payload['cart_id']); if (is_null($reservation)) { throw new EntityNotFoundException(sprintf("there is no reservation with cart_id %s", $payload['cart_id'])); @@ -1794,8 +1796,7 @@ final class SummitLocationService $reservation->setPaid(); return; } - } - catch (ValidationException $ex){ + } catch (ValidationException $ex) { Log::error($ex); Log::warning("doing refund of cancelled reservation"); $reservation->setStatus(SummitRoomReservation::RequestedRefundStatus); @@ -1860,9 +1861,9 @@ final class SummitLocationService throw new EntityNotFoundException(); } - $status = $reservation->getStatus(); + $status = $reservation->getStatus(); $validStatuses = [SummitRoomReservation::RequestedRefundStatus, SummitRoomReservation::PayedStatus]; - if(!in_array($status, $validStatuses)) + if (!in_array($status, $validStatuses)) throw new ValidationException ( sprintf @@ -1872,18 +1873,17 @@ final class SummitLocationService ) ); - if($amount <= 0){ + if ($amount <= 0) { throw new ValidationException("can not refund an amount lower than zero!"); } - if($amount > intval($reservation->getAmount())){ + if ($amount > intval($reservation->getAmount())) { throw new ValidationException("can not refund an amount greater than paid one!"); } - try{ + try { $this->payment_gateway->refundPayment($reservation->getPaymentGatewayCartId(), $amount, $reservation->getCurrency()); - } - catch (\Exception $ex){ + } catch (\Exception $ex) { throw new ValidationException($ex->getMessage()); } @@ -2153,7 +2153,7 @@ final class SummitLocationService if (!$venue instanceof SummitVenue) { throw new EntityNotFoundException ( - "venue not found" + "venue not found" ); } @@ -2277,7 +2277,7 @@ final class SummitLocationService if (is_null($room)) { throw new EntityNotFoundException ( - 'room not found' + 'room not found' ); } @@ -2296,7 +2296,7 @@ final class SummitLocationService throw new ValidationException(sprintf("file exceeds max_file_size (%s MB).", ($max_file_size / 1024) / 1024)); } - $image = $this->file_uploader->build($file, sprintf('summits/%s/locations/%s/rooms', $summit->getId(), $venue_id ), true); + $image = $this->file_uploader->build($file, sprintf('summits/%s/locations/%s/rooms', $summit->getId(), $venue_id), true); $room->setImage($image); return $image; @@ -2331,7 +2331,7 @@ final class SummitLocationService ); } - if(!$room->hasImage()) + if (!$room->hasImage()) throw new ValidationException("room has no image set"); $room->clearImage(); @@ -2339,4 +2339,43 @@ final class SummitLocationService return $room; }); } + + /** + * @param int $minutes + */ + public function revokeBookableRoomsReservedOlderThanNMinutes(int $minutes): void + { + // this is done in this way to avoid db lock contentions + $reservations = $this->tx_service->transaction(function () use ($minutes) { + return $this->reservation_repository->getAllReservedOlderThanXMinutes($minutes); + }); + + foreach ($reservations as $reservation) { + + $this->tx_service->transaction(function () use ($reservation) { + + try { + $reservation = $this->reservation_repository->getByIdExclusiveLock($reservation->getId()); + if (!$reservation instanceof SummitRoomReservation) return; + + Log::warning(sprintf("cancelling reservation %s created at %s", $reservation->getId(), $reservation->getCreated()->format("Y-m-d h:i:sa"))); + $status = $this->payment_gateway->getCartStatus($reservation->getPaymentGatewayCartId()); + if (!$this->payment_gateway->canAbandon($status)) { + Log::warning(sprintf("reservation %s created at %s can not be cancelled external status %s", $reservation->getId(), $reservation->getCreated()->format("Y-m-d h:i:sa"), $status)); + if($this->payment_gateway->isSucceeded($status)){ + $reservation->setPaid(); + } + return; + } + + $this->payment_gateway->abandonCart($reservation->getPaymentGatewayCartId()); + + $reservation->cancel(); + } catch (\Exception $ex) { + Log::warning($ex); + } + + }); + } + } } \ No newline at end of file