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\Models\Store;
7
8use App\Exceptions\InvariantException;
9use App\Exceptions\ModelNotSavedException;
10use App\Exceptions\OrderNotModifiableException;
11use App\Models\Country;
12use App\Models\User;
13use Carbon\Carbon;
14use DB;
15use Illuminate\Database\Eloquent\Builder;
16use Illuminate\Database\Eloquent\Collection;
17use Illuminate\Database\Eloquent\SoftDeletes;
18
19/**
20 * Represents a Store Order.
21 *
22 * Order states:
23 * - cancelled -> Order is cancelled.
24 * - incart -> Order is a cart and items can be modified. This is the only state which should allow items to be modified.
25 * - processing -> The checkout process for this Order has started.
26 * - checkout -> User-side of the payment approval process is complete; awaiting confirmation from payment processor.
27 * - paid -> Payment confirmed by payment processor.
28 * - shipped -> Physical order dispatched; not available in all cases.
29 * - delivered -> If we receive confirmation that the order was delivered; not available in all cases.
30 *
31 * @property Address $address
32 * @property int|null $address_id
33 * @property Carbon $created_at
34 * @property Carbon|null $deleted_at
35 * @property Collection<OrderItem> $items
36 * @property string|null $last_tracking_state
37 * @property int $order_id
38 * @property string|null $provider
39 * @property Carbon|null $paid_at
40 * @property Collection<Payment> $payments
41 * @property string|null $reference For paypal transactions, this is the resource Id of the paypal order; otherwise, it is the same as the transaction_id without the prefix.
42 * @property Carbon|null $shipped_at
43 * @property float|null $shipping
44 * @property mixed $status
45 * @property string|null $tracking_code
46 * @property string|null $transaction_id For paypal transactions, this value is based on the IPN or captured payment Id, not the order resource id.
47 * @property Carbon|null $updated_at
48 * @property User $user
49 * @property int $user_id
50 */
51class Order extends Model
52{
53 use SoftDeletes;
54
55 const ECHECK_CLEARED = 'ECHECK CLEARED';
56 const ECHECK_DENIED = 'ECHECK DENIED';
57 const ORDER_NUMBER_REGEX = '/^(?<prefix>[A-Za-z]+)-(?<userId>\d+)-(?<orderId>\d+)$/';
58 const PENDING_ECHECK = 'PENDING ECHECK';
59
60 const PROVIDER_FREE = 'free';
61 const PROVIDER_PAYPAL = 'paypal';
62 const PROVIDER_SHOPIFY = 'shopify';
63 const PROVIDER_XSOLLA = 'xsolla';
64
65 const STATUS_CANCELLED = 'cancelled';
66 const STATUS_DELIVERED = 'delivered';
67 const STATUS_INCART = 'incart';
68 const STATUS_PAID = 'paid';
69 const STATUS_PAYMENT_APPROVED = 'checkout';
70 const STATUS_PAYMENT_REQUESTED = 'processing';
71 const STATUS_SHIPPED = 'shipped';
72
73 const STATUS_CAN_CHECKOUT = [self::STATUS_INCART, self::STATUS_PAYMENT_REQUESTED];
74
75 const STATUS_HAS_INVOICE = [
76 self::STATUS_CANCELLED,
77 self::STATUS_DELIVERED,
78 self::STATUS_PAID,
79 self::STATUS_PAYMENT_APPROVED,
80 self::STATUS_PAYMENT_REQUESTED,
81 self::STATUS_SHIPPED,
82 ];
83
84 protected $primaryKey = 'order_id';
85
86 protected $casts = [
87 'deleted_at' => 'datetime',
88 'paid_at' => 'datetime',
89 'shipped_at' => 'datetime',
90 'shipping' => 'float',
91 ];
92
93 protected array $macros = ['itemsQuantities'];
94
95 protected static function splitTransactionId($value)
96 {
97 return explode('-', $value, 2);
98 }
99
100 public static function pendingForUser(?User $user): ?Builder
101 {
102 if ($user === null) {
103 return null;
104 }
105
106 return static::where('user_id', $user->getKey())->paymentRequested();
107 }
108
109 public function items()
110 {
111 return $this->hasMany(OrderItem::class);
112 }
113
114 public function address()
115 {
116 return $this->belongsTo(Address::class, 'address_id');
117 }
118
119 public function payments()
120 {
121 return $this->hasMany(Payment::class);
122 }
123
124 public function user()
125 {
126 return $this->belongsTo(User::class, 'user_id');
127 }
128
129 public function scopeWhereCanCheckout($query)
130 {
131 return $query->whereIn('status', [static::STATUS_INCART, static::STATUS_PAYMENT_REQUESTED]);
132 }
133
134 public function scopeInCart($query)
135 {
136 return $query->where('status', static::STATUS_INCART);
137 }
138
139 public function scopePaymentRequested($query)
140 {
141 return $query->where('status', static::STATUS_PAYMENT_REQUESTED);
142 }
143
144 public function scopeStale($query)
145 {
146 return $query->where('updated_at', '<', Carbon::now()->subDays($GLOBALS['cfg']['store']['order']['stale_days']));
147 }
148
149 public function scopeWhereHasInvoice($query)
150 {
151 return $query->whereIn('status', static::STATUS_HAS_INVOICE);
152 }
153
154 public function scopeWhereOrderNumber($query, ?string $orderNumber)
155 {
156 if (
157 $orderNumber === null
158 || !preg_match(static::ORDER_NUMBER_REGEX, $orderNumber, $matches)
159 || $GLOBALS['cfg']['store']['order']['prefix'] !== $matches['prefix']
160 ) {
161 return $query->none();
162 }
163
164 $userId = (int) $matches['userId'];
165 $orderId = (int) $matches['orderId'];
166
167 return $query->where([
168 'order_id' => $orderId,
169 'user_id' => $userId,
170 ]);
171 }
172
173 public function scopeWherePaymentTransactionId($query, $transactionId, $provider)
174 {
175 return $query
176 ->whereIn('order_id', Payment::select('order_id')
177 ->where('provider', $provider)
178 ->where('transaction_id', $transactionId)
179 ->where('cancelled', false));
180 }
181
182 public function scopeWithPayments($query)
183 {
184 return $query->with('payments');
185 }
186
187 public function trackingCodes()
188 {
189 $codes = [];
190 preg_match_all('/([A-Z]{2}[A-Z0-9]{9,11})/', $this->tracking_code, $codes);
191
192 return $codes[0];
193 }
194
195 public function getItemCount()
196 {
197 $total = 0;
198 foreach ($this->items as $i) {
199 $total += $i->quantity;
200 }
201
202 return $total;
203 }
204
205 public function getOrderName()
206 {
207 return "osu!store order #{$this->order_id}";
208 }
209
210 public function getOrderNumber()
211 {
212 return $GLOBALS['cfg']['store']['order']['prefix']."-{$this->user_id}-{$this->order_id}";
213 }
214
215 public function getPaymentProvider()
216 {
217 if (!present($this->transaction_id)) {
218 return;
219 }
220
221 return static::splitTransactionId($this->transaction_id)[0];
222 }
223
224 /**
225 * Payment status that appears on the invoice.
226 *
227 * @return string
228 */
229 public function getPaymentStatusText()
230 {
231 switch ($this->status) {
232 case static::STATUS_CANCELLED:
233 return 'Cancelled';
234 case static::STATUS_PAYMENT_REQUESTED:
235 case static::STATUS_PAYMENT_APPROVED:
236 return 'Confirmation Pending';
237 case static::STATUS_INCART:
238 return '';
239 case static::STATUS_PAID:
240 case static::STATUS_SHIPPED:
241 case static::STATUS_DELIVERED:
242 return 'Paid';
243 default:
244 return 'Unknown';
245 }
246 }
247
248 /**
249 * Returns the reference id for the provider associated with this Order.
250 *
251 * For Paypal transactions, this is "paypal-{$capturedId}" where $capturedId is the IPN txn_id
252 * or captured Id of the payment item in the payment transaction (not the payment itself).
253 *
254 * For other payment providers, this value should be "{$provider}-{$reference}".
255 *
256 * In the case of failed or user-aborted payments, this should be "{$provider}-failed".
257 *
258 * @return string|null
259 */
260 public function getProviderReference(): ?string
261 {
262 if (!present($this->transaction_id)) {
263 return null;
264 }
265
266 return static::splitTransactionId($this->transaction_id)[1] ?? null;
267 }
268
269 public function getSubtotal()
270 {
271 $total = 0;
272 foreach ($this->items as $i) {
273 $total += $i->subtotal();
274 }
275
276 return (float) $total;
277 }
278
279 public function setTransactionIdAttribute($value)
280 {
281 // TODO: migrate to always using provider and reference instead of transaction_id.
282 $this->attributes['transaction_id'] = $value;
283
284 $split = static::splitTransactionId($value);
285 $this->provider = $split[0] ?? null;
286
287 $reference = $split[1] ?? null;
288 // For Paypal we're going to use the PAYID number for reference instead of the IPN txn_id
289 if ($this->provider !== static::PROVIDER_PAYPAL && $reference !== 'failed') {
290 $this->reference = $reference;
291 }
292 }
293
294 public function requiresShipping()
295 {
296 foreach ($this->items as $i) {
297 if ($i->product->requiresShipping()) {
298 return true;
299 }
300 }
301
302 return false;
303 }
304
305 /**
306 * Gets the cost of shipping ignoring whether shipping is required or not.
307 * Returns 0 if shipping is not required.
308 *
309 * @return float Shipping cost.
310 */
311 private function getShipping()
312 {
313 if (!$this->address) {
314 return 0.0;
315 }
316
317 $rate = $this->address->shippingRate();
318
319 $total = 0;
320
321 $primaryShipping = 0;
322 $nextShipping = 0;
323
324 //first find the highest shipping cost, and use that as a base
325 foreach ($this->items as $i) {
326 if ($i->product->base_shipping > $primaryShipping) {
327 $primaryShipping = $i->product->base_shipping;
328 }
329 }
330
331 //then add up the total
332 foreach ($this->items as $i) {
333 if ($primaryShipping === $i->product->base_shipping) {
334 $total += $i->product->base_shipping * 1 + ($i->quantity - 1) * $i->product->next_shipping;
335 } else {
336 $total += $i->quantity * $i->product->next_shipping;
337 }
338 }
339
340 return (float) $total * $rate;
341 }
342
343 public function getTotal()
344 {
345 return $this->getSubtotal() + $this->shipping;
346 }
347
348 public function guardNotModifiable(callable $callable)
349 {
350 return $this->getConnection()->transaction(function () use ($callable) {
351 $locked = $this->exists ? $this->lockSelf() : $this;
352 if ($locked->isModifiable() === false) {
353 throw new OrderNotModifiableException($locked);
354 }
355
356 return $callable();
357 });
358 }
359
360 public function canCheckout(): bool
361 {
362 return in_array($this->status, static::STATUS_CAN_CHECKOUT, true);
363 }
364
365 public function canUserCancel(): bool
366 {
367 return $this->status === static::STATUS_PAYMENT_REQUESTED;
368 }
369
370 public function containsSupporterTag(): bool
371 {
372 return $this->items->contains(fn (OrderItem $item) => $item->product->custom_class === Product::SUPPORTER_TAG_NAME);
373 }
374
375 public function hasInvoice(): bool
376 {
377 return in_array($this->status, static::STATUS_HAS_INVOICE, true);
378 }
379
380 public function isCancelled(): bool
381 {
382 return $this->status === static::STATUS_CANCELLED;
383 }
384
385 public function isCart(): bool
386 {
387 return $this->status === static::STATUS_INCART;
388 }
389
390 public function isDelivered(): bool
391 {
392 return $this->status === static::STATUS_DELIVERED;
393 }
394
395 public function isEmpty(): bool
396 {
397 return !$this->items()->exists();
398 }
399
400 public function isHideSupporterFromActivity(): bool
401 {
402 // Consider all supporter tags should be hidden from activity if any one of them is marked to be hidden.
403 // This also skips needing to perform a product lookup.
404 foreach ($this->items as $item) {
405 $extraData = $item->extra_data;
406 if ($extraData instanceof ExtraDataSupporterTag && $extraData->hidden) {
407 return true;
408 }
409 }
410
411 return false;
412 }
413
414 public function isModifiable(): bool
415 {
416 // new cart is status = null
417 return in_array($this->status, [static::STATUS_INCART, null], true);
418 }
419
420 public function isPendingPaymentCapture(): bool
421 {
422 return in_array($this->status, [static::STATUS_PAYMENT_REQUESTED, static::STATUS_PAYMENT_APPROVED], true);
423 }
424
425 public function isPaymentRequested(): bool
426 {
427 return $this->status === static::STATUS_PAYMENT_REQUESTED;
428 }
429
430 public function isPaid(): bool
431 {
432 return $this->status === static::STATUS_PAID;
433 }
434
435 public function isPaidOrDelivered(): bool
436 {
437 return in_array($this->status, [static::STATUS_PAID, static::STATUS_DELIVERED], true);
438 }
439
440 public function isShipped(): bool
441 {
442 return $this->status === static::STATUS_SHIPPED;
443 }
444
445 public function isShopify(): bool
446 {
447 return $this->getPaymentProvider() === static::PROVIDER_SHOPIFY;
448 }
449
450 public function isShouldShopify(): bool
451 {
452 foreach ($this->items as $item) {
453 if ($item->product->shopify_id !== null) {
454 return true;
455 }
456 }
457
458 return false;
459 }
460
461 /**
462 * Updates the cost of the order for checkout.
463 * Don't call this anywhere except beginning checkout.
464 * Do not call it once the payment process has stated.
465 *
466 * @return void
467 */
468 public function refreshCost()
469 {
470 foreach ($this->items as $i) {
471 $i->refreshCost();
472 $i->saveOrExplode(['skipValidations' => true]);
473 }
474
475 if ($this->requiresShipping()) {
476 $this->shipping = $this->getShipping();
477 } else {
478 $this->shipping = null;
479 }
480
481 $this->saveOrExplode();
482 }
483
484 public function setGiftsHidden(bool $hide = true)
485 {
486 // batch update query not used as it will override extra_data contents.
487 $this->getConnection()->transaction(function () use ($hide) {
488 foreach ($this->items as $item) {
489 $extraData = $item->extra_data;
490 if ($extraData instanceof ExtraDataSupporterTag) {
491 $extraData->hidden = $hide;
492 $item->saveOrExplode();
493 }
494 }
495 });
496 }
497
498 #region public functions for updating cart state
499
500 /**
501 * Marks the Order as cancelled. Does not do anything if already cancelled.
502 *
503 * @param User|null $user The User requesting to cancel the order, null for system.
504 * @return void
505 */
506 public function cancel(?User $user = null)
507 {
508 if ($this->isCancelled()) {
509 return;
510 }
511
512 // TODO: Payment processors should set a context variable flagging the user check to be skipped.
513 // This is currently only fine because the Orders controller requires auth.
514 if ($user !== null && $this->user_id === $user->getKey() && !$this->canUserCancel()) {
515 throw new InvariantException(osu_trans('store.order.cancel_not_allowed'));
516 }
517
518 $this->status = Order::STATUS_CANCELLED;
519 $this->saveOrExplode();
520 }
521
522 public function delete()
523 {
524 $this->guardNotModifiable(function () {
525 parent::delete();
526 });
527 }
528
529 public function paid(?Payment $payment)
530 {
531 if ($this->tracking_code === Order::PENDING_ECHECK) {
532 $this->tracking_code = Order::ECHECK_CLEARED;
533 }
534
535 // TODO: use a no payment object instead?
536 if ($payment !== null) {
537 if (!$this->payments()->save($payment)) {
538 throw new ModelNotSavedException('failed saving model');
539 }
540
541 // Duplicate to existing fields.
542 // Useful for checking store-related issues with a single table.
543 $this->transaction_id = $payment->getOrderTransactionId();
544 $this->paid_at = $payment->paid_at;
545 } else {
546 $this->paid_at = Carbon::now();
547 }
548
549 $this->status = $this->requiresShipping() ? static::STATUS_PAID : static::STATUS_DELIVERED;
550 $this->saveOrExplode();
551 }
552
553 #endregion
554
555 #region public functions for updating cart quantities
556
557 /**
558 * Updates the Order with form parameters.
559 *
560 * Updates the Order with with an item extracted from submitted form parameters.
561 * The function returns null on success; an error message, otherwise.
562 *
563 * @param array $itemForm form parameters.
564 * @param bool $addToExisting whether the quantity should be added or replaced.
565 * @return string|null null on success; error message, otherwise.
566 **/
567 public function updateItem(array $itemForm, $addToExisting = false)
568 {
569 return $this->guardNotModifiable(function () use ($itemForm, $addToExisting) {
570 [$params, $product] = static::orderItemParams($itemForm);
571
572 // done first to allow removing of disabled products from cart.
573 if ($params['quantity'] <= 0) {
574 return $this->removeOrderItem($params);
575 }
576
577 // TODO: better validation handling.
578 if ($product === null) {
579 return osu_trans('model_validation/store/product.not_available');
580 }
581
582 $this->saveOrExplode();
583
584 if ($product->allow_multiple) {
585 $item = $this->newOrderItem($params, $product);
586 } else {
587 $item = $this->updateOrderItem($params, $product, $addToExisting);
588 }
589
590 $item->saveOrExplode();
591 });
592 }
593
594 public function releaseItems()
595 {
596 // locking bottleneck
597 $this->getConnection()->transaction(function () {
598 $items = $this->lockForReserve();
599
600 $items->each->releaseProduct();
601 });
602 }
603
604 public function reserveItems()
605 {
606 // locking bottleneck
607 $this->getConnection()->transaction(function () {
608 $items = $this->lockForReserve();
609 $items->each->reserveProduct();
610 });
611 }
612
613 public function switchItems($orderItem, $newProduct)
614 {
615 $this->getConnection()->transaction(function () use ($orderItem, $newProduct) {
616 $this->lockForReserve([$orderItem->product_id, $newProduct->product_id]);
617
618 $quantity = $orderItem->quantity;
619 $orderItem->releaseProduct();
620 $orderItem->product()->associate($newProduct);
621 $orderItem->reserveProduct();
622
623 $orderItem->saveOrExplode();
624 });
625 }
626
627 #endregion
628
629 public static function cart($user)
630 {
631 return static::query()
632 ->where('user_id', $user->user_id)
633 ->inCart()
634 ->with('items.product')
635 ->first();
636 }
637
638 public function macroItemsQuantities(): \Closure
639 {
640 return function ($query) {
641 $query = clone $query;
642
643 $order = new self();
644 $orderItem = new OrderItem();
645 $product = new Product();
646
647 $query
648 ->join(
649 $orderItem->getTable(),
650 $order->qualifyColumn('order_id'),
651 '=',
652 $orderItem->qualifyColumn('order_id')
653 )
654 ->join(
655 $product->getTable(),
656 $orderItem->qualifyColumn('product_id'),
657 '=',
658 $product->qualifyColumn('product_id')
659 )
660 ->whereNotNull($product->qualifyColumn('weight'))
661 ->groupBy($orderItem->qualifyColumn('product_id'))
662 ->groupBy($product->qualifyColumn('name'))
663 ->select(
664 DB::raw("SUM({$orderItem->qualifyColumn('quantity')}) AS quantity"),
665 $product->qualifyColumn('name'),
666 $orderItem->qualifyColumn('product_id')
667 );
668
669 return $query->get();
670 };
671 }
672
673 private function lockForReserve(array $productIds = null)
674 {
675 $query = $this->items()->with('product')->lockForUpdate();
676 if ($productIds) {
677 $query->whereIn('product_id', $productIds);
678 }
679
680 $items = $query->get();
681 $productIds = array_pluck($items, 'product_id');
682 Product::lockForUpdate()->whereIn('product_id', $productIds)->get();
683
684 return $items;
685 }
686
687 private function removeOrderItem(array $params)
688 {
689 optional($this->items()->find($params['id']))->delete();
690 }
691
692 private function newOrderItem(array $params, Product $product)
693 {
694 // FIXME: custom class stuff should probably not go in Order...
695 switch ($product->custom_class) {
696 case Product::SUPPORTER_TAG_NAME:
697 $params['cost'] ??= 0;
698 $params['extra_data'] = ExtraDataSupporterTag::fromOrderItemParams($params, $this->user);
699 break;
700 // TODO: look at migrating to extra_data
701 case Product::USERNAME_CHANGE:
702 // ignore received cost
703 $params['cost'] = $this->user->usernameChangeCost();
704 break;
705 case 'cwc-supporter':
706 case 'mwc4-supporter':
707 case 'mwc7-supporter':
708 case 'owc-supporter':
709 case 'twc-supporter':
710 $params['extra_data'] = $this->extraDataTournamentBanner($params, $product);
711 $params['cost'] = $product->cost ?? 0;
712 break;
713 case Product::REDIRECT_PLACEHOLDER:
714 throw new InvariantException("Product can't be ordered");
715 default:
716 $params['cost'] = $product->cost ?? 0;
717 }
718
719 $item = $this->items()->make([
720 'quantity' => $params['quantity'],
721 'extra_info' => $params['extra_info'],
722 'extra_data' => $params['extra_data'],
723 'cost' => $params['cost'],
724 'product_id' => $product->getKey(),
725 ]);
726
727 $item->setRelation('product', $product);
728
729 return $item;
730 }
731
732 private function updateOrderItem(array $params, Product $product, $addToExisting = false)
733 {
734 $item = $this->items()->where('product_id', $product->product_id)->get()->first();
735 if ($item === null) {
736 return $this->newOrderItem($params, $product);
737 }
738
739 if ($addToExisting) {
740 $item->quantity += $params['quantity'];
741 } else {
742 $item->quantity = $params['quantity'];
743 }
744
745 return $item;
746 }
747
748 // TODO: maybe move to class later?
749 private function extraDataTournamentBanner(array $orderItemParams, Product $product)
750 {
751 $params = get_params($orderItemParams, 'extra_data', [
752 'tournament_id:int',
753 ]);
754
755 // much dodgy. wow.
756 $matches = [];
757 preg_match('/.+\((?<country>.+)\)$/', $product->name, $matches);
758 $params['cc'] = Country::where('name', $matches['country'])->first()->acronym;
759
760 return new ExtraDataTournamentBanner($params);
761 }
762
763 private static function orderItemParams(array $form)
764 {
765 $params = get_params($form, null, [
766 'id:int',
767 'cost:int',
768 'extra_data:array',
769 'extra_info',
770 'product_id:int',
771 'quantity:int',
772 ], ['null_missing' => true]);
773
774 $product = Product::enabled()->find($params['product_id']);
775
776 unset($params['product_id']);
777
778 return [$params, $product];
779 }
780}