From 3456b6edf42685d81c15d1a2f0f78a4c7c48c429 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 24 Aug 2026 21:43:38 +0200 Subject: [PATCH] feat(orders): add order service and insufficient stock exception --- src/Services/InsufficientStockException.php | 11 ++ src/Services/OrderService.php | 162 ++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/Services/InsufficientStockException.php create mode 100644 src/Services/OrderService.php diff --git a/src/Services/InsufficientStockException.php b/src/Services/InsufficientStockException.php new file mode 100644 index 0000000..33859e4 --- /dev/null +++ b/src/Services/InsufficientStockException.php @@ -0,0 +1,11 @@ +variants = $variants ?? new VariantRepository($db); + $this->coupons = $coupons ?? new CouponRepository($db); + $this->users = new UserRepository($db); + $this->orderMailer = new OrderMailer($mailer ?? new NullMailer(), (string) Config::get('CURRENCY', 'usd')); + } + + public function createFromCart(int $userId, Cart $cart, string $currency, ?array $discount = null): int + { + $lines = $cart->lines(); + if ($lines === []) { + throw new \RuntimeException('Cannot create an order from an empty cart'); + } + + foreach ($lines as $line) { + if ($line['quantity'] > $line['availableStock']) { + throw new InsufficientStockException($line['name']); + } + } + + $subtotal = 0; + foreach ($lines as $line) { + $subtotal += $line['lineTotalCents']; + } + $discountCents = $discount !== null ? min((int) $discount['discountCents'], $subtotal) : 0; + $couponCode = $discount !== null ? ($discount['couponCode'] ?? null) : null; + $total = $subtotal - $discountCents; + + return $this->db->transaction(function () use ($userId, $total, $currency, $discountCents, $couponCode, $lines): int { + $orderId = $this->orders->create($userId, $total, $currency, $discountCents, $couponCode); + foreach ($lines as $line) { + $this->orders->addItem( + $orderId, + $line['productId'], + $line['name'], + $line['unitPriceCents'], + $line['quantity'], + $line['variantId'], + $line['variantLabel'] + ); + } + return $orderId; + }); + } + + public function markPaid(int $orderId, ?string $stripeSessionId = null): void + { + $order = $this->orders->find($orderId); + if ($order === null || $order['status'] !== 'pending') { + return; + } + + $items = $this->orders->items($orderId); + foreach ($items as $orderItem) { + $available = $this->availableStock($orderItem); + if ($available < (int) $orderItem['quantity']) { + throw new InsufficientStockException($orderItem['productName']); + } + } + + $this->db->transaction(function () use ($orderId, $items, $stripeSessionId): void { + foreach ($items as $orderItem) { + $this->decrementStock($orderItem); + } + $this->orders->setStatus($orderId, 'paid', 'paidAt', $stripeSessionId); + }); + + $this->redeemCoupon($order['couponCode'] ?? null); + $this->notify($orderId, 'confirmation'); + } + + public function markFulfilled(int $orderId): void + { + $order = $this->orders->find($orderId); + if ($order === null) { + throw new \RuntimeException('Order not found'); + } + if ($order['status'] !== 'paid') { + throw new \RuntimeException('Only paid orders can be fulfilled'); + } + $this->orders->setStatus($orderId, 'fulfilled', 'fulfilledAt'); + $this->notify($orderId, 'status'); + } + + public function attachStripeSession(int $orderId, string $sessionId): void + { + $this->orders->setStatus($orderId, 'pending', null, $sessionId); + } + + private function availableStock(array $orderItem): int + { + if ($orderItem['variantId'] !== null) { + $variant = $this->variants->find((int) $orderItem['variantId']); + return $variant === null ? 0 : (int) $variant['stockQuantity']; + } + $product = $this->products->find((int) $orderItem['productId']); + return $product === null ? 0 : (int) $product['stockQuantity']; + } + + private function decrementStock(array $orderItem): void + { + if ($orderItem['variantId'] !== null) { + $this->variants->decrementStock((int) $orderItem['variantId'], (int) $orderItem['quantity']); + return; + } + $this->products->decrementStock((int) $orderItem['productId'], (int) $orderItem['quantity']); + } + + private function redeemCoupon(?string $couponCode): void + { + if ($couponCode === null || $couponCode === '') { + return; + } + $coupon = $this->coupons->findByCode($couponCode); + if ($coupon !== null) { + $this->coupons->recordRedemption((int) $coupon['id']); + } + } + + private function notify(int $orderId, string $kind): void + { + $order = $this->orders->find($orderId); + $user = $this->users->findById((int) $order['userId']); + if ($user === null) { + return; + } + if ($kind === 'confirmation') { + $this->orderMailer->orderConfirmation($user['email'], $order, $this->orders->items($orderId)); + return; + } + $this->orderMailer->statusUpdate($user['email'], $order); + } +}