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\InsufficientStockException;
9use Carbon\Carbon;
10
11/**
12 * @property bool $allow_multiple
13 * @property \Carbon\Carbon|null $available_until
14 * @property float $base_shipping
15 * @property float|null $cost
16 * @property \Carbon\Carbon $created_at
17 * @property string|null $custom_class
18 * @property \Carbon\Carbon|null $deleted_at
19 * @property string|null $description
20 * @property int $display_order
21 * @property bool $enabled
22 * @property string|null $header_description
23 * @property string|null $header_image
24 * @property string|null $image
25 * @property string|null $images_json
26 * @property self $masterProduct
27 * @property int|null $master_product_id
28 * @property int $max_quantity
29 * @property string $name
30 * @property float $next_shipping
31 * @property \Illuminate\Database\Eloquent\Collection $notificationRequests NotificationRequest
32 * @property int $product_id
33 * @property bool $promoted
34 * @property string|null $shopify_id
35 * @property int|null $stock
36 * @property string|null $type_mappings_json
37 * @property \Carbon\Carbon|null $updated_at
38 * @property \Illuminate\Database\Eloquent\Collection $variations static
39 * @property int|null $weight
40 */
41class Product extends Model
42{
43 const BUTTON_DISABLED = [self::SUPPORTER_TAG_NAME, self::USERNAME_CHANGE];
44 const REDIRECT_PLACEHOLDER = 'redirect';
45 const SUPPORTER_TAG_NAME = 'supporter-tag';
46 const USERNAME_CHANGE = 'username-change';
47
48 protected $primaryKey = 'product_id';
49
50 protected $casts = [
51 'available_until' => 'datetime',
52 'cost' => 'float',
53 'base_shipping' => 'float',
54 'next_shipping' => 'float',
55 'promoted' => 'boolean',
56 'enabled' => 'boolean',
57 'allow_multiple' => 'boolean',
58 ];
59
60 private $images;
61 private $types;
62
63 public function masterProduct()
64 {
65 return $this->belongsTo(static::class, 'master_product_id');
66 }
67
68 public function variations()
69 {
70 return $this->hasMany(static::class, 'master_product_id');
71 }
72
73 public function notificationRequests()
74 {
75 return $this->hasMany(NotificationRequest::class);
76 }
77
78 public function isRedirectPlaceholder()
79 {
80 return $this->custom_class === static::REDIRECT_PLACEHOLDER;
81 }
82
83 public function inStock($quantity = 1, $includeVariations = false)
84 {
85 $inStock = $this->stock === null || $this->stock >= $quantity;
86
87 if ($inStock === false && $includeVariations === true) {
88 $inStock = ($this->masterProduct ?? $this)
89 ->variations
90 ->contains(function ($variation) use ($quantity) {
91 return $variation->inStock($quantity);
92 });
93 }
94
95 return $inStock;
96 }
97
98 public function getHeaderImageAttribute($value)
99 {
100 if ($this->masterProduct) {
101 return $this->masterProduct->header_image;
102 } else {
103 return $value;
104 }
105 }
106
107 public function getHeaderDescriptionAttribute($value)
108 {
109 if ($this->masterProduct) {
110 return $this->masterProduct->header_description;
111 } else {
112 return $value;
113 }
114 }
115
116 public function getDescriptionAttribute($value)
117 {
118 return presence($value) ?? $this->masterProduct?->description;
119 }
120
121 public function isAvailable(): bool
122 {
123 return $this->enabled
124 && ($this->available_until === null ? true : $this->available_until->isFuture());
125 }
126
127 public function typeMappings()
128 {
129 if ($this->masterProduct) {
130 return $this->masterProduct->typeMappings();
131 } else {
132 return json_decode($this->type_mappings_json, true);
133 }
134 }
135
136 public function images()
137 {
138 if (!$this->images_json && $this->masterProduct) {
139 return $this->masterProduct->images();
140 } else {
141 if (!$this->images && $this->images_json) {
142 $this->images = json_decode($this->images_json, true);
143 }
144
145 return $this->images ?? [];
146 }
147 }
148
149 public function requiresShipping()
150 {
151 return $this->weight !== null;
152 }
153
154 public function scopeAvailable($query)
155 {
156 return $query
157 ->where('enabled', true)
158 ->where(function ($q) {
159 return $q->whereNull('available_until')->orWhere('available_until', '>=', Carbon::now());
160 });
161 }
162
163 public function scopeNotAvailable($query)
164 {
165 return $query->where('available_until', '<', Carbon::now());
166 }
167
168 public function scopeListing($query)
169 {
170 return $query
171 ->available()
172 ->where('master_product_id', null)
173 ->with('masterProduct')
174 ->with('variations')
175 ->orderBy('promoted', 'desc')
176 ->orderBy('display_order', 'desc');
177 }
178
179 public function scopeCustomClass($query, $name)
180 {
181 return $query
182 ->where('custom_class', $name);
183 }
184
185 public function scopeEnabled($query)
186 {
187 return $query->where('enabled', true);
188 }
189
190 public function scopeHasShipping($query)
191 {
192 return $query->whereNotNull('weight');
193 }
194
195 /**
196 * Returns the Shopify product variant GraphQL gid for this Product, null if it is not a Shopify item.
197 * This is currently implemented as convenience for checking the gid matches the one from the Storefront API.
198 *
199 * @return string|null
200 */
201 public function getShopifyVariantGid(): ?string
202 {
203 return $this->isShopify() ? base64_encode("gid://shopify/ProductVariant/{$this->shopify_id}") : null;
204 }
205
206 public function isShopify(): bool
207 {
208 return $this->shopify_id !== null;
209 }
210
211 public function productsInRange()
212 {
213 if (!($mappings = $this->typeMappings())) {
214 return [];
215 }
216
217 return self::whereIn('product_id', array_keys($mappings))->get();
218 }
219
220 public function release($quantity)
221 {
222 if (
223 $this->stock === null
224 // stock may have been directly updated to 0.
225 // TODO: should count reservations and available stock separately or something.
226 || $this->stock <= 0
227 ) {
228 return;
229 }
230
231 $this->incrementInstance('stock', $quantity);
232 }
233
234 public function reserve($quantity)
235 {
236 if ($this->stock === null) {
237 return;
238 }
239
240 $this->decrementInstance('stock', $quantity);
241
242 // operating under the assumtion that the caller will prevent concurrent updates.
243 if ($this->stock < 0) {
244 throw new InsufficientStockException();
245 }
246 }
247
248 public function types()
249 {
250 $mappings = $this->typeMappings();
251 if ($mappings === null) {
252 return;
253 }
254
255 if ($this->types !== null) {
256 return $this->types;
257 }
258
259 $currentMapping = $mappings[strval($this->product_id)];
260 $this->types = [];
261
262 foreach ($mappings as $product_id => $mapping) {
263 foreach ($mapping as $type => $value) {
264 if (!isset($this->types[$type])) {
265 $this->types[$type] = [];
266 }
267 $mappingDiff = array_diff_assoc($mapping, $currentMapping);
268 if ((count($mappingDiff) === 0) || (count($mappingDiff) === 1 && isset($mappingDiff[$type]))) {
269 $this->types[$type][$value] = intval($product_id);
270 }
271 }
272 }
273
274 return $this->types;
275 }
276
277 public function url(): string
278 {
279 return $this->isRedirectPlaceholder()
280 ? $this->description
281 : route('store.products.show', $this);
282 }
283}