the browser-facing portion of osu!
at master 24 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\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}