{p.name}
+{p.sku} · {p.unit_of_measure}
+ {#if p.description}{p.description}
{/if} +diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py
index d813856..544341a 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -184,6 +184,29 @@ def require_client_module_access(module_key: str, minimum_level: str = "view"):
return dependency
+def require_ordering_admin_session(
+ session: AuthSession = Depends(get_auth_session),
+ db: Session = Depends(get_db),
+) -> AuthSession:
+ """Internal-side authorization for managing the ordering portal.
+
+ Accepts the single Lean admin (``role == "admin"``) or an internal Hunter
+ user holding ``manage`` on the ordering module. Client/customer users are
+ rejected — they use the customer-facing ``require_client_module_access``
+ dependency instead.
+ """
+ if session.role == "admin":
+ return session
+ if session.role == "internal":
+ permissions = session.module_permissions or {}
+ if not has_access_level(permissions.get("ordering"), "manage"):
+ log_security_event("authz.denied", role=session.role, module="ordering", access_level="manage")
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires manage access")
+ return session
+ log_security_event("authz.denied", role=session.role, module="ordering", required="admin")
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ordering administration requires internal admin access")
+
+
def require_client_access_manager_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
diff --git a/backend/app/api/ordering.py b/backend/app/api/ordering.py
new file mode 100644
index 0000000..3f91cab
--- /dev/null
+++ b/backend/app/api/ordering.py
@@ -0,0 +1,390 @@
+"""Customer-facing B2B ordering API.
+
+Every endpoint is gated by the ``ordering`` module and scoped to the caller's
+own company (``tenant_id`` + ``client_account_id``). Customers can never see
+another company's catalogue visibility, prices, or orders.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload
+
+from app.api.deps import AuthSession, require_client_module_access
+from app.db.session import get_db
+from app.models.ordering import CatalogueProduct, Order, OrderLine
+from app.schemas.ordering import (
+ DraftOrderCreate,
+ DraftOrderUpdate,
+ OrderLineInput,
+ OrderSubmitRequest,
+)
+from app.services import ordering_service as svc
+from app.services.order_notifications import send_order_submitted_notifications
+from app.services.order_pdf import OrderPdfUnavailableError, build_order_confirmation_pdf
+from app.services.ordering_pricing import resolve_price
+
+router = APIRouter(prefix="/api/ordering", tags=["ordering"])
+
+
+# --- Helpers -----------------------------------------------------------------
+
+
+def _require_active_customer(db: Session, session: AuthSession):
+ account = svc.get_customer_account(db, client_account_id=session.client_account_id)
+ svc.ensure_customer_active(account)
+ return account
+
+
+def _get_visible_product(db: Session, session: AuthSession, product_id: int) -> CatalogueProduct:
+ hidden = svc.visible_product_ids_for_customer(
+ db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
+ )
+ product = db.scalar(
+ select(CatalogueProduct).where(
+ CatalogueProduct.id == product_id,
+ CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
+ CatalogueProduct.active.is_(True),
+ )
+ )
+ if product is None or product.id in hidden:
+ raise HTTPException(status_code=404, detail="Product not available")
+ return product
+
+
+def _load_own_order(db: Session, session: AuthSession, order_id: int) -> Order:
+ order = db.scalar(
+ select(Order)
+ .where(
+ Order.id == order_id,
+ Order.tenant_id == svc.ORDERING_TENANT,
+ Order.client_account_id == session.client_account_id,
+ )
+ .options(selectinload(Order.lines))
+ )
+ if order is None:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return order
+
+
+def _rebuild_lines(db: Session, session: AuthSession, order: Order, lines: list[OrderLineInput]) -> None:
+ """Replace an order's lines with freshly priced lines (server-side pricing).
+
+ Validates product availability and minimum order quantity. Raises 422 on
+ invalid input.
+ """
+ order.lines.clear()
+ db.flush()
+ for index, line_input in enumerate(lines):
+ product = _get_visible_product(db, session, line_input.product_id)
+ if line_input.quantity < product.min_order_quantity:
+ raise HTTPException(
+ status_code=422,
+ detail=(
+ f"{product.name}: minimum order quantity is "
+ f"{product.min_order_quantity:g} {product.unit_of_measure}"
+ ),
+ )
+ resolution = resolve_price(
+ db,
+ client_account_id=session.client_account_id,
+ product=product,
+ quantity=line_input.quantity,
+ )
+ order.lines.append(
+ OrderLine(
+ tenant_id=svc.ORDERING_TENANT,
+ product_id=product.id,
+ product_name=product.name,
+ product_sku=product.sku,
+ quantity=line_input.quantity,
+ unit_price=resolution.unit_price,
+ requires_quote=resolution.requires_quote,
+ price_source=resolution.price_source,
+ price_rule_id=resolution.price_rule_id,
+ discount_percent=resolution.discount_percent,
+ sort_order=index,
+ notes=line_input.notes,
+ )
+ )
+ db.flush()
+ svc.recompute_order_totals(order)
+
+
+# --- Catalogue ---------------------------------------------------------------
+
+
+@router.get("/catalogue")
+def list_catalogue(
+ category: str | None = Query(default=None),
+ q: str | None = Query(default=None),
+ session: AuthSession = Depends(require_client_module_access("ordering")),
+ db: Session = Depends(get_db),
+):
+ _require_active_customer(db, session)
+ products = svc.list_visible_products(
+ db, tenant_id=svc.ORDERING_TENANT, client_account_id=session.client_account_id
+ )
+ if category:
+ products = [p for p in products if p.category == category]
+ if q:
+ needle = q.lower()
+ products = [
+ p
+ for p in products
+ if needle in p.name.lower()
+ or needle in p.sku.lower()
+ or (p.description and needle in p.description.lower())
+ ]
+ return [
+ svc.serialize_product(p, db=db, client_account_id=session.client_account_id)
+ for p in products
+ ]
+
+
+@router.get("/catalogue/{product_id}")
+def get_catalogue_product(
+ product_id: int,
+ quantity: float = Query(default=1.0, gt=0),
+ session: AuthSession = Depends(require_client_module_access("ordering")),
+ db: Session = Depends(get_db),
+):
+ _require_active_customer(db, session)
+ product = _get_visible_product(db, session, product_id)
+ return svc.serialize_product(
+ product, db=db, client_account_id=session.client_account_id, quantity=quantity
+ )
+
+
+# --- Orders ------------------------------------------------------------------
+
+
+@router.get("/orders")
+def list_orders(
+ status_filter: str | None = Query(default=None, alias="status"),
+ session: AuthSession = Depends(require_client_module_access("ordering")),
+ db: Session = Depends(get_db),
+):
+ stmt = (
+ select(Order)
+ .where(
+ Order.tenant_id == svc.ORDERING_TENANT,
+ Order.client_account_id == session.client_account_id,
+ )
+ .options(selectinload(Order.lines))
+ .order_by(Order.created_at.desc())
+ )
+ orders = db.scalars(stmt).all()
+ if status_filter == "draft":
+ orders = [o for o in orders if o.status == "draft"]
+ elif status_filter == "submitted":
+ orders = [o for o in orders if o.status != "draft"]
+ return [svc.serialize_order(o, for_admin=False) for o in orders]
+
+
+@router.get("/orders/{order_id}")
+def get_order(
+ order_id: int,
+ session: AuthSession = Depends(require_client_module_access("ordering")),
+ db: Session = Depends(get_db),
+):
+ order = _load_own_order(db, session, order_id)
+ return svc.serialize_order(order, for_admin=False)
+
+
+@router.post("/orders", status_code=status.HTTP_201_CREATED)
+def create_draft_order(
+ payload: DraftOrderCreate,
+ session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
+ db: Session = Depends(get_db),
+):
+ _require_active_customer(db, session)
+ order = Order(
+ tenant_id=svc.ORDERING_TENANT,
+ client_account_id=session.client_account_id,
+ status="draft",
+ created_by_user_id=session.user_id,
+ created_by_name=session.name,
+ purchase_order_number=payload.purchase_order_number,
+ delivery_notes=payload.delivery_notes,
+ requested_delivery_date=payload.requested_delivery_date,
+ fulfilment_method=payload.fulfilment_method,
+ )
+ db.add(order)
+ db.flush()
+ _rebuild_lines(db, session, order, payload.lines)
+ svc.record_status_change(
+ db, order, to_status="draft", actor_type="customer", actor_name=session.name, note="Draft created"
+ )
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.created", summary="Draft order created."
+ )
+ db.commit()
+ db.refresh(order)
+ return svc.serialize_order(order, for_admin=False)
+
+
+@router.patch("/orders/{order_id}")
+def update_draft_order(
+ order_id: int,
+ payload: DraftOrderUpdate,
+ session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
+ db: Session = Depends(get_db),
+):
+ order = _load_own_order(db, session, order_id)
+ if order.status not in svc.CUSTOMER_EDITABLE_STATUSES:
+ raise HTTPException(
+ status_code=409,
+ detail="This order has been submitted and can no longer be edited. Ask an admin to reopen it.",
+ )
+ changes = payload.model_dump(exclude_unset=True)
+ for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
+ if field_name in changes:
+ setattr(order, field_name, changes[field_name])
+ if payload.lines is not None:
+ _rebuild_lines(db, session, order, payload.lines)
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.updated", summary="Draft order updated."
+ )
+ db.commit()
+ db.refresh(order)
+ return svc.serialize_order(order, for_admin=False)
+
+
+@router.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_draft_order(
+ order_id: int,
+ session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
+ db: Session = Depends(get_db),
+):
+ order = _load_own_order(db, session, order_id)
+ if order.status != "draft":
+ raise HTTPException(status_code=409, detail="Only draft orders can be deleted")
+ db.delete(order)
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/orders/{order_id}/submit")
+def submit_order(
+ order_id: int,
+ payload: OrderSubmitRequest,
+ session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
+ db: Session = Depends(get_db),
+):
+ account = _require_active_customer(db, session)
+ order = _load_own_order(db, session, order_id)
+ if order.status != "draft":
+ raise HTTPException(status_code=409, detail="Only draft orders can be submitted")
+ if not order.lines:
+ raise HTTPException(status_code=422, detail="Cannot submit an empty order")
+
+ # Apply any last-minute header changes supplied on submit.
+ changes = payload.model_dump(exclude_unset=True)
+ for field_name in ("purchase_order_number", "delivery_notes", "requested_delivery_date", "fulfilment_method"):
+ if field_name in changes and changes[field_name] is not None:
+ setattr(order, field_name, changes[field_name])
+
+ # Required PO number, if the tenant configured it.
+ from app.services.order_notifications import get_or_create_settings
+
+ settings = get_or_create_settings(db, order.tenant_id)
+ if settings.require_po_number and not (order.purchase_order_number or "").strip():
+ raise HTTPException(status_code=422, detail="A purchase order number is required to submit this order")
+
+ # Re-resolve and freeze the exact price used at submission time.
+ line_inputs = [
+ OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
+ for line in order.lines
+ ]
+ _rebuild_lines(db, session, order, line_inputs)
+
+ order.order_number = svc.next_order_number(db, order.tenant_id)
+ order.submitted_at = datetime.utcnow()
+ svc.record_status_change(
+ db, order, to_status="submitted", actor_type="customer", actor_name=session.name, note="Submitted by customer"
+ )
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.submitted",
+ summary=f"Order {order.order_number} submitted (subtotal ex GST {order.subtotal_ex_gst:.2f}).",
+ )
+
+ notifications = send_order_submitted_notifications(db, order)
+ db.commit()
+ db.refresh(order)
+ result = svc.serialize_order(order, for_admin=False)
+ result["notifications"] = [
+ {"channel": n.channel, "recipients": n.recipients, "delivered": n.delivered, "detail": n.detail}
+ for n in notifications
+ ]
+ return result
+
+
+@router.post("/orders/{order_id}/reorder", status_code=status.HTTP_201_CREATED)
+def reorder(
+ order_id: int,
+ session: AuthSession = Depends(require_client_module_access("ordering", "edit")),
+ db: Session = Depends(get_db),
+):
+ _require_active_customer(db, session)
+ source = _load_own_order(db, session, order_id)
+ new_order = Order(
+ tenant_id=svc.ORDERING_TENANT,
+ client_account_id=session.client_account_id,
+ status="draft",
+ created_by_user_id=session.user_id,
+ created_by_name=session.name,
+ fulfilment_method=source.fulfilment_method,
+ )
+ db.add(new_order)
+ db.flush()
+ line_inputs = [
+ OrderLineInput(product_id=line.product_id, quantity=line.quantity, notes=line.notes)
+ for line in source.lines
+ ]
+ # Skip lines whose product is no longer available rather than failing.
+ available: list[OrderLineInput] = []
+ for line_input in line_inputs:
+ product = db.scalar(
+ select(CatalogueProduct).where(
+ CatalogueProduct.id == line_input.product_id,
+ CatalogueProduct.tenant_id == svc.ORDERING_TENANT,
+ CatalogueProduct.active.is_(True),
+ )
+ )
+ if product is not None and line_input.quantity >= product.min_order_quantity:
+ available.append(line_input)
+ _rebuild_lines(db, session, new_order, available)
+ svc.record_status_change(
+ db, new_order, to_status="draft", actor_type="customer", actor_name=session.name,
+ note=f"Reordered from {source.order_number or source.id}",
+ )
+ svc.audit_order_event(
+ db, session=session, order=new_order, action="order.reordered",
+ summary=f"Draft created by reordering from {source.order_number or source.id}.",
+ )
+ db.commit()
+ db.refresh(new_order)
+ return svc.serialize_order(new_order, for_admin=False)
+
+
+@router.get("/orders/{order_id}/confirmation.pdf")
+def order_confirmation_pdf(
+ order_id: int,
+ session: AuthSession = Depends(require_client_module_access("ordering")),
+ db: Session = Depends(get_db),
+):
+ order = _load_own_order(db, session, order_id)
+ account = svc.get_customer_account(db, client_account_id=session.client_account_id)
+ try:
+ pdf_bytes = build_order_confirmation_pdf(order, account)
+ except OrderPdfUnavailableError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ filename = f"order-{order.order_number or order.id}.pdf"
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
diff --git a/backend/app/api/ordering_admin.py b/backend/app/api/ordering_admin.py
new file mode 100644
index 0000000..16c3124
--- /dev/null
+++ b/backend/app/api/ordering_admin.py
@@ -0,0 +1,1001 @@
+"""Admin / internal management API for the B2B ordering portal.
+
+Gated by :func:`require_ordering_admin_session` (the Lean admin, or an internal
+user with ``manage`` on the ordering module). Operates across all customers
+within the seller's ordering tenant.
+"""
+from __future__ import annotations
+
+import re
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session, selectinload
+
+from app.api.deps import AuthSession, require_ordering_admin_session
+from app.db.session import get_db
+from app.models.client_access import ClientAccount, ClientUser
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerPriceAssignment,
+ CustomerProductPrice,
+ CustomerProductVisibility,
+ Order,
+ OrderLine,
+ PriceList,
+ PriceListItem,
+ PriceTier,
+ ProductCategory,
+ XeroSyncLog,
+)
+from app.schemas.ordering import (
+ CatalogueProductCreate,
+ CatalogueProductUpdate,
+ CategoryCreate,
+ CategoryUpdate,
+ CustomerCreate,
+ CustomerPriceAssignmentUpsert,
+ CustomerProductPriceUpsert,
+ CustomerUpdate,
+ CustomerUserCreate,
+ CustomerUserUpdate,
+ NotificationSettingsUpdate,
+ OrderLineOverride,
+ OrderStatusUpdate,
+ PriceListCreate,
+ PriceListItemUpsert,
+ ReopenOrderRequest,
+ VisibilityUpdate,
+)
+from app.services import ordering_service as svc
+from app.services.client_access_service import (
+ ensure_user_module_permissions,
+ record_audit_event,
+)
+from app.services.order_notifications import get_or_create_settings
+from app.services.xero_service import submit_order_to_xero, xero_status_snapshot
+
+router = APIRouter(prefix="/api/ordering-admin", tags=["ordering-admin"])
+
+TENANT = svc.ORDERING_TENANT
+
+
+# --- Helpers -----------------------------------------------------------------
+
+
+def _slugify(value: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
+ return slug or "item"
+
+
+def _actor(session: AuthSession) -> dict[str, str]:
+ return {
+ "actor_type": "lean_admin",
+ "actor_name": session.name or "Admin",
+ "actor_email": session.email or "",
+ "actor_role": session.client_role or session.role,
+ }
+
+
+def _customer_or_404(db: Session, customer_id: int) -> ClientAccount:
+ account = db.scalar(select(ClientAccount).where(ClientAccount.id == customer_id))
+ if account is None:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ return account
+
+
+def _product_or_404(db: Session, product_id: int) -> CatalogueProduct:
+ product = db.scalar(
+ select(CatalogueProduct).where(
+ CatalogueProduct.id == product_id, CatalogueProduct.tenant_id == TENANT
+ )
+ )
+ if product is None:
+ raise HTTPException(status_code=404, detail="Product not found")
+ return product
+
+
+def _load_order(db: Session, order_id: int) -> Order:
+ order = db.scalar(
+ select(Order)
+ .where(Order.id == order_id, Order.tenant_id == TENANT)
+ .options(selectinload(Order.lines), selectinload(Order.status_history))
+ )
+ if order is None:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return order
+
+
+def _serialize_tiers(db: Session, *, customer_product_price_id=None, price_list_item_id=None) -> list[dict]:
+ stmt = select(PriceTier)
+ if customer_product_price_id is not None:
+ stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
+ else:
+ stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
+ return [
+ {"id": t.id, "min_quantity": t.min_quantity, "unit_price": t.unit_price}
+ for t in db.scalars(stmt.order_by(PriceTier.min_quantity)).all()
+ ]
+
+
+# --- Customers ---------------------------------------------------------------
+
+
+def _serialize_customer(db: Session, account: ClientAccount) -> dict:
+ users = db.scalars(select(ClientUser).where(ClientUser.client_account_id == account.id)).all()
+ assignment = db.scalar(
+ select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == account.id)
+ )
+ return {
+ "id": account.id,
+ "name": account.name,
+ "client_code": account.client_code,
+ "tenant_id": account.tenant_id,
+ "status": account.status,
+ "notes": account.notes,
+ "user_count": len(users),
+ "price_list_id": assignment.price_list_id if assignment else None,
+ "discount_percent": assignment.discount_percent if assignment else 0.0,
+ "created_at": account.created_at,
+ }
+
+
+@router.get("/customers")
+def list_customers(
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ accounts = db.scalars(select(ClientAccount).order_by(ClientAccount.name)).all()
+ return [_serialize_customer(db, a) for a in accounts]
+
+
+@router.post("/customers", status_code=status.HTTP_201_CREATED)
+def create_customer(
+ payload: CustomerCreate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ tenant_id = (payload.tenant_id or _slugify(payload.client_code)).strip() or _slugify(payload.name)
+ account = ClientAccount(
+ tenant_id=tenant_id,
+ name=payload.name,
+ client_code=payload.client_code,
+ status="active",
+ notes=payload.notes,
+ )
+ db.add(account)
+ try:
+ db.flush()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A customer with that name or code already exists") from exc
+
+ # Enable the ordering feature for the new customer so its users can access
+ # the portal once granted module permissions.
+ from app.models.client_access import ClientFeatureAccess
+ from app.services.client_access_service import MODULE_INDEX
+
+ info = MODULE_INDEX["ordering"]
+ db.add(
+ ClientFeatureAccess(
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ feature_key="ordering",
+ feature_name=info["module_name"],
+ feature_group=info["module_group"],
+ description=info["description"],
+ enabled=True,
+ )
+ )
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="customer.created",
+ target_type="client_account",
+ target_id=account.id,
+ module_key="ordering",
+ summary=f"Customer {account.name} created for ordering.",
+ **_actor(session),
+ )
+ db.commit()
+ db.refresh(account)
+ return _serialize_customer(db, account)
+
+
+@router.patch("/customers/{customer_id}")
+def update_customer(
+ customer_id: int,
+ payload: CustomerUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ for field_name, value in payload.model_dump(exclude_unset=True).items():
+ setattr(account, field_name, value)
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="customer.updated",
+ target_type="client_account",
+ target_id=account.id,
+ module_key="ordering",
+ summary=f"Customer {account.name} updated (status {account.status}).",
+ **_actor(session),
+ )
+ db.commit()
+ db.refresh(account)
+ return _serialize_customer(db, account)
+
+
+# --- Customer users ----------------------------------------------------------
+
+
+def _serialize_user(user: ClientUser) -> dict:
+ return {
+ "id": user.id,
+ "client_account_id": user.client_account_id,
+ "full_name": user.full_name,
+ "email": user.email,
+ "role": user.role,
+ "status": user.status,
+ "created_at": user.created_at,
+ }
+
+
+@router.get("/customers/{customer_id}/users")
+def list_customer_users(
+ customer_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ _customer_or_404(db, customer_id)
+ users = db.scalars(
+ select(ClientUser).where(ClientUser.client_account_id == customer_id).order_by(ClientUser.full_name)
+ ).all()
+ return [_serialize_user(u) for u in users]
+
+
+@router.post("/customers/{customer_id}/users", status_code=status.HTTP_201_CREATED)
+def create_customer_user(
+ customer_id: int,
+ payload: CustomerUserCreate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ user = ClientUser(
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ full_name=payload.full_name,
+ email=payload.email,
+ role=payload.role,
+ status="invited",
+ )
+ db.add(user)
+ try:
+ db.flush()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A user with that email already exists for this customer") from exc
+ ensure_user_module_permissions(db, user)
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="user.created",
+ target_type="client_user",
+ target_id=user.id,
+ module_key="ordering",
+ summary=f"{user.full_name} invited as {user.role}.",
+ **_actor(session),
+ )
+ db.commit()
+ db.refresh(user)
+ return _serialize_user(user)
+
+
+@router.patch("/customers/{customer_id}/users/{user_id}")
+def update_customer_user(
+ customer_id: int,
+ user_id: int,
+ payload: CustomerUserUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ user = db.scalar(
+ select(ClientUser).where(ClientUser.id == user_id, ClientUser.client_account_id == customer_id)
+ )
+ if user is None:
+ raise HTTPException(status_code=404, detail="User not found")
+ changes = payload.model_dump(exclude_unset=True)
+ original_role = user.role
+ for field_name, value in changes.items():
+ setattr(user, field_name, value)
+ if "role" in changes and changes["role"] != original_role:
+ from app.services.client_access_service import default_access_level_for_role
+
+ for permission in user.module_permissions:
+ permission.access_level = default_access_level_for_role(user.role, permission.module_key)
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="user.updated",
+ target_type="client_user",
+ target_id=user.id,
+ module_key="ordering",
+ summary=f"{user.full_name} updated (role {user.role}, status {user.status}).",
+ **_actor(session),
+ )
+ db.commit()
+ db.refresh(user)
+ return _serialize_user(user)
+
+
+# --- Categories --------------------------------------------------------------
+
+
+def _serialize_category(c: ProductCategory) -> dict:
+ return {
+ "id": c.id,
+ "slug": c.slug,
+ "name": c.name,
+ "description": c.description,
+ "sort_order": c.sort_order,
+ "active": c.active,
+ }
+
+
+@router.get("/categories")
+def list_categories(
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ rows = db.scalars(
+ select(ProductCategory).where(ProductCategory.tenant_id == TENANT).order_by(ProductCategory.sort_order, ProductCategory.name)
+ ).all()
+ return [_serialize_category(c) for c in rows]
+
+
+@router.post("/categories", status_code=status.HTTP_201_CREATED)
+def create_category(
+ payload: CategoryCreate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ category = ProductCategory(tenant_id=TENANT, **payload.model_dump())
+ db.add(category)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A category with that slug already exists") from exc
+ db.refresh(category)
+ return _serialize_category(category)
+
+
+@router.patch("/categories/{category_id}")
+def update_category(
+ category_id: int,
+ payload: CategoryUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ category = db.scalar(
+ select(ProductCategory).where(ProductCategory.id == category_id, ProductCategory.tenant_id == TENANT)
+ )
+ if category is None:
+ raise HTTPException(status_code=404, detail="Category not found")
+ for field_name, value in payload.model_dump(exclude_unset=True).items():
+ setattr(category, field_name, value)
+ db.commit()
+ db.refresh(category)
+ return _serialize_category(category)
+
+
+# --- Products (catalogue) ----------------------------------------------------
+
+
+@router.get("/products")
+def list_products(
+ include_inactive: bool = Query(default=True),
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ stmt = select(CatalogueProduct).where(CatalogueProduct.tenant_id == TENANT)
+ if not include_inactive:
+ stmt = stmt.where(CatalogueProduct.active.is_(True))
+ products = db.scalars(stmt.order_by(CatalogueProduct.category, CatalogueProduct.name)).all()
+ return [svc.serialize_product(p) for p in products]
+
+
+@router.post("/products", status_code=status.HTTP_201_CREATED)
+def create_product(
+ payload: CatalogueProductCreate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ product = CatalogueProduct(tenant_id=TENANT, **payload.model_dump())
+ db.add(product)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A product with that SKU already exists") from exc
+ db.refresh(product)
+ return svc.serialize_product(product)
+
+
+@router.patch("/products/{product_id}")
+def update_product(
+ product_id: int,
+ payload: CatalogueProductUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ product = _product_or_404(db, product_id)
+ for field_name, value in payload.model_dump(exclude_unset=True).items():
+ setattr(product, field_name, value)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A product with that SKU already exists") from exc
+ db.refresh(product)
+ return svc.serialize_product(product)
+
+
+# --- Per-customer visibility -------------------------------------------------
+
+
+@router.get("/customers/{customer_id}/visibility")
+def get_customer_visibility(
+ customer_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ _customer_or_404(db, customer_id)
+ hidden = svc.visible_product_ids_for_customer(db, tenant_id=TENANT, client_account_id=customer_id)
+ products = db.scalars(
+ select(CatalogueProduct).where(CatalogueProduct.tenant_id == TENANT).order_by(CatalogueProduct.name)
+ ).all()
+ return [
+ {"product_id": p.id, "name": p.name, "sku": p.sku, "category": p.category, "visible": p.id not in hidden}
+ for p in products
+ ]
+
+
+@router.put("/customers/{customer_id}/visibility")
+def set_customer_visibility(
+ customer_id: int,
+ payload: VisibilityUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ _product_or_404(db, payload.product_id)
+ row = db.scalar(
+ select(CustomerProductVisibility).where(
+ CustomerProductVisibility.client_account_id == customer_id,
+ CustomerProductVisibility.product_id == payload.product_id,
+ )
+ )
+ if row is None:
+ row = CustomerProductVisibility(
+ tenant_id=TENANT,
+ client_account_id=customer_id,
+ product_id=payload.product_id,
+ visible=payload.visible,
+ )
+ db.add(row)
+ else:
+ row.visible = payload.visible
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="visibility.updated",
+ target_type="catalogue_product",
+ target_id=payload.product_id,
+ module_key="ordering",
+ summary=f"Product {payload.product_id} {'shown to' if payload.visible else 'hidden from'} {account.name}.",
+ **_actor(session),
+ )
+ db.commit()
+ return {"product_id": payload.product_id, "visible": payload.visible}
+
+
+# --- Price lists -------------------------------------------------------------
+
+
+def _serialize_price_list(db: Session, pl: PriceList) -> dict:
+ items = db.scalars(select(PriceListItem).where(PriceListItem.price_list_id == pl.id)).all()
+ return {
+ "id": pl.id,
+ "code": pl.code,
+ "name": pl.name,
+ "description": pl.description,
+ "active": pl.active,
+ "items": [
+ {
+ "id": it.id,
+ "product_id": it.product_id,
+ "unit_price": it.unit_price,
+ "tiers": _serialize_tiers(db, price_list_item_id=it.id),
+ }
+ for it in items
+ ],
+ }
+
+
+@router.get("/price-lists")
+def list_price_lists(
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ rows = db.scalars(select(PriceList).where(PriceList.tenant_id == TENANT).order_by(PriceList.name)).all()
+ return [_serialize_price_list(db, pl) for pl in rows]
+
+
+@router.post("/price-lists", status_code=status.HTTP_201_CREATED)
+def create_price_list(
+ payload: PriceListCreate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ price_list = PriceList(tenant_id=TENANT, **payload.model_dump())
+ db.add(price_list)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="A price list with that code already exists") from exc
+ db.refresh(price_list)
+ return _serialize_price_list(db, price_list)
+
+
+@router.put("/price-lists/{price_list_id}/items")
+def upsert_price_list_item(
+ price_list_id: int,
+ payload: PriceListItemUpsert,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ price_list = db.scalar(
+ select(PriceList).where(PriceList.id == price_list_id, PriceList.tenant_id == TENANT)
+ )
+ if price_list is None:
+ raise HTTPException(status_code=404, detail="Price list not found")
+ _product_or_404(db, payload.product_id)
+ item = db.scalar(
+ select(PriceListItem).where(
+ PriceListItem.price_list_id == price_list_id, PriceListItem.product_id == payload.product_id
+ )
+ )
+ if item is None:
+ item = PriceListItem(
+ tenant_id=TENANT, price_list_id=price_list_id, product_id=payload.product_id, unit_price=payload.unit_price
+ )
+ db.add(item)
+ db.flush()
+ else:
+ item.unit_price = payload.unit_price
+ if payload.tiers is not None:
+ db.query(PriceTier).filter(PriceTier.price_list_item_id == item.id).delete()
+ for tier in payload.tiers:
+ db.add(
+ PriceTier(
+ tenant_id=TENANT,
+ price_list_item_id=item.id,
+ min_quantity=tier.min_quantity,
+ unit_price=tier.unit_price,
+ )
+ )
+ db.commit()
+ db.refresh(price_list)
+ return _serialize_price_list(db, price_list)
+
+
+# --- Customer pricing --------------------------------------------------------
+
+
+def _serialize_customer_pricing(db: Session, customer_id: int) -> dict:
+ assignment = db.scalar(
+ select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == customer_id)
+ )
+ product_prices = db.scalars(
+ select(CustomerProductPrice).where(CustomerProductPrice.client_account_id == customer_id)
+ ).all()
+ return {
+ "customer_id": customer_id,
+ "price_list_id": assignment.price_list_id if assignment else None,
+ "discount_percent": assignment.discount_percent if assignment else 0.0,
+ "product_prices": [
+ {
+ "id": pp.id,
+ "product_id": pp.product_id,
+ "unit_price": pp.unit_price,
+ "rule_type": pp.rule_type,
+ "contract_reference": pp.contract_reference,
+ "notes": pp.notes,
+ "active": pp.active,
+ "tiers": _serialize_tiers(db, customer_product_price_id=pp.id),
+ }
+ for pp in product_prices
+ ],
+ }
+
+
+@router.get("/customers/{customer_id}/pricing")
+def get_customer_pricing(
+ customer_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ _customer_or_404(db, customer_id)
+ return _serialize_customer_pricing(db, customer_id)
+
+
+@router.put("/customers/{customer_id}/assignment")
+def upsert_customer_assignment(
+ customer_id: int,
+ payload: CustomerPriceAssignmentUpsert,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ if payload.price_list_id is not None:
+ if db.scalar(select(PriceList.id).where(PriceList.id == payload.price_list_id, PriceList.tenant_id == TENANT)) is None:
+ raise HTTPException(status_code=404, detail="Price list not found")
+ assignment = db.scalar(
+ select(CustomerPriceAssignment).where(CustomerPriceAssignment.client_account_id == customer_id)
+ )
+ if assignment is None:
+ assignment = CustomerPriceAssignment(
+ tenant_id=TENANT,
+ client_account_id=customer_id,
+ price_list_id=payload.price_list_id,
+ discount_percent=payload.discount_percent,
+ )
+ db.add(assignment)
+ else:
+ assignment.price_list_id = payload.price_list_id
+ assignment.discount_percent = payload.discount_percent
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="pricing.assignment_updated",
+ target_type="customer_price_assignment",
+ target_id=None,
+ module_key="ordering",
+ summary=f"{account.name} assigned price list {payload.price_list_id} at {payload.discount_percent:g}% discount.",
+ **_actor(session),
+ )
+ db.commit()
+ return _serialize_customer_pricing(db, customer_id)
+
+
+@router.put("/customers/{customer_id}/product-prices")
+def upsert_customer_product_price(
+ customer_id: int,
+ payload: CustomerProductPriceUpsert,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ account = _customer_or_404(db, customer_id)
+ _product_or_404(db, payload.product_id)
+ cpp = db.scalar(
+ select(CustomerProductPrice).where(
+ CustomerProductPrice.client_account_id == customer_id,
+ CustomerProductPrice.product_id == payload.product_id,
+ )
+ )
+ if cpp is None:
+ cpp = CustomerProductPrice(
+ tenant_id=TENANT,
+ client_account_id=customer_id,
+ product_id=payload.product_id,
+ unit_price=payload.unit_price,
+ rule_type=payload.rule_type,
+ contract_reference=payload.contract_reference,
+ notes=payload.notes,
+ active=payload.active,
+ )
+ db.add(cpp)
+ db.flush()
+ else:
+ cpp.unit_price = payload.unit_price
+ cpp.rule_type = payload.rule_type
+ cpp.contract_reference = payload.contract_reference
+ cpp.notes = payload.notes
+ cpp.active = payload.active
+ if payload.tiers is not None:
+ db.query(PriceTier).filter(PriceTier.customer_product_price_id == cpp.id).delete()
+ for tier in payload.tiers:
+ db.add(
+ PriceTier(
+ tenant_id=TENANT,
+ customer_product_price_id=cpp.id,
+ min_quantity=tier.min_quantity,
+ unit_price=tier.unit_price,
+ )
+ )
+ record_audit_event(
+ db,
+ tenant_id=account.tenant_id,
+ client_account_id=account.id,
+ action="pricing.product_price_updated",
+ target_type="customer_product_price",
+ target_id=cpp.id,
+ module_key="ordering",
+ summary=f"{account.name} {payload.rule_type} price set for product {payload.product_id}.",
+ **_actor(session),
+ )
+ db.commit()
+ return _serialize_customer_pricing(db, customer_id)
+
+
+@router.delete("/customers/{customer_id}/product-prices/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_customer_product_price(
+ customer_id: int,
+ product_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ _customer_or_404(db, customer_id)
+ cpp = db.scalar(
+ select(CustomerProductPrice).where(
+ CustomerProductPrice.client_account_id == customer_id,
+ CustomerProductPrice.product_id == product_id,
+ )
+ )
+ if cpp is not None:
+ db.query(PriceTier).filter(PriceTier.customer_product_price_id == cpp.id).delete()
+ db.delete(cpp)
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+# --- Orders ------------------------------------------------------------------
+
+
+@router.get("/orders")
+def list_all_orders(
+ status_filter: str | None = Query(default=None, alias="status"),
+ customer_id: int | None = Query(default=None),
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ stmt = (
+ select(Order)
+ .where(Order.tenant_id == TENANT)
+ .options(selectinload(Order.lines))
+ .order_by(Order.created_at.desc())
+ )
+ if status_filter:
+ stmt = stmt.where(Order.status == status_filter)
+ if customer_id is not None:
+ stmt = stmt.where(Order.client_account_id == customer_id)
+ orders = db.scalars(stmt).all()
+ # Hide drafts from the admin queue by default unless explicitly requested.
+ if not status_filter:
+ orders = [o for o in orders if o.status != "draft"]
+ result = []
+ for o in orders:
+ data = svc.serialize_order(o, for_admin=True)
+ account = db.scalar(select(ClientAccount.name).where(ClientAccount.id == o.client_account_id))
+ data["customer_name"] = account
+ result.append(data)
+ return result
+
+
+@router.get("/orders/{order_id}")
+def get_order_admin(
+ order_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ order = _load_order(db, order_id)
+ data = svc.serialize_order(order, for_admin=True)
+ account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
+ data["customer_name"] = account.name if account else None
+ return data
+
+
+@router.patch("/orders/{order_id}/status")
+def update_order_status(
+ order_id: int,
+ payload: OrderStatusUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ order = _load_order(db, order_id)
+ if order.status == payload.to_status:
+ raise HTTPException(status_code=409, detail="Order is already in that status")
+ if not svc.can_admin_transition(order.status, payload.to_status):
+ raise HTTPException(
+ status_code=409,
+ detail=f"Cannot move an order from {order.status} to {payload.to_status}",
+ )
+ from_status = order.status
+ svc.record_status_change(
+ db, order, to_status=payload.to_status, actor_type="lean_admin", actor_name=session.name, note=payload.note
+ )
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.status_changed",
+ summary=f"Order {order.order_number or order.id} moved {from_status} → {payload.to_status}.",
+ )
+ db.commit()
+ db.refresh(order)
+ return svc.serialize_order(order, for_admin=True)
+
+
+@router.patch("/orders/{order_id}/lines/{line_id}")
+def override_order_line(
+ order_id: int,
+ line_id: int,
+ payload: OrderLineOverride,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ order = _load_order(db, order_id)
+ if order.status in {"completed", "cancelled", "sent_to_xero"}:
+ raise HTTPException(status_code=409, detail="This order can no longer be adjusted")
+ line = next((l for l in order.lines if l.id == line_id), None)
+ if line is None:
+ raise HTTPException(status_code=404, detail="Order line not found")
+ changes = payload.model_dump(exclude_unset=True)
+ if "quantity" in changes and changes["quantity"] is not None:
+ line.quantity = changes["quantity"]
+ if "unit_price" in changes and changes["unit_price"] is not None:
+ line.admin_override_price = changes["unit_price"]
+ line.requires_quote = False
+ line.price_source = "fixed"
+ if "reason" in changes:
+ line.admin_override_reason = changes["reason"]
+ svc.recompute_order_totals(order)
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.price_override",
+ summary=f"Line {line.product_name} overridden on order {order.order_number or order.id}.",
+ target_id=line.id,
+ )
+ db.commit()
+ db.refresh(order)
+ return svc.serialize_order(order, for_admin=True)
+
+
+@router.post("/orders/{order_id}/reopen")
+def reopen_order(
+ order_id: int,
+ payload: ReopenOrderRequest,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ order = _load_order(db, order_id)
+ if order.status in {"completed", "cancelled"}:
+ raise HTTPException(status_code=409, detail="Completed or cancelled orders cannot be reopened")
+ if order.status == "draft":
+ raise HTTPException(status_code=409, detail="Order is already a draft")
+ svc.record_status_change(
+ db, order, to_status="draft", actor_type="lean_admin", actor_name=session.name,
+ note=payload.note or "Reopened by admin for customer edits",
+ )
+ order.reopened = True
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.reopened",
+ summary=f"Order {order.order_number or order.id} reopened to draft.",
+ )
+ db.commit()
+ db.refresh(order)
+ return svc.serialize_order(order, for_admin=True)
+
+
+@router.post("/orders/{order_id}/send-to-xero")
+def send_order_to_xero(
+ order_id: int,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ order = _load_order(db, order_id)
+ if order.status not in {"confirmed", "in_production", "sent_to_xero"}:
+ raise HTTPException(status_code=409, detail="Only confirmed orders can be sent to Xero")
+ account = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
+ result = submit_order_to_xero(order, account)
+
+ db.add(
+ XeroSyncLog(
+ tenant_id=TENANT,
+ order_id=order.id,
+ status=result.status,
+ request_summary=result.request_summary,
+ xero_invoice_id=result.xero_invoice_id,
+ response_message=result.message,
+ )
+ )
+ order.xero_status = result.status
+ if result.status == "success":
+ order.xero_invoice_id = result.xero_invoice_id
+ if order.status != "sent_to_xero" and svc.can_admin_transition(order.status, "sent_to_xero"):
+ svc.record_status_change(
+ db, order, to_status="sent_to_xero", actor_type="lean_admin", actor_name=session.name,
+ note=f"Xero invoice {result.xero_invoice_id}",
+ )
+ svc.audit_order_event(
+ db, session=session, order=order, action="order.xero_submit",
+ summary=f"Xero submission {result.status} for order {order.order_number or order.id} ({result.message}).",
+ )
+ db.commit()
+ db.refresh(order)
+ data = svc.serialize_order(order, for_admin=True)
+ data["xero_result"] = {
+ "status": result.status,
+ "stubbed": result.stubbed,
+ "message": result.message,
+ "xero_invoice_id": result.xero_invoice_id,
+ }
+ return data
+
+
+# --- Settings / status -------------------------------------------------------
+
+
+@router.get("/notification-settings")
+def get_notification_settings(
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ settings_row = get_or_create_settings(db, TENANT)
+ db.commit()
+ return {
+ "internal_recipients": settings_row.internal_recipients,
+ "send_customer_confirmation": settings_row.send_customer_confirmation,
+ "require_po_number": settings_row.require_po_number,
+ "from_email": settings_row.from_email,
+ }
+
+
+@router.patch("/notification-settings")
+def update_notification_settings(
+ payload: NotificationSettingsUpdate,
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ settings_row = get_or_create_settings(db, TENANT)
+ for field_name, value in payload.model_dump(exclude_unset=True).items():
+ setattr(settings_row, field_name, value)
+ db.commit()
+ return {
+ "internal_recipients": settings_row.internal_recipients,
+ "send_customer_confirmation": settings_row.send_customer_confirmation,
+ "require_po_number": settings_row.require_po_number,
+ "from_email": settings_row.from_email,
+ }
+
+
+@router.get("/xero/status")
+def get_xero_status(
+ session: AuthSession = Depends(require_ordering_admin_session),
+ db: Session = Depends(get_db),
+):
+ recent = db.scalars(
+ select(XeroSyncLog).where(XeroSyncLog.tenant_id == TENANT).order_by(XeroSyncLog.created_at.desc()).limit(20)
+ ).all()
+ return {
+ "connection": xero_status_snapshot(),
+ "recent_syncs": [
+ {
+ "id": log.id,
+ "order_id": log.order_id,
+ "status": log.status,
+ "xero_invoice_id": log.xero_invoice_id,
+ "response_message": log.response_message,
+ "created_at": log.created_at,
+ }
+ for log in recent
+ ],
+ }
diff --git a/backend/app/core/access.py b/backend/app/core/access.py
index 6d277f1..41a4ddf 100644
--- a/backend/app/core/access.py
+++ b/backend/app/core/access.py
@@ -53,6 +53,9 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"),
+ "view_ordering": ("ordering", "view"),
+ "edit_ordering": ("ordering", "edit"),
+ "manage_ordering": ("ordering", "manage"),
"view_scenarios": ("scenarios", "view"),
"edit_scenarios": ("scenarios", "edit"),
"manage_client_access": ("client_access", "manage"),
diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py
index acb805b..60a5ed5 100644
--- a/backend/app/db/migrations.py
+++ b/backend/app/db/migrations.py
@@ -42,6 +42,21 @@ TENANT_TABLES = {
"freight_cost_rules": None,
"throughput_products": None,
"production_throughput_entries": None,
+ # B2B ordering portal
+ "product_categories": None,
+ "catalogue_products": None,
+ "customer_product_visibility": None,
+ "price_lists": None,
+ "price_list_items": None,
+ "customer_price_assignments": None,
+ "customer_product_prices": None,
+ "price_tiers": None,
+ "orders": None,
+ "order_lines": None,
+ "order_status_history": None,
+ "order_attachments": None,
+ "notification_settings": None,
+ "xero_sync_log": None,
}
diff --git a/backend/app/main.py b/backend/app/main.py
index 87f36a2..830b16d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -25,6 +25,8 @@ from app.api.dashboard import router as dashboard_router
from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
+from app.api.ordering import router as ordering_router
+from app.api.ordering_admin import router as ordering_admin_router
from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router
@@ -204,6 +206,8 @@ app.include_router(product_costing_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(throughput_router)
+app.include_router(ordering_router)
+app.include_router(ordering_admin_router)
app.include_router(powerbi_router)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index e25b49e..0223d0b 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -3,6 +3,22 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.mix import Mix, MixIngredient
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerPriceAssignment,
+ CustomerProductPrice,
+ CustomerProductVisibility,
+ NotificationSetting,
+ Order,
+ OrderAttachment,
+ OrderLine,
+ OrderStatusHistory,
+ PriceList,
+ PriceListItem,
+ PriceTier,
+ ProductCategory,
+ XeroSyncLog,
+)
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
@@ -17,14 +33,28 @@ from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct
__all__ = [
+ "CatalogueProduct",
"ClientAccount",
"ClientAccessAuditEvent",
"ClientFeatureAccess",
"ClientUser",
"ClientUserModulePermission",
"CostingResult",
+ "CustomerPriceAssignment",
+ "CustomerProductPrice",
+ "CustomerProductVisibility",
"FreightCostRule",
"Mix",
+ "NotificationSetting",
+ "Order",
+ "OrderAttachment",
+ "OrderLine",
+ "OrderStatusHistory",
+ "PriceList",
+ "PriceListItem",
+ "PriceTier",
+ "ProductCategory",
+ "XeroSyncLog",
"MixCalculatorSession",
"MixCalculatorSessionLine",
"MixIngredient",
diff --git a/backend/app/models/ordering.py b/backend/app/models/ordering.py
new file mode 100644
index 0000000..60ecb7a
--- /dev/null
+++ b/backend/app/models/ordering.py
@@ -0,0 +1,390 @@
+"""B2B ordering portal data model.
+
+This module backs the private customer ordering portal. It deliberately reuses
+the existing tenant/customer primitives rather than introducing parallel ones:
+
+* A **customer/company** is an existing :class:`ClientAccount`.
+* A **customer user** is an existing :class:`ClientUser` (owner/buyer/viewer/
+ accounts roles map onto the existing ``ordering`` module access levels).
+
+Everything here is private: every row is tenant-scoped, and customer-facing
+rows are additionally scoped to a ``client_account_id``. The catalogue itself is
+global to the seller (one tenant), with per-customer visibility and pricing
+layered on top.
+
+Money is stored GST-exclusive (see ``CLAUDE.MD`` costing conventions) as floats,
+matching the rest of the costing platform.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import (
+ Boolean,
+ DateTime,
+ Float,
+ ForeignKey,
+ Integer,
+ String,
+ Text,
+ UniqueConstraint,
+)
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.db.session import Base
+
+# --- Controlled vocabularies -------------------------------------------------
+# Plain string constants (not DB enums) keep SQLite migrations trivial and match
+# the existing model style.
+
+PRODUCT_CATEGORIES = (
+ "grains",
+ "premixed",
+ "bags",
+ "bulk_loads",
+ "custom_blends",
+ "services",
+)
+
+# Full internal order lifecycle. Customers see a simplified subset (see
+# ``CUSTOMER_VISIBLE_STATUS`` in the ordering service).
+ORDER_STATUSES = (
+ "draft",
+ "submitted",
+ "under_review",
+ "confirmed",
+ "sent_to_xero",
+ "in_production",
+ "ready_for_pickup",
+ "dispatched",
+ "completed",
+ "cancelled",
+)
+
+# How a resolved unit price was derived. Stored on each order line so future
+# reporting can always explain which rule applied.
+PRICE_SOURCES = (
+ "fixed", # CustomerProductPrice with rule_type fixed
+ "contract", # CustomerProductPrice with rule_type contract
+ "price_list", # PriceListItem via an assigned price list
+ "tiered", # a quantity tier won over the base rate
+ "base", # catalogue list price (optionally with customer discount)
+ "quote", # manual quote required; no automatic price
+)
+
+FULFILMENT_METHODS = ("delivery", "pickup")
+
+
+class ProductCategory(Base):
+ __tablename__ = "product_categories"
+ __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_product_category_slug"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ slug: Mapped[str] = mapped_column(String(64), index=True)
+ name: Mapped[str] = mapped_column(String(255))
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ sort_order: Mapped[int] = mapped_column(Integer, default=0)
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class CatalogueProduct(Base):
+ """A private catalogue product. Named ``CatalogueProduct`` to avoid clashing
+ with the existing costing ``Product`` (a separate concern)."""
+
+ __tablename__ = "catalogue_products"
+ __table_args__ = (UniqueConstraint("tenant_id", "sku", name="uq_catalogue_product_sku"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ name: Mapped[str] = mapped_column(String(255), index=True)
+ sku: Mapped[str] = mapped_column(String(64), index=True)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ category: Mapped[str] = mapped_column(String(64), default="grains", index=True)
+ image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ unit_size: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ unit_of_measure: Mapped[str] = mapped_column(String(64), default="each")
+ min_order_quantity: Mapped[float] = mapped_column(Float, default=1.0)
+ # Base/list price, GST-exclusive. Used as the fallback price source and as
+ # the basis for a customer discount percentage.
+ base_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ stock_status: Mapped[str] = mapped_column(String(32), default="in_stock")
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
+ requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class CustomerProductVisibility(Base):
+ """Per-customer override of catalogue visibility.
+
+ Products are visible to every customer by default (the catalogue is
+ "standard globally"). A row here with ``visible=False`` hides a product from
+ one customer; ``visible=True`` is an explicit allow (no-op unless a future
+ default flips to opt-in).
+ """
+
+ __tablename__ = "customer_product_visibility"
+ __table_args__ = (
+ UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_visibility"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
+ product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
+ visible: Mapped[bool] = mapped_column(Boolean, default=True)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class PriceList(Base):
+ __tablename__ = "price_lists"
+ __table_args__ = (UniqueConstraint("tenant_id", "code", name="uq_price_list_code"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ code: Mapped[str] = mapped_column(String(64), index=True)
+ name: Mapped[str] = mapped_column(String(255))
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ items: Mapped[list["PriceListItem"]] = relationship(
+ back_populates="price_list",
+ cascade="all, delete-orphan",
+ )
+
+
+class PriceListItem(Base):
+ __tablename__ = "price_list_items"
+ __table_args__ = (UniqueConstraint("price_list_id", "product_id", name="uq_price_list_item"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ price_list_id: Mapped[int] = mapped_column(ForeignKey("price_lists.id"), index=True)
+ product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
+ unit_price: Mapped[float] = mapped_column(Float)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ price_list: Mapped[PriceList] = relationship(back_populates="items")
+
+
+class CustomerPriceAssignment(Base):
+ """Links a customer to an assigned price list and a default discount."""
+
+ __tablename__ = "customer_price_assignments"
+ __table_args__ = (
+ UniqueConstraint("client_account_id", name="uq_customer_price_assignment"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
+ price_list_id: Mapped[int | None] = mapped_column(ForeignKey("price_lists.id"), nullable=True, index=True)
+ # Default percentage discount applied to base prices for this customer when
+ # no more specific rule (fixed/contract/price-list) applies. 0..100.
+ discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ price_list: Mapped[PriceList | None] = relationship()
+
+
+class CustomerProductPrice(Base):
+ """A fixed or contract price for a specific customer + product. Highest
+ priority pricing rule."""
+
+ __tablename__ = "customer_product_prices"
+ __table_args__ = (
+ UniqueConstraint("client_account_id", "product_id", name="uq_customer_product_price"),
+ )
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
+ product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
+ unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ # "fixed" | "contract" | "quote" (quote forces a manual-quote workflow for
+ # this customer+product even if the product itself doesn't require one).
+ rule_type: Mapped[str] = mapped_column(String(32), default="fixed")
+ contract_reference: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ active: Mapped[bool] = mapped_column(Boolean, default=True)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class PriceTier(Base):
+ """Quantity break for a pricing source. Exactly one of
+ ``customer_product_price_id`` / ``price_list_item_id`` is set."""
+
+ __tablename__ = "price_tiers"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ customer_product_price_id: Mapped[int | None] = mapped_column(
+ ForeignKey("customer_product_prices.id"), nullable=True, index=True
+ )
+ price_list_item_id: Mapped[int | None] = mapped_column(
+ ForeignKey("price_list_items.id"), nullable=True, index=True
+ )
+ # Tier applies when ordered quantity >= min_quantity. The highest qualifying
+ # min_quantity wins.
+ min_quantity: Mapped[float] = mapped_column(Float)
+ unit_price: Mapped[float] = mapped_column(Float)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class Order(Base):
+ __tablename__ = "orders"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
+ # Human-friendly order reference, assigned on submit (e.g. ORD-000123).
+ order_number: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
+ status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
+
+ # Who created / submitted it (ClientUser ids; nullable for admin-created).
+ created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True)
+ created_by_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+ # Customer-supplied order details.
+ purchase_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ delivery_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ requested_delivery_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ fulfilment_method: Mapped[str] = mapped_column(String(32), default="delivery")
+
+ # Admin/internal handling.
+ admin_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ reopened: Mapped[bool] = mapped_column(Boolean, default=False)
+
+ # Cached totals (authoritative figures live on lines; this is for listing).
+ subtotal_ex_gst: Mapped[float] = mapped_column(Float, default=0.0)
+ requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
+
+ # Xero integration tracking.
+ xero_status: Mapped[str] = mapped_column(String(32), default="not_sent")
+ xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ lines: Mapped[list["OrderLine"]] = relationship(
+ back_populates="order",
+ cascade="all, delete-orphan",
+ order_by="OrderLine.sort_order",
+ )
+ status_history: Mapped[list["OrderStatusHistory"]] = relationship(
+ back_populates="order",
+ cascade="all, delete-orphan",
+ order_by="OrderStatusHistory.created_at",
+ )
+ attachments: Mapped[list["OrderAttachment"]] = relationship(
+ back_populates="order",
+ cascade="all, delete-orphan",
+ )
+
+
+class OrderLine(Base):
+ __tablename__ = "order_lines"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
+ product_id: Mapped[int] = mapped_column(ForeignKey("catalogue_products.id"), index=True)
+
+ # Snapshot of product identity at order time (so later catalogue edits don't
+ # rewrite historical orders).
+ product_name: Mapped[str] = mapped_column(String(255))
+ product_sku: Mapped[str] = mapped_column(String(64))
+
+ quantity: Mapped[float] = mapped_column(Float, default=1.0)
+
+ # Authoritative unit price, GST-exclusive, computed by the backend pricing
+ # engine and frozen at submission. Null only while quote-only.
+ unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ line_total: Mapped[float | None] = mapped_column(Float, nullable=True)
+ requires_quote: Mapped[bool] = mapped_column(Boolean, default=False)
+
+ # Pricing provenance — enough to explain which rule applied in reporting.
+ price_source: Mapped[str] = mapped_column(String(32), default="base")
+ price_rule_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ discount_percent: Mapped[float] = mapped_column(Float, default=0.0)
+
+ # Admin override of the resolved unit price before confirmation.
+ admin_override_price: Mapped[float | None] = mapped_column(Float, nullable=True)
+ admin_override_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ sort_order: Mapped[int] = mapped_column(Integer, default=0)
+ notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ order: Mapped[Order] = relationship(back_populates="lines")
+
+
+class OrderStatusHistory(Base):
+ __tablename__ = "order_status_history"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
+ from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ to_status: Mapped[str] = mapped_column(String(32))
+ actor_type: Mapped[str] = mapped_column(String(32), default="system")
+ actor_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ order: Mapped[Order] = relationship(back_populates="status_history")
+
+
+class OrderAttachment(Base):
+ __tablename__ = "order_attachments"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
+ # "purchase_order" | "confirmation_pdf" | "other"
+ kind: Mapped[str] = mapped_column(String(32), default="other")
+ filename: Mapped[str] = mapped_column(String(255))
+ content_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ note: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ order: Mapped[Order] = relationship(back_populates="attachments")
+
+
+class NotificationSetting(Base):
+ """Per-tenant notification configuration for the ordering portal."""
+
+ __tablename__ = "notification_settings"
+ __table_args__ = (UniqueConstraint("tenant_id", name="uq_notification_settings_tenant"),)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ # Comma-separated internal recipients notified on new order submissions.
+ internal_recipients: Mapped[str | None] = mapped_column(Text, nullable=True)
+ send_customer_confirmation: Mapped[bool] = mapped_column(Boolean, default=True)
+ require_po_number: Mapped[bool] = mapped_column(Boolean, default=False)
+ from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+
+class XeroSyncLog(Base):
+ __tablename__ = "xero_sync_log"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
+ order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
+ # "queued" | "success" | "failed"
+ status: Mapped[str] = mapped_column(String(32), default="queued")
+ request_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
+ xero_invoice_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/schemas/ordering.py b/backend/app/schemas/ordering.py
new file mode 100644
index 0000000..4c74818
--- /dev/null
+++ b/backend/app/schemas/ordering.py
@@ -0,0 +1,302 @@
+"""Request schemas for the B2B ordering portal.
+
+Responses are returned as plain dicts (FastAPI encodes them); these models
+constrain and validate *inputs* only.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.ordering import (
+ FULFILMENT_METHODS,
+ ORDER_STATUSES,
+ PRODUCT_CATEGORIES,
+)
+
+_CATEGORY_SET = set(PRODUCT_CATEGORIES)
+_STATUS_SET = set(ORDER_STATUSES)
+_FULFILMENT_SET = set(FULFILMENT_METHODS)
+_RULE_TYPES = {"fixed", "contract", "quote"}
+_STOCK_STATUSES = {"in_stock", "low_stock", "out_of_stock", "made_to_order"}
+
+
+# --- Customer ordering -------------------------------------------------------
+
+
+class OrderLineInput(BaseModel):
+ product_id: int
+ quantity: float = Field(gt=0)
+ notes: str | None = None
+
+
+class DraftOrderCreate(BaseModel):
+ lines: list[OrderLineInput] = Field(default_factory=list)
+ purchase_order_number: str | None = None
+ delivery_notes: str | None = None
+ requested_delivery_date: datetime | None = None
+ fulfilment_method: str = "delivery"
+
+ @field_validator("fulfilment_method")
+ @classmethod
+ def _check_fulfilment(cls, value: str) -> str:
+ if value not in _FULFILMENT_SET:
+ raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
+ return value
+
+
+class DraftOrderUpdate(BaseModel):
+ lines: list[OrderLineInput] | None = None
+ purchase_order_number: str | None = None
+ delivery_notes: str | None = None
+ requested_delivery_date: datetime | None = None
+ fulfilment_method: str | None = None
+
+ @field_validator("fulfilment_method")
+ @classmethod
+ def _check_fulfilment(cls, value: str | None) -> str | None:
+ if value is not None and value not in _FULFILMENT_SET:
+ raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
+ return value
+
+
+class OrderSubmitRequest(BaseModel):
+ purchase_order_number: str | None = None
+ delivery_notes: str | None = None
+ requested_delivery_date: datetime | None = None
+ fulfilment_method: str | None = None
+
+ @field_validator("fulfilment_method")
+ @classmethod
+ def _check_fulfilment(cls, value: str | None) -> str | None:
+ if value is not None and value not in _FULFILMENT_SET:
+ raise ValueError(f"fulfilment_method must be one of {sorted(_FULFILMENT_SET)}")
+ return value
+
+
+# --- Admin: catalogue --------------------------------------------------------
+
+
+class CategoryCreate(BaseModel):
+ slug: str = Field(min_length=1, max_length=64)
+ name: str = Field(min_length=1, max_length=255)
+ description: str | None = None
+ sort_order: int = 0
+ active: bool = True
+
+
+class CategoryUpdate(BaseModel):
+ name: str | None = None
+ description: str | None = None
+ sort_order: int | None = None
+ active: bool | None = None
+
+
+class CatalogueProductCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=255)
+ sku: str = Field(min_length=1, max_length=64)
+ description: str | None = None
+ category: str = "grains"
+ image_url: str | None = None
+ unit_size: str | None = None
+ unit_of_measure: str = "each"
+ min_order_quantity: float = Field(default=1.0, ge=0)
+ base_price: float | None = Field(default=None, ge=0)
+ stock_status: str = "in_stock"
+ active: bool = True
+ requires_quote: bool = False
+
+ @field_validator("category")
+ @classmethod
+ def _check_category(cls, value: str) -> str:
+ if value not in _CATEGORY_SET:
+ raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
+ return value
+
+ @field_validator("stock_status")
+ @classmethod
+ def _check_stock(cls, value: str) -> str:
+ if value not in _STOCK_STATUSES:
+ raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
+ return value
+
+
+class CatalogueProductUpdate(BaseModel):
+ name: str | None = None
+ sku: str | None = None
+ description: str | None = None
+ category: str | None = None
+ image_url: str | None = None
+ unit_size: str | None = None
+ unit_of_measure: str | None = None
+ min_order_quantity: float | None = Field(default=None, ge=0)
+ base_price: float | None = Field(default=None, ge=0)
+ stock_status: str | None = None
+ active: bool | None = None
+ requires_quote: bool | None = None
+
+ @field_validator("category")
+ @classmethod
+ def _check_category(cls, value: str | None) -> str | None:
+ if value is not None and value not in _CATEGORY_SET:
+ raise ValueError(f"category must be one of {sorted(_CATEGORY_SET)}")
+ return value
+
+ @field_validator("stock_status")
+ @classmethod
+ def _check_stock(cls, value: str | None) -> str | None:
+ if value is not None and value not in _STOCK_STATUSES:
+ raise ValueError(f"stock_status must be one of {sorted(_STOCK_STATUSES)}")
+ return value
+
+
+class VisibilityUpdate(BaseModel):
+ product_id: int
+ visible: bool
+
+
+# --- Admin: pricing ----------------------------------------------------------
+
+
+class PriceTierInput(BaseModel):
+ min_quantity: float = Field(gt=0)
+ unit_price: float = Field(ge=0)
+
+
+class PriceListCreate(BaseModel):
+ code: str = Field(min_length=1, max_length=64)
+ name: str = Field(min_length=1, max_length=255)
+ description: str | None = None
+ active: bool = True
+
+
+class PriceListItemUpsert(BaseModel):
+ product_id: int
+ unit_price: float = Field(ge=0)
+ tiers: list[PriceTierInput] | None = None
+
+
+class CustomerPriceAssignmentUpsert(BaseModel):
+ price_list_id: int | None = None
+ discount_percent: float = Field(default=0.0, ge=0, le=100)
+
+
+class CustomerProductPriceUpsert(BaseModel):
+ product_id: int
+ unit_price: float | None = Field(default=None, ge=0)
+ rule_type: str = "fixed"
+ contract_reference: str | None = None
+ notes: str | None = None
+ active: bool = True
+ tiers: list[PriceTierInput] | None = None
+
+ @field_validator("rule_type")
+ @classmethod
+ def _check_rule(cls, value: str) -> str:
+ if value not in _RULE_TYPES:
+ raise ValueError(f"rule_type must be one of {sorted(_RULE_TYPES)}")
+ return value
+
+
+# --- Admin: orders -----------------------------------------------------------
+
+
+class OrderStatusUpdate(BaseModel):
+ to_status: str
+ note: str | None = None
+
+ @field_validator("to_status")
+ @classmethod
+ def _check_status(cls, value: str) -> str:
+ if value not in _STATUS_SET:
+ raise ValueError(f"to_status must be one of {sorted(_STATUS_SET)}")
+ return value
+
+
+class OrderLineOverride(BaseModel):
+ quantity: float | None = Field(default=None, gt=0)
+ unit_price: float | None = Field(default=None, ge=0)
+ reason: str | None = None
+
+
+class OrderAdminNotesUpdate(BaseModel):
+ admin_notes: str | None = None
+
+
+class ReopenOrderRequest(BaseModel):
+ note: str | None = None
+
+
+# --- Admin: settings ---------------------------------------------------------
+
+
+class NotificationSettingsUpdate(BaseModel):
+ internal_recipients: str | None = None
+ send_customer_confirmation: bool | None = None
+ require_po_number: bool | None = None
+ from_email: str | None = None
+
+
+# --- Admin: customers & users ------------------------------------------------
+
+_CUSTOMER_STATUSES = {"active", "disabled"}
+_ORDERING_USER_ROLES = {"owner", "buyer", "accounts", "viewer"}
+
+
+class CustomerCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=255)
+ client_code: str = Field(min_length=1, max_length=64)
+ tenant_id: str | None = None
+ notes: str | None = None
+
+
+class CustomerUpdate(BaseModel):
+ name: str | None = None
+ status: str | None = None
+ notes: str | None = None
+
+ @field_validator("status")
+ @classmethod
+ def _check_status(cls, value: str | None) -> str | None:
+ if value is not None and value not in _CUSTOMER_STATUSES:
+ raise ValueError(f"status must be one of {sorted(_CUSTOMER_STATUSES)}")
+ return value
+
+
+class CustomerUserCreate(BaseModel):
+ full_name: str = Field(min_length=1, max_length=255)
+ email: str = Field(min_length=3, max_length=255)
+ role: str = "buyer"
+
+ @field_validator("role")
+ @classmethod
+ def _check_role(cls, value: str) -> str:
+ if value not in _ORDERING_USER_ROLES:
+ raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
+ return value
+
+ @field_validator("email")
+ @classmethod
+ def _normalize_email(cls, value: str) -> str:
+ return value.strip().lower()
+
+
+class CustomerUserUpdate(BaseModel):
+ full_name: str | None = None
+ role: str | None = None
+ status: str | None = None
+
+ @field_validator("role")
+ @classmethod
+ def _check_role(cls, value: str | None) -> str | None:
+ if value is not None and value not in _ORDERING_USER_ROLES:
+ raise ValueError(f"role must be one of {sorted(_ORDERING_USER_ROLES)}")
+ return value
+
+ @field_validator("status")
+ @classmethod
+ def _check_status(cls, value: str | None) -> str | None:
+ if value is not None and value not in {"active", "invited", "suspended"}:
+ raise ValueError("status must be one of ['active', 'invited', 'suspended']")
+ return value
diff --git a/backend/app/seed.py b/backend/app/seed.py
index 6222c2a..af06f68 100644
--- a/backend/app/seed.py
+++ b/backend/app/seed.py
@@ -1285,6 +1285,23 @@ def seed_startup_basics():
logger.info("Product costing module seeded: %s", product_costing_report)
db.commit()
+ # The ordering-portal seed (catalogue + the Riverside Stockfeeds test
+ # customer and its buyer user) runs in its own transaction so it always
+ # commits on a fresh Postgres deploy, independent of the workbook/costing
+ # seeds above. It is idempotent — safe on every boot.
+ seed_ordering_basics()
+
+
+def seed_ordering_basics():
+ """Seed the B2B ordering portal (catalogue + test customer). Idempotent."""
+ from app.seed_ordering import seed_ordering
+
+ with SessionLocal() as db:
+ ordering_report = seed_ordering(db)
+ if any(ordering_report.values()):
+ logger.info("Ordering portal seeded: %s", ordering_report)
+ db.commit()
+
def seed_all():
Base.metadata.create_all(bind=engine)
diff --git a/backend/app/seed_access.py b/backend/app/seed_access.py
index fc63b2e..4bbc363 100644
--- a/backend/app/seed_access.py
+++ b/backend/app/seed_access.py
@@ -31,6 +31,9 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"),
+ ("view_ordering", "View the B2B customer ordering portal and orders"),
+ ("edit_ordering", "Manage catalogue, pricing, and process customer orders"),
+ ("manage_ordering", "Full ordering administration: customers, pricing, order lifecycle, Xero"),
("view_scenarios", "View scenario planning"),
("edit_scenarios", "Create, run, approve, and reject scenarios"),
("manage_client_access", "Manage client accounts, users, feature access, and exports"),
@@ -58,6 +61,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes",
"view_throughput",
"edit_throughput",
+ "view_ordering",
+ "edit_ordering",
+ "manage_ordering",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
@@ -93,6 +99,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes",
"view_throughput",
"edit_throughput",
+ "view_ordering",
+ "edit_ordering",
+ "manage_ordering",
],
},
"lean": {
@@ -110,6 +119,9 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_mixes",
"view_throughput",
"edit_throughput",
+ "view_ordering",
+ "edit_ordering",
+ "manage_ordering",
"view_scenarios",
"edit_scenarios",
"manage_client_access",
diff --git a/backend/app/seed_ordering.py b/backend/app/seed_ordering.py
new file mode 100644
index 0000000..daeea58
--- /dev/null
+++ b/backend/app/seed_ordering.py
@@ -0,0 +1,172 @@
+"""Idempotent seed data for the B2B ordering portal.
+
+Creates a small demo catalogue, a demo ordering customer + buyer user, a price
+list, and a customer-specific price so the acceptance-criteria flow works
+out of the box. Safe to run on every startup — it no-ops once seeded.
+"""
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerPriceAssignment,
+ CustomerProductPrice,
+ NotificationSetting,
+ PriceList,
+ PriceListItem,
+ ProductCategory,
+)
+from app.services.client_access_service import (
+ MODULE_INDEX,
+ ensure_user_module_permissions,
+)
+
+logger = logging.getLogger("data_entry_app.seed")
+
+ORDERING_TENANT = settings.client_tenant_id
+
+_CATEGORIES = [
+ ("grains", "Grains", 10),
+ ("premixed", "Premixed Products", 20),
+ ("bags", "Bags", 30),
+ ("bulk_loads", "Bulk Loads", 40),
+ ("custom_blends", "Custom Blends", 50),
+ ("services", "Services & Delivery", 60),
+]
+
+_PRODUCTS = [
+ # (name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote)
+ ("Cracked Maize", "GRN-MAIZE-20", "grains", "20kg bag", "20kg", 1, 24.50, "in_stock", False),
+ ("Whole Barley", "GRN-BARLEY-20", "grains", "20kg bag", "20kg", 1, 21.00, "in_stock", False),
+ ("Layer Premix", "PMX-LAYER-20", "premixed", "20kg bag", "20kg", 1, 32.75, "in_stock", False),
+ ("Calf Starter Premix", "PMX-CALF-20", "premixed", "20kg bag", "20kg", 5, 38.40, "low_stock", False),
+ ("Bulka Bag (1T)", "BAG-BULKA-1T", "bags", "each", "1 tonne", 1, 9.50, "in_stock", False),
+ ("Bulk Maize Load", "BLK-MAIZE-T", "bulk_loads", "tonne", "per tonne", 1, 685.00, "made_to_order", False),
+ ("Custom Horse Blend", "CST-HORSE-20", "custom_blends", "20kg bag", "20kg", 10, None, "made_to_order", True),
+ ("Delivery (per pallet)", "SVC-DELIVERY", "services", "pallet", "1 pallet", 1, 45.00, "in_stock", False),
+]
+
+
+def seed_ordering(db: Session) -> dict[str, int]:
+ created = {"categories": 0, "products": 0, "customer": 0, "pricing": 0}
+
+ # Categories
+ existing_categories = {
+ c.slug for c in db.scalars(select(ProductCategory).where(ProductCategory.tenant_id == ORDERING_TENANT)).all()
+ }
+ for slug, name, sort_order in _CATEGORIES:
+ if slug in existing_categories:
+ continue
+ db.add(ProductCategory(tenant_id=ORDERING_TENANT, slug=slug, name=name, sort_order=sort_order))
+ created["categories"] += 1
+
+ # Catalogue products
+ existing_skus = {
+ p.sku for p in db.scalars(select(CatalogueProduct).where(CatalogueProduct.tenant_id == ORDERING_TENANT)).all()
+ }
+ product_by_sku: dict[str, CatalogueProduct] = {}
+ for name, sku, category, uom, unit_size, moq, base_price, stock, requires_quote in _PRODUCTS:
+ if sku in existing_skus:
+ continue
+ product = CatalogueProduct(
+ tenant_id=ORDERING_TENANT,
+ name=name,
+ sku=sku,
+ category=category,
+ unit_of_measure=uom,
+ unit_size=unit_size,
+ min_order_quantity=moq,
+ base_price=base_price,
+ stock_status=stock,
+ requires_quote=requires_quote,
+ )
+ db.add(product)
+ product_by_sku[sku] = product
+ created["products"] += 1
+ db.flush()
+
+ # Notification settings row
+ if db.scalar(select(NotificationSetting).where(NotificationSetting.tenant_id == ORDERING_TENANT)) is None:
+ db.add(
+ NotificationSetting(
+ tenant_id=ORDERING_TENANT,
+ internal_recipients=settings.admin_email,
+ send_customer_confirmation=True,
+ require_po_number=False,
+ from_email=settings.admin_email,
+ )
+ )
+
+ # Demo ordering customer + buyer user
+ demo = db.scalar(select(ClientAccount).where(ClientAccount.client_code == "RIVERSIDE"))
+ if demo is None:
+ demo = ClientAccount(
+ tenant_id="riverside-stockfeeds",
+ name="Riverside Stockfeeds",
+ client_code="RIVERSIDE",
+ status="active",
+ notes="Demo B2B ordering customer",
+ )
+ db.add(demo)
+ db.flush()
+ created["customer"] += 1
+
+ info = MODULE_INDEX["ordering"]
+ db.add(
+ ClientFeatureAccess(
+ tenant_id=demo.tenant_id,
+ client_account_id=demo.id,
+ feature_key="ordering",
+ feature_name=info["module_name"],
+ feature_group=info["module_group"],
+ description=info["description"],
+ enabled=True,
+ )
+ )
+ buyer = ClientUser(
+ tenant_id=demo.tenant_id,
+ client_account_id=demo.id,
+ full_name="Riverside Buyer",
+ email="buyer@riverside.example",
+ role="buyer",
+ status="active",
+ is_new_user=False,
+ )
+ db.add(buyer)
+ db.flush()
+ ensure_user_module_permissions(db, buyer)
+
+ # A customer-specific contract price + a small discount on the rest.
+ maize = db.scalar(
+ select(CatalogueProduct).where(
+ CatalogueProduct.tenant_id == ORDERING_TENANT, CatalogueProduct.sku == "GRN-MAIZE-20"
+ )
+ )
+ if maize is not None:
+ db.add(
+ CustomerProductPrice(
+ tenant_id=ORDERING_TENANT,
+ client_account_id=demo.id,
+ product_id=maize.id,
+ unit_price=22.00,
+ rule_type="contract",
+ contract_reference="2026 supply agreement",
+ )
+ )
+ db.add(
+ CustomerPriceAssignment(
+ tenant_id=ORDERING_TENANT,
+ client_account_id=demo.id,
+ price_list_id=None,
+ discount_percent=5.0,
+ )
+ )
+ created["pricing"] += 1
+
+ return created
diff --git a/backend/app/services/client_access_service.py b/backend/app/services/client_access_service.py
index 9afcaf6..15e2b1d 100644
--- a/backend/app/services/client_access_service.py
+++ b/backend/app/services/client_access_service.py
@@ -21,6 +21,7 @@ MODULE_CATALOG = (
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
+ ("ordering", "Ordering Portal", "commerce", "Browse the private catalogue, build and submit B2B orders"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
@@ -79,15 +80,25 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
- return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit"
+ return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput", "ordering"} else "edit"
if normalized == "admin":
- if module_key in {"mix_calculator", "operations_throughput"}:
+ if module_key in {"mix_calculator", "operations_throughput", "ordering"}:
return "manage"
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
- return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none"
+ return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput", "ordering"} else "none"
if normalized == "viewer":
- return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
+ return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput", "ordering"} else "none"
+ # --- B2B ordering-portal customer roles ---------------------------------
+ # Ordering customers are scoped to the ordering module (plus a dashboard
+ # view). They never see costing/admin modules.
+ if normalized == "owner":
+ return "manage" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
+ if normalized == "buyer":
+ return "edit" if module_key == "ordering" else ("view" if module_key == "dashboard" else "none")
+ if normalized == "accounts":
+ # Accounts users review orders/invoicing but don't place orders.
+ return "view" if module_key in {"ordering", "dashboard"} else "none"
return "none"
diff --git a/backend/app/services/order_notifications.py b/backend/app/services/order_notifications.py
new file mode 100644
index 0000000..d4ee240
--- /dev/null
+++ b/backend/app/services/order_notifications.py
@@ -0,0 +1,119 @@
+"""Order notification service (stub interface).
+
+Emits the two notifications the ordering spec requires when an order is
+submitted:
+
+* a confirmation to the customer, and
+* an internal notification to configured recipients.
+
+Real email delivery is intentionally **stubbed** behind this interface: no SMTP
+provider is configured in dev/alpha, so notifications are logged and returned as
+structured results. To go live, implement :func:`_deliver_email` (TODO) — no
+caller changes needed.
+"""
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models.client_access import ClientAccount, ClientUser
+from app.models.ordering import NotificationSetting, Order
+
+logger = logging.getLogger("data_entry_app.ordering")
+
+
+@dataclass
+class NotificationResult:
+ channel: str # "customer" | "internal"
+ recipients: list[str]
+ subject: str
+ delivered: bool
+ detail: str
+
+
+def get_or_create_settings(db: Session, tenant_id: str) -> NotificationSetting:
+ settings = db.scalar(
+ select(NotificationSetting).where(NotificationSetting.tenant_id == tenant_id)
+ )
+ if settings is None:
+ settings = NotificationSetting(tenant_id=tenant_id)
+ db.add(settings)
+ db.flush()
+ return settings
+
+
+def _internal_recipients(settings: NotificationSetting) -> list[str]:
+ if not settings.internal_recipients:
+ return []
+ return [part.strip() for part in settings.internal_recipients.split(",") if part.strip()]
+
+
+def _customer_recipients(db: Session, order: Order) -> list[str]:
+ users = db.scalars(
+ select(ClientUser).where(
+ ClientUser.client_account_id == order.client_account_id,
+ ClientUser.status == "active",
+ )
+ ).all()
+ return [user.email for user in users if user.email]
+
+
+def _deliver_email(*, to: list[str], subject: str, body: str, from_email: str | None) -> bool:
+ """Deliver an email. Stubbed — logs instead of sending.
+
+ TODO (go-live): integrate the chosen provider (SMTP / SendGrid / SES) using
+ credentials from environment variables. Return True only on confirmed
+ delivery.
+ """
+ logger.info("ordering.email.stub to=%s subject=%s", to, subject)
+ return False # stub: nothing actually sent
+
+
+def send_order_submitted_notifications(db: Session, order: Order) -> list[NotificationResult]:
+ """Send customer confirmation + internal notification for a submitted order."""
+ settings = get_or_create_settings(db, order.tenant_id)
+ customer = db.scalar(select(ClientAccount).where(ClientAccount.id == order.client_account_id))
+ customer_name = customer.name if customer else "Customer"
+ order_ref = order.order_number or f"#{order.id}"
+ results: list[NotificationResult] = []
+
+ if settings.send_customer_confirmation:
+ to = _customer_recipients(db, order)
+ subject = f"Order {order_ref} received"
+ body = (
+ f"Thank you — we have received order {order_ref} for {customer_name}.\n"
+ f"Lines: {len(order.lines)}. Subtotal (ex GST): {order.subtotal_ex_gst:.2f}.\n"
+ "Our team will review and confirm shortly."
+ )
+ delivered = _deliver_email(to=to, subject=subject, body=body, from_email=settings.from_email) if to else False
+ results.append(
+ NotificationResult(
+ channel="customer",
+ recipients=to,
+ subject=subject,
+ delivered=delivered,
+ detail="stubbed" if not delivered else "sent",
+ )
+ )
+
+ internal = _internal_recipients(settings)
+ subject = f"New order {order_ref} from {customer_name}"
+ body = (
+ f"New order {order_ref} submitted by {order.created_by_name or 'a customer user'}.\n"
+ f"Customer: {customer_name}. Lines: {len(order.lines)}. "
+ f"Subtotal (ex GST): {order.subtotal_ex_gst:.2f}."
+ )
+ delivered = _deliver_email(to=internal, subject=subject, body=body, from_email=settings.from_email) if internal else False
+ results.append(
+ NotificationResult(
+ channel="internal",
+ recipients=internal,
+ subject=subject,
+ delivered=delivered,
+ detail="no recipients configured" if not internal else ("stubbed" if not delivered else "sent"),
+ )
+ )
+ return results
diff --git a/backend/app/services/order_pdf.py b/backend/app/services/order_pdf.py
new file mode 100644
index 0000000..2235623
--- /dev/null
+++ b/backend/app/services/order_pdf.py
@@ -0,0 +1,112 @@
+"""Order confirmation PDF generation (reportlab)."""
+from __future__ import annotations
+
+from datetime import datetime
+from io import BytesIO
+
+from app.models.client_access import ClientAccount
+from app.models.ordering import Order
+
+
+class OrderPdfUnavailableError(RuntimeError):
+ pass
+
+
+def _fmt_money(value: float | None) -> str:
+ return "Quote" if value is None else f"${value:,.2f}"
+
+
+def build_order_confirmation_pdf(order: Order, customer: ClientAccount | None) -> bytes:
+ try:
+ from reportlab.lib import colors
+ from reportlab.lib.pagesizes import A4
+ from reportlab.pdfgen import canvas
+ except ModuleNotFoundError as exc: # pragma: no cover
+ raise OrderPdfUnavailableError(
+ "PDF generation is unavailable because 'reportlab' is not installed."
+ ) from exc
+
+ buffer = BytesIO()
+ page_width, page_height = A4
+ pdf = canvas.Canvas(buffer, pagesize=A4)
+ margin = 40
+ y = page_height - margin
+
+ pdf.setFont("Helvetica-Bold", 18)
+ pdf.drawString(margin, y, "Order Confirmation")
+ y -= 24
+
+ pdf.setFont("Helvetica", 10)
+ order_ref = order.order_number or f"#{order.id}"
+ pdf.drawString(margin, y, f"Order: {order_ref}")
+ pdf.drawRightString(page_width - margin, y, datetime.utcnow().strftime("%d %b %Y %H:%M UTC"))
+ y -= 14
+ pdf.drawString(margin, y, f"Customer: {customer.name if customer else order.client_account_id}")
+ y -= 14
+ pdf.drawString(margin, y, f"Status: {order.status.replace('_', ' ').title()}")
+ y -= 14
+ if order.purchase_order_number:
+ pdf.drawString(margin, y, f"PO Number: {order.purchase_order_number}")
+ y -= 14
+ pdf.drawString(margin, y, f"Fulfilment: {order.fulfilment_method.title()}")
+ y -= 14
+ if order.requested_delivery_date:
+ pdf.drawString(margin, y, f"Requested date: {order.requested_delivery_date.strftime('%d %b %Y')}")
+ y -= 14
+ y -= 6
+
+ # Table header
+ pdf.setFont("Helvetica-Bold", 9)
+ pdf.setFillColor(colors.HexColor("#22362d"))
+ pdf.drawString(margin, y, "Product")
+ pdf.drawString(margin + 220, y, "SKU")
+ pdf.drawRightString(margin + 330, y, "Qty")
+ pdf.drawRightString(margin + 420, y, "Unit (ex GST)")
+ pdf.drawRightString(page_width - margin, y, "Line total")
+ y -= 6
+ pdf.setStrokeColor(colors.HexColor("#cccccc"))
+ pdf.line(margin, y, page_width - margin, y)
+ y -= 14
+
+ pdf.setFont("Helvetica", 9)
+ pdf.setFillColor(colors.black)
+ for line in order.lines:
+ if y < margin + 60:
+ pdf.showPage()
+ y = page_height - margin
+ pdf.setFont("Helvetica", 9)
+ unit = line.admin_override_price if line.admin_override_price is not None else line.unit_price
+ line_total = None if unit is None else round(unit * line.quantity, 2)
+ pdf.drawString(margin, y, (line.product_name or "")[:38])
+ pdf.drawString(margin + 220, y, (line.product_sku or "")[:16])
+ pdf.drawRightString(margin + 330, y, f"{line.quantity:g}")
+ pdf.drawRightString(margin + 420, y, _fmt_money(unit))
+ pdf.drawRightString(page_width - margin, y, _fmt_money(line_total))
+ y -= 14
+
+ y -= 4
+ pdf.line(margin, y, page_width - margin, y)
+ y -= 16
+ pdf.setFont("Helvetica-Bold", 10)
+ pdf.drawRightString(margin + 420, y, "Subtotal (ex GST)")
+ pdf.drawRightString(page_width - margin, y, _fmt_money(order.subtotal_ex_gst))
+ y -= 20
+
+ if order.requires_quote:
+ pdf.setFont("Helvetica-Oblique", 9)
+ pdf.setFillColor(colors.HexColor("#8a5a00"))
+ pdf.drawString(margin, y, "This order contains quote-only items. Final pricing will be confirmed by our team.")
+ y -= 14
+
+ if order.delivery_notes:
+ pdf.setFont("Helvetica", 9)
+ pdf.setFillColor(colors.black)
+ pdf.drawString(margin, y, f"Notes: {order.delivery_notes[:90]}")
+
+ pdf.setFont("Helvetica-Oblique", 8)
+ pdf.setFillColor(colors.HexColor("#888888"))
+ pdf.drawString(margin, margin - 12, "Prices exclude GST. This is an order confirmation, not a tax invoice.")
+
+ pdf.showPage()
+ pdf.save()
+ return buffer.getvalue()
diff --git a/backend/app/services/ordering_pricing.py b/backend/app/services/ordering_pricing.py
new file mode 100644
index 0000000..225a56b
--- /dev/null
+++ b/backend/app/services/ordering_pricing.py
@@ -0,0 +1,201 @@
+"""Customer-specific pricing engine for the B2B ordering portal.
+
+The backend is the single source of truth for prices — the frontend never
+computes a final price. :func:`resolve_price` returns a fully-attributed
+:class:`PriceResolution` so every order line, and any future report, can explain
+exactly which rule produced the number.
+
+Resolution priority (highest wins):
+
+1. **Quote-only** — the product requires a manual quote, or the customer has a
+ ``quote`` price rule for it. No automatic price.
+2. **Fixed / contract** — a :class:`CustomerProductPrice` for this customer +
+ product (quantity tiers may refine it).
+3. **Price list** — a :class:`PriceListItem` from the customer's assigned price
+ list (quantity tiers may refine it).
+4. **Base + discount** — the catalogue list price, optionally reduced by the
+ customer's default discount percentage.
+5. **Quote fallback** — no resolvable price ⇒ treated as quote-only.
+
+All prices are GST-exclusive.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerPriceAssignment,
+ CustomerProductPrice,
+ PriceListItem,
+ PriceTier,
+)
+
+
+@dataclass(frozen=True)
+class PriceResolution:
+ unit_price: float | None
+ price_source: str # one of ordering.PRICE_SOURCES
+ price_rule_id: int | None
+ discount_percent: float
+ requires_quote: bool
+ label: str
+
+ def line_total(self, quantity: float) -> float | None:
+ if self.unit_price is None:
+ return None
+ return round(self.unit_price * quantity, 4)
+
+
+_SOURCE_LABELS = {
+ "fixed": "Fixed price",
+ "contract": "Contract price",
+ "price_list": "Price list",
+ "tiered": "Tiered price",
+ "base": "List price",
+ "quote": "Quote required",
+}
+
+
+def _best_tier_price(
+ db: Session,
+ *,
+ customer_product_price_id: int | None = None,
+ price_list_item_id: int | None = None,
+ quantity: float,
+) -> float | None:
+ """Return the unit price of the highest qualifying quantity tier, if any."""
+ stmt = select(PriceTier).where(PriceTier.min_quantity <= quantity)
+ if customer_product_price_id is not None:
+ stmt = stmt.where(PriceTier.customer_product_price_id == customer_product_price_id)
+ elif price_list_item_id is not None:
+ stmt = stmt.where(PriceTier.price_list_item_id == price_list_item_id)
+ else:
+ return None
+ tiers = db.scalars(stmt.order_by(PriceTier.min_quantity.desc())).all()
+ return tiers[0].unit_price if tiers else None
+
+
+def _quote(label: str = "Quote required") -> PriceResolution:
+ return PriceResolution(
+ unit_price=None,
+ price_source="quote",
+ price_rule_id=None,
+ discount_percent=0.0,
+ requires_quote=True,
+ label=label,
+ )
+
+
+def get_customer_assignment(
+ db: Session, *, client_account_id: int
+) -> CustomerPriceAssignment | None:
+ return db.scalar(
+ select(CustomerPriceAssignment).where(
+ CustomerPriceAssignment.client_account_id == client_account_id
+ )
+ )
+
+
+def resolve_price(
+ db: Session,
+ *,
+ client_account_id: int,
+ product: CatalogueProduct,
+ quantity: float,
+) -> PriceResolution:
+ """Resolve the GST-exclusive unit price for a customer + product + quantity."""
+ quantity = max(quantity, 0.0)
+
+ # 1. Product-level manual-quote flag.
+ if product.requires_quote:
+ return _quote()
+
+ # 2. Customer-specific fixed/contract/quote rule.
+ cpp = db.scalar(
+ select(CustomerProductPrice).where(
+ CustomerProductPrice.client_account_id == client_account_id,
+ CustomerProductPrice.product_id == product.id,
+ CustomerProductPrice.active.is_(True),
+ )
+ )
+ if cpp is not None:
+ if cpp.rule_type == "quote":
+ return _quote()
+ tier_price = _best_tier_price(
+ db, customer_product_price_id=cpp.id, quantity=quantity
+ )
+ if tier_price is not None:
+ return PriceResolution(
+ unit_price=round(tier_price, 4),
+ price_source="tiered",
+ price_rule_id=cpp.id,
+ discount_percent=0.0,
+ requires_quote=False,
+ label=_SOURCE_LABELS["tiered"],
+ )
+ if cpp.unit_price is not None:
+ source = "contract" if cpp.rule_type == "contract" else "fixed"
+ return PriceResolution(
+ unit_price=round(cpp.unit_price, 4),
+ price_source=source,
+ price_rule_id=cpp.id,
+ discount_percent=0.0,
+ requires_quote=False,
+ label=_SOURCE_LABELS[source],
+ )
+
+ assignment = get_customer_assignment(db, client_account_id=client_account_id)
+
+ # 3. Assigned price list.
+ if assignment is not None and assignment.price_list_id is not None:
+ pli = db.scalar(
+ select(PriceListItem).where(
+ PriceListItem.price_list_id == assignment.price_list_id,
+ PriceListItem.product_id == product.id,
+ )
+ )
+ if pli is not None:
+ tier_price = _best_tier_price(
+ db, price_list_item_id=pli.id, quantity=quantity
+ )
+ if tier_price is not None:
+ return PriceResolution(
+ unit_price=round(tier_price, 4),
+ price_source="tiered",
+ price_rule_id=pli.id,
+ discount_percent=0.0,
+ requires_quote=False,
+ label=_SOURCE_LABELS["tiered"],
+ )
+ return PriceResolution(
+ unit_price=round(pli.unit_price, 4),
+ price_source="price_list",
+ price_rule_id=pli.id,
+ discount_percent=0.0,
+ requires_quote=False,
+ label=_SOURCE_LABELS["price_list"],
+ )
+
+ # 4. Base price (+ optional customer discount).
+ if product.base_price is not None:
+ discount = assignment.discount_percent if assignment else 0.0
+ discount = min(max(discount, 0.0), 100.0)
+ unit_price = round(product.base_price * (1.0 - discount / 100.0), 4)
+ label = _SOURCE_LABELS["base"]
+ if discount:
+ label = f"List price less {discount:g}%"
+ return PriceResolution(
+ unit_price=unit_price,
+ price_source="base",
+ price_rule_id=product.id,
+ discount_percent=discount,
+ requires_quote=False,
+ label=label,
+ )
+
+ # 5. No resolvable price ⇒ quote.
+ return _quote("No price set — quote required")
diff --git a/backend/app/services/ordering_service.py b/backend/app/services/ordering_service.py
new file mode 100644
index 0000000..306f171
--- /dev/null
+++ b/backend/app/services/ordering_service.py
@@ -0,0 +1,313 @@
+"""Shared ordering-portal business logic: lifecycle, visibility, totals,
+serialization, and audit.
+
+Customer isolation is enforced by callers passing ``tenant_id`` /
+``client_account_id`` into every query; this module never widens that scope.
+"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.api.deps import AuthSession
+from app.core.config import settings
+from app.models.client_access import ClientAccount
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerProductVisibility,
+ Order,
+ OrderLine,
+ OrderStatusHistory,
+)
+from app.services.client_access_service import record_audit_event
+from app.services.ordering_pricing import resolve_price
+
+# The ordering catalogue is "standard globally" — it lives under a single
+# seller/ordering tenant, while individual customers (ClientAccounts) may have
+# their own tenant_id. Customer isolation therefore keys on the globally-unique
+# ``client_account_id``, NOT on the customer's session tenant. Admin and
+# customer code both store/read ordering data under ORDERING_TENANT.
+ORDERING_TENANT = settings.client_tenant_id
+
+# --- Lifecycle ---------------------------------------------------------------
+
+# Full internal lifecycle → the simplified status a customer sees.
+CUSTOMER_VISIBLE_STATUS = {
+ "draft": "draft",
+ "submitted": "submitted",
+ "under_review": "processing",
+ "confirmed": "confirmed",
+ "sent_to_xero": "confirmed",
+ "in_production": "processing",
+ "ready_for_pickup": "ready",
+ "dispatched": "dispatched",
+ "completed": "completed",
+ "cancelled": "cancelled",
+}
+
+# Statuses an admin may move an order into from a given status. Cancellation is
+# allowed from any non-terminal status (handled separately).
+ALLOWED_ADMIN_TRANSITIONS: dict[str, set[str]] = {
+ "draft": {"submitted", "cancelled"},
+ "submitted": {"under_review", "confirmed", "cancelled"},
+ "under_review": {"confirmed", "submitted", "cancelled"},
+ "confirmed": {"sent_to_xero", "in_production", "cancelled"},
+ "sent_to_xero": {"in_production", "cancelled"},
+ "in_production": {"ready_for_pickup", "dispatched", "cancelled"},
+ "ready_for_pickup": {"dispatched", "completed", "cancelled"},
+ "dispatched": {"completed", "cancelled"},
+ "completed": set(),
+ "cancelled": set(),
+}
+
+# Customers may only edit an order while it is a draft.
+CUSTOMER_EDITABLE_STATUSES = {"draft"}
+
+
+def can_admin_transition(from_status: str, to_status: str) -> bool:
+ if to_status == "cancelled" and from_status not in {"completed", "cancelled"}:
+ return True
+ return to_status in ALLOWED_ADMIN_TRANSITIONS.get(from_status, set())
+
+
+def record_status_change(
+ db: Session,
+ order: Order,
+ *,
+ to_status: str,
+ actor_type: str,
+ actor_name: str | None,
+ note: str | None = None,
+) -> None:
+ db.add(
+ OrderStatusHistory(
+ tenant_id=order.tenant_id,
+ order_id=order.id,
+ from_status=order.status,
+ to_status=to_status,
+ actor_type=actor_type,
+ actor_name=actor_name,
+ note=note,
+ )
+ )
+ order.status = to_status
+
+
+def next_order_number(db: Session, tenant_id: str) -> str:
+ # Cheap monotonic-ish numbering based on max id; good enough for v1.
+ last_id = db.scalar(select(Order.id).order_by(Order.id.desc()).limit(1)) or 0
+ return f"ORD-{last_id + 1:06d}"
+
+
+# --- Product visibility ------------------------------------------------------
+
+
+def visible_product_ids_for_customer(db: Session, *, tenant_id: str, client_account_id: int) -> set[int]:
+ """Ids the customer is explicitly *hidden* from (opt-out model)."""
+ rows = db.scalars(
+ select(CustomerProductVisibility.product_id).where(
+ CustomerProductVisibility.client_account_id == client_account_id,
+ CustomerProductVisibility.tenant_id == tenant_id,
+ CustomerProductVisibility.visible.is_(False),
+ )
+ ).all()
+ return set(rows)
+
+
+def list_visible_products(
+ db: Session, *, tenant_id: str, client_account_id: int
+) -> list[CatalogueProduct]:
+ hidden = visible_product_ids_for_customer(
+ db, tenant_id=tenant_id, client_account_id=client_account_id
+ )
+ products = db.scalars(
+ select(CatalogueProduct)
+ .where(CatalogueProduct.tenant_id == tenant_id, CatalogueProduct.active.is_(True))
+ .order_by(CatalogueProduct.category, CatalogueProduct.name)
+ ).all()
+ return [p for p in products if p.id not in hidden]
+
+
+# --- Totals ------------------------------------------------------------------
+
+
+def effective_unit_price(line: OrderLine) -> float | None:
+ if line.admin_override_price is not None:
+ return line.admin_override_price
+ return line.unit_price
+
+
+def recompute_order_totals(order: Order) -> None:
+ subtotal = 0.0
+ requires_quote = False
+ for line in order.lines:
+ unit = effective_unit_price(line)
+ if line.requires_quote or unit is None:
+ requires_quote = True
+ line.line_total = None
+ continue
+ line.line_total = round(unit * line.quantity, 4)
+ subtotal += line.line_total
+ order.subtotal_ex_gst = round(subtotal, 4)
+ order.requires_quote = requires_quote
+
+
+# --- Serialization -----------------------------------------------------------
+
+
+def serialize_product(
+ product: CatalogueProduct,
+ *,
+ db: Session | None = None,
+ client_account_id: int | None = None,
+ quantity: float = 1.0,
+) -> dict:
+ """Serialize a catalogue product. When ``db`` + ``client_account_id`` are
+ given, attach the resolved customer-specific price + provenance."""
+ data = {
+ "id": product.id,
+ "name": product.name,
+ "sku": product.sku,
+ "description": product.description,
+ "category": product.category,
+ "image_url": product.image_url,
+ "unit_size": product.unit_size,
+ "unit_of_measure": product.unit_of_measure,
+ "min_order_quantity": product.min_order_quantity,
+ "stock_status": product.stock_status,
+ "active": product.active,
+ "requires_quote": product.requires_quote,
+ "base_price": product.base_price,
+ "created_at": product.created_at,
+ }
+ if db is not None and client_account_id is not None:
+ resolution = resolve_price(
+ db, client_account_id=client_account_id, product=product, quantity=quantity
+ )
+ data["price"] = {
+ "unit_price": resolution.unit_price,
+ "price_source": resolution.price_source,
+ "price_rule_id": resolution.price_rule_id,
+ "discount_percent": resolution.discount_percent,
+ "requires_quote": resolution.requires_quote,
+ "label": resolution.label,
+ }
+ return data
+
+
+def serialize_order_line(line: OrderLine, *, for_admin: bool) -> dict:
+ data = {
+ "id": line.id,
+ "product_id": line.product_id,
+ "product_name": line.product_name,
+ "product_sku": line.product_sku,
+ "quantity": line.quantity,
+ "unit_price": effective_unit_price(line),
+ "line_total": line.line_total,
+ "requires_quote": line.requires_quote,
+ "price_source": line.price_source,
+ "discount_percent": line.discount_percent,
+ "notes": line.notes,
+ }
+ if for_admin:
+ data.update(
+ {
+ "resolved_unit_price": line.unit_price,
+ "admin_override_price": line.admin_override_price,
+ "admin_override_reason": line.admin_override_reason,
+ "price_rule_id": line.price_rule_id,
+ }
+ )
+ return data
+
+
+def serialize_order(order: Order, *, for_admin: bool) -> dict:
+ status = order.status if for_admin else CUSTOMER_VISIBLE_STATUS.get(order.status, order.status)
+ data = {
+ "id": order.id,
+ "order_number": order.order_number,
+ "status": status,
+ "client_account_id": order.client_account_id,
+ "created_by_name": order.created_by_name,
+ "purchase_order_number": order.purchase_order_number,
+ "delivery_notes": order.delivery_notes,
+ "requested_delivery_date": order.requested_delivery_date,
+ "fulfilment_method": order.fulfilment_method,
+ "subtotal_ex_gst": order.subtotal_ex_gst,
+ "requires_quote": order.requires_quote,
+ "submitted_at": order.submitted_at,
+ "created_at": order.created_at,
+ "updated_at": order.updated_at,
+ "editable": order.status in CUSTOMER_EDITABLE_STATUSES,
+ "lines": [serialize_order_line(line, for_admin=for_admin) for line in order.lines],
+ }
+ if for_admin:
+ data.update(
+ {
+ "raw_status": order.status,
+ "admin_notes": order.admin_notes,
+ "reopened": order.reopened,
+ "xero_status": order.xero_status,
+ "xero_invoice_id": order.xero_invoice_id,
+ "status_history": [
+ {
+ "id": h.id,
+ "from_status": h.from_status,
+ "to_status": h.to_status,
+ "actor_type": h.actor_type,
+ "actor_name": h.actor_name,
+ "note": h.note,
+ "created_at": h.created_at,
+ }
+ for h in order.status_history
+ ],
+ }
+ )
+ return data
+
+
+# --- Audit -------------------------------------------------------------------
+
+
+def audit_order_event(
+ db: Session,
+ *,
+ session: AuthSession,
+ order: Order,
+ action: str,
+ summary: str,
+ target_id: int | None = None,
+) -> None:
+ """Record an ordering audit event onto the shared audit trail."""
+ actor_type = "lean_admin" if session.role in {"admin", "internal"} else "customer"
+ record_audit_event(
+ db,
+ tenant_id=order.tenant_id,
+ client_account_id=order.client_account_id,
+ actor_type=actor_type,
+ actor_name=session.name or "",
+ actor_email=session.email or "",
+ actor_role=session.client_role or session.role,
+ action=action,
+ target_type="order",
+ target_id=target_id if target_id is not None else order.id,
+ module_key="ordering",
+ summary=summary,
+ )
+
+
+def get_customer_account(db: Session, *, client_account_id: int) -> ClientAccount | None:
+ # client_account_id is a global primary key; a customer's own tenant_id may
+ # differ from ORDERING_TENANT, so we look up by id alone.
+ return db.scalar(select(ClientAccount).where(ClientAccount.id == client_account_id))
+
+
+def ensure_customer_active(account: ClientAccount | None) -> None:
+ from fastapi import HTTPException, status
+
+ if account is None:
+ raise HTTPException(status_code=404, detail="Customer account not found")
+ if account.status != "active":
+ raise HTTPException(status_code=403, detail="This customer account is disabled")
diff --git a/backend/app/services/xero_service.py b/backend/app/services/xero_service.py
new file mode 100644
index 0000000..3e3aa66
--- /dev/null
+++ b/backend/app/services/xero_service.py
@@ -0,0 +1,181 @@
+"""Xero integration layer for the ordering portal.
+
+This is a **clean, stubbed interface**. No Xero credentials are hard-coded — all
+configuration comes from environment variables (``XERO_*``). When credentials
+are absent (the default in dev/alpha), the service runs in "stub" mode: it
+records what *would* be sent and returns a deterministic fake invoice id so the
+rest of the order lifecycle can be exercised end-to-end.
+
+To go live, implement the real HTTP calls inside :func:`_submit_to_xero_api`
+(see the TODOs) — no caller needs to change.
+
+Service responsibilities (per spec):
+* Map a customer (ClientAccount) to a Xero contact.
+* Map catalogue products to Xero item codes.
+* Create a draft invoice equivalent for a confirmed order.
+* Record Xero response ids/status (via XeroSyncLog, written by the caller).
+* Handle failures gracefully (never raise into the request path).
+"""
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass, field
+from datetime import datetime
+
+from app.models.client_access import ClientAccount
+from app.models.ordering import Order
+
+
+@dataclass
+class XeroConfig:
+ client_id: str | None = None
+ client_secret: str | None = None
+ tenant_id: str | None = None
+ base_url: str = "https://api.xero.com/api.xro/2.0"
+
+ @property
+ def configured(self) -> bool:
+ return bool(self.client_id and self.client_secret and self.tenant_id)
+
+ @classmethod
+ def from_env(cls) -> "XeroConfig":
+ return cls(
+ client_id=os.getenv("XERO_CLIENT_ID") or None,
+ client_secret=os.getenv("XERO_CLIENT_SECRET") or None,
+ tenant_id=os.getenv("XERO_TENANT_ID") or None,
+ base_url=os.getenv("XERO_API_BASE_URL", "https://api.xero.com/api.xro/2.0"),
+ )
+
+
+@dataclass
+class XeroSubmissionResult:
+ status: str # "success" | "failed"
+ xero_invoice_id: str | None
+ message: str
+ request_summary: str
+ stubbed: bool = False
+ line_items: list[dict] = field(default_factory=list)
+
+
+def map_customer_to_contact(customer: ClientAccount) -> dict:
+ """Map a customer account onto a Xero contact payload."""
+ return {
+ # TODO: persist and reuse a real Xero ContactID once the contact has
+ # been created/matched in Xero. For now we key on the client code.
+ "ContactNumber": customer.client_code,
+ "Name": customer.name,
+ }
+
+
+def map_product_to_item_code(product_sku: str) -> str:
+ """Map a catalogue SKU onto a Xero item code.
+
+ TODO: support an explicit SKU→Xero item-code mapping table if the codes
+ diverge. Today the SKU is used directly.
+ """
+ return product_sku
+
+
+def build_invoice_payload(order: Order, customer: ClientAccount) -> dict:
+ """Build the Xero draft-invoice payload for a confirmed order."""
+ line_items = []
+ for line in order.lines:
+ if line.requires_quote or line.unit_price is None:
+ # Quote-only lines can't carry a price; skip until quoted.
+ continue
+ unit_price = line.admin_override_price if line.admin_override_price is not None else line.unit_price
+ line_items.append(
+ {
+ "ItemCode": map_product_to_item_code(line.product_sku),
+ "Description": line.product_name,
+ "Quantity": line.quantity,
+ "UnitAmount": unit_price,
+ "AccountCode": os.getenv("XERO_SALES_ACCOUNT_CODE", "200"),
+ # Prices are GST-exclusive throughout the platform.
+ "TaxType": os.getenv("XERO_TAX_TYPE", "OUTPUT"),
+ }
+ )
+ return {
+ "Type": "ACCREC",
+ "Status": "DRAFT",
+ "Contact": map_customer_to_contact(customer),
+ "Reference": order.purchase_order_number or order.order_number or f"Order {order.id}",
+ "LineAmountTypes": "Exclusive",
+ "LineItems": line_items,
+ }
+
+
+def _submit_to_xero_api(config: XeroConfig, payload: dict) -> XeroSubmissionResult:
+ """Real Xero submission. Stubbed until credentials/endpoints are wired.
+
+ TODO (go-live):
+ 1. Obtain an OAuth2 token (client-credentials or stored refresh token).
+ 2. POST ``payload`` to ``{config.base_url}/Invoices`` with the
+ ``Xero-tenant-id`` header set to ``config.tenant_id``.
+ 3. Parse ``Invoices[0].InvoiceID`` from the response.
+ 4. Map non-2xx responses onto ``status="failed"`` with the error body.
+ """
+ summary = f"{len(payload.get('LineItems', []))} line(s) for {payload['Contact']['Name']}"
+ # Real call would go here. Intentionally not implemented yet.
+ return XeroSubmissionResult(
+ status="failed",
+ xero_invoice_id=None,
+ message="Xero live submission is not implemented yet (stub interface).",
+ request_summary=summary,
+ stubbed=False,
+ line_items=payload.get("LineItems", []),
+ )
+
+
+def submit_order_to_xero(order: Order, customer: ClientAccount) -> XeroSubmissionResult:
+ """Submit a confirmed order to Xero, or stub it when unconfigured.
+
+ Never raises — failures are returned as ``status="failed"`` results so the
+ order lifecycle can record the attempt and continue.
+ """
+ config = XeroConfig.from_env()
+ payload = build_invoice_payload(order, customer)
+ summary = f"{len(payload['LineItems'])} line(s) for {payload['Contact']['Name']}"
+
+ if not config.configured:
+ # Stub mode: deterministic fake invoice id so downstream flows work.
+ fake_id = f"STUB-INV-{order.id:06d}"
+ return XeroSubmissionResult(
+ status="success",
+ xero_invoice_id=fake_id,
+ message="Xero not configured — order recorded in stub mode.",
+ request_summary=summary,
+ stubbed=True,
+ line_items=payload["LineItems"],
+ )
+
+ try:
+ return _submit_to_xero_api(config, payload)
+ except Exception as exc: # pragma: no cover - defensive: never break the request path
+ return XeroSubmissionResult(
+ status="failed",
+ xero_invoice_id=None,
+ message=f"Xero submission error: {exc}",
+ request_summary=summary,
+ stubbed=False,
+ line_items=payload["LineItems"],
+ )
+
+
+def xero_status_snapshot() -> dict:
+ config = XeroConfig.from_env()
+ return {
+ "configured": config.configured,
+ "mode": "live" if config.configured else "stub",
+ "base_url": config.base_url,
+ "checked_at": datetime.utcnow().isoformat(),
+ "missing_env": [
+ name
+ for name, value in (
+ ("XERO_CLIENT_ID", config.client_id),
+ ("XERO_CLIENT_SECRET", config.client_secret),
+ ("XERO_TENANT_ID", config.tenant_id),
+ )
+ if not value
+ ],
+ }
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index cc45669..af1d27a 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "data-entry-app-backend"
-version = "0.1.12"
+version = "0.1.14"
description = "Costing platform MVP backend"
requires-python = ">=3.11"
dependencies = [
diff --git a/backend/tests/test_ordering_flow.py b/backend/tests/test_ordering_flow.py
new file mode 100644
index 0000000..01e2f60
--- /dev/null
+++ b/backend/tests/test_ordering_flow.py
@@ -0,0 +1,249 @@
+"""End-to-end ordering flow test exercising the acceptance criteria.
+
+Drives the real FastAPI app via TestClient against an in-memory database, using
+directly-issued auth tokens (bypassing the password-based login endpoints).
+"""
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine, select
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.core.access import INTERNAL_USER_SUBJECT
+from app.core.config import settings
+from app.core.security import issue_token
+from app.db.session import Base, get_db
+from app.main import app
+from app.models.access import User
+from app.models.client_access import ClientUser
+from app.seed_access import seed_access
+from app.services import ordering_service as svc
+
+ORDERING_TENANT = svc.ORDERING_TENANT
+
+
+@pytest.fixture()
+def client():
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
+
+ def override_get_db():
+ db = TestingSession()
+ try:
+ yield db
+ finally:
+ db.close()
+
+ app.dependency_overrides[get_db] = override_get_db
+ with TestClient(app) as test_client:
+ test_client._session_factory = TestingSession # type: ignore[attr-defined]
+ yield test_client
+ app.dependency_overrides.clear()
+
+
+def _admin_headers() -> dict[str, str]:
+ token = issue_token({"name": "Admin", "email": settings.admin_email, "role": "admin"})
+ return {"Authorization": f"Bearer {token}"}
+
+
+def _customer_headers(db_session_factory, *, account_id: int) -> dict[str, str]:
+ db = db_session_factory()
+ user = db.scalar(select(ClientUser).where(ClientUser.client_account_id == account_id))
+ payload = {
+ "name": user.full_name,
+ "email": user.email,
+ "role": "client",
+ "tenant_id": user.tenant_id,
+ "client_role": user.role,
+ "user_id": user.id,
+ "client_account_id": user.client_account_id,
+ }
+ db.close()
+ token = issue_token(payload)
+ return {"Authorization": f"Bearer {token}"}
+
+
+def test_full_b2b_ordering_flow(client):
+ admin = _admin_headers()
+
+ # 1. Admin creates a customer.
+ r = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Acme Farms", "client_code": "ACME"})
+ assert r.status_code == 201, r.text
+ customer = r.json()
+ customer_id = customer["id"]
+
+ # 2. Admin creates a user for that customer.
+ r = client.post(
+ f"/api/ordering-admin/customers/{customer_id}/users",
+ headers=admin,
+ json={"full_name": "Buyer Bob", "email": "bob@acme.test", "role": "buyer"},
+ )
+ assert r.status_code == 201, r.text
+
+ # 3. Admin creates products.
+ r = client.post(
+ "/api/ordering-admin/products",
+ headers=admin,
+ json={"name": "Maize 1T", "sku": "MAIZE-1T", "category": "grains", "base_price": 100.0, "min_order_quantity": 1},
+ )
+ assert r.status_code == 201, r.text
+ product = r.json()
+ product_id = product["id"]
+
+ r = client.post(
+ "/api/ordering-admin/products",
+ headers=admin,
+ json={"name": "Hidden Blend", "sku": "HIDDEN-1", "category": "custom_blends", "base_price": 50.0},
+ )
+ hidden_product_id = r.json()["id"]
+
+ # 4. Admin assigns a customer-specific price (contract @ 92.5).
+ r = client.put(
+ f"/api/ordering-admin/customers/{customer_id}/product-prices",
+ headers=admin,
+ json={"product_id": product_id, "unit_price": 92.5, "rule_type": "contract"},
+ )
+ assert r.status_code == 200, r.text
+
+ # 4b. Admin hides the second product from this customer.
+ r = client.put(
+ f"/api/ordering-admin/customers/{customer_id}/visibility",
+ headers=admin,
+ json={"product_id": hidden_product_id, "visible": False},
+ )
+ assert r.status_code == 200, r.text
+
+ # 5. Customer logs in (token) and sees only their available product + price.
+ cust = _customer_headers(client._session_factory, account_id=customer_id)
+ r = client.get("/api/ordering/catalogue", headers=cust)
+ assert r.status_code == 200, r.text
+ catalogue = r.json()
+ skus = {p["sku"] for p in catalogue}
+ assert skus == {"MAIZE-1T"} # hidden product is not visible
+ maize = catalogue[0]
+ assert maize["price"]["unit_price"] == 92.5
+ assert maize["price"]["price_source"] == "contract"
+
+ # 6. Customer creates a draft order.
+ r = client.post(
+ "/api/ordering/orders",
+ headers=cust,
+ json={"lines": [{"product_id": product_id, "quantity": 3}], "fulfilment_method": "delivery"},
+ )
+ assert r.status_code == 201, r.text
+ draft = r.json()
+ assert draft["status"] == "draft"
+ assert draft["subtotal_ex_gst"] == 277.5 # 3 * 92.5
+ order_id = draft["id"]
+
+ # Minimum-order-quantity validation.
+ r = client.post(
+ "/api/ordering/orders",
+ headers=cust,
+ json={"lines": [{"product_id": product_id, "quantity": 0.5}]},
+ )
+ # quantity below MOQ of 1 → caught by schema (gt=0 ok) then MOQ check 422.
+ assert r.status_code == 422
+
+ # 7. Customer submits the order — price is frozen on the line.
+ r = client.post(f"/api/ordering/orders/{order_id}/submit", headers=cust, json={"purchase_order_number": "PO-77"})
+ assert r.status_code == 200, r.text
+ submitted = r.json()
+ assert submitted["status"] == "submitted"
+ assert submitted["order_number"].startswith("ORD-")
+ assert submitted["lines"][0]["unit_price"] == 92.5
+ assert submitted["purchase_order_number"] == "PO-77"
+
+ # Customer can no longer edit a submitted order.
+ r = client.patch(f"/api/ordering/orders/{order_id}", headers=cust, json={"delivery_notes": "late edit"})
+ assert r.status_code == 409
+
+ # 8. Admin sees the submitted order and manages it.
+ r = client.get("/api/ordering-admin/orders", headers=admin)
+ assert r.status_code == 200, r.text
+ admin_orders = r.json()
+ assert any(o["id"] == order_id for o in admin_orders)
+ assert admin_orders[0]["customer_name"] == "Acme Farms"
+
+ # Status progression with lifecycle enforcement.
+ r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "confirmed"})
+ assert r.status_code == 200, r.text
+ # Illegal jump is rejected.
+ r = client.patch(f"/api/ordering-admin/orders/{order_id}/status", headers=admin, json={"to_status": "completed"})
+ assert r.status_code == 409
+
+ # 9. Xero submission (stub) succeeds and records an invoice id.
+ r = client.post(f"/api/ordering-admin/orders/{order_id}/send-to-xero", headers=admin)
+ assert r.status_code == 200, r.text
+ xero = r.json()
+ assert xero["xero_result"]["status"] == "success"
+ assert xero["xero_result"]["stubbed"] is True
+ assert xero["xero_invoice_id"].startswith("STUB-INV-")
+ assert xero["raw_status"] == "sent_to_xero"
+
+
+def test_customer_isolation_between_companies(client):
+ admin = _admin_headers()
+ # Two customers, each with a user.
+ a = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Alpha", "client_code": "ALPHA"}).json()
+ b = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Beta", "client_code": "BETA"}).json()
+ client.post(f"/api/ordering-admin/customers/{a['id']}/users", headers=admin, json={"full_name": "A1", "email": "a1@alpha.test", "role": "owner"})
+ client.post(f"/api/ordering-admin/customers/{b['id']}/users", headers=admin, json={"full_name": "B1", "email": "b1@beta.test", "role": "owner"})
+ product = client.post("/api/ordering-admin/products", headers=admin, json={"name": "Barley 1T", "sku": "BAR-1T", "category": "grains", "base_price": 80.0}).json()
+
+ headers_a = _customer_headers(client._session_factory, account_id=a["id"])
+ headers_b = _customer_headers(client._session_factory, account_id=b["id"])
+
+ # Alpha creates and submits an order.
+ order = client.post("/api/ordering/orders", headers=headers_a, json={"lines": [{"product_id": product["id"], "quantity": 2}]}).json()
+ # Beta must not be able to read Alpha's order.
+ r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_b)
+ assert r.status_code == 404
+ # Alpha can.
+ r = client.get(f"/api/ordering/orders/{order['id']}", headers=headers_a)
+ assert r.status_code == 200
+
+
+def test_internal_staff_can_manage_orders(client):
+ # Internal Hunter Stock Feeds staff (not the legacy /admin login) manage the
+ # ordering portal via the client/internal session + manage_ordering perm.
+ db = client._session_factory()
+ seed_access(db)
+ db.commit()
+ admin_user = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
+ user_id = admin_user.id
+ db.close()
+
+ token = issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user_id})
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Can list customers and create catalogue products through the admin API.
+ assert client.get("/api/ordering-admin/customers", headers=headers).status_code == 200
+ r = client.post(
+ "/api/ordering-admin/products",
+ headers=headers,
+ json={"name": "Staff Wheat", "sku": "WHEAT-STAFF", "category": "grains", "base_price": 30.0},
+ )
+ assert r.status_code == 201, r.text
+
+
+def test_disabled_customer_cannot_order(client):
+ admin = _admin_headers()
+ cust = client.post("/api/ordering-admin/customers", headers=admin, json={"name": "Gamma", "client_code": "GAMMA"}).json()
+ client.post(f"/api/ordering-admin/customers/{cust['id']}/users", headers=admin, json={"full_name": "G1", "email": "g1@gamma.test", "role": "buyer"})
+ client.post("/api/ordering-admin/products", headers=admin, json={"name": "Oats 1T", "sku": "OATS-1T", "category": "grains", "base_price": 70.0})
+
+ # Disable the customer account.
+ r = client.patch(f"/api/ordering-admin/customers/{cust['id']}", headers=admin, json={"status": "disabled"})
+ assert r.status_code == 200
+
+ headers = _customer_headers(client._session_factory, account_id=cust["id"])
+ r = client.get("/api/ordering/catalogue", headers=headers)
+ assert r.status_code == 403
diff --git a/backend/tests/test_ordering_pricing.py b/backend/tests/test_ordering_pricing.py
new file mode 100644
index 0000000..268eda1
--- /dev/null
+++ b/backend/tests/test_ordering_pricing.py
@@ -0,0 +1,156 @@
+"""Unit tests for the backend-owned ordering pricing engine."""
+from __future__ import annotations
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.pool import StaticPool
+
+from app.db.session import Base
+from app.models.ordering import (
+ CatalogueProduct,
+ CustomerPriceAssignment,
+ CustomerProductPrice,
+ PriceList,
+ PriceListItem,
+ PriceTier,
+)
+from app.services.ordering_pricing import resolve_price
+
+CUSTOMER_ID = 1
+TENANT = "test-tenant"
+
+
+def _session() -> Session:
+ engine = create_engine(
+ "sqlite:///:memory:",
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+ Base.metadata.create_all(bind=engine)
+ return sessionmaker(bind=engine, expire_on_commit=False)()
+
+
+def _product(db: Session, **overrides) -> CatalogueProduct:
+ values = {
+ "tenant_id": TENANT,
+ "name": "Maize 1T",
+ "sku": "MAIZE-1T",
+ "category": "grains",
+ "base_price": 100.0,
+ "min_order_quantity": 1.0,
+ "requires_quote": False,
+ }
+ values.update(overrides)
+ product = CatalogueProduct(**values)
+ db.add(product)
+ db.flush()
+ return product
+
+
+def test_base_price_with_no_rules():
+ db = _session()
+ product = _product(db)
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=2)
+ assert res.unit_price == 100.0
+ assert res.price_source == "base"
+ assert res.requires_quote is False
+ assert res.line_total(2) == 200.0
+
+
+def test_customer_discount_applies_to_base():
+ db = _session()
+ product = _product(db)
+ db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, discount_percent=10.0))
+ db.flush()
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ assert res.unit_price == 90.0
+ assert res.price_source == "base"
+ assert res.discount_percent == 10.0
+
+
+def test_price_list_overrides_base():
+ db = _session()
+ product = _product(db)
+ pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
+ db.add(pl)
+ db.flush()
+ db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
+ db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id, discount_percent=10.0))
+ db.flush()
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ # Price list wins over base+discount.
+ assert res.unit_price == 80.0
+ assert res.price_source == "price_list"
+
+
+def test_customer_fixed_price_wins_over_price_list():
+ db = _session()
+ product = _product(db)
+ pl = PriceList(tenant_id=TENANT, code="WHL", name="Wholesale")
+ db.add(pl)
+ db.flush()
+ db.add(PriceListItem(tenant_id=TENANT, price_list_id=pl.id, product_id=product.id, unit_price=80.0))
+ db.add(CustomerPriceAssignment(tenant_id=TENANT, client_account_id=CUSTOMER_ID, price_list_id=pl.id))
+ db.add(
+ CustomerProductPrice(
+ tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=72.5, rule_type="contract"
+ )
+ )
+ db.flush()
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ assert res.unit_price == 72.5
+ assert res.price_source == "contract"
+
+
+def test_quantity_tier_wins_when_threshold_met():
+ db = _session()
+ product = _product(db)
+ cpp = CustomerProductPrice(
+ tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=90.0, rule_type="fixed"
+ )
+ db.add(cpp)
+ db.flush()
+ db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=10, unit_price=85.0))
+ db.add(PriceTier(tenant_id=TENANT, customer_product_price_id=cpp.id, min_quantity=50, unit_price=80.0))
+ db.flush()
+
+ # Below first tier → flat fixed price.
+ assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=5).unit_price == 90.0
+ # Meets first tier.
+ mid = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=10)
+ assert mid.unit_price == 85.0
+ assert mid.price_source == "tiered"
+ # Meets highest tier.
+ assert resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=60).unit_price == 80.0
+
+
+def test_product_requires_quote():
+ db = _session()
+ product = _product(db, requires_quote=True)
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ assert res.requires_quote is True
+ assert res.unit_price is None
+ assert res.price_source == "quote"
+ assert res.line_total(5) is None
+
+
+def test_customer_quote_rule_forces_quote():
+ db = _session()
+ product = _product(db)
+ db.add(
+ CustomerProductPrice(
+ tenant_id=TENANT, client_account_id=CUSTOMER_ID, product_id=product.id, unit_price=None, rule_type="quote"
+ )
+ )
+ db.flush()
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ assert res.requires_quote is True
+ assert res.price_source == "quote"
+
+
+def test_no_price_falls_back_to_quote():
+ db = _session()
+ product = _product(db, base_price=None)
+ res = resolve_price(db, client_account_id=CUSTOMER_ID, product=product, quantity=1)
+ assert res.requires_quote is True
+ assert res.price_source == "quote"
diff --git a/frontend/package.json b/frontend/package.json
index 79778d4..20be484 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "hunter-app",
- "version": "0.1.12",
+ "version": "0.1.14",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/app.html b/frontend/src/app.html
index 12a3f94..b51dc5f 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -5,15 +5,14 @@
-
-
Admin Area
-Checking Session
-Refreshing the current route with the saved operator session before prompting for sign-in.
-Restricted
-Client access controls are only available inside the separate admin workspace.
- Go to admin sign-in -