"""Catalog, cart, inventory, and Stripe checkout for FEATURE_SHOP.""" from __future__ import annotations import logging from datetime import date from decimal import Decimal from django.conf import settings from django.db import transaction from django.db.models import F, Sum from django.utils import timezone from shop.models import Order, OrderItem, Product logger = logging.getLogger(__name__) CART_SESSION_KEY = "shop_cart" class ShopError(RuntimeError): pass def _stripe(): secret = (settings.STRIPE_SECRET_KEY or "").strip() if not secret: raise ShopError("STRIPE_SECRET_KEY is not configured") try: import stripe except ImportError as exc: raise ShopError("stripe package is not installed") from exc stripe.api_key = secret return stripe def next_order_number() -> str: today = date.today().strftime("%Y%m%d") prefix = f"ORD-{today}-" existing = Order.objects.filter(number__startswith=prefix).count() return f"{prefix}{existing + 1:03d}" def get_cart(session) -> dict[str, int]: raw = session.get(CART_SESSION_KEY) or {} cart: dict[str, int] = {} for key, qty in raw.items(): try: n = int(qty) except (TypeError, ValueError): continue if n > 0: cart[str(key)] = n return cart def save_cart(session, cart: dict[str, int]) -> None: session[CART_SESSION_KEY] = cart session.modified = True def add_to_cart(session, product: Product, quantity: int = 1) -> dict[str, int]: if quantity < 1: raise ShopError("Quantity must be at least 1.") cart = get_cart(session) pid = str(product.pk) cart[pid] = cart.get(pid, 0) + quantity save_cart(session, cart) return cart def set_cart_qty(session, product: Product, quantity: int) -> dict[str, int]: cart = get_cart(session) pid = str(product.pk) if quantity < 1: cart.pop(pid, None) else: cart[pid] = quantity save_cart(session, cart) return cart def cart_lines(session) -> list[dict]: cart = get_cart(session) products = { str(p.pk): p for p in Product.objects.filter(pk__in=cart.keys(), is_published=True) } lines = [] for pid, qty in cart.items(): product = products.get(pid) if not product: continue lines.append( { "product": product, "quantity": qty, "unit_price": product.price, "line_total": product.price * qty, } ) return lines def cart_total(lines: list[dict]) -> Decimal: return sum((line["line_total"] for line in lines), Decimal("0")) def queued_print_minutes() -> int: total = ( OrderItem.objects.filter( order__status__in=[Order.Status.OPEN, Order.Status.PAID], product__fulfillment=Product.Fulfillment.MADE_TO_ORDER, ).aggregate(total=Sum(F("print_minutes") * F("quantity")))["total"] or 0 ) return int(total) def available_qty(product: Product) -> int | None: """Units that can be sold. None means unlimited (untracked made-to-order).""" if not product.track_inventory: return None if product.fulfillment == Product.Fulfillment.STOCKED: return max(product.stock_qty, 0) limit = int(getattr(settings, "SHOP_PRINT_QUEUE_LIMIT_MINUTES", 0) or 0) if not limit or not product.print_minutes: return None remaining_minutes = max(limit - queued_print_minutes(), 0) return remaining_minutes // product.print_minutes def assert_can_sell(product: Product, quantity: int) -> None: if quantity < 1: raise ShopError("Quantity must be at least 1.") if not product.is_published: raise ShopError("This product is not available.") avail = available_qty(product) if avail is not None and quantity > avail: raise ShopError(f"Only {avail} of {product.name} available.") def adjust_stock(product: Product, delta: int) -> Product: """Increment (positive) or decrement (negative) on-hand stock.""" product.stock_qty = product.stock_qty + delta if product.stock_qty < 0: raise ShopError(f"Insufficient stock for {product.sku}.") product.save(update_fields=["stock_qty", "updated_at"]) return product def create_order_from_cart( session, *, email: str, customer_name: str = "", shipping_address: dict | None = None, notes: str = "", ) -> Order: lines = cart_lines(session) if not lines: raise ShopError("Cart is empty.") email = (email or "").strip() if not email: raise ShopError("Email is required.") for line in lines: assert_can_sell(line["product"], line["quantity"]) currency = (settings.STRIPE_CURRENCY or "usd").lower() with transaction.atomic(): order = Order.objects.create( number=next_order_number(), email=email, customer_name=(customer_name or "").strip(), status=Order.Status.DRAFT, amount=cart_total(lines), currency=currency, shipping_address=shipping_address or {}, notes=notes, ) for line in lines: product = line["product"] OrderItem.objects.create( order=order, product=product, name=product.name, sku=product.sku, quantity=line["quantity"], unit_price=product.price, print_minutes=product.print_minutes, ) return order def create_checkout_session(order: Order, *, success_url: str, cancel_url: str) -> str: stripe = _stripe() line_items = [ { "quantity": item.quantity, "price_data": { "currency": (order.currency or "usd").lower(), "unit_amount": int( (item.unit_price * Decimal("100")).quantize(Decimal("1")) ), "product_data": {"name": item.name}, }, } for item in order.items.all() ] if not line_items: raise ShopError("Order has no items.") session = stripe.checkout.Session.create( mode="payment", customer_email=order.email or None, line_items=line_items, metadata={"shop_order_id": str(order.pk), "order_number": order.number}, success_url=success_url, cancel_url=cancel_url, ) order.stripe_checkout_session_id = session.id order.hosted_checkout_url = session.url or "" order.status = Order.Status.OPEN order.save( update_fields=[ "stripe_checkout_session_id", "hosted_checkout_url", "status", "updated_at", ] ) return session.url or "" def _reserve_inventory(order: Order) -> None: for item in order.items.select_related("product"): product = item.product if product is None or not product.track_inventory: continue if product.fulfillment == Product.Fulfillment.STOCKED: adjust_stock(product, -item.quantity) def _notify_pos(order: Order) -> None: from django.apps import apps if not apps.is_installed("pos_sync"): return from pos_sync.services import enqueue_online_sale enqueue_online_sale(order) def mark_paid(order: Order, *, stripe_id: str = "") -> None: if order.status == Order.Status.PAID: return with transaction.atomic(): locked = Order.objects.select_for_update().get(pk=order.pk) if locked.status == Order.Status.PAID: return _reserve_inventory(locked) locked.status = Order.Status.PAID locked.paid_at = timezone.now() locked.save(update_fields=["status", "paid_at", "updated_at"]) order.refresh_from_db() try: send_order_email(order) except Exception: logger.exception("order confirmation email failed for %s", order.number) try: _notify_pos(order) except Exception: logger.exception("POS notify failed for %s", order.number) def send_order_email(order: Order) -> bool: to_email = (order.email or "").strip() if not to_email: raise ShopError("Order has no email address") from django.core.mail import EmailMultiAlternatives name = order.customer_name or "there" amount = f"{order.amount} {order.currency.upper()}" lines = "\n".join( f"- {item.quantity}× {item.name} ({item.sku})" for item in order.items.all() ) subject = f"Order {order.number} from {settings.SITE_NAME}" text = ( f"Hi {name},\n\n" f"We received order {order.number} for {amount}.\n\n" f"{lines}\n\nThank you.\n" ) html = ( f"
Hi {name},
" f"We received order {order.number} for " f"{amount}.
" f"{lines}"
)
mail = EmailMultiAlternatives(
subject=subject,
body=text,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
mail.attach_alternative(html, "text/html")
mail.send(fail_silently=False)
return True