the browser-facing portion of osu!
at master 6.1 kB view raw
1<?php 2 3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0. 4// See the LICENCE file in the repository root for full licence text. 5 6namespace App\Libraries; 7 8use App\Exceptions\InvariantException; 9use App\Libraries\Payments\InvalidOrderStateException; 10use App\Models\Store\Order; 11use DB; 12 13class OrderCheckout 14{ 15 public function __construct(private Order $order, private ?string $provider = null, private ?string $providerReference = null) 16 { 17 if ($provider === Order::PROVIDER_SHOPIFY && $providerReference === null) { 18 throw new InvariantException('shopify provider requires a providerReference (checkout id).'); 19 } 20 } 21 22 public function getOrder(): Order 23 { 24 return $this->order; 25 } 26 27 public function getProvider(): ?string 28 { 29 return $this->provider; 30 } 31 32 /** 33 * @return string[] 34 */ 35 public function allowedCheckoutProviders(): array 36 { 37 if ($this->order->isShouldShopify()) { 38 return [Order::PROVIDER_SHOPIFY]; 39 } 40 41 if ($this->order->getTotal() > 0) { 42 $allowed = [Order::PROVIDER_PAYPAL]; 43 44 if ($this->allowXsollaPayment()) { 45 $allowed[] = Order::PROVIDER_XSOLLA; 46 } 47 48 return $allowed; 49 } 50 51 return [Order::PROVIDER_FREE]; 52 } 53 54 public function beginCheckout(): void 55 { 56 // something that shouldn't happen just happened. 57 if (!in_array($this->provider, $this->allowedCheckoutProviders(), true)) { 58 throw new InvariantException("{$this->provider} not in allowed checkout providers."); 59 } 60 61 DB::connection('mysql-store')->transaction(function () { 62 $order = $this->order->lockSelf(); 63 if (!$order->canCheckout()) { 64 throw new InvalidOrderStateException( 65 "`Order {$order->order_id}` cannot be checked out: `{$order->status}`" 66 ); 67 } 68 69 $order->status = Order::STATUS_PAYMENT_REQUESTED; 70 $order->transaction_id = $this->newOrderTransactionId(); 71 $order->reserveItems(); 72 73 $order->saveorExplode(); 74 }); 75 } 76 77 public function completeCheckout(): Order 78 { 79 return DB::connection('mysql-store')->transaction(function () { 80 $order = $this->order->lockSelf(); 81 82 // cart should only be in: 83 // processing -> if user hits the callback first. 84 // paid -> if payment provider hits the callback first. 85 // any other state should be considered invalid. 86 if ($order->isPaymentRequested()) { 87 $order->status = Order::STATUS_PAYMENT_APPROVED; 88 $order->saveorExplode(); 89 } elseif (!$order->isPaidOrDelivered()) { 90 // TODO: use validation errors instead? 91 throw new InvalidOrderStateException( 92 "`Order {$order->order_id}` in wrong state: `{$order->status}`" 93 ); 94 } 95 96 return $order; 97 }); 98 } 99 100 public function failCheckout(): Order 101 { 102 return DB::connection('mysql-store')->transaction(function () { 103 $order = $this->order->lockSelf(); 104 if ($order->isPaymentRequested() === false) { 105 throw new InvalidOrderStateException( 106 "`Order {$order->order_id}` failed checkout but is not processing" 107 ); 108 } 109 110 $order->transaction_id = "{$this->provider}-failed"; 111 $order->releaseItems(); 112 113 $order->saveorExplode(); 114 115 return $order; 116 }); 117 } 118 119 public function validate(): array 120 { 121 $shouldShopify = $this->order->isShouldShopify(); 122 // TODO: nested indexed ValidationError...somehow. 123 $itemErrors = []; 124 $items = $this->order->items()->with('product')->get(); 125 foreach ($items as $item) { 126 $messages = []; 127 if (!$item->isValid()) { 128 $messages[] = $item->validationErrors()->allMessages(); 129 } 130 131 $product = $item->product; 132 133 // Checkout process level validations, should not be part of OrderItem validation. 134 if ($product === null || !$product->isAvailable()) { 135 $messages[] = osu_trans('model_validation/store/product.not_available'); 136 } 137 138 if (!$product->inStock($item->quantity)) { 139 $messages[] = osu_trans('model_validation/store/product.insufficient_stock'); 140 } 141 142 if ($item->quantity > $product->max_quantity) { 143 $messages[] = osu_trans('model_validation/store/product.too_many', ['count' => $product->max_quantity]); 144 } 145 146 if ($shouldShopify && !$product->isShopify()) { 147 $messages[] = osu_trans('model_validation/store/product.must_separate'); 148 } 149 150 if ($product->requiresShipping() && !$product->isShopify()) { 151 $messages[] = osu_trans('model_validation/store/product.not_available'); 152 } 153 154 $customClass = $item->getCustomClassInstance(); 155 if ($customClass !== null) { 156 $messages[] = $customClass->validate()->allMessages(); 157 } 158 159 $flattened = array_flatten($messages); 160 if (!empty($flattened)) { 161 $itemErrors[$item->id] = $flattened; 162 } 163 } 164 165 return $itemErrors === [] ? [] : ['orderItems' => $itemErrors]; 166 } 167 168 /** 169 * Helper method for creating an OrderCheckout with just the order number. 170 */ 171 public static function for(?string $orderNumber): self 172 { 173 return new static(Order::whereOrderNumber($orderNumber)->firstOrFail()); 174 } 175 176 private function allowXsollaPayment(): bool 177 { 178 return !$this->order->requiresShipping(); 179 } 180 181 private function newOrderTransactionId(): string 182 { 183 return $this->provider === Order::PROVIDER_SHOPIFY ? "{$this->provider}-{$this->providerReference}" : $this->provider; 184 } 185}