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}