WebOrbiton
v1.0.0.0

StocketBase

282 lines · 8.9 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/site-front.php';
  7. require_once __DIR__ . '/currency.php';
  8. ​
  9. final class Cart
  10. {
  11. private const SESSION_KEY = 'cart';
  12. ​
  13. public static function boot(): void
  14. {
  15. if (session_status() !== PHP_SESSION_ACTIVE) {
  16. session_set_cookie_params([
  17. 'lifetime' => 0,
  18. 'path' => '/',
  19. 'secure' => Config::get('SESSION_SECURE', '1') === '1',
  20. 'httponly' => true,
  21. 'samesite' => 'Lax',
  22. ]);
  23. session_start();
  24. }
  25. ​
  26. if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) {
  27. $_SESSION[self::SESSION_KEY] = [];
  28. }
  29. }
  30. ​
  31. private static function lineKey(int $productId, ?int $variantId): string
  32. {
  33. return $productId . ':' . ($variantId ?? 0);
  34. }
  35. ​
  36. public static function add(int $productId, ?int $variantId, int $quantity): void
  37. {
  38. self::boot();
  39. ​
  40. $quantity = max(1, $quantity);
  41. $key = self::lineKey($productId, $variantId);
  42. $existing = $_SESSION[self::SESSION_KEY][$key] ?? null;
  43. $newQuantity = $quantity + (is_array($existing) ? (int) $existing['quantity'] : 0);
  44. ​
  45. $_SESSION[self::SESSION_KEY][$key] = [
  46. 'product_id' => $productId,
  47. 'variant_id' => $variantId,
  48. 'quantity' => min(999, $newQuantity),
  49. ];
  50. }
  51. ​
  52. public static function updateQuantity(int $productId, ?int $variantId, int $quantity): void
  53. {
  54. self::boot();
  55. ​
  56. $key = self::lineKey($productId, $variantId);
  57. ​
  58. if ($quantity <= 0) {
  59. unset($_SESSION[self::SESSION_KEY][$key]);
  60. return;
  61. }
  62. ​
  63. if (!isset($_SESSION[self::SESSION_KEY][$key])) {
  64. return;
  65. }
  66. ​
  67. $_SESSION[self::SESSION_KEY][$key]['quantity'] = min(999, $quantity);
  68. }
  69. ​
  70. public static function remove(int $productId, ?int $variantId): void
  71. {
  72. self::boot();
  73. unset($_SESSION[self::SESSION_KEY][self::lineKey($productId, $variantId)]);
  74. }
  75. ​
  76. public static function clear(): void
  77. {
  78. self::boot();
  79. $_SESSION[self::SESSION_KEY] = [];
  80. unset($_SESSION['cart_discount_code']);
  81. }
  82. ​
  83. public static function count(): int
  84. {
  85. self::boot();
  86. ​
  87. $count = 0;
  88. foreach ($_SESSION[self::SESSION_KEY] as $row) {
  89. $count += (int) ($row['quantity'] ?? 0);
  90. }
  91. ​
  92. return $count;
  93. }
  94. ​
  95. public static function isEmpty(): bool
  96. {
  97. self::boot();
  98. return empty($_SESSION[self::SESSION_KEY]);
  99. }
  100. ​
  101. public static function items(): array
  102. {
  103. self::boot();
  104. ​
  105. $entries = $_SESSION[self::SESSION_KEY];
  106. if (empty($entries)) {
  107. return [];
  108. }
  109. ​
  110. $db = Database::site();
  111. $storeCurrency = (string) (SiteFront::settings()['store_currency'] ?? Currency::DEFAULT_CODE);
  112. $items = [];
  113. ​
  114. foreach ($entries as $key => $entry) {
  115. $productId = (int) ($entry['product_id'] ?? 0);
  116. $variantId = isset($entry['variant_id']) && $entry['variant_id'] !== null ? (int) $entry['variant_id'] : null;
  117. $quantity = max(1, (int) ($entry['quantity'] ?? 1));
  118. ​
  119. $statement = $db->prepare(
  120. "SELECT id, title, slug, cover_image_path, price_cents, currency, track_inventory, stock_quantity, status, product_kind
  121. FROM products WHERE id = :id LIMIT 1"
  122. );
  123. $statement->execute(['id' => $productId]);
  124. $product = $statement->fetch();
  125. ​
  126. if (!$product || $product['status'] !== 'published') {
  127. unset($_SESSION[self::SESSION_KEY][$key]);
  128. continue;
  129. }
  130. ​
  131. $variant = null;
  132. if ($variantId !== null) {
  133. $variantStatement = $db->prepare(
  134. 'SELECT id, name, sku, price_cents, stock_quantity FROM product_variants WHERE id = :id AND product_id = :product_id LIMIT 1'
  135. );
  136. $variantStatement->execute(['id' => $variantId, 'product_id' => $productId]);
  137. $variant = $variantStatement->fetch() ?: null;
  138. if (!$variant) {
  139. $variantId = null;
  140. }
  141. }
  142. ​
  143. $unitPriceCents = ($variant && $variant['price_cents'] !== null)
  144. ? (int) $variant['price_cents']
  145. : (int) ($product['price_cents'] ?? 0);
  146. $currency = (string) ($product['currency'] ?: $storeCurrency);
  147. $stockQuantity = ($variant && $variant['stock_quantity'] !== null) ? $variant['stock_quantity'] : $product['stock_quantity'];
  148. ​
  149. if ($unitPriceCents <= 0) {
  150. unset($_SESSION[self::SESSION_KEY][$key]);
  151. continue;
  152. }
  153. ​
  154. if ((int) ($product['track_inventory'] ?? 0) === 1 && $stockQuantity !== null) {
  155. if ((int) $stockQuantity <= 0) {
  156. unset($_SESSION[self::SESSION_KEY][$key]);
  157. continue;
  158. }
  159. if ($quantity > (int) $stockQuantity) {
  160. $quantity = (int) $stockQuantity;
  161. $_SESSION[self::SESSION_KEY][$key]['quantity'] = $quantity;
  162. }
  163. }
  164. ​
  165. $items[] = [
  166. 'key' => $key,
  167. 'product_id' => $productId,
  168. 'variant_id' => $variantId,
  169. 'title' => $product['title'],
  170. 'slug' => $product['slug'],
  171. 'variant_name' => $variant['name'] ?? null,
  172. 'cover_image_path' => $product['cover_image_path'],
  173. 'unit_price_cents' => $unitPriceCents,
  174. 'currency' => $currency,
  175. 'quantity' => $quantity,
  176. 'total_cents' => $unitPriceCents * $quantity,
  177. 'product_kind' => (string) ($product['product_kind'] ?? 'physical'),
  178. 'track_inventory' => (bool) ($product['track_inventory'] ?? 0),
  179. 'stock_quantity' => $stockQuantity !== null ? (int) $stockQuantity : null,
  180. ];
  181. }
  182. ​
  183. return $items;
  184. }
  185. ​
  186. public static function requiresShipping(?array $items = null): bool
  187. {
  188. foreach ($items ?? self::items() as $item) {
  189. if (($item['product_kind'] ?? 'physical') === 'physical') {
  190. return true;
  191. }
  192. }
  193. ​
  194. return false;
  195. }
  196. ​
  197. public static function subtotalCents(): int
  198. {
  199. $total = 0;
  200. foreach (self::items() as $item) {
  201. $total += $item['total_cents'];
  202. }
  203. ​
  204. return $total;
  205. }
  206. ​
  207. public static function currency(): string
  208. {
  209. foreach (self::items() as $item) {
  210. return $item['currency'];
  211. }
  212. ​
  213. return (string) (SiteFront::settings()['store_currency'] ?? Currency::DEFAULT_CODE);
  214. }
  215. ​
  216. public static function findDiscount(string $code, ?string $currency = null): ?array
  217. {
  218. $code = trim($code);
  219. if ($code === '') {
  220. return null;
  221. }
  222. ​
  223. $statement = Database::site()->prepare('SELECT * FROM discounts WHERE UPPER(code) = UPPER(:code) LIMIT 1');
  224. $statement->execute(['code' => $code]);
  225. $discount = $statement->fetch();
  226. ​
  227. if (!$discount || (int) $discount['is_active'] !== 1) {
  228. return null;
  229. }
  230. ​
  231. $now = date('Y-m-d H:i:s');
  232. if (!empty($discount['starts_at']) && (string) $discount['starts_at'] > $now) {
  233. return null;
  234. }
  235. if (!empty($discount['ends_at']) && (string) $discount['ends_at'] < $now) {
  236. return null;
  237. }
  238. if ($discount['max_redemptions'] !== null && (int) $discount['times_redeemed'] >= (int) $discount['max_redemptions']) {
  239. return null;
  240. }
  241. if ($currency !== null && !empty($discount['currency']) && strtoupper((string) $discount['currency']) !== strtoupper($currency)) {
  242. return null;
  243. }
  244. ​
  245. return $discount;
  246. }
  247. ​
  248. public static function discountAmountCents(array $discount, int $subtotalCents): int
  249. {
  250. if ($discount['min_order_cents'] !== null && $subtotalCents < (int) $discount['min_order_cents']) {
  251. return 0;
  252. }
  253. ​
  254. if ($discount['discount_type'] === 'percentage') {
  255. $amount = (int) round($subtotalCents * (((int) $discount['value']) / 100));
  256. } else {
  257. $amount = (int) $discount['value'];
  258. }
  259. ​
  260. return max(0, min($amount, $subtotalCents));
  261. }
  262. ​
  263. public static function appliedDiscountCode(): ?string
  264. {
  265. self::boot();
  266. $code = $_SESSION['cart_discount_code'] ?? null;
  267. ​
  268. return is_string($code) && $code !== '' ? $code : null;
  269. }
  270. ​
  271. public static function setDiscountCode(?string $code): void
  272. {
  273. self::boot();
  274. if ($code === null || $code === '') {
  275. unset($_SESSION['cart_discount_code']);
  276. return;
  277. }
  278. ​
  279. $_SESSION['cart_discount_code'] = $code;
  280. }
  281. }
  282. ​