Skip to content

Backend Modules

Vectis organizes business logic into self-contained modules under vectis/backend/vectis/modules/. Each module owns its tables, services, and GraphQL resolvers. Modules communicate through service interfaces — never direct cross-module DB access.

Module Structure

vectis/modules/product/
├── models.py       # SQLAlchemy ORM models
├── services.py     # Business logic (session-scoped)
├── resolvers.py    # Strawberry GraphQL queries & mutations
├── types.py        # Strawberry input/output types
├── dataloaders.py  # DataLoader batching (optional)
└── strategies.py   # Strategy implementations (optional)

Defining Models

All models inherit from Base and TimestampMixin. PKs must be BigInteger. Money columns use the shared MoneyColumn type (never Float).

from decimal import Decimal
from typing import Optional
from sqlalchemy import BigInteger, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from vectis.core.database import Base, MoneyColumn, TimestampMixin

class Product(TimestampMixin, Base):
    __tablename__ = "products"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    name: Mapped[str] = mapped_column(String(500))
    slug: Mapped[str] = mapped_column(String(500), unique=True)
    custom_fields: Mapped[Optional[dict]] = mapped_column(JSONB, default=dict)
    variants: Mapped[list["ProductVariant"]] = relationship(back_populates="product")

class ProductVariant(TimestampMixin, Base):
    __tablename__ = "product_variants"
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
    sku: Mapped[str] = mapped_column(String(100), unique=True, index=True)
    price: Mapped[Decimal] = mapped_column(MoneyColumn)

Note

pk_column() and money_column() helpers exist for terser definitions.

Writing a Service

Services accept an AsyncSession and encapsulate all DB access. Other modules call your service — they never import your models directly.

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from vectis.core.events import Event, event_bus
from vectis.core.exceptions import NotFoundError
from vectis.modules.product.models import Product

class ProductService:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def get_by_id(self, product_id: int) -> Product:
        result = await self.session.execute(
            select(Product).where(Product.id == product_id)
        )
        product = result.scalar_one_or_none()
        if product is None:
            raise NotFoundError("Product", product_id)
        return product

Adding GraphQL Resolvers

Define Query / Mutation classes with Strawberry decorators, then register them in vectis/core/graphql.py via multiple inheritance.

import strawberry
from strawberry.types import Info
from vectis.core.deps import get_session

@strawberry.type
class ProductQuery:
    @strawberry.field
    async def product(self, info: Info, id: strawberry.ID) -> ProductType | None:
        async with get_session(info) as session:
            svc = ProductService(session)
            p = await svc.get_by_id(int(id))
            return ProductType(id=strawberry.ID(str(p.id)), name=p.name)

Register in vectis/core/graphql.py:

@strawberry.type
class Query(ProductQuery, CartQuery, OrderQuery, ...):
    ...

Cross-Module Communication

Warning

Never import another module's models.py or query its tables. Always call through the owning module's service.

# order/services.py — correct cross-module call
from vectis.modules.product.services import VariantService

class OrderService:
    async def validate_line(self, session, variant_id: int):
        svc = VariantService(session)
        variant = await svc.get_by_id(variant_id)

