Template
Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
"""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"<p>Hi {name},</p>"
|
||||
f"<p>We received order <strong>{order.number}</strong> for "
|
||||
f"<strong>{amount}</strong>.</p>"
|
||||
f"<pre>{lines}</pre>"
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user