Inventory module (Decided OQ-7, #113)

The inventory module manages multi-warehouse stock tracking with full audit trails.

Models

  • Warehouse — physical or logical stock locations with address, contact info, and priority for allocation ordering
  • StockLevel — per-variant per-warehouse stock: quantity_on_hand, quantity_reserved, quantity_available
  • StockReservation — temporary holds for carts/orders with expiration
  • InventoryLot — batch/lot tracking with cost and FIFO ordering
  • StockAdjustmentLog — immutable audit record: before/after qty, delta, reason, source, user_id

Service: InventoryService

  • adjust_stock / set_stock — single-variant updates, always writes to StockAdjustmentLog
  • bulk_adjust_stock — batch updates for multiple variants at once
  • reserve / release_reservation — stock reservation lifecycle
  • list_stock_levels — paginated, enriched with product/variant info via joins
  • stock_levels_for_product — all variants for a single product (bulk editor)
  • list_adjustments — paginated audit log with user email
  • Warehouse CRUD: create_warehouse, update_warehouse, deactivate_warehouse, list_warehouses

GraphQL

Queries: warehouses, stockLevels (paginated), stockLevelsForProduct, stockAdjustmentLog

Mutations: createWarehouse, updateWarehouse, deactivateWarehouse, adjustStock, bulkAdjustStock, importInventory

All mutations require inventory.manage permission. All queries require inventory.view.

Registration module (Decided #114)

The registration module owns all B2B/B2C account onboarding: form builder, geo-conditional rules engine, document verification, e-signatures, invitation codes, and the registration submission lifecycle.

Models

  • RegistrationForm — named form definition with steps, sections, and fields. Supports channel scoping, commerce mode filtering, customer group assignment, and invitation code gating.
  • RegistrationFormStep / RegistrationFormSection / RegistrationFormField — nested form structure for multi-step wizard rendering. Fields support 18+ types, conditional visibility, validation rules, and maps_to targeting (account, address, meta, or document).
  • RegistrationRule — geo-conditional rule with rule_type (require/block/exempt), geo_conditions JSONB (countries, states, counties, cities, postal codes), and requirements JSONB with AND/OR logic groups.
  • DocumentType — configurable document categories (e.g., business license, EIN). Controls allowed file types, size, expiration/number/authority fields.
  • RegistrationDocumentUpload — uploaded document with type FK, AI confidence score, OCR extracted data, and review status.
  • RegistrationVerificationRecord — AI/OCR verification result with strategy name, confidence, extracted fields, flags.
  • AgreementTemplate — versioned agreement content with signature requirement toggle.
  • SignatureRecord — captured e-signature (drawn/typed/checkbox/external) with IP, user agent, content hash (SHA-256).
  • InvitationCode — access code with form/group assignment, max uses, expiration.
  • RegistrationSubmission — full lifecycle (submitted → documents_pending → in_review → approved/denied/expired) with nested documents, signatures, verifications.

Strategies

  • DocumentVerificationStrategy (ABC) — AI document analysis. Default: ManualReviewStrategy (no-op).
  • OCRExtractionStrategy (ABC) — extract fields from document images.
  • ESignatureStrategy (ABC) — create/check/retrieve e-signatures. Default: BuiltInSignatureStrategy. Extensions: ext_docusign, ext_hellosign.

Temporal Workflows

  • RegistrationWorkflow — validate → create account → notify → wait for admin decision → activate/deny/expire.
  • DocumentVerificationWorkflow — OCR extraction → AI verification → store results.

Permissions

registration.manage (form/rule/code CRUD), registration.review (approve/deny submissions).

Reporting module (Decided #82)

The reporting module owns SavedReport and ScheduledReport. ReportExecutionService maps report_type + JSON config to SQL aggregates (orders, line items, stock levels, tax). ReportingService handles CRUD for saved and scheduled definitions. GraphQL exposes runReport, exportReport, and scheduled-report mutations; run_scheduled_reports is a Temporal activity (daily schedule) that executes enabled schedules and logs results—email/S3 delivery can be added later.

Packaging & MMOQ (Decided #25, #228, #236)

Packaging and MMOQ ship as columns and helpers inside the existing product module — there's no standalone packaging module.

Packaging

Product.packaging_enabled toggles the per-product ladder. The packages table holds global package definitions (slug, label, multiplier, active); the product_packages join carries per-product overrides (sku, price, eaches conversion). CartLineItem.unit_package_id records the selected package; CartLineItem.unit_changed flags when reorder fell back because the original ladder shifted. OrderLineItem.product_snapshot.unit captures the full {package_id, slug, label, multiplier, unit_price_in_unit} snapshot at order time.

MMOQ

The caps live on ProductVariant: max_per_customer_per_order, max_per_customer_30d, max_backorder_qty, mmoq_display_unit_id. The cart aggregate at finalize re-validates inside a serializable transaction (Decided #236) and stamps refunded_quantity_eaches on refunds so a customer's effective window credits restored quantities. The storefront previews violations via MmoqViolationDetail returned on cartBulkLookup rows. The 30-day window honours channels.timezone.

Product Label Module (Decided #228)

Predicate-driven product labels evaluated in batches:

  • Predicate language — namespaced JSON (e.g., { "all": [{"channel.country": "US"}, {"product.price": {"<=": 10}}] }). Predicates are versioned; resolved instances carry the predicate version that produced them.
  • Batch evaluationevaluate_many(products, channel_id) and supports_batch flag let the service evaluate hundreds of labels against thousands of products in one pass.
  • Stale referencesclearProductLabelStaleReferences mutation garbage-collects resolved instances whose predicate version no longer matches; surfaced in the admin via a banner when stale rows exist.
  • RenderingProductType.labels is batch-loaded on lists; the storefront renders SVG components on PDP, related-products, recently-viewed, and search-result cards.

Refund Approval Module (OQ #26 / #27)

Two paired modules implement async, durable refund execution:

  • refund_approvalRefundRequest model, the admin inbox UI, self-approval guard, and the GraphQL surface (submitRefundForApproval, decideRefundApproval, retryRefundExecution).
  • payment (workflow extension)RefundExecutionWorkflow Temporal workflow with per-tender progress, idempotent retry, and empty-transaction handling. Faults emit on vectis.workflow.fault.v1; success emits vectis.audit.refund_execution_completed.v1.

B2B Workflow

The B2B cart-approval workflow is composed across cart, order, payment, and notification. Module-owned tables that ship the contract:

  • cartCartApprovalRequest, CartRejectionEvent, Cart.cart_approved_grand_total, Cart.peer_view_count. The cart's status enum includes cart_approved_blocked_inventory for the post-approval inventory-revalidation gate (Decided C9).
  • orderOrder.inventory_risk_flag, Order.external_handoff_acknowledged_at / external_handoff_reference, the reissue chain (is_reissue, reissued_from_order_id, reissue_reason), OrderLineItem.tracking_enabled_at_checkout
  • paymentPaymentTender.source provenance column (cascade vs buyer_prepended, Decided C12), cascade re-curation helpers
  • notification — templates for cart-submitted, payment-composed, refund-decided, fault-occurred

There is no standalone workflow module — workflow faults are an event domain with the vectis.workflow.fault.v1 schema and a workflow_faults table; queries surface through the existing extension/admin schema. Protocol details and the C-series gates live in vectis/MINIFORGE.md.