The POS Platform Blueprint
A Complete Guide to Building an Enterprise Multi-Tenant Point of Sale System
Version: 7.0.0
Created: December 29, 2025
Updated: March 2, 2026
Target Platform: /volume1/docker/pos-platform/
╔═══════════════════════════════════════════════════════════════════════════════╗
║ ║
║ ████████╗██╗ ██╗███████╗ ║
║ ╚══██╔══╝██║ ██║██╔════╝ ║
║ ██║ ███████║█████╗ ║
║ ██║ ██╔══██║██╔══╝ ║
║ ██║ ██║ ██║███████╗ ║
║ ╚═╝ ╚═╝ ╚═╝╚══════╝ ║
║ ║
║ ██████╗ ██████╗ ███████╗ ██████╗ ██╗ ██╗ ██╗███████╗ ║
║ ██╔══██╗██╔═══██╗██╔════╝ ██╔══██╗██║ ██║ ██║██╔════╝ ║
║ ██████╔╝██║ ██║███████╗ ██████╔╝██║ ██║ ██║█████╗ ║
║ ██╔═══╝ ██║ ██║╚════██║ ██╔══██╗██║ ██║ ██║██╔══╝ ║
║ ██║ ╚██████╔╝███████║ ██████╔╝███████╗╚██████╔╝███████╗ ║
║ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝ ║
║ ║
║ ██████╗ ██████╗ ██╗███╗ ██╗████████╗ ║
║ ██╔══██╗██╔══██╗██║████╗ ██║╚══██╔══╝ ║
║ ██████╔╝██████╔╝██║██╔██╗ ██║ ██║ ║
║ ██╔═══╝ ██╔══██╗██║██║╚██╗██║ ██║ ║
║ ██║ ██║ ██║██║██║ ╚████║ ██║ ║
║ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═╝ ║
║ ║
║ Enterprise Multi-Tenant Point of Sale ║
║ Architecture & Implementation ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════════╝
How to Use This Book
This Blueprint is a self-contained guide for building a production-grade, multi-tenant POS system using Claude Code’s multi-agent orchestration. Every diagram, code sample, database schema, and implementation detail is included directly in these pages.
Reading Order
| If You Want To… | Start With… |
|---|---|
| Understand the vision | Part I: Foundation (Ch 01) |
| Review architecture decisions | Part II: Architecture (Ch 02-05) — 52 ADRs in Ch 02 |
| Build the database | Part III: Database (Ch 06-09) |
| Trace BRD to code | Appendix F: BRD-to-Code Module Mapping |
Table of Contents
Front Matter
- 00-BOOK-INDEX.md – You are here
- 01-PREFACE.md - Why this book exists
- BLUEPRINT-INSTRUCTIONS.md - How to maintain this blueprint
Part I: Foundation (Chapter 01)
What this book is and how to use it
Part II: Architecture (Chapters 02-05)
System design, quality attributes, and key decisions
- Chapter 02: Architecture Decision Records
- Chapter 03: Architecture Characteristics
- Chapter 04: Architecture Styles Analysis
- Chapter 05: Architecture Components (BRD v20.0)
Note: In v3.0.0, standalone architecture chapters were consolidated into enriched Characteristics and Styles chapters. In v4.0.0, the full BRD v20.0 was integrated. In v5.0.0, Foundation chapters 02-04 were removed and all chapters renumbered.
Part III: Database (Chapters 06-09)
Complete data layer specification
- Chapter 06: Database Strategy
- Chapter 07: Schema Design
- Chapter 08: Entity Specifications
- Chapter 09: Indexes & Performance
Appendices
Note: In v6.0.0, Parts IV-VIII (Backend, Frontend, Implementation, Operations, Reference) and Appendices A-E were removed for architect-led rewrite. All architecture decisions are consolidated as 52 ADRs in Chapter 02. Safety tag
v5.3.0-pre-restructurepreserves the previous content.
Book Statistics
| Metric | Value |
|---|---|
| Total Chapters | 9 |
| Parts | 3 |
| Appendices | 2 (F, G) |
| ADRs | 51 |
| Database Tables | 69 |
| API Endpoints | 75+ |
| Domain Events | 80 |
| Code Services | 142 |
| Target Grade | A (Production-Ready) |
How to Print This Book
Option 1: Markdown to PDF (Recommended)
Use Pandoc to compile all chapters into a single PDF:
# Install Pandoc (if not installed)
# macOS: brew install pandoc
# Ubuntu: apt install pandoc texlive-xetex
# Navigate to Blueprint folder
cd /volume1/docker/planning/000-POS-Learning/00-Blue-Print
# Compile to PDF
pandoc \
00-BOOK-INDEX.md \
01-PREFACE.md \
Part-I-Foundation/*.md \
Part-II-Architecture/*.md \
Part-III-Database/*.md \
Appendices/*.md \
-o POS-Blueprint-Book.pdf \
--toc \
--toc-depth=3 \
--pdf-engine=xelatex \
-V geometry:margin=1in \
-V fontsize=11pt \
-V mainfont="DejaVu Sans" \
-V monofont="DejaVu Sans Mono"
Option 2: VS Code Extension
- Install “Markdown PDF” extension in VS Code
- Open each chapter
- Right-click > “Markdown PDF: Export (pdf)”
- Combine PDFs using any PDF merger
Option 3: Web-Based
Use online tools like:
- Dillinger.io - Paste markdown, export as PDF
- GitHub - Each .md file renders nicely for printing
- Grip - Local GitHub-style markdown preview
Option 4: Build Script (Automated)
#!/bin/bash
# save as: build-book.sh
BOOK_DIR="/volume1/docker/planning/000-POS-Learning/00-Blue-Print"
OUTPUT="$BOOK_DIR/POS-Blueprint-Book.pdf"
# Collect all markdown files in order
FILES=(
"$BOOK_DIR/00-BOOK-INDEX.md"
"$BOOK_DIR/01-PREFACE.md"
)
# Add Part I-III + Appendices
for part in Part-I-Foundation Part-II-Architecture Part-III-Database Appendices; do
for file in "$BOOK_DIR/$part"/*.md; do
[ -f "$file" ] && FILES+=("$file")
done
done
# Build PDF
pandoc "${FILES[@]}" -o "$OUTPUT" --toc --toc-depth=3
echo "Book compiled: $OUTPUT"
Estimated Print Size
| Format | Pages | Notes |
|---|---|---|
| Full Book | ~150-200 | All chapters and appendix |
| Core (Parts II-III) | ~120 | Architecture + Database |
Version History
| Version | Date | Changes |
|---|---|---|
| 7.0.0 | 2026-03-02 | Unified Web Application: Nexus POS + Nexus Admin consolidated into single “Nexus POS” React web app with role-based navigation (OWNER, MANAGER, CASHIER, BUYER, AUDITOR). Tauri desktop wrapper removed — hardware via web protocols (Star WebPRNT, USB HID, Stripe Terminal SDK). New ADR-052 (Unified Web App), ADR-046 superseded. ADR-048 stays active — better-sqlite3→SQLite WASM (sql.js/wa-sqlite + OPFS). 52 ADRs (43 active, 7 superseded, 2 removed). All “Nexus Admin”/“Admin Portal” references → “Nexus POS” with role context. Appendix G screen inventory: POS/Admin/Both→role-based columns. Safety tag: v6.4.0-pre-unification. |
| 6.4.0 | 2026-03-01 | New Appendix G: Application Screen Reference. 118 screens cataloged across 7 modules (Sales 22, Customers 10, Catalog 18, Inventory 23, Setup 22, Integrations 12, Raptag 8, Cross-cutting 3). 13 ASCII wireframes. Full BRD traceability (screen→BRD section→database table→Appendix F service). POS Terminal context (hardware, offline, shortcuts, flows). 8 navigation flow maps. Traceability matrix. ~6,000 lines. Appendices: 1→2. |
| 6.3.0 | 2026-03-01 | Comprehensive review: 50 findings resolved. 3 new ADRs (049-051: Socket.io, Prisma+RLS, State Management). ADR-015/037 superseded. 6 missing tables added (pricing_rules, purchase/transfer orders, store_credits). 69 total tables. 8 FK type fixes. 9 RFID RLS variable fixes. Ch 03 K.2.1 Availability rewritten for online-first. Ch 05: 8 offline-first locations rewritten, 5 decisions fixed, state machine 7.12 updated. 25 BRD-v12→v20 NFR citations fixed. All footers to 6.3.0. 52 ADRs total (43 active, 6 superseded, 2 removed). |
| 6.2.0 | 2026-03-01 | Online-first pivot: offline-first → online-first with thin offline fallback. New ADR-048, ADR-002 superseded. Ch 04 L.10A.1 rewritten: 6-table SQLite → 2-table, CRDTs removed, 3-state connection monitor, flag-on-sync price discrepancy, safety buffers. ADR-046 updated. 48 ADRs total. |
| 6.1.0 | 2026-02-28 | Tech stack pivot: .NET/C# → TypeScript/Node.js + Nexus branding across ALL chapters. ADR surgery: 9 modified, 2 new (046-047), 3 superseded, 2 removed (47 total). Ch 04: 22 C# code blocks → TypeScript, 6 diagrams. Ch 05: 33 naming fixes. Ch 06-08: code blocks converted. Cross-ref audit: 7 fixes. |
| 6.0.0 | 2026-02-27 | Major restructure: removed Parts IV-VIII + Appendices A-E for architect-led rewrite. 28 new ADRs (018-045) consolidated into Ch 02 (45 total). Cross-references audited (38 fixes). 32→9 chapters, 8→3 Parts, 6→1 Appendix |
| 5.3.0 | 2026-02-27 | Stitch design handoff: Appendix D rewritten (33 fixes), 29 Admin screens added (Ch 15), 4 Raptag sub-screens (Ch 16), 16 new components + icon library + token gaps (Ch 17), Stitch-Design-Handoff.md + Stitch-Design-Spec.md created |
| 5.2.0 | 2026-02-27 | Full architect review: 10 new ADRs (007-016), namespace unified to POS.*, RabbitMQ to LISTEN/NOTIFY, schema-per-tenant to RLS, security standardized (Argon2id/BCrypt/RS256), 163+ findings resolved |
| 5.1.0 | 2026-02-27 | Full review + enhancement of Parts III-V: fixed schema-per-tenant artifacts, added tenant_id to 38 tables, compound tax redesign, event_outbox + state_transitions tables, CQRS/MediatR for Sales, transactional outbox, 6-Gate Security Pyramid, ACL per provider, error code catalogue, SQLite schema rewrite, performance budgets, WCAG 2.1 AA accessibility, component state patterns |
| 5.0.0 | 2026-02-25 | Removed Ch 02-04 (Foundation), rewritten Ch 01 as Blueprint Purpose, renumbered 35 to 32 chapters |
| 4.0.0 | 2026-02-25 | BRD v20.0 integrated as Chapter 08: Architecture Components; all subsequent chapters renumbered (+1); 26 contradictions reconciled across 12 chapters and 4 appendices |
| 3.3.0 | 2026-02-25 | RFID Counting Subsystem: BRD v20 (Section 5.16 RFID Config, Section 4.6.8 multi-operator counting, 6 new decisions #108-113), schema fixes (tenant_id/RLS on all RFID tables, 3 new tables), chunked sync API, Raptag home dashboard + progress tracking + auto-save/recovery |
| 3.2.0 | 2026-02-24 | Added Appendix F: BRD-to-Code Module Mapping (142 services across 7 modules, 80 domain events, 19 state machines, 107 decisions mapped with full traceability) |
| 3.1.0 | 2026-02-22 | Structural cleanup: section numbering standardized across all 34 chapters + 5 appendices, Ch 08/09 rewritten for RLS, Document Information footers added, cross-references audited and fixed |
| 3.0.0 | 2026-02-22 | Chapter consolidation (39 to 34): merged High-Level Architecture, Multi-Tenancy, Domain Model, Event Sourcing, and Offline-First into Architecture Characteristics (Ch 06) and Architecture Styles (Ch 07); full renumbering |
| 2.0.0 | 2026-02-19 | Expert panel review, integration module additions |
| 1.0.0 | 2025-12-29 | Initial Blueprint Book (39 chapters) |
Contributors
| Role | Contributor |
|---|---|
| Architect | Claude Code Architect Agent |
| Author | Claude Code Editor Agent |
| Reviewer | Claude Code Engineer Agent |
| Research | Claude Code Researcher Agent |
| Coordinator | Claude Code Orchestrator |
Blueprint Maintenance
How to Update This Blueprint
See BLUEPRINT-INSTRUCTIONS.md for complete maintenance procedures.
Quick Reference:
| Task | Action |
|---|---|
| Edit content | Update master file, copy to mdbook-src/src/ |
| Add chapter | Create file, update this index, update SUMMARY.md |
| Add appendix | Create file, update this index, update SUMMARY.md |
| Build PDF | Run ./build-book.sh |
| Deploy web | Run cd mdbook-src && mdbook build && wrangler pages deploy book |
CRITICAL: When adding or removing chapters/appendices, you MUST update:
- This file (
00-BOOK-INDEX.md) mdbook-src/src/SUMMARY.md- Copy updated index to
mdbook-src/src/00-BOOK-INDEX.md
Live Site
URL: https://pos-blueprint.pages.dev/
Thoughts & Recommendations
Current Status (as of 2026-03-01)
The blueprint is in architecture foundation state after v6.0.0 restructure. Parts I-III + Appendix F provide the complete architecture and database foundation. Parts IV-VIII and Appendices A-E have been removed for architect-led rewrite.
What’s Complete
- 51 Architecture Decision Records (Ch 02) — all key decisions formalized
- Complete database schema (69 tables across 16 domains)
- Full BRD (19,900+ lines, 7 modules, 113 business decisions)
- Architecture Styles (~5,000 lines of implementation patterns)
- BRD-to-Code traceability (142 services, 80 events, 19 state machines)
What Needs Rewrite (Planned)
- Part IV: Backend — API Design, Service Layer, Security, Integrations
- Part V: Frontend — Nexus POS, Nexus Admin, Nexus Raptag, Component Library
- Part VI: Implementation — Dev Environment, Roadmap, 4 Phases
- Part VII: Operations — Deployment, Monitoring, Security, DR, Tenant Lifecycle
- Part VIII: Reference — Claude Commands, Glossary, Checklists, Troubleshooting
- Appendices A-E — API Reference, ERD, Events, Mockups, Templates
Implementation Notes
- Follow the ADRs — Chapter 02 has 51 formalized decisions with rationale
- Ch 04 is the mega-reference — ~5,000 lines covering all implementation patterns
- Ch 05 is the BRD — ~19,900 lines with complete business requirements
- Schema provisioning — Use the SQL functions in Chapter 06 or Chapter 07
- Safety tag —
v5.3.0-pre-restructurepreserves all removed content for reference
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-03-02 | v7.0.0 - Unified Web Application: Nexus POS + Nexus Admin → single “Nexus POS” web app. Tauri removed. ADR-052 added, ADR-046 superseded. SQLite WASM. 52 ADRs. | Claude Code |
| 2026-03-01 | v6.4.0 - New Appendix G: Application Screen Reference (118 screens, 13 wireframes, 8 nav flows, full BRD traceability) | Claude Code |
| 2026-03-01 | v6.3.0 - Comprehensive review: 50 findings resolved, 3 new ADRs (049-051), 6 new tables, Ch 03 K.2.1 rewritten, Ch 05 offline rewrite (8 locations), 25 NFR citations fixed, all footers updated | Claude Code |
| 2026-03-01 | v6.2.0 - Online-first pivot: ADR-048 (online-first data strategy), ADR-002 superseded, Ch 04 L.10A.1 rewritten (2-table SQLite, 3-state monitor, flag-on-sync, CRDTs removed) | Claude Code |
| 2026-02-28 | v6.1.0 - Tech stack pivot: .NET/C# to TypeScript/Node.js in Ch 04 (22 code blocks, 6 diagrams, naming pass) | Claude Code |
| 2026-02-27 | v6.0.0 - Major restructure: removed Parts IV-VIII + Appendices A-E, consolidated 45 ADRs | Claude Code |
| 2026-02-27 | v5.3.0 - Stitch design handoff: gap-fill (Appendix D, Ch 15-17) + 2 new handoff documents | Claude Code |
| 2026-02-27 | v5.2.0 - Full architect review: 10 new ADRs (007-016), namespace unified to POS.*, 163+ findings resolved | Claude Code |
| 2026-02-27 | v5.1.0 - Full review + enhancement of Parts III, IV, V (12 chapters) | Claude Code |
| 2025-12-29 | Initial Blueprint v1.0.0 | Claude Code |
| 2026-01-24 | Added maintenance instructions, updated appendix list | Claude Code |
| 2026-02-22 | v3.0.0 - Chapter consolidation (39 to 34), full renumbering | Claude Code |
| 2026-02-23 | v3.1.0 - Structural cleanup: section numbering, RLS fix, footers, cross-refs | Claude Code |
| 2026-02-25 | v5.0.0 - Removed Ch 02-04 (Foundation), rewritten Ch 01 as Blueprint Purpose, renumbered 35 to 32 chapters | Claude Code |
| 2026-02-25 | v4.0.0 - BRD v20.0 as Ch 08, full renumber (34 to 35 chapters), contradiction reconciliation | Claude Code |
| 2026-02-24 | v3.2.0 - Added Appendix F: BRD-to-Code Module Mapping | Claude Code |
“Build it right the first time. This Blueprint is your guide.”
Preface
Why This Book Exists
In late 2025, we embarked on a journey to replace an aging QuickBooks Point of Sale system with a modern, multi-tenant platform. What started as a simple migration project evolved into something much more ambitious: a comprehensive Blueprint for building enterprise-grade retail software.
This book captures everything we learned—the architecture decisions, the database designs, the implementation patterns, and the operational procedures. It’s not a theoretical exercise; it’s a practical guide born from real retail operations across five store locations.
The Three-Phase Philosophy
We adopted a deliberate three-phase approach:
╔═══════════════════════════════════════════════════════════════════════════╗
║ ║
║ PHASE 1: LEARN PHASE 2: DESIGN ║
║ ───────────── ────────────── ║
║ Build Stanly (bridge to QB) Clean room architecture ║
║ Build Raptag (RFID system) Domain models without legacy ║
║ Test with real stores Database schema for scale ║
║ Document every discovery API specifications ║
║ ║
║ ↓ ║
║ ║
║ PHASE 3: BUILD ║
║ ────────────── ║
║ Fresh POS system from scratch ║
║ Using learnings, not legacy code ║
║ Multi-tenant from day one ║
║ Production-grade quality ║
║ ║
╚═══════════════════════════════════════════════════════════════════════════╝
The critical insight: We don’t evolve legacy systems into production. We learn from them, then build fresh. This book is the culmination of Phase 2—the complete design that enables Phase 3.
What Makes This Blueprint Different
1. Self-Contained
Every diagram, code sample, and SQL statement is included directly in these pages. You won’t find “see external documentation” or “refer to file X.” Everything you need is here.
2. Production-Grade
This isn’t a prototype specification. The database schema has 69 tables. The API has 75+ endpoints. The architecture handles offline operations, multi-tenancy, and PCI-DSS compliance. We designed for the edge cases.
3. Claude Code Native
This Blueprint is designed to be built using Claude Code’s multi-agent orchestration. Every chapter includes the exact commands to implement each section. The book and the tool work together.
4. Battle-Tested Patterns
The patterns in this book come from real retail operations—from handling offline sales during network outages to reconciling inventory across multiple locations. These aren’t theoretical; they’re proven.
Who Should Read This Book
| Reader | Focus Areas |
|---|---|
| Architects | Parts II, III (Architecture, Database) |
| Backend Developers | Parts II (Ch 04-05), III (Database) |
| Frontend Developers | Part II Ch 05 (BRD modules), Appendix F (service mapping) |
| DevOps Engineers | Part III (Database Strategy), Part II Ch 04 (Observability, Security) |
| Product Managers | Part I (Foundation), Part II Ch 05 (BRD) |
| Business Analysts | Part I (Foundation), Part II Ch 05 (BRD) |
How to Use This Book
If Starting Fresh
Read sequentially from Part I through Part III. This gives you the full context before implementation.
If Joining Mid-Project
- Read Part I (Foundation) for context
- Read the relevant Part for your work area
- Use Appendix F for BRD-to-code service mapping
If Looking Up Specific Information
Jump directly to:
- Ch 04 (Architecture Styles) for implementation patterns (~5,000 lines)
- Ch 05 (Architecture Components / BRD) for business requirements (~19,900 lines)
- Appendix F for BRD-to-code module mapping (142 services)
The Technology Stack
This Blueprint specifies a complete technology stack:
┌─────────────────────────────────────────────────────────────────────────┐
│ TECHNOLOGY STACK │
│ │
│ BACKEND FRONTEND │
│ ─────── ──────── │
│ Node.js + TypeScript (API) React/TypeScript (Nexus POS Web) │
│ Prisma ORM React Native (Nexus Raptag) │
│ PostgreSQL 16 SQLite WASM (Offline Storage) │
│ Socket.io (Real-time) Vite + TailwindCSS + shadcn/ui │
│ │
│ INFRASTRUCTURE INTEGRATIONS │
│ ────────────── ──────────── │
│ Docker + Docker Compose Shopify (E-commerce) │
│ Prometheus + Grafana Stripe/Square (Payments) │
│ Redis 7.x (Caching) Zebra (RFID Printers) │
│ Tailscale (VPN Mesh) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Conventions Used
Code Samples
// TypeScript code appears in blocks like this
export class OrderService {
// Implementation
}
SQL Statements
-- SQL code appears in blocks like this
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
Claude Commands
# Claude Code commands appear like this
/dev-team implement OrderService with event sourcing
Important Notes
Note: Important information appears in blockquotes like this.
Warning: Critical warnings that could cause issues.
Diagrams
ASCII diagrams are used throughout for portability:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │────►│ API │────►│Database │
└─────────┘ └─────────┘ └─────────┘
Acknowledgments
This Blueprint was created through collaboration between:
- Business stakeholders who defined the requirements
- Store employees who provided real-world feedback
- The Stanly project which taught us what works (and what doesn’t)
- The Raptag project which proved mobile RFID feasibility
- Claude Code agents who helped design, implement, and review
A Note on Quality
We set a goal: this system should be Grade A production quality. That means:
- Availability: 99.9% uptime (less than 9 hours downtime per year)
- Performance: Sub-2-second transaction completion
- Security: PCI-DSS compliant, zero card data storage
- Reliability: Works offline, syncs when connected
- Scalability: Multi-tenant from day one
Every design decision in this book was made with these targets in mind.
Let’s Build
You’re holding the complete Blueprint. The architecture is designed. The database is specified. The APIs are defined. The UI is wireframed. The operations procedures are documented.
All that’s left is to build it.
Turn the page and let’s begin.
December 2025
Document Information
| Attribute | Value |
|---|---|
| Book Title | The POS Platform Blueprint |
| Version | 7.0.0 |
| Created | December 29, 2025 |
| Updated | March 1, 2026 |
| Total Chapters | 9 |
| Total Appendices | 1 (F) |
| Target Platform | /volume1/docker/pos-platform/ |
| Print Command | See “How to Print This Book” in Index |
Chapter 01: Blueprint Purpose
1.1 What This Book Is
The POS Platform Blueprint is the complete architecture and implementation guide for building an enterprise multi-tenant retail Point of Sale system. It is the single source of truth for every design decision, database schema, API contract, deployment procedure, and implementation pattern needed to build the platform from scratch.
This book is self-contained. Every diagram, code sample, and specification lives within these chapters. There are no external dependencies or unresolved references. A development team should be able to build the entire system using only this document.
1.2 Who Uses This Book
This Blueprint is designed for Claude Code teams and agents during the coding and development phase. It serves as the foundation plan that AI-assisted development teams follow when implementing the POS platform.
| Audience | How They Use It |
|---|---|
| Claude Code agents | Primary reference during implementation; follow specs exactly |
| Team leads | Assign work by chapter/module; verify implementations against specs |
| Architects | Review ADRs and architecture decisions before implementation |
| Developers | Look up API contracts, database schemas, service patterns |
| DevOps | Follow deployment, monitoring, and DR procedures |
1.3 What the POS Platform Does
The POS Platform is a unified commerce solution for small-to-mid-size retailers operating both online and brick-and-mortar stores. It replaces legacy point-of-sale systems with a modern, multi-tenant SaaS platform.
Core Capabilities
| Capability | Description |
|---|---|
| Point of Sale | Process sales, returns, exchanges across physical locations |
| Inventory Management | Real-time stock tracking across all locations |
| Multi-Location | Support any number of stores per tenant |
| Shopify Integration | Two-way inventory and order synchronization |
| Online-First + Offline Fallback | API-primary operation with 2-table SQLite fallback during network outages (ADR-048) |
| RFID Counting | Rapid bulk inventory counting via dedicated Raptag mobile app |
| Multi-Tenant | Row-level isolation with PostgreSQL RLS; one platform, many retailers |
| Payment Processing | PCI SAQ-A semi-integrated via Stripe (no card data touches our system) |
Key Architecture Decisions
- Event-Driven Modular Monolith (Central API) + Microkernel (Nexus POS)
- PostgreSQL with Row-Level Security for tenant isolation
- Online-first with offline fallback — API-primary data access via React Query, 2-table SQLite fallback for brief outages (ADR-048)
- Node.js/TypeScript backend, React/TypeScript single web application (Nexus POS), React Native mobile (Nexus Raptag)
- PostgreSQL LISTEN/NOTIFY for v1.0 events (Kafka deferred to v2.0)
1.4 How to Use This Book
Structure: 9 Chapters, 3 Parts, 2 Appendices
| Part | Chapters | Purpose |
|---|---|---|
| I. Foundation | Ch 01 | This chapter – what the book is and how to navigate it |
| II. Architecture | Ch 02-05 | ADRs, architecture characteristics, styles analysis, and the full BRD |
| III. Database | Ch 06-09 | Database strategy, schema design, entity specifications, indexes |
Appendix F provides BRD-to-code module mapping (142 services, 80 events, full traceability). Appendix G provides the Application Screen Reference (118 screens, 13 wireframes, full BRD-to-UI traceability).
Reading Guide
Starting a new implementation? Read Parts I-II first for context, then jump to Part III for database design.
Implementing a specific module? Start with Ch 05 (Architecture Components / BRD) to find your module’s requirements, then check Ch 04 (Architecture Styles) for implementation patterns.
Looking up architecture patterns? Ch 04 contains the full implementation reference (~5,000 lines) including online-first with offline fallback, multi-tenancy, security, testing, and observability strategies.
1.5 Key Mega-References
Two chapters serve as the primary implementation references and contain the bulk of the technical detail:
Chapter 04: Architecture Styles Analysis (~5,000 lines)
The architectural backbone of the system. Key sections include:
| Section | Content |
|---|---|
| L.4 | Selected Architecture Strategy (modular monolith + event-driven) |
| L.4A | CQRS and Event Sourcing scope |
| L.9A | System Architecture Reference (3-tier, service boundaries) |
| L.9B | Data Flow Reference (online/offline sale and sync flows) |
| L.9C | Domain Model Reference (bounded contexts, aggregates) |
| L.10A.1 | Online-First with Offline Fallback (2-table SQLite, 3-state connection monitor, ADR-048) |
| L.10A.4 | Multi-Tenancy with Row-Level RLS |
| L.6 | QA and Testing Strategy |
| L.7 | Observability (LGTM Stack) |
| L.8 | Security (6-Gate Pyramid) |
Chapter 05: Architecture Components / BRD v20.0 (~19,900 lines)
The complete Business Requirements Document with 7 modules and 113 decisions:
| Module | Scope |
|---|---|
| Module 1 | Sales (Transaction Processing & POS Operations) |
| Module 2 | Customers (Customer Management & Loyalty) |
| Module 3 | Catalog (Product Catalog & Pricing) |
| Module 4 | Inventory (Inventory Management & Supply Chain) |
| Module 5 | Setup & Configuration (System Administration) |
| Module 6 | Integrations & External Systems |
| Module 7 | State Machines & Workflow Reference |
Authority rule: When the BRD (Ch 05) conflicts with any other chapter, the BRD wins.
1.6 Conventions Used in This Book
- Section numbering: All chapters use
## X.Y Titleformat (e.g.,## 1.1 What This Book Is) - Cross-references: Point to chapter numbers and filenames (e.g., “See Chapter 04”)
- Code samples: Complete, copy-paste ready; file paths included as comments
- Document footer: Every chapter ends with a standardized Document Information table
- Dual-file sync: Every chapter exists in both the master directory and
mdbook-src/src/for web publishing
1.7 Summary
This Blueprint Book is the complete specification for the POS Platform. It is designed to be consumed by Claude Code agents and development teams who will implement the system. Start with the architecture chapters (Part II) for the big picture, then drill into the specific Part relevant to your current task.
Next Chapter: Chapter 02: Architecture Decision Records
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | I - Foundation |
| Chapter | 01 of 9 |
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 02: Architecture Decision Records
Documenting Key Technical Decisions
This chapter documents the major architectural decisions for the POS Platform using Architecture Decision Records (ADRs). Each ADR captures the context, decision, and consequences of a significant technical choice.
What is an ADR?
Architecture Decision Records provide a structured way to document important technical decisions:
ADR Structure
=============
+------------------------------------------------------------------+
| ADR-XXX: [Title] |
+------------------------------------------------------------------+
| Status: [proposed | accepted | deprecated | superseded] |
| Date: YYYY-MM-DD |
| Deciders: [who made the decision] |
+------------------------------------------------------------------+
| |
| CONTEXT |
| - What is the issue? |
| - What forces are at play? |
| - What constraints exist? |
| |
| DECISION |
| - What is the change? |
| - What did we choose? |
| |
| CONSEQUENCES |
| - What are the positive outcomes? |
| - What are the negative outcomes? |
| - What risks are introduced? |
| |
+------------------------------------------------------------------+
ADR-001: Shared Tables with Row-Level Security Multi-Tenancy
Note: This ADR originally documented Schema-Per-Tenant (Strategy C) but was corrected to reflect the actual decision: Shared Tables with Row-Level Security (Strategy A). The RLS implementation is detailed in Chapter 04, Section L.10A.4.
+==================================================================+
| ADR-001: Shared Tables with Row-Level Security Multi-Tenancy |
+==================================================================+
| Status: SUPERSEDED (corrected to Row-Level RLS, Ch 04 |
| Section L.10A.4) |
| Date: 2025-12-29 |
| Deciders: Architecture Team |
+==================================================================+
CONTEXT
-------
We are building a multi-tenant POS platform that will serve multiple
independent retail businesses. Each tenant needs:
1. Strong data isolation for security and compliance
2. Easy backup and restore of individual tenant data
3. Ability to scale individual tenants independently
4. Compliance with SOC 2 and potential HIPAA requirements
5. Efficient connection pooling across all tenants
We evaluated three multi-tenancy strategies:
Strategy A: Shared Tables (Row-Level Security)
- All tenants share tables
- tenant_id column on every business table
- PostgreSQL RLS policies enforce isolation
Strategy B: Separate Databases
- Each tenant gets own database
- Complete isolation
- High connection overhead
Strategy C: Schema-Per-Tenant
- Single database, separate schemas
- SET search_path per request
- Logical isolation, shared infrastructure
DECISION
--------
We will use SHARED TABLES with ROW-LEVEL SECURITY multi-tenancy
(Strategy A).
Each tenant is identified by a tenant_id column on every business
table. PostgreSQL Row-Level Security (RLS) policies enforce isolation:
CREATE POLICY tenant_isolation ON <table>
USING (tenant_id = current_setting('app.current_tenant')::uuid);
The tenant is resolved from the subdomain (e.g., nexus.pos-platform.com)
and SET app.current_tenant is called per request via middleware.
CONSEQUENCES
------------
Positive:
+ Strong data isolation via PostgreSQL RLS (database-enforced)
+ Single schema — migrations apply once, not per-tenant
+ tenant_id enables straightforward cross-tenant analytics
(platform admin)
+ Standard PostgreSQL feature — no custom middleware risk
+ All tenants share connection pool
Negative:
- tenant_id required on every business table (discipline needed)
- Every query must be RLS-aware (mitigated by Prisma middleware)
- Cross-tenant queries require explicit bypasses
(SET app.current_tenant = '')
- Noisy neighbor risk on shared tables (mitigated by index
partitioning)
Risks:
- Forgetting tenant_id on new tables breaks isolation
- RLS policies must be applied to every new table
- Need robust middleware to always set app.current_tenant
Mitigations:
- Prisma middleware automatically injects tenant_id on every query
- CI/CD linter checks all tables for tenant_id + RLS policy
- Integration tests verify tenant isolation per API endpoint
ADR-002: Offline-First POS Architecture
Superseded: The offline-first approach has been replaced by an online-first with offline fallback strategy. Target retail environments have reliable internet (outages measured in minutes/year). The online-first approach eliminates CRDTs, reduces SQLite from 6 tables to 2, and simplifies integration flows while preserving sales continuity during brief outages. See ADR-048.
+==================================================================+
| ADR-002: Offline-First POS Architecture |
+==================================================================+
| Status: SUPERSEDED (by ADR-048: Online-First POS Data Strategy) |
| Date: 2025-12-29 |
| Deciders: Architecture Team |
+==================================================================+
CONTEXT
-------
POS terminals operate in retail environments where network
connectivity is unreliable:
1. Internet outages occur (ISP issues, weather, accidents)
2. WiFi can be congested during peak shopping hours
3. Store networks may have maintenance windows
4. Rural locations may have poor connectivity
A traditional online-required POS would:
- Block sales during outages (lost revenue)
- Show errors during slow connections (poor UX)
- Require manual workarounds (paper receipts)
Business requirements:
- Sales must NEVER be blocked by network issues
- Receipts must print immediately
- Data must eventually sync to central system
- Inventory should be reasonably accurate
DECISION
--------
We will implement OFFLINE-FIRST architecture for POS clients.
Key design elements:
1. Local SQLite database on each POS terminal
2. All operations work against local database first
3. Event queue for pending changes
4. Background sync when connectivity available
5. Conflict resolution for concurrent changes
Data flow:
User Action -> Local DB -> Event Queue -> [Background] -> Central API
CONSEQUENCES
------------
Positive:
+ Sales never blocked by network issues
+ Instant response time (local operations)
+ Resilient to any connectivity problem
+ Business continues regardless of server status
+ Better user experience for cashiers
Negative:
- Data is eventually consistent (not immediate)
- Inventory counts may drift until sync
- More complex architecture
- Conflict resolution logic required
- Local storage management needed
Risks:
- Data loss if local device fails before sync
- Inventory overselling possible during outages
- Conflict resolution edge cases
Mitigations:
- Aggressive sync when online (every 30 seconds)
- Local database backup to secondary storage
- Conservative inventory thresholds
- Clear offline indicator in UI
- Deterministic conflict resolution rules
ADR-003: Event Sourcing for Sales Domain
+==================================================================+
| ADR-003: Event Sourcing for Sales Domain |
+==================================================================+
| Status: ACCEPTED |
| Date: 2025-12-29 |
| Deciders: Architecture Team |
+==================================================================+
CONTEXT
-------
The Sales domain has specific requirements that traditional CRUD
does not adequately address:
1. Complete audit trail required (PCI-DSS compliance)
2. Need to answer "what happened?" not just "what is?"
3. Offline clients need conflict-free merge capability
4. Historical analysis (sales trends, patterns)
5. Debugging production issues by replaying events
Traditional CRUD limitations:
- Only stores current state
- Updates overwrite history
- Hard to reconstruct past states
- Audit logs separate from data model
DECISION
--------
We will use EVENT SOURCING for the Sales aggregate.
Implementation:
1. Append-only event store in PostgreSQL
2. Events are the source of truth
3. Read models (projections) for queries
4. Snapshots for performance on long streams
Events captured:
- SaleCreated, SaleLineItemAdded, PaymentReceived, SaleCompleted
- SaleVoided, RefundProcessed
- All inventory changes (InventorySold, InventoryAdjusted)
NOT event-sourced (traditional CRUD):
- Products (read-heavy, infrequent changes)
- Employees (HR data, simple lifecycle)
- Locations (configuration data)
CONSEQUENCES
------------
Positive:
+ Complete audit trail built into data model
+ Temporal queries ("inventory on Dec 15 at 3pm")
+ Offline sync via event merge (append-only = no conflicts)
+ Debugging by event replay
+ Analytics on event streams
+ Natural fit for CQRS pattern
Negative:
- More complex than CRUD
- Requires event versioning strategy
- Projections must be rebuilt if logic changes
- Storage grows over time (mitigated by snapshots)
- Learning curve for developers
Risks:
- Event schema evolution complexity
- Projection bugs cause stale read models
- Performance without proper snapshotting
Mitigations:
- Event versioning from day one
- Automated projection rebuild process
- Snapshot every 100 events
- Clear documentation and training
ADR-004: JWT + PIN Authentication
+==================================================================+
| ADR-004: JWT + PIN Authentication |
+==================================================================+
| Status: ACCEPTED |
| Date: 2025-12-29 |
| Deciders: Architecture Team, Security Team |
+==================================================================+
CONTEXT
-------
POS systems have unique authentication requirements:
1. API access needs secure, stateless authentication
2. Cashiers need quick clock-in at physical terminals
3. Sensitive actions need additional verification
4. Multiple employees may share a terminal
5. Terminals may be offline
Requirements:
- Strong authentication for API/Admin access
- Fast authentication for cashiers (< 2 seconds)
- Manager override capability
- Works offline for cashier PIN
Industry standards:
- JWT is standard for API authentication
- PINs are standard for POS quick access
- Password + MFA for Nexus Admin access
DECISION
--------
We will implement a HYBRID authentication system:
1. JWT for API Authentication
- Nexus Admin uses email + password + optional MFA
- Issues JWT token (15 min access, 7 day refresh)
- Standard Bearer token in Authorization header
2. PIN for POS Terminal Access
- 4-6 digit PIN per employee
- Stored as bcrypt hash in database
- Used for: clock-in, sale attribution, drawer access
3. Manager Override
- Sensitive actions require manager PIN
- Void, large discount, price override
- Manager enters their PIN to authorize
4. Offline PIN Validation
- Employee records with PIN hashes cached locally
- Validated against local cache when offline
- Sync employee changes when online
CONSEQUENCES
------------
Positive:
+ Secure API access with industry-standard JWT
+ Fast cashier workflow with PIN
+ Manager oversight on sensitive operations
+ Works offline for POS operations
+ Clear audit trail (who did what)
Negative:
- Two authentication systems to maintain
- PIN is less secure than password (brute force)
- Local PIN cache could be extracted
- Token refresh complexity
Risks:
- PIN guessing attacks
- Stolen JWT tokens
- Stale employee cache (terminated employee)
Mitigations:
- Rate limiting on PIN attempts (3 failures = lockout)
- Short JWT expiry (15 minutes)
- Aggressive employee sync (every 5 minutes)
- PIN attempt logging and alerting
- Secure local storage encryption
ADR-005: PostgreSQL as Primary Database
+==================================================================+
| ADR-005: PostgreSQL as Primary Database |
+==================================================================+
| Status: ACCEPTED |
| Date: 2025-12-29 |
| Deciders: Architecture Team |
+==================================================================+
CONTEXT
-------
We need a database that supports:
1. Row-Level Security multi-tenancy
2. JSONB for flexible event storage
3. Strong ACID guarantees for financial data
4. Good performance at scale
5. Mature ecosystem and tooling
Options considered:
- PostgreSQL: Schema support, JSONB, mature
- MySQL: Popular, but weaker schema support
- SQL Server: Good, but licensing costs
- MongoDB: Document store, no ACID, no schemas
- CockroachDB: Distributed, but complexity
DECISION
--------
We will use POSTGRESQL 16 as the primary database.
Justifications:
1. Native Row-Level Security (RLS) for multi-tenancy isolation
(Originally: schema support; updated per ADR-001 supersession)
2. Excellent JSONB for event storage
3. Strong ACID for financial transactions
4. Proven at scale (Instagram, Uber, etc.)
5. Rich extension ecosystem (PostGIS, etc.)
6. Open source, no licensing costs
7. Excellent tooling (pgAdmin, pg_dump)
CONSEQUENCES
------------
Positive:
+ Native RLS for multi-tenant data isolation (see ADR-001 supersession)
+ JSONB enables flexible event data
+ Strong consistency guarantees
+ Mature, well-documented
+ No licensing costs
+ Excellent community support
Negative:
- Single point of failure without replication
- Requires PostgreSQL expertise
- Not as horizontally scalable as NoSQL
- Schema migrations need coordination
Mitigations:
- Streaming replication for HA
- Regular backups with pg_dump
- Team training on PostgreSQL
- Migration automation tooling
ADR-006: Node.js + TypeScript for Central API
2.6 ADR-006: Central API Framework
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-28 |
| Decision Makers | Architecture Review Team |
| Context | The Central API needs a backend framework that supports high-performance I/O, strong typing, real-time features, and alignment with the frontend TypeScript ecosystem. |
Context
The Central API is the backbone of the POS platform — serving REST endpoints for sales, inventory, customers, reporting, admin/setup, and integrations. It must support real-time inventory broadcasts to connected POS terminals, type-safe database access with automatic migrations, and Docker-based deployment on commodity hardware.
With the frontend stack standardized on React/TypeScript (Nexus POS via Tauri, Nexus Admin via web, Nexus Raptag via React Native), selecting a TypeScript-based backend enables a unified language across the entire platform. Shared types, validation schemas (Zod), and API contracts can be published as npm packages consumed by all clients.
Decision
We will use Node.js + Express/Fastify + TypeScript for the Central API.
Considered Options
- ASP.NET Core (C#) — High performance, strong typing, EF Core, SignalR
- Node.js + Express/Fastify (TypeScript) — Unified TypeScript stack, Prisma ORM, Socket.io
- Go (Gin) — Raw performance, small binary, but no type sharing with frontend
- Python (FastAPI) — Excellent for ML, but weaker typing and slower I/O
- Java (Spring) — Enterprise-grade, but verbose and no frontend code sharing
Decision Outcome
Chosen: Node.js + Express/Fastify + TypeScript because it unifies the entire platform on a single language (TypeScript), enables shared types between API and all client applications via npm packages, provides excellent I/O performance for the database-heavy POS workload, and offers the largest package ecosystem (2M+ npm packages).
Team context: Full TypeScript expertise aligned with React (Nexus POS/Admin) and React Native (Nexus Raptag) frontends. No language context-switching between backend and frontend development.
Trade-offs
Pros:
- Unified TypeScript across entire stack — API, Nexus POS (Tauri + React), Nexus Admin (React web), Nexus Raptag (React Native)
- Prisma ORM for type-safe PostgreSQL queries with automatic migrations and introspection
- Socket.io for real-time inventory broadcasts to connected POS terminals (replaces SignalR)
- Massive npm ecosystem (2M+ packages) — battle-tested libraries for every integration need
- Excellent Docker support — Alpine Node.js images with small footprint (~50MB)
- Same language for frontend and backend eliminates context-switching and enables code sharing
- Strong typing via TypeScript catches errors at compile time with strict mode enabled
- Shared validation schemas (Zod) and API types published as npm packages
Cons:
- Single-threaded event loop — CPU-bound tasks require worker threads (mitigated by worker_threads for report generation)
- Less raw compute performance than Go/Rust/C# — acceptable for I/O-bound POS workloads (database queries, Redis lookups, HTTP calls)
- Node.js ecosystem moves fast — dependency churn (mitigated by pinned versions and lock file, see ADR-014)
References
- Ch 04: Architecture Styles, Section L.9A (System Architecture)
- ADR-014: npm Package Versioning (Pinned Major.Minor with Lock File)
- ADR-046: Nexus Dual Deployment Architecture
ADR Index
| ADR | Title | Status | Date |
|---|---|---|---|
| ADR-001 | Shared Tables with Row-Level Security Multi-Tenancy | Superseded (corrected to Row-Level RLS, Ch 04 L.10A.4) | 2025-12-29 |
| ADR-002 | Offline-First POS Architecture | Superseded (by ADR-048) | 2025-12-29 |
| ADR-003 | Event Sourcing for Sales Domain | Accepted | 2025-12-29 |
| ADR-004 | JWT + PIN Authentication | Accepted | 2025-12-29 |
| ADR-005 | PostgreSQL as Primary Database | Accepted | 2025-12-29 |
| ADR-006 | Node.js + TypeScript for Central API | Accepted | 2026-02-28 |
| ADR-007 | Admin Portal Framework (Blazor Server) | Superseded (by ADR-046) | 2026-02-27 |
| ADR-008 | POS Client Framework (Tauri 2.0 + React/TypeScript) | Accepted | 2026-02-28 |
| ADR-009 | Redis for Session & Cache | Accepted | 2026-02-27 |
| ADR-010 | Shopify Sync Strategy (Webhook + Polling) | Accepted | 2026-02-27 |
| ADR-011 | Payment Gateway (SAQ-A Semi-Integrated) | Accepted | 2026-02-27 |
| ADR-012 | Logging & Monitoring (LGTM Stack) | Accepted | 2026-02-27 |
| ADR-013 | RFID Configuration in Tenant Admin | Superseded (by ADR-046) | 2026-01-01 |
| ADR-014 | npm Package Versioning (Pinned Major.Minor with Lock File) | Accepted | 2026-02-28 |
| ADR-015 | Offline Sync Strategy (Queue-and-Sync with CRDTs) | Accepted | 2026-02-27 |
| ADR-016 | Error Code Structure (ERR-Mxxx Hierarchical) | Accepted | 2026-02-27 |
| ADR-017 | Test Strategy (Layered Testing Pyramid) | Accepted | 2026-02-27 |
| ADR-018 | Affirm BNPL Integration | Accepted | 2026-02-27 |
| ADR-019 | SAQ-A Semi-Integrated Payment Scope | Accepted | 2026-02-27 |
| ADR-020 | Split Tender Payment Support | Accepted | 2026-02-27 |
| ADR-021 | Layaway Payment Plans | Accepted | 2026-02-27 |
| ADR-022 | Tax-Inclusive Display with Compound Calculation | Accepted | 2026-02-27 |
| ADR-023 | Compound Tax (3-Level State/County/City) | Accepted | 2026-02-27 |
| ADR-024 | Gift Card Compliance (State Escheatment) | Accepted | 2026-02-27 |
| ADR-025 | 6-Status Inventory State Machine | Accepted | 2026-02-27 |
| ADR-026 | Reservation-Based Inventory Hold Model | Accepted | 2026-02-27 |
| ADR-027 | RFID Counting-Only Scope (No Lifecycle) | Accepted | 2026-02-27 |
| ADR-028 | Physical Count Freeze Period | Accepted | 2026-02-27 |
| ADR-029 | Adjustment Manager Approval (Universal) | Accepted | 2026-02-27 |
| ADR-030 | Auto-Suggest Transfers Algorithm | Accepted | 2026-02-27 |
| ADR-031 | Shopify Webhook + Polling Dual Sync | Accepted | 2026-02-27 |
| ADR-032 | Strictest-Rule-Wins Cross-Platform Validation | Accepted | 2026-02-27 |
| ADR-033 | Amazon SP-API Integration Strategy | Accepted | 2026-02-27 |
| ADR-034 | Google Merchant Center Feed Strategy | Accepted | 2026-02-27 |
| ADR-035 | Channel Safety Buffer Calculation | Accepted | 2026-02-27 |
| ADR-036 | POS-Master Default for External Channels | Accepted | 2026-02-27 |
| ADR-037 | Offline Conflict Resolution via CRDTs | Accepted | 2026-02-27 |
| ADR-038 | Transactional Outbox for Event Publishing | Accepted | 2026-02-27 |
| ADR-039 | CQRS Boundary (Sales Domain Only) | Accepted | 2026-02-27 |
| ADR-040 | Eventual Consistency SLA (5s Online, 30min Offline) | Accepted | 2026-02-27 |
| ADR-041 | 6-Gate Security Pyramid | Accepted | 2026-02-27 |
| ADR-042 | Removed (duplicate of ADR-017) | 2026-02-27 | |
| ADR-043 | Removed (duplicate of ADR-012) | 2026-02-27 | |
| ADR-044 | API Performance Targets | Accepted | 2026-02-27 |
| ADR-045 | Blue-Green Deployment Strategy | Accepted | 2026-02-27 |
| ADR-046 | Nexus Dual Deployment Architecture | Accepted | 2026-02-28 |
| ADR-047 | Raptag Mobile Framework (React Native) | Accepted | 2026-02-28 |
| ADR-048 | Online-First POS Data Strategy | Accepted | 2026-03-01 |
ADR-013: RFID Configuration Embedded in Tenant Admin Portal
Superseded: The “Admin Portal” concept has been eliminated. RFID configuration is now accessed via Nexus Admin web app > Settings > RFID section. The decision to embed RFID in the main application (rather than a separate portal) remains valid — only the product surface name has changed. See ADR-046.
+==================================================================+
| ADR-013: RFID Configuration Embedded in Tenant Admin Portal |
+==================================================================+
| Status: SUPERSEDED (by ADR-046: Nexus Dual Deployment |
| Architecture) |
| Date: 2026-01-01 |
| Deciders: Architecture Team |
+==================================================================+
CONTEXT
-------
RapOS includes RFID inventory capabilities via the Raptag mobile app.
The question arose: where should RFID configuration (device management,
printer setup, tag encoding settings, templates) be managed?
We evaluated three options:
Option A: Embed in Tenant Admin Portal (app.rapos.com)
- RFID settings as feature-flagged section in existing portal
- Uses existing authentication, permissions, navigation
- Shared context with products, locations, users
Option B: Separate RFID Portal (rfid.rapos.com)
- Dedicated portal just for RFID configuration
- 4th portal in the architecture
- Independent scaling and development
Option C: Hybrid Approach
- Basic settings in Tenant Admin
- Advanced configuration in separate portal
- Users navigate between portals
Research was conducted on major RFID vendors:
- SML Clarity: Single platform, modular components
- Checkpoint HALO/ItemOptix: Unified SaaS platform
- Avery Dennison atma.io: Role-based dashboards in one platform
- Impinj ItemSense: Single Management Console
Key finding: NO major RFID vendor uses separate portals for RFID
configuration. All embed RFID features within unified platforms.
DECISION
--------
We will EMBED RFID configuration in the Tenant Admin Portal (Option A).
Implementation:
- Settings > RFID section (feature-flagged)
- Devices tab: Claim codes, device list, release
- Printers tab: IP configuration, test connectivity
- Tag Configuration tab: EPC prefix (read-only), variance thresholds
- Templates tab: Label template library
Mobile app downloads configuration from central API on startup.
No RFID configuration in the mobile app itself.
CONSEQUENCES
------------
Positive:
+ Matches industry pattern (SML, Checkpoint, Avery Dennison)
+ Single login/URL for all tenant management
+ Shared context with products, locations, users
+ Lower development cost (one portal, not two)
+ Progressive disclosure manages complexity
+ Same permissions system applies to RFID
Negative:
- Could become bloated if RFID features grow significantly
- Enterprise customers might want dedicated RFID admin
- Feature flags add slight complexity
Risks:
- Tenant Admin may feel "cluttered" with many features
- RFID power users may want more dedicated experience
Mitigations:
- Use progressive disclosure (collapse advanced settings)
- Role-based visibility (hide RFID from non-RFID users)
- Monitor feedback; re-evaluate if enterprise demand grows
- Feature-flagged sections can be extracted later if needed
Re-evaluation Triggers:
- Multiple enterprise customers (100+ stores) request separation
- RFID feature count exceeds 20+ configuration screens
- Evidence that RFID admins are different people than Tenant admins
ADR-007: Admin Portal Framework — Blazor Server
Superseded: This ADR documents the original C#/Blazor Server architecture that was rejected during the v6.1.0 tech stack pivot. The separate Admin Portal has been eliminated. Administration is now integrated into the Nexus web application — the same React/TypeScript codebase deployed as both a Tauri desktop app (Nexus POS) and a standard web app (Nexus Admin). The current architecture uses React/TypeScript with Tauri 2.0 for the desktop POS client (see ADR-046 Nexus Dual Deployment). This record is preserved for historical context.
2.7 ADR-007: Admin Portal Framework
| Field | Value |
|---|---|
| Status | Superseded (by ADR-046) |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The Admin Portal needs a frontend framework that integrates with the .NET backend and supports real-time features. |
Context
The Admin Portal (app.rapos.com) is the central management interface for tenant administrators. It provides dashboards, product management, employee management, reporting, and configuration. The portal requires real-time data updates (inventory levels, sales dashboards, integration sync status) and must share authentication and authorization logic with the Central API.
The team already uses C# for the Central API (ASP.NET Core 8.0), the POS Client (.NET MAUI), and the Mobile App (.NET MAUI). Introducing a JavaScript-based frontend would require maintaining two toolchains, two build systems, and two sets of domain models with mapping layers.
Admin portals are inherently server-heavy workloads: data-dense tables, reporting dashboards, configuration forms, and audit logs. Unlike consumer-facing SPAs, admin portals benefit more from server-side rendering and direct database access than from client-side interactivity.
Decision
We will use Blazor Server for the Admin Portal.
Considered Options
- React SPA — JavaScript/TypeScript Single Page Application with REST/GraphQL API calls
- Angular SPA — TypeScript-based enterprise SPA framework
- Vue.js SPA — Progressive JavaScript framework
- Blazor Server — Server-side Razor components with SignalR real-time updates
Decision Outcome
Chosen: Blazor Server because it unifies the entire stack on C#/.NET, eliminates the need for a separate JavaScript build toolchain, provides built-in real-time updates via SignalR (already used for inventory broadcasts), and enables sharing of domain models, validation logic, and DTOs directly between the API and the portal.
Trade-offs
Pros:
- Unified .NET stack — same language, same models, same tooling across API, Admin Portal, POS Client, and Mobile
- Built-in real-time via SignalR — dashboard updates, inventory alerts, sync status without polling
- No separate build toolchain — no Node.js, npm, webpack, or Vite required for the Admin Portal
- Server-side rendering — thin client, no large JavaScript bundles, fast initial load
- Shared Blazor components with POS Client (.NET MAUI Blazor Hybrid)
- Full access to .NET ecosystem (FluentValidation, MediatR, EF Core) in UI logic
- Simplified authentication — shares the same ASP.NET Core Identity/JWT infrastructure
Cons:
- Requires persistent SignalR connection — higher server memory per concurrent user
- Latency on every UI interaction (round-trip to server) — acceptable for admin workloads, not for consumer SPAs
- Smaller UI component ecosystem compared to React (mitigated by MudBlazor, Radzen, Syncfusion)
- Team must learn Razor component model if unfamiliar (low risk given existing C# expertise)
References
- Ch 04: Architecture Styles, Section L.9A (System Architecture) (Admin Portal details — planned future rewrite)
- ADR-006: Node.js + TypeScript for Central API
ADR-008: POS Client Framework — Tauri 2.0 + React/TypeScript
Note (v7.0.0): The Tauri 2.0 desktop wrapper has been replaced by a pure React web application (ADR-052). Hardware peripherals now use web protocols (Star WebPRNT for receipt printers, USB HID for barcode scanners, Stripe Terminal SDK for payment terminals). SQLite offline storage uses WASM (sql.js/wa-sqlite + OPFS) instead of native better-sqlite3. The React/TypeScript architecture and shared codebase principles from this ADR remain valid.
2.8 ADR-008: POS Client Framework
| Field | Value |
|---|---|
| Status | Accepted (Tauri-specific parts superseded by ADR-052) |
| Date | 2026-02-28 |
| Decision Makers | Architecture Review Team |
| Context | The POS Client runs on store terminals (Windows desktops/tablets), needs native hardware access (receipt printers ESC/POS, barcode scanners HID/serial, cash drawers RJ-11), offline-first local SQLite, and cross-platform desktop deployment. |
Context
The POS Client (Nexus POS) runs on store terminals (Windows desktops/tablets) and must integrate with physical retail hardware: receipt printers (ESC/POS protocol), barcode scanners (HID/serial), cash drawers (RJ-11 trigger). It must operate fully offline with a local SQLite database and sync queued transactions when connectivity is restored.
With the tech stack pivot to TypeScript (ADR-006), the POS Client should use the same React/TypeScript codebase as the Nexus Admin web application. Tauri 2.0 enables wrapping a React web app as a native desktop application with Rust-powered backend commands for hardware access and performance-critical operations. The same React codebase is deployed as both a Tauri desktop app (Nexus POS) and a standard web app (Nexus Admin) — see ADR-046.
Decision
We will use Tauri 2.0 + React/TypeScript for the POS Client, with better-sqlite3 for local offline storage.
Considered Options
- Electron — Chromium-based desktop app with Node.js backend (rejected: 150MB+ bundle, Chromium overhead)
- Tauri 2.0 — Rust-based lightweight desktop app with web frontend (chosen)
- PWA (Progressive Web App) — Browser-based with service worker caching (rejected: no native hardware access)
- .NET MAUI Blazor Hybrid — Native .NET desktop app with embedded Blazor WebView (rejected: different language ecosystem from TypeScript stack)
Decision Outcome
Chosen: Tauri 2.0 + React/TypeScript because it provides full offline capability with local SQLite via better-sqlite3 (Tauri sidecar or native plugin), direct hardware access via Tauri Rust commands (receipt printer ESC/POS, barcode scanner, cash drawer), shares the same React codebase with Nexus Admin web app (dual deployment from single source), and produces a lightweight binary (~10MB vs Electron 150MB+).
Trade-offs
Pros:
- Full offline capability with local SQLite via better-sqlite3 (Tauri sidecar or native plugin)
- Direct hardware access via Tauri Rust commands (receipt printer ESC/POS, barcode scanner, cash drawer)
- Same React codebase as Nexus Admin web app — dual deployment from single source (ADR-046)
- Lightweight binary (~10MB vs Electron 150MB+) — important for store terminal hardware
- No bundled Chromium — uses system WebView2 (Windows) reducing memory footprint
- Rust backend for performance-critical paths (encryption, local DB operations, sync)
- TypeScript shared types with Central API via npm packages
- Single design system (TailwindCSS + shadcn/ui) across Nexus POS and Nexus Admin
Cons:
- Tauri 2.0 is newer than Electron — smaller community, fewer third-party plugins (growing rapidly)
- Rust commands require Rust expertise for hardware integration layer (contained scope)
- WebView2 dependency on Windows (auto-installed on Windows 10 21H2+ and Windows 11)
- Some rendering differences between WebView2 and Chrome (mitigated by consistent React component library)
References
- Ch 04: Architecture Styles, Section L.10A.1 (Offline Strategy) (POS Client details — planned future rewrite)
- ADR-002: Offline-First POS Architecture
- ADR-046: Nexus Dual Deployment Architecture
ADR-009: Redis for Session & Cache
2.9 ADR-009: Redis for Session & Cache
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The platform needs distributed session management and caching for a horizontally scaled API layer. |
Context
The Central API is deployed as multiple stateless instances behind a load balancer. User sessions (JWT refresh tokens, active cart state for the Nexus Admin) and frequently accessed data (product catalog, tax rates, tenant configuration) must be available to any API instance. In-memory caching per-instance leads to inconsistency when requests are load-balanced across instances.
Additionally, Module 6 (Integrations) requires real-time pub/sub for broadcasting inventory updates to connected POS terminals via Socket.io, and caching safety buffer computations to avoid recalculating on every channel sync.
Decision
We will use Redis 7.x for distributed session management, cache-aside pattern, and pub/sub real-time notifications.
Considered Options
- In-memory per-instance — Each API instance maintains its own cache
- Memcached — Simple distributed key-value cache
- PostgreSQL-based sessions — Store sessions in the primary database
- Redis 7.x — Distributed cache, session store, and pub/sub
Decision Outcome
Chosen: Redis 7.x because it supports all three use cases (session, cache, pub/sub) in a single infrastructure component, has excellent Node.js integration via ioredis, and provides sub-millisecond read latency for product lookups during checkout.
Trade-offs
Pros:
- Distributed session — any API instance can serve any user without sticky sessions
- Cache-aside pattern — product catalog, tax rates, and tenant config cached with configurable TTL
- Pub/sub for real-time — inventory update broadcasts to Socket.io rooms without polling PostgreSQL
- Sub-millisecond read latency — critical for checkout performance (NFR-PERF-001: < 500ms p99)
- Built-in data structures (sorted sets for leaderboards, streams for event buffering)
- Proven at scale — used by GitHub, Twitter, Stack Overflow
Cons:
- Additional infrastructure component to deploy and monitor
- Data loss on restart if not using AOF persistence (mitigated by AOF + RDB snapshots)
- Memory-bound — cost increases with data volume (mitigated by TTL eviction policies)
- Single-threaded command processing — throughput limited per instance (mitigated by Redis Cluster for scale)
References
- Chapter 04: Architecture Styles, Section L.9A (Data Layer)
- Chapter 09: Indexes & Performance
ADR-010: Shopify Sync Strategy — Webhook + Polling Hybrid
2.10 ADR-010: Shopify Sync Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Shopify integration requires real-time inventory sync with fallback for missed webhooks. |
Context
The POS platform syncs product catalog and inventory levels bidirectionally with Shopify. Shopify provides webhooks for real-time notifications (products/update, inventory_levels/update, orders/create) but webhooks can be missed due to network issues, Shopify outages, or endpoint failures. The platform must guarantee eventual consistency between POS inventory and Shopify inventory.
BRD v18.0 Module 6 (Section 6.3) defines Shopify as the primary e-commerce integration with OAuth 2.0/PKCE authentication, GraphQL Admin API at 50 points/second rate limiting, and mandatory @idempotent mutations (required 2026-04).
Decision
We will use a Webhook + Polling hybrid strategy for Shopify synchronization.
Considered Options
- Pure Webhook — Rely solely on Shopify webhooks for all sync
- Pure Polling — Poll Shopify API on intervals for all changes
- Shopify Flow — Use Shopify’s built-in automation workflows
- Webhook + Polling hybrid — Webhooks for real-time, polling as fallback
Decision Outcome
Chosen: Webhook + Polling hybrid because webhooks provide near-real-time sync (< 5 seconds processing) for the common case, while scheduled polling (every 15 minutes) catches any missed webhooks and ensures eventual consistency. Both paths use idempotent processing with the same event pipeline.
Trade-offs
Pros:
- Near-real-time sync via webhooks (< 5 seconds processing per NFR-INTG-001)
- Guaranteed eventual consistency via polling fallback
- Idempotent processing — same handler for webhook and polling events (no double-counting)
- Resilient to webhook delivery failures (Shopify retries for 48 hours, polling catches the rest)
- Rate-limit-aware polling with adaptive backoff
Cons:
- More complex than pure polling (webhook endpoint, signature verification, retry handling)
- Polling adds API calls that count against Shopify rate limits (mitigated by delta queries with
updated_at_min) - Must handle duplicate events from both webhook and poll (mitigated by idempotency framework with 24-hour dedup window)
References
- Ch 04: Architecture Styles, Section L.4B (Integration Architecture) (Integration patterns — see also Ch 05 Module 6)
- BRD v20.0 Section 6.3 (Shopify Integration)
ADR-011: Payment Gateway — SAQ-A Semi-Integrated
2.11 ADR-011: Payment Gateway
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Security Team |
| Context | The platform must process card payments with minimal PCI compliance scope. |
Context
POS terminals must accept card payments (chip, tap, swipe) in physical stores. PCI-DSS compliance is mandatory, but the level of compliance effort varies dramatically based on how card data is handled. Full integration (SAQ-D) requires 300+ controls; semi-integrated (SAQ-A) requires ~30 controls because card data never touches our system.
The platform must support multiple payment providers to avoid vendor lock-in and enable tenant choice. The offline capability requires that payment tokens (not card data) can be stored locally for void/refund operations.
Decision
We will use SAQ-A Semi-Integrated terminals with Stripe Terminal and Square Terminal as supported providers.
Considered Options
- Full Integration (SAQ-D) — Card data flows through our system, encrypted and tokenized
- Semi-Integrated (SAQ-A) — Card data handled entirely by terminal/processor, we receive tokens only
- Redirect-only — Customer redirected to payment page (not applicable for in-store POS)
- Hosted Fields — Embedded payment form from provider (web-only, not applicable for desktop POS)
Decision Outcome
Chosen: SAQ-A Semi-Integrated because no card data (PAN, CVV, track data, PIN block) ever touches our system. The POS Client sends a payment request to the terminal SDK, the terminal communicates directly with the payment processor, and we receive only a token, approval code, and masked card number (****1234). This reduces PCI scope from 300+ controls to ~30.
Trade-offs
Pros:
- Minimal PCI scope (SAQ-A: ~30 controls vs. SAQ-D: 300+ controls)
- No card data storage, transmission, or processing in our system
- Multi-provider support — Stripe Terminal and Square Terminal via provider abstraction
- Token-based void/refund — works offline using stored payment tokens
- Terminal firmware managed by provider (no EMV kernel maintenance)
Cons:
- Dependent on terminal hardware availability and provider SDK updates
- Terminal communication adds latency (~1-3 seconds for chip transactions)
- Limited control over payment UX (terminal screen is provider-controlled)
- Two provider SDKs to maintain (Stripe Terminal SDK, Square Terminal SDK)
References
- Ch 04: Architecture Styles, Section L.10A.3 (Payment Integration) (Security & Auth details — planned future rewrite)
- Ch 04: Architecture Styles, Section L.8 (Security — 6-Gate Pyramid)
- BRD v20.0 Section 1.18 (Payments)
ADR-012: Logging & Monitoring — LGTM Stack
2.12 ADR-012: Logging & Monitoring
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Infrastructure Team |
| Context | The platform needs unified observability across API, POS clients, integrations, and infrastructure. |
Context
The POS platform has multiple observable surfaces: the Central API (multiple instances), POS terminals in stores (offline-capable), external integrations (Shopify, Amazon, Google Merchant), and infrastructure (PostgreSQL, Redis, Kafka v2.0). Operators need logs, metrics, and distributed traces to diagnose issues like “why did this sale fail to sync?” or “why is the Shopify circuit breaker open?”
Cloud-native SaaS solutions (Datadog, New Relic) offer convenience but at significant cost ($15-25/host/month) and with vendor lock-in. The platform uses OpenTelemetry for instrumentation, which enables backend-agnostic telemetry collection.
Decision
We will use the LGTM Stack (Loki, Grafana, Tempo, Mimir/Prometheus) for observability.
Considered Options
- ELK Stack (Elasticsearch, Logstash, Kibana) — Established log aggregation platform
- Datadog — Cloud-native SaaS observability platform
- Cloud-native (CloudWatch, Azure Monitor) — Cloud provider native tools
- LGTM Stack (Loki, Grafana, Tempo, Prometheus) — Open-source observability platform
Decision Outcome
Chosen: LGTM Stack because it is fully open-source (no per-host licensing), self-hosted (data stays on our infrastructure for PCI compliance), and designed for the OpenTelemetry ecosystem. Grafana provides unified dashboards for logs (Loki), traces (Tempo), and metrics (Prometheus), with native Node.js auto-instrumentation via @opentelemetry/sdk-node.
Trade-offs
Pros:
- Open-source — no per-host licensing costs, no vendor lock-in
- Self-hosted — data stays on infrastructure (PCI compliance for audit logs)
- Unified dashboards in Grafana — logs, metrics, and traces correlated by trace ID
- Loki uses label-based indexing (not full-text) — lower storage costs than Elasticsearch
- Native OpenTelemetry support — Node.js auto-instrumentation for Express/Fastify, Prisma, HTTP client
- Integration-specific dashboards: circuit breaker state, DLQ depth, sync latency, safety buffer violations
Cons:
- Operational overhead — must manage Loki, Tempo, Prometheus, Grafana infrastructure
- Less feature-rich than Datadog for APM (no automatic service maps, no AI anomaly detection)
- Grafana alerting is functional but less sophisticated than PagerDuty/OpsGenie (mitigated by Alertmanager integration)
- Storage management required for long-term log/metric retention
References
- Ch 04: Architecture Styles, Section L.7 (Observability) (Monitoring details — planned future rewrite)
- Ch 04: Architecture Styles, Section L.8 (Security — FIM via Wazuh)
ADR-014: npm Package Versioning — Pinned Major.Minor with Lock File
2.14 ADR-014: npm Package Versioning
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-28 |
| Decision Makers | Architecture Review Team |
| Context | The platform depends on critical npm packages that must be version-controlled for build reproducibility and security. |
Context
The POS platform uses multiple npm packages for core functionality: Prisma for type-safe PostgreSQL access, ioredis for caching, Socket.io for real-time broadcasts, Zod for schema validation, pino for structured logging, jose for JWT operations, and argon2 for password hashing. The frontend uses React, TailwindCSS, shadcn/ui, React Query, and Zustand.
Floating version ranges (*, latest) can introduce breaking changes in CI/CD. Exact pinning (4.18.0) prevents security patches. A balanced approach is needed. The monorepo uses pnpm as the package manager with a committed lock file.
Decision
We will use pinned major.minor in package.json (e.g., "express": "^4.18") with pnpm-lock.yaml committed for full reproducibility. Dependabot/Renovate automates PR-based updates.
Considered Options
- Floating versions (
*) — Always use latest available - Exact pinning (
4.18.2) — Lock to specific patch version - Caret ranges (
^4.18.0) — Allow minor + patch updates - Pinned major.minor with lock file (
^4.18+ committed pnpm-lock.yaml)
Decision Outcome
Chosen: Pinned major.minor with lock file because it ensures build reproducibility via the committed pnpm-lock.yaml (identical installs across developer machines and CI/CD) while allowing patch-level security fixes. Dependabot/Renovate creates PRs for major/minor bumps with changelog review.
Key Package Versions:
| Package | Pinned Version | Purpose |
|---|---|---|
| express or fastify | ^4.18 / ^5.0 | HTTP framework |
| @prisma/client | ^5.x | PostgreSQL ORM (type-safe) |
| ioredis | ^5.x | Redis client |
| socket.io | ^4.x | Real-time WebSocket |
| zod | ^3.x | Schema validation |
| pino | ^8.x | Structured logging |
| @opentelemetry/sdk-node | ^1.x | Observability instrumentation |
| jose | ^5.x | JWT signing/verification (RS256) |
| argon2 | ^0.x | Password hashing (Argon2id) |
| better-sqlite3 | ^11.x | SQLite for Tauri POS local DB |
| kafkajs | ^2.x | Kafka client (v2.0 future) |
Trade-offs
Pros:
- Build reproducibility — pnpm-lock.yaml ensures identical dependency trees across all environments
- Automatic security patches — patch versions flow through automatically
- Consistent across developer machines and CI/CD
- Dependabot/Renovate creates PRs for major/minor bumps with changelog review
- pnpm strict mode prevents phantom dependencies
Cons:
- Patch-level changes could theoretically introduce bugs (extremely rare, mitigated by CI test suite)
- Requires manual intervention for major/minor upgrades (by design — these are reviewed)
- Lock file must be committed and kept up to date (
pnpm-lock.yaml) - npm ecosystem has higher dependency churn than .NET (mitigated by lock file + Renovate)
References
- Ch 04: Architecture Styles, Section L.8 (SCA — Snyk/OWASP) (Dev Environment details — planned future rewrite)
- PCI-DSS 4.0 Req 6.3.2 (SBOM generation)
ADR-015: Offline Sync Strategy — Queue-and-Sync with CRDTs
SUPERSEDED: This ADR has been superseded by ADR-048 (Online-First with Offline Fallback). CRDTs were eliminated in v6.2.0. This record is preserved for historical context.
2.15 ADR-015: Offline Sync Strategy
| Field | Value |
|---|---|
| Status | Superseded (by ADR-048) |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | POS terminals operating offline must sync transactions and inventory changes without data loss or conflicts. |
Context
ADR-002 established offline-first as a core requirement. This ADR specifies the sync mechanism. When POS terminals are offline, sales, payments, and inventory changes accumulate locally. When connectivity is restored, these changes must be pushed to the Central API and merged with changes from other terminals and the Nexus Admin.
The key challenge is conflict resolution: two terminals may sell the last unit of a product simultaneously, or an admin may update a price while a terminal is offline. The sync strategy must handle these cases deterministically without data loss.
Decision
We will use Queue-and-Sync with CRDTs for offline synchronization.
Considered Options
- Sync-on-connect — Full database sync when connectivity is restored
- Optimistic sync — Push local changes, accept server response as authority
- Operational Transforms (OT) — Transform operations based on concurrent changes
- Queue-and-Sync with CRDTs — Priority-based sync queue with CRDT merge for conflict-free data types
Decision Outcome
Chosen: Queue-and-Sync with CRDTs because it combines append-only event queuing (sales are conflict-free by nature) with CRDT data structures for data types that need merge (inventory counters, price updates, cart items). Priority-based queuing ensures critical data (sales, payments) syncs before less critical data (customer updates, analytics).
Sync Priority Tiers:
| Priority | Event Types | Sync Timing |
|---|---|---|
| 1 (Critical) | Sales, Payments, Refunds, Voids | Immediate when online |
| 2 (Important) | Inventory adjustments, Transfers | Within 5 minutes |
| 3 (Normal) | Customer updates, Loyalty changes | Within 15 minutes |
| 4 (Low) | Analytics events, Logs | Batch sync hourly |
CRDT Usage:
| CRDT Type | Use Case | Merge Strategy |
|---|---|---|
| PN-Counter | Inventory levels (+/-) | Sum increments, sum decrements |
| LWW-Register | Price updates, last modified | Highest timestamp wins |
| OR-Set | Cart items, applied discounts | Union with tombstones |
| G-Counter | Transaction counts, sales counts | Sum all increments |
Trade-offs
Pros:
- Sales never conflict — append-only events with unique IDs
- Inventory converges automatically — PN-Counter CRDTs are mathematically guaranteed to converge
- Priority-based sync — critical financial data syncs before convenience data
- Parked sales support — up to 5 parked sales per terminal with 4-hour TTL
- Queue limit (100 transactions) prevents unbounded offline operation
Cons:
- CRDT implementation adds complexity to the sync layer
- PN-Counters can temporarily show incorrect inventory (converges after sync)
- Tombstone management for OR-Sets requires periodic compaction (7-day TTL)
- Some operations blocked offline (customer create, gift card activation) to prevent inconsistencies
References
- Chapter 04: Architecture Styles, Section L.10A.1 (Online-First with Offline Fallback)
- ADR-002: Offline-First POS Architecture (superseded)
- ADR-003: Event Sourcing for Sales Domain
- ADR-048: Online-First POS Data Strategy (supersedes this ADR)
ADR-016: Error Code Structure — ERR-Mxxx Hierarchical
2.16 ADR-016: Error Code Structure
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The platform needs a structured error code system for consistent error handling across 7 modules. |
Context
The POS platform has 7 BRD modules, each generating different types of errors. Without a structured error code system, error handling degrades to HTTP status codes and free-form messages, making it difficult for POS Client developers, integration partners, and support teams to programmatically handle specific error conditions.
BRD v20.0 already defines module-specific error codes (ERR-5xxx for Module 5, ERR-6xxx for Module 6). This ADR formalizes the structure across all modules.
Decision
We will use a hierarchical ERR-Mxxx error code structure where M identifies the module (1-6) and xxx identifies the specific error within that module.
Considered Options
- HTTP-only — Rely solely on HTTP status codes (400, 404, 409, 500)
- Free-form strings — Arbitrary error codes like “SALE_NOT_FOUND”, “INVENTORY_INSUFFICIENT”
- Exception-based — Let exception types define error categories
- ERR-Mxxx hierarchical — Structured numeric codes with module prefix
Decision Outcome
Chosen: ERR-Mxxx hierarchical because it provides predictable, documented, machine-parseable error codes that map directly to BRD module boundaries. POS Client developers can switch on error code ranges, and support teams can triage by module.
Error Code Ranges:
| Range | Module | Examples |
|---|---|---|
| ERR-1xxx | Module 1: Sales | ERR-1001 (sale not found), ERR-1010 (void window expired) |
| ERR-2xxx | Module 2: Inventory | ERR-2001 (insufficient stock), ERR-2010 (transfer rejected) |
| ERR-3xxx | Module 3: Customers | ERR-3001 (duplicate email), ERR-3010 (loyalty balance insufficient) |
| ERR-4xxx | Module 4: Reporting | ERR-4001 (date range too large), ERR-4010 (export limit exceeded) |
| ERR-5xxx | Module 5: Admin/Setup | ERR-5071 (register IP change limit), ERR-5072 (register retire requires OWNER) |
| ERR-6xxx | Module 6: Integrations | ERR-6001 (provider auth failed), ERR-6010 (circuit breaker open) |
Trade-offs
Pros:
- Predictable structure — POS Client can switch on error range (1xxx = sales, 2xxx = inventory)
- Machine-parseable — error codes are numeric, not free-form strings
- Aligned with BRD module boundaries — easy to trace errors to requirements
- Supports i18n — error codes mapped to localized messages on the client
- Documented in API reference (Appendix A — planned future rewrite) — developers know all possible errors per endpoint
Cons:
- Requires maintaining error code registry (mitigated by code generation from registry file)
- Must avoid error code conflicts as modules grow (mitigated by 1000-code range per module)
- Error codes are less self-descriptive than string codes (mitigated by including
messagefield in error response)
References
- Ch 05: Architecture Components (BRD v20.0 Sections 5.x and 6.x — error code definitions) (API Design chapter — planned future rewrite)
ADR-017: Test Strategy — Layered Testing Pyramid
2.17 ADR-017: Test Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, QA Team |
| Context | The platform needs a testing strategy that balances coverage, speed, and confidence for a multi-tenant POS system. |
Context
The POS platform processes financial transactions, manages inventory across multiple locations, and integrates with 6 external provider families. Testing must verify correctness at multiple levels: domain logic (tax calculation, commission reversal), API contracts (multi-tenant isolation, error codes), integration behavior (Shopify webhooks, payment terminals), and end-to-end workflows (offline sale → sync → inventory update).
BRD v18.0 defines 36 user stories with Gherkin acceptance criteria. Three platform sandboxes (Shopify Dev Store, Amazon SP-API Sandbox, Google Merchant test account) must be exercised in CI/CD.
Decision
We will use a Layered Testing Pyramid with specific tool choices per layer.
Considered Options
- Flat testing — Equal effort at all levels, no pyramid structure
- E2E-heavy — Focus on end-to-end tests with minimal unit tests
- Property-based — Use property-based testing (QuickCheck/FsCheck) as primary strategy
- Layered Testing Pyramid — Traditional pyramid: many unit, fewer integration, fewest E2E
Decision Outcome
Chosen: Layered Testing Pyramid because it provides fast feedback at the bottom (unit tests in < 5 seconds), confidence in the middle (integration tests with real PostgreSQL via Testcontainers-node), and end-to-end validation at the top (Playwright for browser automation). This matches the team’s TypeScript expertise and CI/CD pipeline constraints.
Testing Pyramid:
| Layer | Tool | Coverage Target | Speed | Scope |
|---|---|---|---|---|
| Unit | Vitest | 80% | < 5 sec | Domain logic, validators, calculators |
| Integration | Testcontainers-node + Vitest | 15% | < 2 min | API endpoints, DB queries, Redis, RLS |
| E2E | Playwright | 5% | < 10 min | Full workflows: login → sale → receipt |
| Load | k6 | N/A | 30 min | Black Friday simulation: 500 concurrent, 1000 TPS |
| Contract | Pact | N/A | < 1 min | Shopify/Amazon/Google sandbox API contracts |
| Security | 6-Gate Pyramid | N/A | < 5 min | SAST, SCA, Secrets, ArchUnit, Pact, Manual |
Trade-offs
Pros:
- Fast feedback — Vitest unit tests run in seconds with native TypeScript support, catching regressions immediately
- Real database testing — Testcontainers-node spins up PostgreSQL 16 with RLS for integration tests
- Multi-tenant isolation verified — integration tests confirm tenant_id RLS policies prevent cross-tenant access
- Contract testing with external platforms — Pact verifies Shopify/Amazon/Google API contracts
- Load testing prevents performance regressions — k6 validates NFR-PERF-001 (< 500ms p99 checkout)
Cons:
- Testcontainers-node requires Docker in CI/CD (standard in modern CI)
- Playwright E2E tests are slower and more brittle (mitigated by limiting to critical paths only)
- Load testing requires dedicated environment (not run on every commit, only on release candidates)
- Contract tests depend on external sandbox availability (mitigated by recorded responses as fallback)
References
- Chapter 04: Architecture Styles, Section L.6 (QA & Testing)
- Chapter 04: Architecture Styles, Section L.8 (6-Gate Security Pyramid)
- (Dev Environment and Checklists chapters — planned future rewrite)
ADR-018: Affirm BNPL Integration
2.18 ADR-018: Affirm BNPL Integration
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The platform needs a Buy Now Pay Later (BNPL) option for high-value retail transactions at the point of sale. |
Context
Retail clothing transactions can reach $200-$500+, creating friction for customers who prefer installment payments. The POS must offer a third-party financing option that does not add PCI scope, integrates with the existing checkout flow, and pays the merchant in full immediately while the customer repays the financing provider directly.
BRD v20.0 Section 1.3 defines Third-Party Financing as a payment method alongside cash, card, gift card, on-account, and layaway. The financing flow must support both in-store QR code presentation and customer-device redirect.
Decision
We will integrate Affirm as the BNPL provider for in-store financing.
Considered Options
- Affirm — Established BNPL provider with in-store POS SDK, QR code flow, and merchant dashboard
- Klarna — Popular BNPL with strong e-commerce presence but limited in-store POS integration
- Afterpay/Clearpay — Fixed 4-installment model, limited flexibility for higher-value purchases
- In-house installment plans — Build custom financing directly in the POS system
Decision Outcome
Chosen: Affirm because it provides a well-documented in-store API, supports variable loan terms (3-36 months), pays the merchant full amount immediately (the store receives 100% of the sale amount from Affirm), and the customer completes the entire application on their own device. No card data or financial data touches the POS system — only a charge_id, loan_id, and approval status are stored.
Trade-offs
Pros:
- Full payment received from Affirm immediately — no credit risk for the merchant
- No PCI scope increase — customer’s financial data handled entirely by Affirm
- QR code flow integrates cleanly into existing POS checkout sequence
- Affirm handles all underwriting, collections, and customer communication
- Established retail brand (Peloton, Shopify, Walmart) provides customer trust
Cons:
- Affirm charges merchant fees (typically 3-6% per transaction) reducing margin
- Approval is not guaranteed — customer may be declined, requiring fallback to another payment method
- Adds dependency on Affirm API availability during checkout (mitigated by circuit breaker)
- Limited to Affirm-supported markets (US primarily)
References
- Chapter 05: Architecture Components, Section 1.3 (Financial Settlement)
- Ch 05: Architecture Components, Module 6 (Integrations) (Integration Patterns chapter — planned future rewrite)
- ADR-019: SAQ-A Semi-Integrated Payment Scope
ADR-019: SAQ-A Semi-Integrated Payment Scope
2.19 ADR-019: SAQ-A Semi-Integrated Payment Scope
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Security Team |
| Context | Card payment processing requires PCI-DSS compliance; the scope of compliance depends on how card data is handled. |
Context
POS terminals must accept chip, tap, and swipe card payments. PCI-DSS compliance levels range from SAQ-A (~30 controls, card data never touches our system) to SAQ-D (300+ controls, full card data flow through our system). The choice fundamentally shapes the security architecture, development effort, and ongoing compliance cost.
BRD v20.0 Section 1.18 mandates that “Card data NEVER touches your system” and specifies a semi-integrated terminal architecture where the payment terminal communicates directly with the payment processor. The POS backend receives only tokens, approval codes, and masked card numbers (last 4 digits).
Decision
We will implement SAQ-A semi-integrated payment terminals where card data is handled entirely by the terminal hardware and payment processor SDK. The POS system stores only: transaction_id, payment_token, approval_code, masked_card_number (****1234), card_brand, entry_method, terminal_id, timestamp, and amount.
Considered Options
- SAQ-D Full Integration — Card data encrypted and tokenized through our system (300+ PCI controls)
- SAQ-A Semi-Integrated — Card data handled by terminal/processor, we receive tokens only (~30 controls)
- SAQ-A-EP (E-commerce) — Redirect to hosted payment page (not applicable for in-store POS)
Decision Outcome
Chosen: SAQ-A Semi-Integrated because it reduces PCI compliance scope by 90% (from 300+ to ~30 controls), eliminates the risk of card data breach from our systems, and supports token-based void/refund operations that work offline. Stripe Terminal and Square Terminal are supported as interchangeable providers via the IIntegrationProvider abstraction (Ch 05 Section 6.2.1).
Trade-offs
Pros:
- 90% reduction in PCI compliance scope and audit effort
- Zero card data in our system — breach of our database exposes no payment card information
- Token-based refund/void works offline using stored payment tokens
- Terminal firmware and EMV kernel managed by provider — no maintenance burden
- Multi-provider support via provider abstraction prevents vendor lock-in
Cons:
- Dependent on terminal hardware availability and SDK compatibility
- Terminal communication adds 1-3 seconds latency for chip transactions
- Limited control over payment UX (terminal screen controlled by provider)
- Must maintain two provider SDKs (Stripe Terminal, Square Terminal)
References
- Chapter 05: Architecture Components, Section 1.18 (Payment Integration)
- Ch 04: Architecture Styles, Section L.8 (Security) (Security chapters — planned future rewrite)
- ADR-011: Payment Gateway (SAQ-A Semi-Integrated)
ADR-020: Split Tender Payment Support
2.20 ADR-020: Split Tender Payment Support
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Retail customers frequently need to pay with multiple payment methods in a single transaction. |
Context
Retail transactions commonly involve multiple payment methods: cash + card, multiple credit cards, gift card + card, on-account + cash, or Affirm for the remaining balance. BRD v20.0 Section 1.3 defines a tender loop where the cashier selects payment methods iteratively until the remaining balance reaches zero. Each tender is tracked independently for refund routing — a refund must be returned to the original payment method.
Decision
We will support unlimited split tender combinations where any payment method can be combined with any other. Each tender in a transaction is stored as a separate payment record with its own token/reference, enabling per-tender refund routing.
Considered Options
- Single tender only — One payment method per transaction (simplest but poor UX)
- Two-tender maximum — Allow at most two payment methods (limits flexibility)
- Unlimited split tender — Any number of payment methods per transaction
Decision Outcome
Chosen: Unlimited split tender because retail customers expect payment flexibility, and gift card partial balances naturally require a second tender for the remainder. Each payment record stores its own token (for card), reference (for Affirm), or cash amount, enabling precise refund routing back to the original payment source.
Trade-offs
Pros:
- Maximum payment flexibility — matches customer expectations in retail
- Gift card partial balance + card is a common scenario handled naturally
- Per-tender refund routing — each payment token tracked independently
- Supports combining all 6 payment types: cash, card, gift card, on-account, layaway deposit, Affirm
Cons:
- Refund logic complexity — must track which tender to refund to and in what order
- Multiple card tenders mean multiple terminal interactions during checkout
- Receipt layout must accommodate variable number of payment lines
- Reconciliation reports must aggregate across tender types
References
- Chapter 05: Architecture Components, Section 1.3 (Financial Settlement)
- Ch 05: Architecture Components, Section 3.8 (Payment Processing) (API Design chapter — planned future rewrite)
ADR-021: Layaway Payment Plans
2.21 ADR-021: Layaway Payment Plans
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Some customers need to pay for high-value items over time with a deposit and installments, with inventory reserved until paid in full. |
Context
Layaway is a traditional retail financing model where the customer pays a minimum deposit, inventory is reserved (not released), and the customer makes additional payments over time until the full amount is paid. BRD v20.0 Section 1.3 defines a layaway state machine: DEPOSIT_PAID -> RESERVED -> PAID_IN_FULL -> COMPLETED, with CANCELLED and FORFEITED as terminal states. The credit limit calculation must include pending layaway balances.
Decision
We will implement native layaway with configurable minimum deposit percentage, reservation-based inventory hold, and a state machine governing the layaway lifecycle. Layaway balances are included in the credit limit calculation: Available Credit = Credit Limit - (Current Debt + Pending Layaway Balances + Current Cart Total).
Considered Options
- No layaway — Direct customers to Affirm BNPL instead
- Basic layaway — Deposit + single final payment, no partial installments
- Full layaway with installments — Deposit + multiple partial payments with deadline tracking
Decision Outcome
Chosen: Full layaway with installments because it is a standard expectation in brick-and-mortar retail, allows flexible payment schedules, and reserves inventory to guarantee availability. Unlike Affirm, layaway involves no third-party fees — the store manages the payment plan directly.
Trade-offs
Pros:
- No third-party fees — merchant keeps full margin
- Inventory reserved for customer until paid in full
- Configurable minimum deposit percentage per tenant
- Overdue tracking with forfeiture rules protects against abandoned layaways
- Familiar model for retail staff and customers
Cons:
- Inventory is tied up during the layaway period (not available for other sales)
- Risk of forfeiture — must handle cancellation refund policies (configurable)
- Adds complexity to credit limit calculations
- Reporting must track outstanding layaway liability
References
- Chapter 05: Architecture Components, Section 1.3 (Layaway State Machine)
- Chapter 05: Architecture Components, Module 7 (State Machine Reference)
- ADR-020: Split Tender Payment Support
ADR-022: Tax-Inclusive Display with Compound Calculation
2.22 ADR-022: Tax-Inclusive Display with Compound Calculation
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The POS must calculate and display tax correctly for US retail, where tax is calculated externally (not embedded in the price). |
Context
US retail uses tax-exclusive pricing — product prices on the shelf do not include tax, and tax is calculated at checkout based on the store’s jurisdiction. BRD v20.0 Section 1.17 defines a tax hierarchy where product-level exemptions have highest priority, followed by customer-level exemptions, followed by the store’s location-based compound jurisdiction rate. Tax is computed per line item and displayed as a separate total on the receipt.
Section 5.9 defines the compound tax model: State + County + City rates summed at time of sale. Example: Norfolk, VA = State 4.3% + Regional 0.7% + City 1.0% = 6.0% compound rate.
Decision
We will use tax-exclusive pricing with compound tax calculation at checkout. Product prices are stored without tax. At checkout, all active rates for the store’s tax jurisdiction are summed and applied to each taxable line item. The receipt displays subtotal, tax breakdown (optionally by level), and total.
Considered Options
- Tax-inclusive pricing — Embed tax in the product price (common in EU/UK, not US)
- Tax-exclusive with flat rate — Single tax rate per location
- Tax-exclusive with compound rate — Multi-level (State/County/City) summed at checkout
- External tax service — Delegate to TaxJar/Avalara API for real-time calculation
Decision Outcome
Chosen: Tax-exclusive with compound rate because it matches US retail practice, supports the 3-level Virginia tax structure (the reference implementation), and enables future expansion to other states with complex district overlays (California) or no sales tax (Oregon). The tax engine is built internally rather than delegated to external services to ensure offline capability.
Trade-offs
Pros:
- Matches US retail standard — prices on shelf exclude tax
- 3-level compound model handles all US jurisdictions (State + County + City + special districts)
- Offline-capable — tax rates cached locally on POS terminal, no API call needed
- Product-level and customer-level exemptions supported (reseller, non-profit, diplomatic)
- Future-proof for multi-state expansion (California district taxes, Oregon no-tax)
Cons:
- More complex than flat-rate tax — must manage jurisdiction-to-location mapping
- Tax rate changes require admin updates (mitigated by scheduled effective dates)
- Multi-jurisdiction reporting adds complexity to tax liability reports
- Not suitable for EU/UK VAT without redesign (acceptable — target market is US)
References
- Chapter 05: Architecture Components, Section 1.17 (Tax Calculation Engine)
- Chapter 05: Architecture Components, Section 5.9 (Tax Configuration)
- Chapter 07: Schema Design (tax_jurisdictions, tax_rates tables)
ADR-023: Compound Tax (3-Level State/County/City)
2.23 ADR-023: Compound Tax (3-Level State/County/City)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The tax data model must support compound (additive) tax rates at multiple jurisdictional levels. |
Context
US sales tax varies by jurisdiction and can consist of multiple additive layers: state tax, county tax, city tax, and sometimes special district surcharges. BRD v20.0 Section 5.9 defines a tax_jurisdictions table (jurisdiction code, name, state) and a tax_rates table with a level enum (STATE, COUNTY, CITY). Each location references a jurisdiction, and at time of sale all active rates for that jurisdiction are summed.
Example: Norfolk, VA = State 4.300% + Regional 0.700% + City 1.000% = 6.000% compound. Northern Virginia adds an additional 0.7% regional rate. Rate changes can be scheduled via effective_date with automatic activation.
Decision
We will implement a 3-level compound tax model using tax_jurisdictions and tax_rates tables. Each jurisdiction can have up to 3 active rate levels (STATE, COUNTY, CITY). Rates are summed at time of sale. Future rates are scheduled via effective_date with background activation.
Considered Options
- Single flat rate per location — One rate column on the location table
- 2-level (State + Local) — State rate plus a single combined local rate
- 3-level compound (State/County/City) — Separate rate rows per level, summed at checkout
- N-level with district overlay — Unlimited levels including special taxing districts
Decision Outcome
Chosen: 3-level compound because it covers the vast majority of US jurisdictions without the complexity of unlimited district overlays. The Virginia reference implementation (4 stores across different regions) validates this model. Special districts (California Proposition) can be modeled as a CITY-level rate until N-level support is needed. Unique constraint on (jurisdiction_id, level, effective_date) prevents duplicate rates.
Trade-offs
Pros:
- Covers all current US jurisdictions (State + County + City covers 95%+ of cases)
- Scheduled rate changes via
effective_date— no manual intervention on tax change dates - Preserves historical rates for audit — rate changes never modify existing records
- Simple SUM query at checkout:
SELECT SUM(rate_percent) FROM tax_rates WHERE jurisdiction_id = ? AND is_active = true
Cons:
- Cannot model California special district overlays (4th+ level) without schema extension
- Requires admin to configure jurisdiction-to-location mapping per tenant
- Rate scheduling background job must run reliably at midnight
References
- Chapter 05: Architecture Components, Section 5.9 (Tax Configuration)
- Chapter 07: Schema Design (Domain 15: Tax)
- ADR-022: Tax-Inclusive Display with Compound Calculation
ADR-024: Gift Card Compliance (State Escheatment)
2.24 ADR-024: Gift Card Compliance (State Escheatment)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Legal |
| Context | Gift card management must comply with varying state-level escheatment and consumer protection laws. |
Context
Gift cards are subject to state-specific regulations governing expiration, inactivity fees, and mandatory cash-out thresholds. BRD v20.0 Section 1.5 defines a jurisdiction compliance matrix: Virginia allows 5-year minimum expiry and inactivity fees after 12 months; California prohibits expiry, prohibits fees, and mandates cash-out at $10.00; New York prohibits both expiry and fees. The gift card state machine includes INACTIVE, ACTIVE, DEPLETED, EXPIRED, and CASHED_OUT states.
The system must default to the most restrictive rules (California-style: no expiry, no fees, cash-out required) and enable features only where jurisdiction permits.
Decision
We will implement jurisdiction-aware gift card rules that default to the most restrictive configuration (California-style) and enable expiry, fees, and cash-out thresholds per store location’s jurisdiction. The store’s physical location determines which rules apply.
Considered Options
- Uniform national policy — Apply the most restrictive state’s rules everywhere (simple but limits flexibility)
- Per-jurisdiction rules — Configure rules per state/jurisdiction with most-restrictive defaults
- External compliance service — Delegate gift card compliance to a third-party service
Decision Outcome
Chosen: Per-jurisdiction rules with most-restrictive defaults because multi-state retail operations need location-specific compliance. Defaulting to California-style (no expiry, no fees, mandatory cash-out) ensures legal compliance even if jurisdiction configuration is incomplete. Stores in permissive jurisdictions can enable expiry and fees explicitly.
Trade-offs
Pros:
- Legal compliance across all US jurisdictions from day one
- Safe defaults — unconfigured jurisdictions use most restrictive rules
- Cash-out workflow at POS for California compliance (balance <= $10.00)
- Gift card liability reporting for accounting (outstanding balances = liability)
- State machine enforces valid transitions (no invalid state changes)
Cons:
- Jurisdiction rules must be maintained as laws change
- Cash-out workflow adds complexity to POS checkout flow
- Escheatment reporting (unclaimed property) required in some states after dormancy period
- Gift card liability grows over time — reporting must track aging and dormant cards
References
- Chapter 05: Architecture Components, Section 1.5 (Gift Card Management)
- Chapter 05: Architecture Components, Section 1.5.2 (Jurisdiction Compliance Matrix)
- Chapter 05: Architecture Components, Module 7 (Gift Card State Machine)
ADR-025: 6-Status Inventory State Machine
2.25 ADR-025: 6-Status Inventory State Machine
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Inventory at each location needs status tracking beyond simple quantity to manage quality holds, transit, reservations, and damage. |
Context
Retail inventory is not simply “in stock” or “out of stock.” BRD v20.0 Section 4.2 defines six inventory statuses with a strict state machine governing transitions: AVAILABLE (sellable), QUARANTINE (quality hold), DAMAGED (cannot sell), PENDING_INSPECTION (received, needs review), RESERVED (allocated to order/transfer), and IN_TRANSIT (moving between locations). Only AVAILABLE stock can be sold at POS or transferred. All status changes require reason codes and are logged to the movement history audit trail.
Decision
We will implement a 6-status inventory state machine where each product-variant-location combination tracks quantity per status. Only AVAILABLE status is sellable. Transitions follow a strict state machine validated at the application layer against a state_transitions reference table.
Considered Options
- Binary (in-stock / out-of-stock) — Simple quantity tracking
- 3-status (Available / Reserved / Damaged) — Minimal status tracking
- 6-status state machine — Full lifecycle with quality management and transit tracking
- Continuous status field — Free-form status string (no transition enforcement)
Decision Outcome
Chosen: 6-status state machine because retail clothing operations require quality holds (QUARANTINE for items with potential defects), receiving inspection (PENDING_INSPECTION for new deliveries), reservation management (RESERVED for carts, transfers, online orders), and transit tracking (IN_TRANSIT between locations). Invalid transitions (e.g., QUARANTINE directly to RESERVED) are rejected by the API.
Trade-offs
Pros:
- Only AVAILABLE stock appears as sellable — prevents selling damaged or quarantined items
- RESERVED status prevents overselling in multi-terminal, multi-channel environments
- IN_TRANSIT gives visibility into inventory movement between locations
- Reason codes on every transition create a complete audit trail
- State machine prevents invalid transitions (enforced at API and DB level)
Cons:
- More complex than simple quantity tracking — 6 quantities per product-location instead of 1
- Staff must understand status meanings and transition rules
- Reporting must aggregate or filter by status
- State machine logic adds validation overhead to every inventory operation
References
- Chapter 05: Architecture Components, Section 4.2 (Inventory Status Model)
- Chapter 05: Architecture Components, Module 7 (State Machine Reference)
- Chapter 08: Entity Specifications
ADR-026: Reservation-Based Inventory Hold Model
2.26 ADR-026: Reservation-Based Inventory Hold Model
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Multiple terminals, parked transactions, online orders, and transfers all compete for the same inventory. A mechanism is needed to prevent overselling. |
Context
BRD v20.0 Section 4.2.2 defines five reservation types: Sale Cart (hard reserve until payment or void), Parked Transaction (soft reserve with 4-hour TTL, overridable with warning), Transfer (hard reserve at source until shipped), Online Order (hard reserve at assigned store), and Hold-for-Pickup (hard reserve with configurable expiry, default 48 hours). When two terminals attempt to reserve the last unit simultaneously, first-commit-wins via database-level row locking.
Decision
We will implement a reservation-based inventory hold model with 5 reservation types, each with its own lifecycle and TTL. Reservations atomically move quantity from AVAILABLE to RESERVED. Concurrent conflicts are resolved by database row locking (first-commit-wins).
Considered Options
- No reservation (optimistic) — Check quantity at payment time only, accept oversell risk
- Soft reservation with warnings — Show warnings but allow selling through reserved stock
- Hard reservation with TTL — Atomic reserve on add-to-cart, auto-release on expiry
- Mixed hard/soft by type — Hard for carts and online orders, soft for parked transactions
Decision Outcome
Chosen: Mixed hard/soft by type because sale carts, online orders, and transfers need hard reserves to prevent overselling, while parked transactions benefit from soft reserves (other terminals can sell through with a warning, since parked sales may never be completed). Auto-release via background job (every 5 minutes) prevents inventory from being permanently locked by abandoned sessions.
Trade-offs
Pros:
- Prevents overselling across multi-terminal, multi-channel environments
- Parked transaction soft reserve allows override when stock is genuinely needed
- Auto-release on expiry prevents permanent inventory lockup
- 5 reservation types cover all business scenarios (sale, park, transfer, online, hold)
- Database row locking guarantees first-commit-wins under concurrent access
Cons:
- Reservation management adds overhead to every cart operation (add/remove/void)
- Background expiry job must run reliably (5-minute interval)
- Soft reserve override can lead to parked transactions that can’t be recalled (reconciled at recall time)
- Reservation table grows with transaction volume (mitigated by archival of COMMITTED/RELEASED records)
References
- Chapter 05: Architecture Components, Section 4.2.2 (Reservation Model)
- ADR-025: 6-Status Inventory State Machine
- ADR-002: Offline-First POS Architecture
ADR-027: RFID Counting-Only Scope (No Lifecycle)
2.27 ADR-027: RFID Counting-Only Scope (No Lifecycle)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | RFID integration scope must be defined — either counting-only or full lifecycle tracking (sales, transfers, receiving). |
Context
BRD v20.0 Section 5.16 explicitly scopes RFID as a “dedicated inventory counting subsystem.” RFID readers (Zebra MC3390R, RFD40, FX9600) are used for bulk inventory counting and auditing via the Raptag mobile app. Barcode scanners remain the input device for sales transactions, receiving, and transfers. The rfid_tags table tracks tag status as active, void, or lost — there are no sold_at, transferred_at, or sold_order_id fields.
This separation means RFID and barcode scanning are independent abstractions that coexist: Scanner = barcode (POS register, one-item-at-a-time via USB HID); RFID = counting (Raptag app, 40+ tags/second via radio frequency).
Decision
We will scope RFID to counting and auditing only. RFID does not participate in sales, receiving, or transfer workflows. The core inventory system tracks stock movements via barcode. RFID provides a parallel counting channel for physical inventory verification.
Considered Options
- Full RFID lifecycle — Track every tag through sale, transfer, receiving, and returns
- Counting-only — RFID for inventory counting and auditing, barcode for all other workflows
- Hybrid (phased) — Start with counting, extend to receiving in v2.0
Decision Outcome
Chosen: Counting-only because full lifecycle RFID tracking would require replacing the barcode-based POS checkout flow with RFID readers at every register, fundamentally changing the hardware requirements and staff workflows. Counting-only provides the highest ROI (bulk counts in minutes vs. hours) with minimal disruption to existing barcode-based workflows. Tag status is limited to active, void, lost — no sales or transfer lifecycle fields.
Trade-offs
Pros:
- Highest ROI — bulk inventory counts (2,000-100,000 items) completed in minutes vs. hours
- No disruption to existing barcode-based POS, receiving, and transfer workflows
- Simpler RFID schema — 12 tables vs. potentially 20+ for full lifecycle
- Raptag mobile app focused on single purpose (counting) with clear UX
- Scope can be expanded to receiving in v2.0 if business case emerges
Cons:
- Cannot automatically decrement RFID tag counts on sale (counting snapshot may drift)
- Receiving workflow still requires barcode scanning (no RFID speed benefit)
- Two parallel inventory tracking systems (barcode quantity vs. RFID tag count) — reconciliation needed
- Cannot provide real-time tag location or anti-theft alerts
References
- Chapter 05: Architecture Components, Section 5.16 (RFID Configuration)
- Ch 05: Architecture Components, Section 5.16 (RFID Counting) (Raptag Mobile chapter — planned future rewrite)
- ADR-013: RFID Configuration in Tenant Admin
ADR-028: Physical Count Freeze Period
2.28 ADR-028: Physical Count Freeze Period
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | During physical inventory counts, sales and transfers can change stock levels, causing reconciliation errors. |
Context
BRD v20.0 Section 4.6.4 defines two counting modes: FREEZE mode (POS sales blocked at counting location, transfers queued) and SNAPSHOT mode (operations continue normally, system reconciles movements post-count). FREEZE mode provides highest accuracy for annual audits. SNAPSHOT mode enables counting during business hours without blocking sales. The mode is chosen per count by the manager and cannot be changed after the count starts.
Decision
We will support configurable count freeze with two modes (FREEZE and SNAPSHOT), selected per count session. FREEZE blocks POS sales at the counting location and queues inbound transfers. SNAPSHOT takes a point-in-time inventory snapshot and reconciles against post-count movements.
Considered Options
- Always freeze — Block sales during every count (accurate but high business impact)
- Never freeze (snapshot only) — Always count during business hours (lower accuracy)
- Configurable per count — Manager chooses FREEZE or SNAPSHOT per count session
Decision Outcome
Chosen: Configurable per count because different counting scenarios have different accuracy requirements. Annual full physical counts benefit from FREEZE mode (after hours, maximum accuracy). Weekly cycle counts and monthly scans use SNAPSHOT mode (during hours, minimal disruption). The system defaults to SNAPSHOT; FREEZE must be explicitly selected by MANAGER/OWNER role.
Trade-offs
Pros:
- Maximum flexibility — manager picks the right mode for each situation
- FREEZE mode: perfect accuracy, no reconciliation needed
- SNAPSHOT mode: zero business disruption, counts during peak hours
- SNAPSHOT reconciliation formula:
adjusted_expected = snapshot_qty - sales_during_count + receives_during_count - Only MANAGER/OWNER can initiate counts (access-controlled)
Cons:
- FREEZE mode blocks revenue during the count window (mitigated by off-hours scheduling)
- SNAPSHOT reconciliation is more complex and has slightly lower accuracy
- Staff must understand the difference between modes
- FREEZE mode queues transfers that must be processed after count approval
References
- Chapter 05: Architecture Components, Section 4.6.4 (Configurable Count Freeze)
- Chapter 05: Architecture Components, Section 4.6 (Inventory Counting & Auditing)
- ADR-025: 6-Status Inventory State Machine
ADR-029: Adjustment Manager Approval (Universal)
2.29 ADR-029: Adjustment Manager Approval (Universal)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Manual inventory adjustments directly affect stock levels and financial records. A control mechanism is needed. |
Context
BRD v20.0 Section 4.7 mandates that all inventory adjustments require manager approval — positive (found stock), negative (shrinkage), and zero-net (reclassification). There is no auto-approval threshold. Adjustments are created with approval_status = PENDING and inventory is NOT changed until a MANAGER or OWNER explicitly approves. Rejected adjustments are preserved for audit. The cost impact (qty_change x weighted_avg_cost) is calculated and shown to the manager before approval.
Decision
We will require universal manager approval for all manual inventory adjustments, regardless of quantity or direction. No threshold-based auto-approval. Inventory quantities change only upon explicit manager approval.
Considered Options
- No approval — Staff adjustments apply immediately (fast but no oversight)
- Threshold-based — Small adjustments auto-approve, large adjustments require manager
- Universal approval — All adjustments require manager review before inventory changes
Decision Outcome
Chosen: Universal approval because inventory accuracy is critical for a multi-store retail operation with financial audit requirements. Even small adjustments can indicate systematic issues (repeated theft, receiving errors). The cost impact display enables managers to make informed decisions. Approved adjustments are logged as ADJUSTMENT_UP or ADJUSTMENT_DOWN movements in the audit trail.
Trade-offs
Pros:
- Complete management oversight of all inventory changes
- Cost impact shown before approval — managers see financial consequence
- PENDING status prevents premature inventory changes
- Rejected adjustments preserved for audit — pattern analysis possible
- Standard reason codes + custom tenant-defined codes for categorization
Cons:
- Manager bottleneck — adjustments may wait for approval (mitigated by push notifications)
- Additional workflow steps compared to instant adjustments
- Managers must be responsive to avoid approval backlog
- No fast-track for trivially small adjustments (by design)
References
- Chapter 05: Architecture Components, Section 4.7 (Inventory Adjustments)
- ADR-025: 6-Status Inventory State Machine
ADR-030: Auto-Suggest Transfers Algorithm
2.30 ADR-030: Auto-Suggest Transfers Algorithm
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Multi-store retail operations frequently have inventory imbalances — one store overstocked while another is understocked on the same product. |
Context
BRD v20.0 Section 4.8.7 defines an auto-suggest transfer algorithm that continuously monitors inventory distribution relative to sales velocity across all locations. The algorithm calculates days of supply per product per location (qty_on_hand / avg_daily_sales_velocity), detects imbalances (one location >60 days of supply, another <15 days), and generates transfer suggestions targeting 30 days of supply at each location. The algorithm runs weekly (configurable) and never creates transfers automatically — all suggestions require manager review.
Decision
We will implement a velocity-based auto-suggest transfer algorithm that analyzes days of supply across locations, generates rebalancing suggestions, and presents them to managers for review and approval. Suggestions that are approved create transfer requests via the standard transfer workflow.
Considered Options
- Manual only — Managers identify imbalances and create transfers manually
- Rule-based alerts — Alert when stock is below threshold, but no transfer suggestion
- Auto-suggest with manager review — Algorithm suggests specific transfers, manager approves
- Fully automated — Algorithm creates and ships transfers without human review
Decision Outcome
Chosen: Auto-suggest with manager review because it combines algorithmic efficiency (analyzing hundreds of product-location combinations weekly) with human judgment (manager knowledge of upcoming promotions, seasonal shifts, display requirements). The algorithm provides data-driven starting points; managers adjust quantities and approve or reject.
Trade-offs
Pros:
- Data-driven rebalancing across all locations — impossible to replicate manually at scale
- Manager review preserves business judgment (upcoming promotions, seasonal knowledge)
- Configurable thresholds: overstocked (>60 days), understocked (<15 days), target (30 days)
- Trailing 30-day sales velocity adapts to changing demand patterns
- Suggestions expire after 7 days if unreviewed — no stale recommendations
Cons:
- Algorithm may suggest transfers that conflict with upcoming promotions (mitigated by manager review)
- Dead stock (zero velocity at both locations) excluded — requires separate manual review
- HQ warehouse uses different thresholds than stores (90-day overstocked threshold)
- Weekly batch analysis may miss rapid demand changes (mitigated by on-demand trigger option)
References
- Chapter 05: Architecture Components, Section 4.8.7 (Auto-Suggest Transfers)
- Chapter 05: Architecture Components, Section 4.5 (Reorder Management)
ADR-031: Shopify Webhook + Polling Dual Sync
2.31 ADR-031: Shopify Webhook + Polling Dual Sync
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Shopify inventory and product sync must be near-real-time with guaranteed eventual consistency. |
Context
BRD v20.0 Section 6.3 defines Shopify as the primary e-commerce integration. Shopify webhooks provide near-real-time notifications (products/update, inventory_levels/update, orders/create) but can be missed due to network issues, Shopify outages, or endpoint failures. Shopify retries failed webhook deliveries for 48 hours, but delivery is not guaranteed. The platform must guarantee eventual consistency between POS and Shopify inventory.
This ADR formalizes the dual-sync strategy previously captured in ADR-010. The detailed implementation in Section 6.3 specifies OAuth 2.0/PKCE authentication, GraphQL Admin API at 50 points/second rate limiting, and mandatory @idempotent mutations (required April 2026).
Decision
We will use a Webhook + Polling hybrid for Shopify synchronization. Webhooks provide near-real-time sync (<5 seconds processing) for the common case. Scheduled polling (every 15 minutes) using updated_at_min delta queries catches any missed webhooks. Both paths use the same idempotent event handler pipeline (24-hour dedup window).
Considered Options
- Pure Webhook — Rely solely on Shopify webhooks for all sync
- Pure Polling — Poll Shopify API on intervals for all changes
- Webhook + Polling hybrid — Real-time webhooks with polling fallback
Decision Outcome
Chosen: Webhook + Polling hybrid because webhooks alone cannot guarantee delivery, and polling alone introduces unacceptable latency for inventory updates that could cause overselling. The hybrid approach provides <5-second normal latency with guaranteed eventual consistency via 15-minute polling catchup. Idempotent processing prevents double-counting when both webhook and poll detect the same change.
Trade-offs
Pros:
- Near-real-time sync via webhooks (<5 seconds for the common case)
- Guaranteed eventual consistency via polling fallback
- Idempotent processing handles duplicates from webhook + poll overlap
- Rate-limit-aware polling with adaptive backoff protects against API throttling
Cons:
- More complex than either pure approach
- Polling adds API calls against Shopify rate limits (mitigated by delta queries)
- Webhook endpoint requires HMAC signature verification and retry handling
- Must maintain webhook registration lifecycle (register on connect, deregister on disconnect)
References
- Chapter 05: Architecture Components, Section 6.3 (Shopify Integration)
- ADR-010: Shopify Sync Strategy (foundational decision)
- Ch 05: Architecture Components, Module 6 (Integrations) (Integration Patterns chapter — planned future rewrite)
ADR-032: Strictest-Rule-Wins Cross-Platform Validation
2.32 ADR-032: Strictest-Rule-Wins Cross-Platform Validation
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Products listed on Shopify, Amazon, and Google Merchant Center must meet each platform’s distinct validation requirements. |
Context
BRD v20.0 Section 6.6 defines a unified product data validation matrix comparing field-level requirements across Shopify, Amazon, and Google Merchant Center. Each platform has different constraints — Google limits titles to 150 chars, Amazon requires 1000x1000px minimum images, Shopify is most permissive. The common pattern of “create now, fix later” leads to suppressed listings, disapproved products, and lost revenue.
The strictest-rule-wins principle means: title max 150 chars (Google strictest), image min 1000x1000px (Amazon strictest), no watermarks (Amazon + Google), barcode required (treat as mandatory for channel eligibility), brand required (Amazon + Google).
Decision
We will enforce strictest-rule-wins validation at the point of product data entry in the POS system. Any product passing POS validation is immediately eligible for listing on all connected platforms without remediation. Products failing validation can still be used for in-store POS sales but are blocked from external channel sync.
Considered Options
- Per-platform validation at sync time — Validate only when pushing to each platform
- Strictest-rule-wins at data entry — Enforce most restrictive requirements for all platforms upfront
- Tiered validation — POS-only products have relaxed rules; channel-listed products have strict rules
Decision Outcome
Chosen: Strictest-rule-wins at data entry because it eliminates the expensive “fix after suppression” cycle. Products created correctly the first time avoid listing delays, disapprovals, and the operational cost of chasing validation errors across three platforms. The pre-sync validation engine (PASS/WARN/FAIL) provides clear feedback at product creation time.
Trade-offs
Pros:
- Any product passing POS validation is immediately listable on all channels
- Eliminates suppressed listings, disapprovals, and remediation cycles
- Single validation standard — staff learns one set of rules, not three
- Pre-sync engine provides actionable PASS/WARN/FAIL feedback with remediation guidance
- Image validation catches the #1 cause of listing suppression (watermarks, resolution, background)
Cons:
- POS-only products must meet stricter requirements than necessary (mitigated by channel-listing flag)
- Requirements may change as platforms update their rules (mitigated by configurable validation matrix)
- More fields required at product creation (brand, weight, barcode) — slightly longer data entry
- Google’s 150-char title limit is more restrictive than many retailers want
References
- Chapter 05: Architecture Components, Section 6.6 (Cross-Platform Product Data Requirements)
- Chapter 05: Architecture Components, Section 6.6.2 (Image Requirements Matrix)
ADR-033: Amazon SP-API Integration Strategy
2.33 ADR-033: Amazon SP-API Integration Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Multi-channel retail requires Amazon marketplace integration for product listings, order fulfillment, and inventory sync. |
Context
BRD v20.0 Section 6.4 defines Amazon integration via the Selling Partner API (SP-API) with OAuth 2.0/LWA authentication, regional endpoints (NA/EU/FE), and support for both FBA (Fulfilled by Amazon) and FBM (Fulfilled by Merchant) fulfillment models. The integration covers catalog items API, listings API, orders API, feeds API, and push notifications (SQS). Amazon SP-API polls every 2 minutes for inventory updates.
Key constraints: rate limits vary by API (5 requests/second for catalog, 1 request/second for feeds), access tokens expire every 1 hour, and per-marketplace pricing is required.
Decision
We will integrate with Amazon SP-API supporting both FBA and FBM fulfillment models, with OAuth 2.0/LWA token lifecycle management, per-marketplace catalog sync, and SQS-based push notifications for order and inventory events.
Considered Options
- No Amazon integration — POS-only retail without Amazon marketplace
- Amazon MWS (legacy) — Older Marketplace Web Service API (deprecated)
- Amazon SP-API — Current Selling Partner API with OAuth 2.0 and modern endpoints
- Third-party aggregator — Use a service like ChannelAdvisor to manage Amazon listing
Decision Outcome
Chosen: Direct Amazon SP-API integration because it provides full control over the integration, avoids third-party aggregator fees, and aligns with the provider abstraction architecture (IIntegrationProvider interface). MWS is deprecated. The POS backend handles token lifecycle transparently — proactive refresh at T-5 minutes before expiry, fallback force-refresh on 401 responses.
Trade-offs
Pros:
- Full control over catalog, listing, order, and inventory sync
- FBA + FBM support — tenants choose fulfillment model per product
- SQS push notifications reduce polling overhead for order/inventory events
- OAuth 2.0/LWA aligns with modern authentication standards
- Per-marketplace support (US, CA, MX under NA region)
Cons:
- Complex API with different rate limits per endpoint
- Token management complexity (1-hour expiry, proactive refresh)
- Amazon-specific field mappings (Browse Node taxonomy, product type definitions)
- Amazon Brand Registry requirements add complexity for branded products
- FBA inventory is read-only (Amazon manages stock) — requires separate monitoring
References
- Chapter 05: Architecture Components, Section 6.4 (Amazon SP-API Integration)
- Ch 05: Architecture Components, Module 6 (Integrations) (Integration Patterns chapter — planned future rewrite)
- ADR-032: Strictest-Rule-Wins Cross-Platform Validation
ADR-034: Google Merchant Center Feed Strategy
2.34 ADR-034: Google Merchant Center Feed Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Google Shopping and Local Inventory Ads require product data feeds managed via the Google Merchant API. |
Context
BRD v20.0 Section 6.5 defines Google Merchant Center integration for product data management, local inventory advertising, and Google Business Profile linkage. CRITICAL: The Content API for Shopping reaches end-of-life on August 18, 2026 — all new development MUST target the Merchant API (v1beta/v1). Google uses OAuth 2.0 with service accounts (self-signed JWTs exchanged for 60-minute access tokens).
The Merchant API separates writes (ProductInput resource) from reads (Product resource — Google-enriched version after validation). Disapproval prevention is critical as Google can suspend product listings for policy violations.
Decision
We will target the Merchant API (v1beta/v1) from day one, with OAuth 2.0 service account authentication, outbound product feed management, and local inventory advertising. No development against the deprecated Content API.
Considered Options
- Content API (v2.1) — Current API but reaching EOL August 2026
- Merchant API (v1beta/v1) — New API, long-term supported
- Supplemental feed only — Use Google’s automated crawl with supplemental data
- Third-party feed manager — Delegate to GoDataFeed, DataFeedWatch, etc.
Decision Outcome
Chosen: Direct Merchant API integration because Content API EOL is August 2026 (within the platform’s launch timeline), the Merchant API provides new features (local inventory, GBP integration) only available on the new API, and direct integration avoids third-party feed manager costs. Service account auth with tenant-specific encryption keys aligns with the credential vault architecture.
Trade-offs
Pros:
- Future-proof — no migration needed when Content API shuts down
- Local Inventory Ads support for brick-and-mortar stores
- Google Business Profile linkage for “available nearby” search results
- Product status API enables proactive disapproval monitoring and remediation
- Service account auth avoids user-interactive OAuth flows
Cons:
- Merchant API is still in v1beta — minor API changes possible before GA
- Google processing adds 30-minute latency to inventory updates
- Product disapproval rules are complex and change frequently
- Service account JSON key management adds security complexity (AES-256-GCM encrypted at rest)
- 2x daily batch sync cadence for Google (vs. near-real-time for Shopify)
References
- Chapter 05: Architecture Components, Section 6.5 (Google Merchant API Integration)
- ADR-032: Strictest-Rule-Wins Cross-Platform Validation
ADR-035: Channel Safety Buffer Calculation
2.35 ADR-035: Channel Safety Buffer Calculation
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | External channels sync inventory with varying latency (Shopify <5s, Amazon <2min, Google <30min). During sync gaps, concurrent sales can cause overselling. |
Context
BRD v20.0 Section 6.7.2 defines safety buffers that withhold a configurable number of units from external channel listings. The primary formula is: Channel Available Qty = POS Available Qty - Safety Buffer. Three buffer modes are supported: FIXED (subtract fixed units), PERCENTAGE (subtract % of stock), and MIN_RESERVE (floor-based). Buffers are configurable per-product, per-channel, with a 4-level priority resolution: product+channel > product > channel > tenant-wide default.
Recommended defaults: Shopify 0-2 units (low latency), Amazon FBM 5-10% (2-minute lag), Google Merchant 10-15% (30-minute processing delay).
Decision
We will implement configurable safety buffers per product per channel with 3 calculation modes and 4-level priority resolution. Higher-latency channels receive larger default buffers to compensate for sync lag.
Considered Options
- No buffers — List full POS quantity on all channels (highest oversell risk)
- Flat global buffer — Same buffer for all channels and products
- Per-channel default buffers — Different buffer per channel, same for all products
- Per-product per-channel configurable — Full flexibility with priority resolution
Decision Outcome
Chosen: Per-product per-channel configurable because sync latency varies dramatically between channels (Shopify <5s vs. Google <30min), and high-velocity products need different buffers than slow movers. The 4-level priority resolution enables tenants to set sensible defaults while overriding for specific products or channels. min_channel_qty threshold hides products from channel when available falls below minimum (default: 1).
Trade-offs
Pros:
- Tunable oversell protection per channel based on sync latency
- High-velocity products can have larger buffers than slow movers
max_channel_qtycap prevents revealing full warehouse stock to competitors- Priority resolution (product+channel > product > channel > tenant) minimizes configuration effort
- Walk-in customer stock protected — buffers ensure in-store availability
Cons:
- Configuration complexity — many possible combinations of product x channel x mode
- Buffers reduce listed quantity — may lose online sales if set too aggressively
- Buffer calculations add overhead to every inventory sync event
- Must recalculate buffers when POS quantity changes (event-driven)
References
- Chapter 05: Architecture Components, Section 6.7.2 (Safety Buffer Configuration)
- Chapter 05: Architecture Components, Section 6.7.3 (Oversell Prevention Rules)
- ADR-031: Shopify Webhook + Polling Dual Sync
ADR-036: POS-Master Default for External Channels
2.36 ADR-036: POS-Master Default for External Channels
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | When product data conflicts exist between POS and external channels, a source-of-truth must be defined. |
Context
BRD v20.0 Section 6.1 establishes that the POS system is the “single source of truth for product data.” All external channels (Shopify, Amazon, Google Merchant) receive product catalog and inventory levels from the POS system. No external channel can directly modify POS inventory — all inbound changes are processed through the sync engine with conflict resolution. Section 6.7 states: “Auto-correction pushes POS quantity to the platform (POS always wins in reconciliation).”
Decision
We will use POS-master default where the POS system is the authoritative source for product data and inventory levels. External channels receive computed quantities. During reconciliation, discrepancies between POS and channel-reported quantities are resolved by pushing the POS value to the channel.
Considered Options
- POS-master — POS is source of truth, channels receive data from POS
- Channel-master — Each channel is source of truth for its own data
- Bidirectional merge — Changes from any source merged via conflict resolution
- Last-write-wins — Most recent change from any source wins
Decision Outcome
Chosen: POS-master because the physical store is where inventory physically exists. The POS system tracks every stock movement (sale, return, adjustment, transfer, count, receiving) with a complete audit trail. External channels may report stale or incorrect quantities due to sync delays, customer cancellations, or platform glitches. POS-master ensures one source of truth for financial reporting and inventory accuracy.
Trade-offs
Pros:
- Single source of truth — no ambiguity about correct inventory levels
- Reconciliation is deterministic — POS always wins, no merge conflicts
- Financial reports based on POS data (auditable, event-sourced)
- Protects against external platform data corruption or unauthorized changes
- Simplifies sync architecture — one-way authority, bidirectional data flow
Cons:
- Shopify admin inventory adjustments are overwritten at next reconciliation
- Staff must make all inventory changes in the POS system, not in external platforms
- If POS data is incorrect, the error propagates to all channels
- External-only inventory (e.g., FBA stock managed by Amazon) must be handled as read-only exception
References
- Chapter 05: Architecture Components, Section 6.1 (Integration Overview)
- Chapter 05: Architecture Components, Section 6.7 (Cross-Platform Inventory Sync)
- ADR-035: Channel Safety Buffer Calculation
ADR-037: Offline Conflict Resolution via CRDTs
SUPERSEDED: This ADR has been superseded by ADR-048 (Online-First with Offline Fallback). CRDTs were eliminated in v6.2.0. This record is preserved for historical context.
2.37 ADR-037: Offline Conflict Resolution via CRDTs
| Field | Value |
|---|---|
| Status | Superseded (by ADR-048) |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | When multiple POS terminals operate offline simultaneously, their local changes must merge without data loss when connectivity is restored. |
Context
Chapter 04, Section L.10A.1H defines CRDTs (Conflict-free Replicated Data Types) as the merge strategy for offline POS terminals. The traditional sync problem: Terminal A sells 5 units offline (local: 95), Terminal B receives shipment +20 offline (local: 120) — neither 95 nor 120 is correct; the answer is 115. CRDTs solve this by tracking operations, not state.
Four CRDT types are used: PN-Counter for inventory levels (+/-), LWW-Register for price updates (highest timestamp wins), OR-Set for cart items and discounts (union with tombstones), and G-Counter for transaction counts (sum all increments). Sales themselves are conflict-free by nature (append-only events with unique IDs).
Decision
We will use CRDTs for offline data merge alongside append-only event queuing for sales. PN-Counters track inventory, LWW-Registers track last-modified data, OR-Sets track collections, and G-Counters track monotonic counts. This is complementary to ADR-015 (Queue-and-Sync).
Considered Options
- Last-write-wins globally — Most recent change overwrites all others
- Server-authoritative — Server state overwrites all offline changes
- Operational transforms (OT) — Transform operations based on concurrent edits
- CRDTs — Mathematically guaranteed convergence without coordination
Decision Outcome
Chosen: CRDTs because they are mathematically guaranteed to converge regardless of message ordering, duplication, or network partition duration. PN-Counters are particularly suited for inventory (sum increments, sum decrements, compute net). Sales events are inherently conflict-free (append-only with unique IDs), so CRDTs complement rather than replace event sourcing.
Trade-offs
Pros:
- Mathematically guaranteed convergence — no coordination required between terminals
- PN-Counter correctly handles concurrent sales and receives (example: 100 - 5 + 20 = 115)
- LWW-Register handles price updates with deterministic resolution (highest timestamp)
- OR-Set handles cart item additions/removals with tombstone-based conflict resolution
- No data loss — all offline operations are preserved and merged
Cons:
- CRDT implementation adds complexity to the sync layer
- PN-Counters can temporarily show incorrect inventory until all terminals sync
- OR-Set tombstones require periodic compaction (7-day TTL)
- Development team must understand CRDT semantics for correct implementation
- MV-Register (for customer preferences) keeps all concurrent values — may need manual resolution
References
- Chapter 04: Architecture Styles, Section L.10A.1H (CRDTs)
- ADR-015: Offline Sync Strategy (Queue-and-Sync with CRDTs)
- ADR-002: Offline-First POS Architecture
ADR-038: Transactional Outbox for Event Publishing
2.38 ADR-038: Transactional Outbox for Event Publishing
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | Domain events must be reliably published to downstream consumers (Socket.io, webhooks, sync engine) without losing events or creating inconsistency. |
Context
Chapter 04, Section L.4A defines the Transactional Outbox pattern: business data and outbox event are written atomically in the same database transaction. A relay process polls the outbox and publishes events, guaranteeing at-least-once delivery without distributed transactions. The event_outbox table (Section L.4A.1) stores: event_id, destination (socketio/webhook/sync), status (pending/processed), attempts, last_error, and timestamps.
This eliminates the dual-write problem: if the application writes to the database but the event publish fails (or vice versa), data and events become inconsistent. The outbox ensures both succeed or both fail within the same DB transaction.
Decision
We will use a Transactional Outbox pattern with a PostgreSQL event_outbox table. Domain events are written to the outbox in the same transaction as the business data. A background relay polls the outbox and publishes events to destinations (Socket.io rooms, webhook endpoints, sync engine).
Considered Options
- Publish-then-write — Publish event first, then write to database (lost data if DB write fails)
- Write-then-publish — Write to database first, then publish event (lost events if publish fails)
- Distributed transaction (2PC) — Coordinate DB and message broker atomically (complex, slow)
- Transactional Outbox — Write data + event in same DB transaction, relay publishes asynchronously
Decision Outcome
Chosen: Transactional Outbox because it guarantees at-least-once delivery using only the existing PostgreSQL database — no additional message broker infrastructure required for v1.0. The outbox relay runs as a background service, polling every 1 second for pending events. Failed publications are retried with exponential backoff and eventually routed to a dead-letter table.
Trade-offs
Pros:
- Atomic write — business data and event are guaranteed consistent
- No additional infrastructure — uses PostgreSQL (already deployed)
- At-least-once delivery with retry and dead-letter handling
- Destinations are pluggable (Socket.io, webhook, sync, future Kafka)
- Works with PostgreSQL LISTEN/NOTIFY for low-latency relay notification
Cons:
- Polling relay adds slight latency vs. direct publish (~1 second)
- Outbox table grows and needs periodic cleanup (processed events archived)
- At-least-once means consumers must be idempotent (handled by idempotency framework)
- Single relay process is a potential bottleneck (mitigated by partition-based relay in v2.0)
References
- Chapter 04: Architecture Styles, Section L.4A.1 (Event Store & Outbox Schema)
- Chapter 05: Architecture Components, Section 6.2.3 (Transactional Outbox)
- ADR-003: Event Sourcing for Sales Domain
ADR-039: CQRS Boundary (Sales Domain Only)
2.39 ADR-039: CQRS Boundary (Sales Domain Only)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | CQRS adds complexity; it should be applied only where the read/write model divergence justifies the overhead. |
Context
Chapter 04, Section L.4A defines per-module CQRS scope. Module 1 (Sales) uses full CQRS with separate read/write models and Event Sourcing. Module 4 (Inventory) uses materialized read models with ES for audit trail. Modules 2 (Customers), 3 (Catalog), 5 (Setup) use standard CRUD. Module 6 (Integrations) uses audit-trail-only ES. The Sales domain has the strongest case for CQRS: financial audit requirements, offline sync via event replay, temporal queries (“what was inventory at 3pm?”), and complex read models (dashboard aggregations).
Decision
We will apply full CQRS only to the Sales domain (Module 1). All other modules use standard CRUD with optional materialized views for performance. A command/query bus dispatches commands and queries in the Sales module.
Considered Options
- CQRS everywhere — Full CQRS for all modules
- CQRS for Sales only — Full CQRS for Sales, CRUD for everything else
- CQRS for Sales + Inventory — Full CQRS for both financial domains
- No CQRS — Standard CRUD everywhere with audit logging
Decision Outcome
Chosen: CQRS for Sales only because Sales has the strongest requirement (PCI-DSS audit trail, offline event replay, complex read models for dashboards). Inventory uses a lighter pattern — materialized read models for current levels with ES for the movement audit trail, but not full CQRS command/query separation. Applying CQRS everywhere would add unnecessary complexity to simple CRUD modules like Customers and Setup.
Trade-offs
Pros:
- Full audit trail and temporal queries for the financial domain (Sales)
- Command/query bus dispatch provides clean separation of concerns
- Read models optimized for dashboard queries without affecting write performance
- Non-Sales modules remain simple CRUD — lower development and maintenance cost
- Event replay capability for offline sync and debugging
Cons:
- Developers must understand two patterns (CQRS for Sales, CRUD for others)
- Read model projections must be rebuilt if projection logic changes
- Event versioning adds complexity for Sales domain events
- Boundary between CQRS and CRUD modules must be clearly documented
References
- Chapter 04: Architecture Styles, Section L.4A (CQRS & Event Sourcing Scope)
- ADR-003: Event Sourcing for Sales Domain
- ADR-038: Transactional Outbox for Event Publishing
ADR-040: Eventual Consistency SLA (5s Online, 30min Offline)
2.40 ADR-040: Eventual Consistency SLA
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The platform accepts eventual consistency for inventory sync. Concrete SLA targets are needed for each sync channel. |
Context
Chapter 04, Section L.10A.1 establishes that the online-first architecture accepts eventual consistency for inventory sync across channels. BRD v20.0 Section 6.7.1 defines per-channel sync latency targets: Shopify <5 seconds via webhooks, Amazon FBM <2 minutes via SP-API push, Google Merchant <30 minutes (Google processing time). Reconciliation polls run at defined intervals (Shopify 15min, Amazon 30min, Google 6hr). POS terminals operating in offline fallback mode sync critical data within 30 seconds of connectivity restoration.
Decision
We will define explicit eventual consistency SLAs per sync channel with target latencies, reconciliation intervals, and maximum acceptable lag. Online POS terminals have 5-second consistency targets; offline terminals sync critical data within 30 seconds of reconnection.
Considered Options
- Strong consistency — All changes immediately visible everywhere (requires always-online)
- Best-effort eventual — No defined SLA, sync when possible
- Tiered SLA per channel — Explicit targets per sync channel and data priority
Decision Outcome
Chosen: Tiered SLA per channel because different channels have fundamentally different latency characteristics and business impact. Shopify needs near-real-time to prevent overselling; Google Merchant tolerates 30-minute processing; offline POS terminals prioritize sales/payment sync over analytics. The SLA framework provides measurable targets for monitoring and alerting.
Trade-offs
Pros:
- Measurable sync targets for monitoring and SLA alerting
- Priority-based sync ensures critical financial data (sales, payments) syncs first
- Channel-specific targets match actual platform capabilities
- Reconciliation intervals catch drift before it becomes operationally significant
Cons:
- Inventory counts may be temporarily inaccurate across channels during sync windows
- Overselling possible during sync gaps (mitigated by safety buffers — ADR-035)
- Monitoring infrastructure needed to track sync latency per channel
- Offline sync queue may grow large during extended outages (capped at 100 transactions)
References
- Chapter 04: Architecture Styles, Section L.10A.1 (Online-First with Offline Fallback)
- Chapter 05: Architecture Components, Section 6.7.1 (Sync Latency Targets)
- ADR-048: Online-First POS Data Strategy
- ADR-035: Channel Safety Buffer Calculation
ADR-041: 6-Gate Security Pyramid
2.41 ADR-041: 6-Gate Security Pyramid
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Security Team |
| Context | The codebase is generated by Claude Code agents. A single security gate is insufficient for AI-generated code that processes financial transactions. |
Context
Chapter 04, Section L.8 identifies that AI-generated code requires defense-in-depth security validation. A single SonarQube gate cannot catch missing authorization checks, incorrect OAuth implementation, SAQ-A violations, architecture drift, or insecure CORS/CSP headers. The platform processes PCI-scoped financial transactions and stores encrypted credentials for 6 external provider families.
The 6-Gate Security Pyramid provides layered verification: SAST (Gate 1), SCA + SBOM (Gate 2), Secrets Detection (Gate 3), Architecture Conformance (Gate 4), Contract Tests (Gate 5), and Manual Security Review (Gate 6). All 6 gates block merge. FIM via Wazuh monitors deployed systems.
Decision
We will implement a 6-Gate Security Test Pyramid in the CI/CD pipeline where all 6 gates must pass before code can be merged to the main branch.
Considered Options
- Single SAST gate — SonarQube/CodeQL only
- SAST + SCA — Static analysis plus dependency scanning
- Cloud security suite — Snyk/Datadog full platform (vendor-dependent)
- 6-Gate Pyramid — Layered security with SAST, SCA, Secrets, ArchUnit, Pact, Manual
Decision Outcome
Chosen: 6-Gate Pyramid because each gate catches different vulnerability classes that others miss. SAST finds code-level bugs; SCA finds vulnerable dependencies; Secrets Detection finds leaked credentials; Architecture Conformance prevents module boundary violations; Contract Tests verify external API behavior; Manual Review covers security-critical paths that automated tools cannot fully validate. All gates are merge-blocking.
Trade-offs
Pros:
- Defense-in-depth — 6 independent verification layers
- SBOM generation (Gate 2) satisfies PCI-DSS 4.0 Req 6.3.2
- Architecture Conformance (Gate 4) prevents Module 6 from accessing Module 1 internals
- Contract Tests (Gate 5) verify Shopify/Amazon/Google sandbox API behavior
- Manual Review (Gate 6) provides human oversight for payment and credential flows
Cons:
- 6 gates add CI/CD pipeline time (mitigated by parallel execution of Gates 1-4)
- Manual Review (Gate 6) creates human bottleneck for security-tagged PRs
- Must maintain ArchUnit rules and Pact contracts as system evolves
- Tooling cost: SonarQube, Snyk, GitLeaks, ArchUnit, Pact licenses
References
- Chapter 04: Architecture Styles, Section L.8 (Security & Compliance Strategy)
- Ch 04: Architecture Styles, Section L.8 (Security) (Security Compliance chapter — planned future rewrite)
- ADR-019: SAQ-A Semi-Integrated Payment Scope
ADR-042: [REMOVED — Duplicate of ADR-017]
This ADR was removed in v6.1.0. The E2E testing strategy (Playwright + k6) is fully covered by ADR-017: Test Strategy (Layered Testing Pyramid). Consolidating to avoid duplicated guidance.
ADR-043: [REMOVED — Duplicate of ADR-012]
This ADR was removed in v6.1.0. The LGTM Observability Stack is fully covered by ADR-012: Logging & Monitoring (LGTM Stack). Consolidating to avoid duplicated guidance.
ADR-044: API Performance Targets
2.44 ADR-044: API Performance Targets
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team |
| Context | The POS API must meet specific latency targets to ensure responsive checkout and withstand peak retail traffic. |
Context
Chapter 04, Section L.6 defines the Black Friday load testing scenario: 500 concurrent users, 1000 TPS target, p99 latency <500ms over 30 minutes. The API Gateway processes requests through 5 stages: rate limiting (100 req/min/client), JWT authentication, tenant resolution, request logging, and route dispatch. Redis caching provides sub-millisecond reads for product catalog, tax rates, and tenant configuration during checkout.
The POS checkout path is latency-critical: cashiers expect instant response to item scan, price lookup, and payment initiation. Non-checkout paths (reporting, configuration) have relaxed targets.
Decision
We will define explicit API performance targets with p99 latency budgets per endpoint category, validated by k6 load testing in CI/CD.
Performance Targets:
| Endpoint Category | p99 Target | Measurement |
|---|---|---|
| Checkout (item scan, payment) | < 500ms | k6 load test |
| Product lookup (cached) | < 100ms | Redis cache hit |
| Inventory query | < 200ms | Materialized read model |
| Reporting / Dashboard | < 2s | Acceptable for non-interactive |
| Webhook processing | < 5s | Shopify, Amazon inbound |
Considered Options
- No defined targets — Optimize as needed based on user complaints
- Single global target — One latency target for all endpoints
- Tiered targets by category — Different targets for checkout vs. reporting vs. webhook
Decision Outcome
Chosen: Tiered targets by category because checkout latency directly impacts cashier productivity and customer experience, while reporting and dashboard queries are inherently slower and non-blocking. The k6 load testing framework (ADR-017) validates these targets on every release candidate.
Trade-offs
Pros:
- Clear, measurable targets for development teams
- k6 load tests enforce targets in CI/CD — performance regressions caught before deployment
- Redis caching ensures sub-100ms product lookups during checkout
- Tiered approach avoids over-engineering low-priority endpoints
Cons:
- Must maintain k6 test scripts as API evolves
- Load testing requires dedicated environment (resource cost)
- Targets may need revision as user base scales
- p99 targets require careful measurement methodology (warm-up periods, steady state)
References
- Chapter 04: Architecture Styles, Section L.6 (Load Testing)
- Chapter 04: Architecture Styles, Section L.9A (System Architecture)
- ADR-009: Redis for Session & Cache
- ADR-017: Test Strategy (Layered Testing Pyramid)
ADR-045: Blue-Green Deployment Strategy
2.45 ADR-045: Blue-Green Deployment Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-27 |
| Decision Makers | Architecture Review Team, Infrastructure Team |
| Context | Deployments of the Central API must not disrupt active POS terminals, ongoing payment transactions, or integration sync operations. |
Context
The Architecture Styles Review (Upload/Architecture-Styles-Review.md, Finding HIGH-5) identified that no deployment strategy was specified. A failed deployment could break inventory sync across all channels for all tenants. BRD Section 6.7.5 mandates channel freeze after 2 hours of sync failure. The modular monolith architecture means the entire Central API deploys as a single unit — a failed deployment affects all modules simultaneously.
Database migration rollback, integration freeze procedures, and health check-based automatic rollback are required for safe deployments.
Decision
We will use blue-green deployment with automatic rollback on health check failure. The load balancer switches traffic from the current (blue) environment to the new (green) environment only after health checks pass. If health checks fail, traffic automatically routes back to blue.
Considered Options
- Rolling update — Gradually replace instances (risk of mixed-version routing)
- Canary deployment — Route small percentage to new version, gradually increase
- Blue-green deployment — Full parallel environment with instant switchover
- Feature flags only — Deploy code but gate features behind flags
Decision Outcome
Chosen: Blue-green deployment because the modular monolith deploys as a single unit, making canary (partial routing) complex without microservices boundaries. Blue-green provides instant rollback by switching the load balancer back to the previous environment. Database migrations must be backward-compatible (expand-then-contract pattern) so both blue and green can run against the same database schema during transition.
Trade-offs
Pros:
- Instant rollback — switch load balancer back to previous environment
- Zero-downtime deployment — green environment validated before receiving traffic
- Health check validation before cutover (API, database connectivity, Redis, integration endpoints)
- Full environment parity — green runs the same infrastructure as blue
- Simplifies post-deployment verification — green serves all traffic or none
Cons:
- Requires 2x infrastructure during deployment window (cost)
- Database migrations must be backward-compatible (expand-then-contract)
- Long-running transactions during switchover may be interrupted
- POS terminal WebSocket (Socket.io) connections must reconnect after switchover
- Integration webhook endpoints must handle brief unavailability during DNS propagation
References
- Architecture Styles Review, Finding HIGH-5 (deployment strategy gap)
- Chapter 04: Architecture Styles, Section L.9A (System Architecture)
- Ch 04: Architecture Styles, Section L.9A (System Architecture) (Deployment Guide chapter — planned future rewrite)
ADR-046: Nexus Dual Deployment Architecture (Tauri Desktop + Web App)
Superseded by ADR-052: The dual deployment architecture (Tauri desktop + separate web admin) has been replaced by a unified React web application. “Nexus POS” is now a single web app with role-based navigation. Hardware peripherals use web protocols (Star WebPRNT, USB HID, Stripe Terminal SDK) instead of Tauri Rust commands. See ADR-052.
2.46 ADR-046: Nexus Dual Deployment Architecture
| Field | Value |
|---|---|
| Status | SUPERSEDED (by ADR-052: Unified Web Application) |
| Date | 2026-02-28 |
| Decision Makers | Architecture Review Team |
| Context | The platform needs both a desktop POS application (store terminals with hardware access and offline capability) and a web-based admin interface (browser-based management). Previously these were separate applications with separate codebases (ADR-007: Blazor Server admin, ADR-008: .NET MAUI POS). This created duplicated UI development and inconsistent UX. |
Context
The platform requires two deployment targets for its user interface: (1) a desktop POS application running on store terminals with hardware access (receipt printers, barcode scanners, cash drawers), offline-first SQLite storage, and sync capability; and (2) a web-based administration interface for tenant managers to configure products, employees, locations, integrations, and view reports from any browser.
Previously, these were planned as separate applications with separate codebases (ADR-007: Blazor Server for Admin Portal, ADR-008: .NET MAUI for POS Client). This approach would have duplicated UI components, state management logic, and design system implementation across two different frameworks.
With the tech stack pivot to TypeScript (ADR-006), both targets can share a single React/TypeScript codebase — deployed as a Tauri 2.0 desktop app for POS terminals and as a standard React web app for admin browser access.
Decision
We will use a single React/TypeScript codebase deployed in two modes:
- Nexus POS (Tauri 2.0 desktop): For store terminals. Includes hardware access (printers, scanners, drawers via Tauri commands), local SQLite database (better-sqlite3), offline-first capability with sync queue.
- Nexus Admin (React web app): For administrator browser access. Standard React SPA served via CDN or Central API static hosting. No hardware access needed, always-online, connects directly to Central API.
Product naming: Nexus POS (desktop), Nexus Admin (web), Nexus Raptag (mobile RFID).
Considered Options
- Separate codebases — Different frameworks for desktop (Tauri) and web (Next.js/React)
- Single codebase with dual deployment — Same React app, Tauri wraps for desktop, deployed as web for admin
- Desktop-only with remote access — All users including admins use Tauri app
Decision Outcome
Chosen: Single codebase with dual deployment because it reduces UI development by ~40%, ensures consistent UX between POS and admin, and shares all components, routing, and state management. Hardware-dependent features are abstracted behind isTauri() runtime checks. Admin-only and POS-only routes use role-based code splitting.
Trade-offs
Pros:
- Single React component library — design once, deploy twice
- Shared state management (React Query + Zustand) across both targets
- Consistent UX — admin and POS share visual language
- Conditional hardware features via Tauri API detection (
window.__TAURI__) - One design system (TailwindCSS + shadcn/ui or Radix UI)
- Shared authentication flow — JWT tokens work identically in both targets
Cons:
- Must carefully abstract hardware-dependent code behind feature checks
- Some admin-only views (reporting, user management) not needed on POS (managed via route-based code splitting and lazy loading)
- Tauri-specific Rust commands need separate build pipeline alongside TypeScript
- Web and desktop share the same online-first data strategy (React Query → Central API) but desktop adds a thin offline fallback (2-table SQLite: product cache + sales queue). The abstraction layer detects connectivity state and routes transparently (see ADR-048).
Implementation Risks
- Testing surface doubles — Every data hook needs testing in both Tauri and web mode. Mitigation: CI runs test suite with
isTauri()mocked to bothtrueandfalse. - Feature drift — POS gets hardware features, Admin gets reporting dashboards, shared codebase becomes conditional-heavy. Mitigation: Route-based code splitting, lazy loading, shared components must never import platform-specific code.
- Offline cache staleness — SQLite product cache (2 tables) may have stale prices during brief outages. Mitigation: Flag-on-sync detects price discrepancies; cache shows
last_refreshedwarning after 1 hour offline (see ADR-048, L.10A.1E). - Tauri Rust command maintenance — Custom Rust commands for hardware access need Rust-capable developers. Mitigation: Limit custom commands to thin wrappers; most hardware access via established Tauri plugins.
Supersedes
- ADR-007: Admin Portal Framework (Blazor Server) — the separate Admin Portal has been eliminated; administration is now integrated into the Nexus web application
- ADR-013: RFID Configuration in Tenant Admin Portal — the “Admin Portal” concept has been replaced by Nexus Admin; RFID configuration is accessed via Nexus Admin > Settings > RFID section
References
- ADR-008: POS Client Framework (Tauri 2.0 + React/TypeScript)
- ADR-006: Node.js + TypeScript for Central API
- ADR-047: Raptag Mobile Framework (React Native)
- ADR-048: Online-First POS Data Strategy
ADR-047: Raptag Mobile Framework — React Native
2.47 ADR-047: Raptag Mobile Framework
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-02-28 |
| Decision Makers | Architecture Review Team |
| Context | The Nexus Raptag RFID counting app runs on Android mobile devices with Zebra RFID readers (TC21/TC26 with RFD40 sleds). It requires Zebra RFID SDK integration, offline-first SQLite storage, barcode scanning, and sync with the Central API. With the tech stack pivot to TypeScript (ADR-006), the mobile framework should align with the unified language strategy. |
Context
The Nexus Raptag mobile app is a dedicated RFID inventory counting application used by store staff on Android handheld devices (Zebra TC21/TC26 with RFD40 RFID sleds). It requires integration with the Zebra RFID SDK for bulk tag reading (40+ tags/second), offline-first SQLite storage for counting sessions, barcode scanning for item lookup, and background sync with the Central API for uploading count results.
With the platform standardized on TypeScript (Central API via Node.js, Nexus POS via React per ADR-052), the mobile framework should maintain the unified language strategy to enable code sharing and reduce developer context-switching.
Decision
We will use React Native with Expo for the Nexus Raptag mobile RFID app.
Considered Options
- .NET MAUI — Cross-platform .NET mobile framework (rejected: different language ecosystem from TypeScript stack)
- React Native + Expo — TypeScript-based mobile framework with native module support (chosen)
- Flutter — Dart-based cross-platform framework (rejected: Dart language breaks TypeScript unity)
- Kotlin native — Android-only native development (rejected: no code sharing with web/desktop)
Decision Outcome
Chosen: React Native with Expo because it maintains TypeScript as the unified language across the entire platform, enables sharing of business logic, domain types, and validation schemas with the Central API via npm packages, and provides Expo OTA updates for pushing RFID configuration changes to field devices without app store review cycles.
Trade-offs
Pros:
- Unified TypeScript — same types, validators (Zod), and API client shared with Central API
- Expo OTA updates — critical for deploying RFID configuration changes to field devices without app store review
- Shared npm packages — domain models, API types, validation schemas reused across all platform clients
- React component patterns familiar to Nexus POS developers — reduced learning curve
- Hot reload during development — fast iteration on scanning UI
- React Native New Architecture (Fabric + TurboModules) provides near-native performance for scanning UI
Cons:
- Zebra RFID SDK bridge requires native Java/Kotlin module maintenance
- React Native performance adequate for scanning UI but not compute-heavy tasks (acceptable — RFID counting is I/O-bound)
- Expo may need ejection for deep Zebra hardware integration (Expo Dev Client handles this without full ejection)
- Larger APK size than pure native (~30MB vs ~10MB) — acceptable for enterprise devices
References
- ADR-027: RFID Counting-Only Scope
- ADR-052: Unified Web Application (Nexus POS)
- Ch 05: Architecture Components, Section 5.16 (RFID Counting Subsystem)
ADR-048: Online-First POS Data Strategy
2.48 ADR-048: Online-First POS Data Strategy
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-01 |
| Decision Makers | Architecture Review Team |
| Context | The Blueprint originally specified offline-first architecture for POS terminals (ADR-002) — 6-table SQLite cache, CRDTs for conflict resolution, sync queue with priority tiers, platform-aware data hooks. Through structured analysis of the target retail environment, this was found to create daily complexity for a scenario (internet outages) that occurs minutes per year. |
Context
The POS platform’s data architecture must address two concerns: (1) how POS terminals access product, inventory, and customer data during normal operation; and (2) how sales continue during internet outages.
ADR-002 chose an offline-first strategy where all POS operations run against a local SQLite database first, with background sync to the Central API. This required 6 SQLite tables, CRDTs for conflict resolution, platform-aware data hooks (useLocalFirst() vs useAPI()), sync priority tiers, and complex conflict resolution logic.
Analysis of the target market revealed:
- Internet outages are rare and brief (minutes per year) for target tenants
- Near real-time sync (1-5 minutes) is required for Shopify inventory accuracy
- Immediate config propagation is expected (admin saves → POS reflects within seconds)
- Integration flows (Shopify, Amazon, Google Merchant) are simpler when all data flows through the Central API in real-time
- The offline-first architecture doubles the testing surface (every data hook tested in both Tauri and web mode) and introduces daily consistency gaps (stale caches, integration timing) for a scenario that barely occurs
Decision
We will use an online-first data strategy for POS terminals, with a thin offline safety net:
- ONLINE (99.99% of time): Nexus POS reads/writes directly to the Central API via React Query. WebSocket push delivers real-time config updates and inventory changes. React Query’s in-memory cache provides instant lookups for recently-scanned products.
- OFFLINE (rare, brief): Nexus POS falls back to a 2-table SQLite WASM store (sql.js/wa-sqlite + OPFS) — a read-only product cache for pricing lookups and an append-only sales queue for transactions. Sales never stop.
Nexus POS uses React Query → Central API for all data access. The SQLite WASM offline fallback is a thin safety net — not a separate data layer.
Product names: Nexus POS (unified web app, ADR-052), Nexus Raptag (mobile RFID — retains full offline-first per ADR-047, as RFID counting sessions are legitimately disconnected).
Considered Options
- Keep offline-first (ADR-002 status quo) — 6-table SQLite, CRDTs, platform-aware hooks, sync priority tiers
- Online-first with thin offline fallback — React Query → API, 2-table SQLite WASM (product cache + sales queue), no CRDTs
- Online-only (no offline capability) — reject sales during outages; simplest but unacceptable for retail
Decision Outcome
Chosen: Online-first with thin offline fallback because it optimizes for the 99.99% online case (immediate data consistency, simpler integrations, unified data access) while preserving sales continuity for the rare offline scenario. Eliminates CRDTs, reduces SQLite from 6 to 2 tables, removes platform-aware data hooks, and simplifies the sync layer from priority-tiered event queue to simple FIFO sales flush. SQLite runs in the browser via WASM (sql.js/wa-sqlite) with OPFS for persistence.
3-State Connection Monitor
The POS terminal uses a layered detection system to determine connectivity state:
| State | Detection | Behavior |
|---|---|---|
| ONLINE | WebSocket connected + health ping OK | All reads/writes → Central API via React Query |
| DEGRADED | WebSocket dropped, health ping intermittent | Reads: try API (2s timeout) → fallback to SQLite cache. Writes: API + local backup queue |
| OFFLINE | 3 consecutive health pings fail (~15 sec) | Reads: SQLite product cache. Writes: local sales queue only |
Detection layers (fastest → most reliable):
- Socket.io events — instant
connect/disconnectsignals - Health ping — HTTP GET
/healthevery 5 seconds (catches stale WebSocket) navigator.onLine— Browser API (instant hint, verified by health ping)
The DEGRADED state prevents rapid flapping between ONLINE and OFFLINE during spotty internet. Components never observe the connection state directly — the data access layer routes transparently.
SQLite Schema (2 Tables)
product_cache — Read-only, server-authoritative (SQLite WASM via OPFS):
- Pre-warmed on Nexus POS startup (full catalog download in background)
- Updated incrementally via WebSocket push events (
product.updated,product.created) - Never written to by Nexus POS — only by sync from Central API
- Includes
last_refreshedtimestamp for staleness detection
sales_queue — Append-only, offline transactions (SQLite WASM via OPFS):
- Written only during OFFLINE/DEGRADED states
- Each sale has a UUID (
sale_id) for idempotent processing - Flushed to Central API on recovery (FIFO order, oldest first)
- API uses
sale_idfor upsert — safe to retry partial flushes
Recovery Sequence
When connectivity restores (OFFLINE → DEGRADED → ONLINE):
- Flush sales queue — POST each queued sale to Central API (oldest first, in order)
- Idempotent processing — API uses
sale_idUUID for upsert; partial retries are safe - Wait for confirmations — each sale acknowledged before moving to next
- Refresh product cache — prices/inventory may have changed during outage
- Resume WebSocket — re-subscribe to real-time push events
- Switch to API mode — data layer routes all reads/writes through Central API
Cashier sees: status indicator transitions from red (offline) → yellow (flushing) → green (online). No manual action required.
Price Discrepancy Handling (Flag-on-Sync)
When offline sales sync, the API compares sale.unit_price against product.current_price:
- If prices match: sale accepted normally
- If prices differ: sale accepted but flagged as
price_discrepancy: truewithsold_price,current_price, anddifferencerecorded - Admin sees a “Price Discrepancies” alert in Nexus POS (MANAGER+ role) with options to issue a credit or dismiss
- Additional safeguard: if the product cache is older than 1 hour during offline mode, the POS shows a subtle banner: “Product data may be outdated”
Shopify Inventory Protection (Safety Buffers)
The existing BRD safety buffer mechanism (Section 6.x) protects against overselling during outages:
Channel Available = POS Available - Safety Buffer- Buffer absorbs the discrepancy window during brief outages (configurable per product category, default 2-3 units)
- On recovery: queue flush → inventory adjusted → integration layer immediately pushes corrected counts to Shopify/Amazon/Google Merchant
- Overselling requires: outage + in-store sales exceeding buffer + simultaneous online sales on same SKU (extremely unlikely given minutes/year outages)
Trade-offs
Pros:
- Eliminates CRDTs — no two-way merge needed (cache is read-only, queue is append-only)
- Reduces SQLite from 6 tables to 2 — dramatically simpler local schema
- Removes platform-aware data hooks — Nexus POS uses React Query → API uniformly
- Simplifies sync from priority-tiered event queue to simple FIFO sales flush
- Real-time config propagation — admin changes reflected on POS within seconds via WebSocket
- Simpler integration flows — all data flows through Central API; Shopify/Amazon see real-time inventory
- Reduced testing surface — single web deployment target, no platform-conditional code paths for data access
Cons:
- API latency affects scan speed when online (mitigated by React Query in-memory cache + Redis server-side cache; <200ms for simple lookups)
- Central API becomes a hard dependency when online (mitigated by 3-state fallback; SQLite WASM takes over within 15 seconds)
- Product cache may be stale during offline periods (mitigated by flag-on-sync + staleness warning)
- Does not support extended offline operation (hours/days) as gracefully as offline-first (accepted: target market has reliable internet)
- SQLite WASM has slightly higher overhead than native better-sqlite3 (acceptable for the offline fallback use case; OPFS provides persistence)
Note on Raptag: This ADR does not change the Nexus Raptag mobile app’s data strategy. RFID counting sessions are legitimately disconnected (warehouse floor, intermittent device connectivity) and retain full offline-first per ADR-047.
Supersedes
- ADR-002: Offline-First POS Architecture — replaced by online-first with offline fallback
References
- ADR-002: Offline-First POS Architecture (superseded)
- ADR-052: Unified Web Application (Nexus POS is now a single React web app)
- ADR-047: Raptag Mobile Framework (React Native) — retains offline-first
- Ch 04: Architecture Styles, Section L.10A.1 (Online-First with Offline Fallback)
- Ch 05: Architecture Components, Section 6.x (Safety Buffers for Channel Inventory)
ADR-049: Real-Time Transport — Socket.io
2.49 ADR-049: Real-Time Transport — Socket.io
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-01 |
| Decision Makers | Architecture Review Team |
| Context | The 3-state connection monitor (ONLINE/DEGRADED/OFFLINE from ADR-048) needs a real-time transport for server-push updates, connection health heartbeats, and multi-device coordination. Nexus POS is a unified web application (ADR-052). |
Context
ADR-048 defines a 3-state connection monitor (ONLINE/DEGRADED/OFFLINE) that requires a real-time transport for: (1) server-push price and inventory updates to Nexus POS, (2) connection health heartbeats that feed the DEGRADED state detection, and (3) multi-device coordination such as register locking (preventing two users from operating the same register simultaneously). The transport must work reliably in retail network environments and gracefully handle intermittent connectivity.
Decision
We will use Socket.io with WebSocket as the primary transport and HTTP long-polling as the automatic fallback.
Considered Options
- Socket.io — Bidirectional, room-based, auto-reconnect, transport fallback
- Server-Sent Events (SSE) — Unidirectional server-to-client push over HTTP
- Raw WebSocket — Native browser WebSocket API without abstraction layer
- Long Polling — Periodic HTTP requests simulating real-time
Decision Outcome
Chosen: Socket.io because it provides bidirectional communication (needed for register lock/unlock commands from server to client), built-in reconnection with exponential backoff (critical for 3-state monitor DEGRADED detection), room-based broadcasting (per-tenant, per-location event routing), and automatic transport fallback (WebSocket → HTTP long-polling) for restrictive network environments.
Trade-offs
Pros:
- Bidirectional — server can push updates AND send commands (register lock, force-logout, config refresh)
- Built-in reconnection with exponential backoff — feeds directly into ADR-048’s 3-state connection monitor
- Room-based broadcasting — events routed per-tenant and per-location without client-side filtering
- Automatic transport fallback — WebSocket → HTTP long-polling handles corporate firewalls and proxy servers
- Mature ecosystem — well-tested with Node.js/Express, Redis adapter for horizontal scaling
Cons:
- Socket.io client dependency adds ~50KB to the client bundle
- Sticky sessions required if horizontal scaling (mitigated by Redis adapter:
@socket.io/redis-adapter) - Not a standard protocol — custom framing on top of WebSocket (mitigated by widespread adoption and tooling)
References
- ADR-048: Online-First POS Data Strategy (3-state connection monitor)
- ADR-052: Unified Web Application (Nexus POS)
- Ch 04: Architecture Styles, Section L.9A (System Architecture)
ADR-050: Prisma Migrate with Custom RLS Policies
2.50 ADR-050: Prisma Migrate with Custom RLS Policies
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-01 |
| Decision Makers | Architecture Review Team |
| Context | Prisma ORM provides Prisma Migrate for schema management but has no native understanding of PostgreSQL Row-Level Security (RLS) policies required by ADR-001. |
Context
Prisma ORM (selected as part of the ADR-046 tech stack) provides Prisma Migrate for schema management — generating migration files from schema changes, tracking migration history, and applying migrations in order. However, Prisma has no native understanding of PostgreSQL Row-Level Security (RLS) policies (ADR-001). Every tenant-scoped table requires both standard DDL (CREATE TABLE, indexes) and custom RLS SQL (CREATE POLICY, ENABLE ROW LEVEL SECURITY). These must be created and updated together as part of the same migration workflow.
Decision
We will use Prisma Migrate for schema DDL with custom SQL migration files for RLS policies. Tenant provisioning uses a dedicated service that: (1) runs Prisma Migrate for schema, (2) executes RLS policy SQL scripts, and (3) seeds tenant configuration.
Considered Options
- Prisma Migrate + custom SQL files — Prisma handles DDL, companion
.sqlfiles handle RLS - Raw SQL migrations only — Skip Prisma Migrate, manage all DDL and RLS in hand-written SQL
- Prisma Migrate with
$executeRawin seed scripts — RLS policies applied outside the migration system - Third-party migration tool (dbmate, golang-migrate) — Replace Prisma Migrate entirely
Decision Outcome
Chosen: Prisma Migrate + custom SQL files because it preserves Prisma’s schema diffing, TypeScript type generation, and migration history while accommodating RLS policies that Prisma cannot generate. Each migration that adds a tenant-scoped table includes a companion RLS policy file in the same migrations folder.
Implementation pattern:
- Standard Prisma schema changes generate migration SQL via
prisma migrate dev - Developer adds a companion SQL file in the same migration folder for RLS policies
- CI validation checks every table with
tenant_idhas a corresponding RLS policy - Prisma Client middleware calls
SET LOCAL app.current_tenant = $tenantIdon every connection via$queryRaw
Trade-offs
Pros:
- Preserves Prisma’s schema diffing, type generation, and migration history tracking
- RLS policies live alongside the DDL migrations they relate to (co-located, not scattered)
- CI can validate RLS coverage: every table with
tenant_idmust have a corresponding policy - Prisma Client middleware provides a clean interception point for
SET LOCAL app.current_tenant - Migration rollback includes both DDL and RLS changes
Cons:
- Every new tenant-scoped table requires both a Prisma schema change AND a custom RLS SQL file (discipline needed)
- Prisma Migrate does not track or diff the custom SQL files — developer must remember to add them
- Custom SQL files are not reflected in the Prisma schema (RLS is invisible to
schema.prisma) SET LOCAL app.current_tenantmust be called on every connection — forgetting breaks isolation
References
- ADR-001: Shared Tables with Row-Level Security Multi-Tenancy
- ADR-052: Unified Web Application (Prisma ORM selection, originally ADR-046)
- Ch 04: Architecture Styles, Section L.10A.4 (Multi-Tenancy)
- Ch 06: Database Strategy
ADR-051: State Management — React Query (Server) + Zustand (Client)
2.51 ADR-051: State Management — React Query + Zustand
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-01 |
| Decision Makers | Architecture Review Team |
| Context | Two client applications (Nexus POS web app, Nexus Raptag mobile) need state management for both server-fetched data and local UI state under the ADR-048 online-first architecture. |
Context
Two client applications (Nexus POS unified web app per ADR-052, Nexus Raptag mobile per ADR-047) need state management for both server-fetched data and local UI state. The ADR-048 online-first architecture means most state comes from the Central API — product data, inventory, customer records, and configuration are all server-authoritative. However, the Nexus POS active cart must survive brief offline periods without losing items, and UI state (connection status, preferences, active modals) is purely local.
The state management solution must clearly separate server state (cached API responses) from client state (cart, UI, connection status) to avoid the common pitfall of treating all state identically.
Decision
We will use React Query (TanStack Query) for all server state and Zustand for client-only state. Clear boundary: if data exists on the server, use React Query; if data is local-only or must survive offline, use Zustand with optional persistence.
Considered Options
- React Query + Zustand — Dedicated server-state cache + lightweight client-state store
- Redux Toolkit (RTK Query + slices) — Unified state management with built-in API cache
- React Context API + custom hooks — Built-in React state with no external dependencies
- Jotai / Recoil — Atomic state management libraries
Decision Outcome
Chosen: React Query + Zustand because React Query eliminates manual fetch/cache/retry code for the 80% of state that comes from the API, while Zustand provides a minimal, unopinionated store for the 20% that is local-only. The two libraries have no overlap and no conflict.
State boundary rule: “If it has a REST endpoint, use React Query. If it’s local-only, use Zustand.”
React Query manages:
- Product catalog, inventory levels, customer records (cached API responses)
- Background refetch, optimistic updates, retry logic, pagination
- Stale-while-revalidate for instant UI with background freshness checks
Zustand manages:
- Active cart items (must survive DEGRADED state without losing items mid-sale)
- Register UI state (active modal, selected tab, sidebar collapsed)
- 3-state connection monitor status (ONLINE/DEGRADED/OFFLINE from ADR-048)
- User preferences (theme, receipt format, default payment method)
- Cart persistence: Zustand
persistmiddleware writes to localStorage (Nexus POS web) or AsyncStorage (Nexus Raptag mobile). On reconnection, cart syncs via API
Trade-offs
Pros:
- React Query handles caching, background refetch, optimistic updates, retry, pagination — eliminates manual fetch/cache code
- Zustand is ~1KB, no boilerplate, no reducers, no actions — just a function that returns state
- Clear separation prevents the “everything in Redux” anti-pattern
- Cart items survive DEGRADED/OFFLINE states via Zustand
persistmiddleware - Both libraries are TypeScript-first with excellent type inference
Cons:
- Two state libraries to learn (mitigated by clear boundary rule and small Zustand API surface)
- Cart items live in Zustand during a sale but must be persisted to the server on sale completion via React Query mutation (two-step)
- Zustand
persistmiddleware uses localStorage which has ~5MB limit (sufficient for cart state, not for catalog)
References
- ADR-048: Online-First POS Data Strategy
- ADR-052: Unified Web Application (Nexus POS)
ADR-052: Unified Web Application (Nexus POS)
2.52 ADR-052: Unified Web Application
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-02 |
| Decision Makers | Architecture Review Team |
| Context | ADR-046 defined dual deployment (Tauri desktop + React web). Analysis revealed target retailers use standard PCs/tablets with Chrome/Edge, hardware peripherals work via web protocols, and the “Admin Portal” vs “POS Terminal” split creates artificial product complexity when role-based routing achieves the same outcome. |
Context
ADR-046 established a dual deployment architecture: “Nexus POS” as a Tauri 2.0 desktop application for store terminals with native hardware access, and “Nexus Admin” as a React web application for browser-based administration. Both shared a single React/TypeScript codebase but required separate build pipelines, platform-conditional code (isTauri() checks), and Rust-based hardware integration for the desktop variant.
Analysis of the target market (small-to-medium multi-location retailers) revealed:
- Standard hardware: Target retailers use commodity PCs and tablets running Chrome or Edge — not dedicated POS terminals requiring native desktop wrappers
- Web-based peripherals: Modern receipt printers (Star Micronics, Epson) expose HTTP/WebSocket APIs (Star WebPRNT, Epson ePOS SDK); barcode scanners operate as USB HID keyboard wedge devices; cash drawers connect to receipt printers via kick-out cables; payment terminals use browser-compatible SDKs (Stripe Terminal)
- Artificial product split: The “Nexus POS” vs “Nexus Admin” distinction created two product names for what is functionally one application with different role-based views. A CASHIER needs the sales terminal; a MANAGER needs reports and configuration; both use the same codebase
- Build complexity: Tauri requires a Rust build pipeline, WebView2 dependency management, and platform-specific installers — overhead for minimal benefit when web deployment achieves the same outcome
Decision
We will deploy a single React/TypeScript web application called “Nexus POS”. Users see different menus and pages based on their assigned roles (OWNER, MANAGER, CASHIER, BUYER, AUDITOR). There is no separate “Nexus Admin” product.
Product names: Nexus POS (web app), Nexus Raptag (mobile RFID, unchanged per ADR-047).
Hardware integration via web protocols:
- Receipt Printers: Star WebPRNT (HTTP POST to printer’s built-in web server) or Epson ePOS SDK (WebSocket). ESC/POS commands sent over network — no native access required.
- Barcode Scanners: USB HID keyboard wedge — scanner outputs keystrokes captured by standard
keydownevent listeners. Works identically in any browser. - Cash Drawers: Connected to receipt printer via RJ-11 kick-out cable. Drawer opens when receipt printer sends ESC/POS drawer-open command. Solved automatically when receipt printing is solved.
- Payment Terminals: Stripe Terminal JavaScript SDK communicates with Verifone/WisePOS reader over local network. Browser-native, SAQ-A compliant (no card data touches our system).
Offline storage: SQLite WASM (sql.js/wa-sqlite) with OPFS for browser-persistent storage. Same 2-table schema from ADR-048 (product_cache + sales_queue), same 3-state connection monitor — different runtime (WASM instead of native better-sqlite3).
Considered Options
- Keep dual deployment (Tauri + web) — Maintain ADR-046 architecture with
isTauri()conditional code - Unified web application — Single React SPA, role-based routing, web-based hardware integration
- Progressive Web App (PWA) — Web app with service worker for offline, installable on desktop
- Electron — Desktop wrapper with full Node.js access (rejected: 150MB+ bundle, Chromium overhead)
Decision Outcome
Chosen: Unified web application because it eliminates the Rust build pipeline, removes platform-conditional code, unifies the product naming, and uses web-standard hardware protocols that work across all modern browsers. The SQLite WASM runtime provides the same offline fallback capability as native better-sqlite3 with slightly higher overhead (acceptable for the rare offline scenario).
Trade-offs
Pros:
- Single build pipeline — React + Vite, no Rust compilation
- No platform-conditional code — eliminates
isTauri()checks and allwindow.__TAURI__detection - Unified product name — “Nexus POS” for all users regardless of role
- Role-based navigation — CASHIER sees sales terminal, MANAGER sees dashboard + reports, OWNER sees configuration
- Web-standard hardware — Star WebPRNT and USB HID work across Chrome, Edge, Firefox
- Instant deployment — CDN-served SPA, no desktop installer distribution
- Simpler testing — single deployment target, no dual-mode test matrix
Cons:
- SQLite WASM has ~2-3x overhead vs native better-sqlite3 (acceptable: offline fallback is rare, performance-critical path is online API access)
- Web Serial API and WebUSB have limited browser support (Firefox) — mitigated by targeting Chrome/Edge which dominate enterprise retail
- No offline application startup — web app requires network to load initially (mitigated: service worker can cache app shell for offline reload)
- Browser tab can be accidentally closed — no system tray or always-on-top (mitigated: POS terminals use kiosk mode or dedicated browser profile)
Supersedes
- ADR-046: Nexus Dual Deployment Architecture (Tauri Desktop + Web App)
- ADR-007: Admin Portal Framework (Blazor Server) — already superseded by ADR-046, now further obsoleted
- ADR-013: RFID Configuration in Tenant Admin Portal — “Admin Portal” concept fully eliminated
References
- ADR-008: POS Client Framework (React/TypeScript architecture principles remain valid; Tauri-specific parts superseded)
- ADR-047: Raptag Mobile Framework (React Native — unchanged)
- ADR-048: Online-First POS Data Strategy (unchanged; SQLite runtime changes from native to WASM)
- Ch 04: Architecture Styles, Section L.9A (System Architecture)
How to Propose a New ADR
ADR Proposal Process
====================
1. Copy the ADR template
2. Fill in Context, Decision, Consequences
3. Set Status to "proposed"
4. Submit for architecture review
5. Discuss in architecture meeting
6. Update based on feedback
7. Set Status to "accepted" when approved
8. Add to ADR Index
MADR Template (Markdown Any Decision Records)
We use the MADR (Markdown Any Decision Records) format, which is more comprehensive than the basic ADR format and better suited for complex architectural decisions.
Full MADR Template
# ADR-XXX: [Short Title of Solved Problem and Solution]
## Status
[proposed | accepted | deprecated | superseded by ADR-YYY]
## Date
YYYY-MM-DD
## Decision-Makers
- [Name/Role 1]
- [Name/Role 2]
## Technical Story
[Link to ticket/issue: JIRA-123, GitHub Issue #456]
## Context and Problem Statement
[Describe the context and problem statement, e.g., in free form
using two to three sentences or in the form of an illustrative
story. You may want to articulate the problem in form of a question.]
## Decision Drivers
* [Driver 1, e.g., a force, facing concern, …]
* [Driver 2, e.g., a force, facing concern, …]
* [Driver 3, e.g., a force, facing concern, …]
## Considered Options
1. [Option 1]
2. [Option 2]
3. [Option 3]
4. [Option 4]
## Decision Outcome
**Chosen Option**: "[Option X]"
### Justification
[Justification for why this option was chosen. Reference the
decision drivers and explain how this option best addresses them.]
### Positive Consequences
* [e.g., improvement of quality attribute satisfaction, follow-up
decisions required, …]
* …
### Negative Consequences
* [e.g., compromising quality attribute, follow-up decisions required,
technical debt introduced, …]
* …
## Pros and Cons of the Options
### [Option 1]
[Example: Schema-per-tenant multi-tenancy]
**Pros:**
* Good, because [argument a]
* Good, because [argument b]
**Cons:**
* Bad, because [argument c]
* Bad, because [argument d]
### [Option 2]
[Example: Row-level multi-tenancy]
**Pros:**
* Good, because [argument a]
* Good, because [argument b]
**Cons:**
* Bad, because [argument c]
### [Option 3]
[Example: Database-per-tenant]
**Pros:**
* Good, because [argument a]
**Cons:**
* Bad, because [argument b]
* Bad, because [argument c]
## Links
* [Link type] [Link to ADR] <!-- example: Refined by ADR-007 -->
* [Link type] [Link to external resource]
* Supersedes ADR-XXX
* Related to ADR-YYY
## Notes
[Any additional notes, discussion points, or future considerations]
MADR Example: Kafka Selection
# ADR-014: Apache Kafka for Event Streaming
## Status
accepted
## Date
2026-01-15
## Decision-Makers
- Architecture Team
- Infrastructure Team
## Technical Story
ARCH-456: Select event streaming platform for POS event sourcing
## Context and Problem Statement
Our POS platform uses event sourcing for the Sales and Inventory
domains. We need an event streaming platform that supports:
- Event replay for new consumers
- Durable storage for audit compliance
- High throughput during peak retail periods (Black Friday)
- Multi-datacenter replication for disaster recovery
Which event streaming platform should we use?
## Decision Drivers
* Replayability - New analytics services must process historical events
* Durability - Events must survive broker failures (PCI compliance)
* Throughput - Handle 10,000+ events/second during peak
* Ecosystem - Good client libraries for .NET
* Operations - Team can manage without dedicated staff
## Considered Options
1. Apache Kafka
2. RabbitMQ with Shovel plugin
3. Amazon Kinesis
4. Redis Streams
5. PostgreSQL LISTEN/NOTIFY
## Decision Outcome
**Chosen Option**: "Apache Kafka (with KRaft mode)"
### Justification
Kafka is the only option that provides true event replayability with
configurable retention. New consumers can start from the beginning
of the log and process all historical events. This is critical for:
- Adding new analytics modules
- Rebuilding projections after bugs
- Audit investigations
KRaft mode eliminates ZooKeeper dependency, simplifying operations.
### Positive Consequences
* Complete replayability for compliance and analytics
* Proven at massive scale (LinkedIn, Uber)
* Strong .NET client (Confluent.Kafka)
* Schema Registry for event versioning
### Negative Consequences
* More complex than RabbitMQ
* Requires understanding of partitioning
* Higher resource usage than simpler queues
## Pros and Cons of the Options
### Apache Kafka
**Pros:**
* Good, because events are retained for configurable duration
* Good, because consumers can replay from any offset
* Good, because it handles 100K+ messages/second
* Good, because KRaft mode simplifies deployment
**Cons:**
* Bad, because it requires more operational knowledge
* Bad, because partition management adds complexity
### RabbitMQ with Shovel
**Pros:**
* Good, because it's simpler to operate
* Good, because team has existing experience
**Cons:**
* Bad, because messages are deleted after consumption
* Bad, because replay requires external archival
### Amazon Kinesis
**Pros:**
* Good, because it's fully managed
* Good, because it has replay capability
**Cons:**
* Bad, because of vendor lock-in
* Bad, because pricing is complex at scale
### Redis Streams
**Pros:**
* Good, because it's simple
* Good, because it's low latency
**Cons:**
* Bad, because durability is limited
* Bad, because it's not designed for long-term storage
### PostgreSQL LISTEN/NOTIFY
**Pros:**
* Good, because no additional infrastructure
**Cons:**
* Bad, because it doesn't scale
* Bad, because messages are ephemeral
## Links
* Refined by ADR-015 (Schema Registry Selection)
* Related to ADR-003 (Event Sourcing for Sales Domain)
* [Kafka Documentation](https://kafka.apache.org/documentation/)
## Notes
Evaluated during Q1 2026 architecture review. Confluent Cloud was
considered but rejected due to cost; self-hosted Kafka preferred.
**UPDATE (v3.0.0)**: Kafka is **deferred to v2.0**. Per the Architecture
Styles analysis (Chapter 04, Section L.4A.2),
v1.0 uses PostgreSQL event tables with LISTEN/NOTIFY for event notification
and Transactional Outbox for guaranteed delivery. This ADR remains valid
for v2.0 planning when scale justifies the Kafka operational overhead.
ADR Tooling & Automation
Recommended Tools
| Tool | Purpose | Installation |
|---|---|---|
| adr-tools | CLI for creating/managing ADRs | brew install adr-tools |
| Log4brains | ADR documentation site generator | npm install -g log4brains |
| adr-viewer | Web-based ADR viewer | Docker image available |
ADR Tools CLI
# Install adr-tools
brew install adr-tools # macOS
# or
sudo apt install adr-tools # Ubuntu
# Initialize ADR directory
adr init docs/adr
# Create new ADR
adr new "Use Kafka for Event Streaming"
# Creates: docs/adr/0014-use-kafka-for-event-streaming.md
# Supersede an ADR
adr new -s 3 "Replace Event Sourcing with Outbox Pattern"
# Creates new ADR that supersedes ADR-003
# List all ADRs
adr list
# Generate ADR index
adr generate toc > docs/adr/README.md
Log4brains Integration
Log4brains generates a searchable documentation website from ADRs:
# Install Log4brains
npm install -g log4brains
# Initialize in project
log4brains init
# Start preview server
log4brains preview
# Build static site
log4brains build
# Deploy to GitHub Pages
log4brains build --basePath /pos-platform-adr
# .github/workflows/adr-docs.yml
name: ADR Documentation
on:
push:
branches: [main]
paths:
- 'docs/adr/**'
jobs:
build-adr-site:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for dates
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Log4brains
run: npm install -g log4brains
- name: Build ADR site
run: log4brains build --basePath /pos-platform-adr
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: .log4brains/out
ADR Linting
# .github/workflows/adr-lint.yml
name: ADR Lint
on:
pull_request:
paths:
- 'docs/adr/**'
jobs:
lint-adr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate ADR Format
run: |
for file in docs/adr/*.md; do
# Check required sections
if ! grep -q "## Status" "$file"; then
echo "ERROR: $file missing Status section"
exit 1
fi
if ! grep -q "## Context" "$file" && ! grep -q "## Context and Problem Statement" "$file"; then
echo "ERROR: $file missing Context section"
exit 1
fi
if ! grep -q "## Decision" "$file" && ! grep -q "## Decision Outcome" "$file"; then
echo "ERROR: $file missing Decision section"
exit 1
fi
done
echo "All ADRs pass validation"
- name: Check ADR Numbering
run: |
# Ensure sequential numbering
expected=1
for file in docs/adr/[0-9]*.md; do
num=$(basename "$file" | grep -o '^[0-9]*')
if [ "$num" != "$expected" ]; then
echo "WARNING: Expected ADR-$expected, found ADR-$num"
fi
expected=$((expected + 1))
done
ADR Review Checklist
# ADR Review Checklist
Before accepting an ADR, verify:
## Structure
- [ ] Uses MADR template
- [ ] Has clear title
- [ ] Status is set correctly
- [ ] Date is current
- [ ] Decision-makers are listed
## Content Quality
- [ ] Context clearly explains the problem
- [ ] Decision drivers are explicit
- [ ] At least 3 options were considered
- [ ] Pros/cons are documented for each option
- [ ] Chosen option justification references drivers
## Completeness
- [ ] Positive consequences listed
- [ ] Negative consequences listed (be honest!)
- [ ] Risks identified
- [ ] Mitigations proposed for risks
- [ ] Links to related ADRs
## Traceability
- [ ] Linked to technical story/ticket
- [ ] References relevant documentation
- [ ] Supersedes/relates to other ADRs if applicable
## Approval
- [ ] Architecture team reviewed
- [ ] Security team reviewed (if applicable)
- [ ] Infrastructure team reviewed (if applicable)
ADR Template
Summary
These Architecture Decision Records capture the foundational technical decisions for the POS Platform:
| ADR | Key Decision | Primary Benefit |
|---|---|---|
| ADR-001 | Tenant isolation via tenant_id + PostgreSQL RLS policies | |
| ADR-002 | Replaced by online-first with offline fallback | |
| ADR-003 | Event sourcing | Complete audit trail and temporal queries |
| ADR-004 | JWT + PIN | Secure API + fast cashier workflow |
| ADR-005 | PostgreSQL | RLS multi-tenancy and JSONB flexibility |
| ADR-006 | Node.js + TypeScript (Central API) | Unified TypeScript stack, Prisma ORM, Socket.io |
| ADR-007 | Replaced by Nexus dual deployment (React web app) | |
| ADR-008 | Tauri 2.0 + React/TypeScript (Nexus POS) | Native hardware access, shared React codebase, lightweight binary |
| ADR-009 | Redis for session & cache | Distributed session, sub-ms cache, pub/sub |
| ADR-010 | Webhook + Polling (Shopify) | Real-time sync with fallback consistency |
| ADR-011 | SAQ-A Semi-Integrated payments | Minimal PCI scope, no card data in system |
| ADR-012 | LGTM Stack (observability) | Open-source, self-hosted, unified dashboards |
| ADR-013 | RFID config now in Nexus Admin > Settings > RFID | |
| ADR-014 | Pinned major.minor npm versions with lock file | Build reproducibility with security patches |
| ADR-015 | Replaced by online-first; CRDTs eliminated | |
| ADR-016 | ERR-Mxxx error codes | Structured, machine-parseable, module-aligned |
| ADR-017 | Layered Testing Pyramid (Vitest + Playwright + k6) | Fast feedback, real DB tests, contract testing |
| ADR-018 | Affirm BNPL Integration | Third-party financing without PCI scope |
| ADR-019 | SAQ-A Semi-Integrated Payment Scope | Zero card data in POS, minimal PCI burden |
| ADR-020 | Split Tender Payment Support | Multiple payment methods per transaction |
| ADR-021 | Layaway Payment Plans | State-machine-driven installment lifecycle |
| ADR-022 | Tax-Inclusive Display with Compound Calc | Accurate 3-level tax, customer transparency |
| ADR-023 | Compound Tax (3-Level State/County/City) | Jurisdiction-accurate tax computation |
| ADR-024 | Gift Card Compliance (State Escheatment) | Legal compliance with state unclaimed-property laws |
| ADR-025 | 6-Status Inventory State Machine | Deterministic status transitions, audit trail |
| ADR-026 | Reservation-Based Inventory Hold Model | Prevents overselling across channels |
| ADR-027 | RFID Counting-Only Scope | Focused RFID value, reduced complexity |
| ADR-028 | Physical Count Freeze Period | Data integrity during full counts |
| ADR-029 | Adjustment Manager Approval | Shrinkage control, accountability |
| ADR-030 | Auto-Suggest Transfers Algorithm | Velocity-based stock redistribution |
| ADR-031 | Shopify Webhook + Polling Dual Sync | Real-time events with polling fallback |
| ADR-032 | Strictest-Rule-Wins Validation | Cross-platform compliance by default |
| ADR-033 | Amazon SP-API Integration | Marketplace reach with OAuth 2.0/LWA auth |
| ADR-034 | Google Merchant Center Feed | Product visibility before Content API EOL |
| ADR-035 | Channel Safety Buffer Calculation | Prevents overselling across channels |
| ADR-036 | POS-Master Default for Channels | Single source of truth for product data |
| ADR-037 | Replaced by online-first; CRDTs eliminated | |
| ADR-038 | Transactional Outbox for Events | Reliable event publishing, no dual-write |
| ADR-039 | CQRS Boundary (Sales Domain Only) | Targeted complexity where value is highest |
| ADR-040 | Eventual Consistency SLA | Predictable sync guarantees per channel |
| ADR-041 | 6-Gate Security Pyramid | Automated layered security scanning |
| ADR-042 | — | |
| ADR-043 | — | |
| ADR-044 | API Performance Targets | SLA-driven p99 latency budgets |
| ADR-045 | Blue-Green Deployment Strategy | Zero-downtime releases with instant rollback |
| ADR-046 | Replaced by unified web application | |
| ADR-047 | Raptag Mobile Framework (React Native) | Unified TypeScript for RFID mobile app |
| ADR-048 | Online-First POS Data Strategy | Online-first API access, 2-table SQLite WASM offline fallback |
| ADR-049 | Real-Time Transport — Socket.io | Bidirectional push, auto-reconnect, room-based routing |
| ADR-050 | Prisma Migrate with Custom RLS Policies | Schema DDL + companion RLS SQL in same migration |
| ADR-051 | State Management — React Query + Zustand | Server-state cache + lightweight client-state store |
| ADR-052 | Unified Web Application (Nexus POS) | Single React web app, role-based navigation, web hardware protocols |
These 52 records (43 active, 7 superseded [001, 002, 007, 013, 015, 037, 046], 2 removed [042, 043]) form the architectural foundation upon which the rest of the system is built.
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | II - Architecture |
| Chapter | 02 of 9 |
Change Log
| Version | Date | Changes |
|---|---|---|
| 7.0.0 | 2026-03-02 | Unified Web Application: Added ADR-052 (single React web app replacing Tauri desktop + web admin split). Superseded ADR-046 (Dual Deployment). Updated ADR-048 (better-sqlite3 → SQLite WASM via sql.js/wa-sqlite + OPFS). Updated ADR-008 (Tauri-specific parts superseded by ADR-052). Updated ADR-049 (Socket.io references). Updated ADR-051 (2 apps not 3, localStorage not Tauri). Updated ADR-047 references. Total: 52 records (43 active, 7 superseded [001, 002, 007, 013, 015, 037, 046], 2 removed [042, 043]). |
| 6.3.0 | 2026-03-01 | Online-first consolidation: Superseded ADR-015 (CRDTs) and ADR-037 (CRDT conflict resolution) by ADR-048. Fixed ADR-040 context (offline-first→online-first, ADR-015→ADR-048). Fixed ADR-038 destination (signalr→socketio). Expanded ADR-007 superseded note. Added ADR-049 (Socket.io real-time transport), ADR-050 (Prisma Migrate + RLS), ADR-051 (React Query + Zustand state management). Total: 51 records (43 active, 6 superseded [001, 002, 007, 013, 015, 037], 2 removed [042, 043]). |
| 6.2.0 | 2026-03-01 | Online-first pivot: Added ADR-048 (Online-First POS Data Strategy), superseding ADR-002 (Offline-First). ADR-046 con and Implementation Risk #3 updated for 2-table SQLite. Total: 48 records (42 active, 4 superseded [001, 002, 007, 013], 2 removed [042, 043]). |
| 6.1.0 | 2026-02-28 | Tech stack pivot Phase 1: ADR-006 rewritten (ASP.NET Core → Node.js + TypeScript), ADR-008 rewritten (.NET MAUI → Tauri 2.0 + React), ADR-014 rewritten (NuGet → npm), ADR-017 updated (xUnit → Vitest), ADR-001 corrected (Strategy C → Strategy A RLS), ADR-007 superseded (by ADR-046), ADR-013 superseded (by ADR-046), ADR-042 removed (duplicate of ADR-017), ADR-043 removed (duplicate of ADR-012). Added ADR-046 (Nexus Dual Deployment Architecture) and ADR-047 (Raptag Mobile Framework — React Native). Updated SignalR → Socket.io, MediatR → command/query bus, StackExchange.Redis → ioredis. Total: 47 records (42 active, 3 superseded, 2 removed). |
| 1.0.0 | 2025-12-29 | Initial ADRs (001-006) |
| 2.0.0 | 2026-01-01 | Added ADR-013 (RFID Configuration), MADR template, tooling section |
| 3.0.0 | 2026-02-22 | ADR-001 marked SUPERSEDED (Schema-Per-Tenant replaced by Row-Level RLS per Ch 04 L.10A.4); added Kafka v2.0 deferral note to ADR-014 example (per Ch 04 L.4A.2); fixed Next Chapter link; renumbered chapter references for v3.0.0 |
| 5.2.0 | 2026-02-27 | Added 10 new ADRs (007-012, 014-017): Blazor Server (Admin), .NET MAUI Blazor Hybrid (POS), Redis, Shopify Webhook+Polling, SAQ-A Payments, LGTM Stack, NuGet Versioning, Queue-and-Sync CRDTs, ERR-Mxxx Error Codes, Layered Testing Pyramid. Removed Future ADRs table (all now accepted). Updated Summary table with all 17 ADRs. |
| 5.2.1 | 2026-02-27 | Added 28 new ADRs (018-045) covering Payment & Financials, Inventory & Stock Management, Multi-Channel Integration, Data Consistency & Conflict Resolution, and Architecture Patterns & Infrastructure. Sourced from Ch 04 and Ch 05. Total ADRs: 45. |
Next Chapter: Chapter 03: Architecture Characteristics
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 03: Architecture Characteristics
Purpose
This appendix documents the formal architecture characteristics analysis for the Nexus POS Platform. It identifies the driving quality attributes that shape architectural decisions and provides justification for each characteristic’s priority.
Source: Architecture Characteristics Worksheet v2.0 (Expert Panel-Reviewed) System/Project: Nexus - Omnichannel Retail POS (Multi-Tenant Cloud) Architect/Team: Cloud AI Architecture Agents Domain/Quantum: Retail / Inventory Management / Multi-Platform Integration Date Modified: February 19, 2026 Panel Review Score: 6.50/10 → Updated per 4-member expert panel recommendations
K.1 Top 9 Driving Characteristics
These are the primary quality attributes that drive architectural decisions. They are listed in priority order.
| Rank | Characteristic | Priority Level | Blueprint Reference |
|---|---|---|---|
| 1 | Availability | Critical | Ch 04: Architecture Styles, Section L.10A.1 |
| 2 | Interoperability | Critical | Ch 05: BRD Module 6 (Integrations) |
| 3 | Data Consistency | Critical | Ch 04: Architecture Styles, Section L.4A |
| 4 | Security | Elevated | Ch 04: Architecture Styles, Section L.8 (Security) |
| 5 | Compliance (NEW) | Critical | Ch 05: BRD Module 6 (Integrations) |
| 6 | Modifiability | High | Ch 04: Architecture Styles, Section L.9A |
| 7 | Scalability | High | Ch 04: Architecture Styles, Section L.10A.4 |
| 8 | Configurability (ELEVATED from implicit) | High | Ch 04: Architecture Styles Section L.10A.4, BRD Module 5 |
| 9 | Performance | High | Ch 09: Indexes & Performance |
K.2 Characteristics with Definitions and Justifications
K.2.1 Availability (Rank 1)
| Attribute | Value |
|---|---|
| Definition | The amount of uptime of a system; usually measured in 9’s (e.g., 99.9%). |
| Priority | Critical |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.10A.1 |
Justification:
Continuous POS operation is non-negotiable. Physical stores must be able to process transactions even during brief network outages. The system uses an online-first architecture with a thin offline fallback (ADR-048) — the API is the primary data source under normal conditions, with a minimal SQLite cache that activates only when connectivity is lost.
Architectural Implications:
- API-primary data access via React Query (server state management)
- 3-state connection monitor (ONLINE / DEGRADED / OFFLINE)
- 2-table SQLite fallback (
product_cache+sales_queue) - FIFO queue flush on reconnection
- Flag-on-sync price discrepancy for manager review
Online-First Justification
The online-first design keeps POS terminals operational under all network conditions while avoiding the complexity of full local database replication:
Online-First with Offline Fallback
====================================
Normal Operation (ONLINE):
All reads via React Query → Central API → PostgreSQL
Real-time prices, inventory, customer data
No local database sync overhead
Intermittent Connectivity (DEGRADED):
Reads continue via React Query (stale-while-revalidate)
Writes queue locally in sales_queue table
Socket.io heartbeat detects degraded state
Network Outage (OFFLINE):
Product lookups from product_cache (read-only SQLite)
Sales written to sales_queue (FIFO)
POS continues processing sales with cached prices
Reconnection:
sales_queue entries flush to API in FIFO order
Server applies current prices, flags discrepancies
product_cache refreshes from API
The online-first design is governed by five core principles (from Ch 04, Section L.10A.1 / ADR-048):
| Principle | Description |
|---|---|
| API-Primary Data Access | All reads go through React Query with server-side caching. React Query handles background refetching, stale-while-revalidate, and retry logic. |
| 3-State Connection Monitor | ONLINE (full API access), DEGRADED (intermittent, queue writes), OFFLINE (local fallback only). Transitions based on Socket.io heartbeat + HTTP health check. |
| 2-Table SQLite Fallback | product_cache (read-only product/price lookups) and sales_queue (FIFO write queue for completed sales). No full local database replication. |
| FIFO Queue Flush | When connection restores, sales_queue entries are submitted to the API in order. No conflict resolution needed — server applies current prices and flags discrepancies. |
| Flag-on-Sync Price Discrepancy | If a sale was completed offline with a cached price that differs from the current server price, the system flags it for manager review rather than auto-adjusting. |
Event Sourcing Supports Offline Queue
Event sourcing (Ch 04 Section L.4A) supports availability by providing a natural model for the offline sales queue. When the POS client is offline, completed sales are captured as immutable events in the local sales_queue. When connectivity is restored, these events are submitted to the central API in FIFO order. Because each event has a unique ID and the server is the authority on current state, the queue flush is straightforward — no conflict resolution or event merging is required. The server accepts each sale, applies current pricing, and flags any discrepancies for review.
K.2.2 Interoperability (Rank 2)
| Attribute | Value |
|---|---|
| Definition | The ability of the system to interface and interact with other systems to complete a business request. |
| Priority | Critical |
| Blueprint Reference | Ch 05: BRD Module 6 (Integrations) |
Justification:
BRD v18.0 Module 6 defines integration with 6 provider families across fundamentally different protocols:
| Provider Family | Auth Model | Rate Limiting | Sync Cadence |
|---|---|---|---|
| Shopify | OAuth 2.0 / PKCE | 50 points/second (GraphQL) | Real-time webhooks |
| Amazon SP-API | OAuth / Login with Amazon (LWA) | Burst + restore token bucket | 2-minute polling |
| Google Merchant | API key + Service Account | Quota-based (daily limits) | 2x/day batch + real-time local inventory |
| Payment Processor | API key / OAuth | Per-transaction | Real-time |
| Email (SMTP) | SMTP credentials | Provider-specific | Event-triggered |
| Carrier APIs | API key | Per-request | On-demand |
The system must maintain an Anti-Corruption Layer (ACL) per provider to prevent external schema changes from propagating into the core POS domain. BRD Section 6.2 mandates:
- Provider Abstraction:
IntegrationProviderinterface with 5 standard methods per provider:connect(),syncProducts(),syncInventory(),validateData(),healthCheck() - Circuit Breaker: 5 failures within 60 seconds triggers OPEN state; 30-second cooldown before HALF_OPEN
- Idempotency Framework: 24-hour deduplication windows with SHA-256 keying (Section 6.2.5)
- Transactional Outbox: Atomic inventory reservation + guaranteed event publication
Architectural Implications:
- Anti-Corruption Layer (ACL) per provider preventing external schema leakage
- Provider Abstraction pattern (
IntegrationProvider) for uniform integration interface - Circuit breaker pattern (CLOSED → OPEN → HALF_OPEN state machine)
- Idempotency framework with configurable dedup windows
- Transactional Outbox for guaranteed event delivery
- Rate limiter per provider with adaptive throttling
K.2.3 Data Consistency (Rank 3)
| Attribute | Value |
|---|---|
| Definition | The data across the system is in sync and consistent across databases and tables. |
| Priority | Critical |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.4A |
Justification:
Critical for handling the “Inventory Sync” race conditions between physical shoppers and online orders to prevent overselling.
Architectural Implications:
- Event Sourcing for Sales and Inventory domains
- Eventual consistency model with conflict resolution
- Optimistic concurrency control
- Idempotent event handlers
Event Sourcing Justification
Traditional CRUD systems store only current state. Event sourcing stores every change as an immutable event, providing the full history needed for audit trails, temporal queries, and offline conflict resolution (from Ch 04 Section L.4A):
Traditional CRUD vs Event Sourcing
==================================
CRUD Approach:
+------------------+
| inventory_items |
|------------------|
| sku: NXP001 |
| quantity: 45 | <- Only current state
| updated_at: now |
+------------------+
Event Sourcing Approach:
+------------------+
| events |
|------------------|
| InventoryReceived: +100 @ 2025-01-01 09:00 |
| ItemSold: -2 @ 2025-01-01 10:15 |
| ItemSold: -1 @ 2025-01-01 11:30 |
| ItemSold: -3 @ 2025-01-01 14:22 |
| AdjustmentMade: -49 @ 2025-01-01 16:00 | <- Caught discrepancy!
| ItemSold: -1 @ 2025-01-02 09:15 |
| Current State: 45 (sum of all events) |
+------------------+
Benefits for Retail POS:
| Benefit | Description |
|---|---|
| Complete Audit Trail | Every sale, void, refund, adjustment is recorded forever |
| Temporal Queries | “What was our inventory on December 15th at 3pm?” |
| Offline Sync | Events queue locally, merge when online |
| Conflict Resolution | Compare event streams, not states |
| Debugging | Replay events to reproduce issues |
| Compliance | PCI-DSS, SOX require transaction logs |
K.2.4 Security (Rank 4 - Elevated)
| Attribute | Value |
|---|---|
| Definition | The ability of the system to prevent malicious actions, protect credentials, and restrict access across all trust boundaries. |
| Priority | Elevated (due to multi-platform OAuth, GenAI code generation, PCI-DSS 4.0) |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.8 (Security) |
Justification:
BRD v18.0 elevates security from basic PCI-DSS to 5 concrete security sub-domains:
| Security Sub-Domain | Scope | Key Requirements |
|---|---|---|
| 1. Authentication & Authorization | Multi-provider OAuth lifecycle | 3 OAuth providers (Shopify, Amazon LWA, Google), MFA for admin users (PCI-DSS 4.0 Req 8.4.2), role-based access control |
| 2. Credential Lifecycle Management | Secrets vault and rotation | HashiCorp Vault for 6 credential types, automated 90-day rotation, tenant-specific encryption keys, emergency rotation procedures |
| 3. Supply Chain Security | Dependency and package safety | Snyk/OWASP SCA with package firewall, SBOM generation (PCI-DSS 4.0 Req 6.3.2), real-time vulnerability scanning |
| 4. GenAI Governance | AI-generated code safety | 6-gate Security Test Pyramid: SAST + SCA + Secrets Detection + Architecture Conformance (dependency-cruiser) + Contract Tests (Pact) + Manual Security Review |
| 5. PCI-DSS 4.0 Compliance | Payment card security | SAQ-A boundaries, FIM via Wazuh/OSSEC (Req 11.5.1), session management, audit trail retention (365 days), vulnerability scanning (Req 11.3.1) |
Architectural Implications:
- HashiCorp Vault (Docker container) for centralized credential management
- 6-gate Security Test Pyramid in CI/CD pipeline
- Wazuh/OSSEC agents on all POS terminals for File Integrity Monitoring
- Architecture conformance tests (dependency-cruiser) enforcing module boundaries
- Pact contract tests against Shopify/Amazon/Google sandbox APIs
- Audit trail with INTEGRATION category for OAuth operations and webhook verification
POS Security Layers
The system implements a 5-layer defense-in-depth security model (from Ch 04 Section L.9A):
Security Layers
===============
+------------------------------------------------------------------+
| INTERNET |
+---------------------------+--------------------------------------+
|
v
+---------------------------+--------------------------------------+
| TLS TERMINATION |
| (Let's Encrypt) |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| API GATEWAY |
| +-----------------------+ +-----------------------+ |
| | Rate Limiting | | IP Whitelisting | |
| | 100 req/min/client | | (Nexus POS only) | |
| +-----------------------+ +-----------------------+ |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| AUTHENTICATION |
| +-----------------------+ +-----------------------+ |
| | JWT Validation | | PIN Verification | |
| | - Signature check | | - Employee clock-in | |
| | - Expiry check | | - Sensitive actions | |
| | - Tenant claim | +-----------------------+ |
| +-----------------------+ |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| AUTHORIZATION |
| +-----------------------+ +-----------------------+ |
| | Role-Based (RBAC) | | Permission Policies | |
| | - Admin | | - can:create_sale | |
| | - Manager | | - can:void_sale | |
| | - Cashier | | - can:view_reports | |
| +-----------------------+ +-----------------------+ |
+------------------------------------------------------------------+
Each layer provides independent protection: TLS encrypts data in transit, the API gateway enforces rate limits and IP restrictions, JWT authentication validates identity and tenant context, PIN verification secures sensitive in-store actions, and RBAC authorization controls access to specific operations.
Tenant Data Isolation as Security Evidence
Row-Level Security (RLS) isolation (from Ch 04 Section L.10A.4) provides a strong security boundary enforced at the PostgreSQL database level. Every tenant table includes a tenant_id column and an RLS policy that automatically filters queries by the tenant context set in the connection session variable. Even if application code omits a WHERE tenant_id = ? clause, PostgreSQL’s RLS policies prevent cross-tenant data access:
-- RLS policy on every tenant table
CREATE POLICY tenant_isolation ON products
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Middleware sets tenant context from JWT claims
SET app.current_tenant = 'uuid-of-nexus-tenant';
-- Query automatically filtered by RLS — returns only Nexus products
SELECT * FROM products;
-- Even a query without WHERE clause is safe — RLS enforces isolation
SELECT * FROM orders;
-- Returns only orders for the current tenant
K.2.5 Compliance (Rank 5 - NEW)
| Attribute | Value |
|---|---|
| Definition | Adherence to regulatory standards, platform marketplace policies, and legal requirements across all operating jurisdictions and external channels. |
| Priority | Critical |
| Blueprint Reference | Ch 05: BRD Module 6 (Integrations) |
Justification:
BRD v18.0 introduces non-negotiable compliance requirements from 3 external platforms plus existing regulatory frameworks:
| Compliance Domain | Requirements | Impact |
|---|---|---|
| PCI-DSS SAQ-A | No card data stored, tokenized payments, FIM on POS terminals | Payment architecture, audit trail, monitoring |
| Amazon SP-API | Product taxonomy compliance, FBA packaging rules, listing quality standards, content policy enforcement | Catalog validation, product data enrichment |
| Google Merchant | Product data specifications, disapproval prevention, local inventory accuracy, API v1 migration (Content API EOL August 2026) | Data quality engine, inventory sync accuracy |
| Shopify | @idempotent mutation mandate (required 2026-04), webhook verification, POS non-native compliance rules (Decision #99) | API client design, idempotency framework |
| State Regulations | Virginia 5-year gift card minimum expiry, consumer protection, data privacy | Configuration per jurisdiction |
Architectural Implications:
- Platform policy validation engine (“strictest-rule-wins” cross-platform validation per BRD Section 6.6)
- Automated compliance checking on product data before channel publication
- Credential rotation policies per platform requirement
- Audit trail for all external interactions with INTEGRATION event category
- Jurisdiction-aware configuration (geographic expansion design from ADR-BRD-006)
Tenant Isolation Compliance Benefits
Row-Level Security (RLS) isolation (from Ch 04 Section L.10A.4) directly supports SOC 2, GDPR, and HIPAA compliance requirements:
SOC 2 / GDPR Compliance
=======================
Requirement: "Customer data must be logically separated"
With Row-Level Security (RLS):
- Every table has tenant_id + RLS policy enforced at database level
- RLS policies automatically filter queries — no risk of missing WHERE clause
- Clear audit trail per tenant_id
- Data export for GDPR: SELECT * FROM customers WHERE tenant_id = :id
- Data deletion for "right to be forgotten": DELETE cascading on tenant_id
- Defense-in-depth: even buggy application code cannot leak cross-tenant data
Per-tenant data export is achieved via COPY (SELECT * FROM table WHERE tenant_id = :id) TO STDOUT, making data portability and right-to-erasure requests straightforward. RLS enforcement at the database level provides defense-in-depth isolation that does not depend on application-level query correctness.
K.2.6 Modifiability (Rank 6)
| Attribute | Value |
|---|---|
| Definition | The ease with which a system can adapt to changes in environment and functionality. |
| Priority | High |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.9A |
Justification:
Plugin architecture is needed for frequent hardware/tax updates without full system rewrites.
Architectural Implications:
- Microkernel (Plugin) architecture for Nexus POS
- Hardware abstraction layer
- Tax calculation plugins
- Payment processor adapters
K.2.7 Scalability (Rank 7)
| Attribute | Value |
|---|---|
| Definition | Degree to which a product can handle growing or shrinking workloads. |
| Priority | High |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.10A.4 |
Justification:
At some point the system must be able to grow to accommodate the number of new tenants.
Architectural Implications:
- Row-Level Isolation with PostgreSQL RLS (tenant_id + RLS policies)
- Horizontal scaling of stateless API layer
- Connection pooling per tenant
- Resource quotas and throttling
K.2.8 Configurability (Rank 8 - ELEVATED from implicit)
| Attribute | Value |
|---|---|
| Definition | The ability of the system to support multiple configurations and customize behavior on-demand per tenant, channel, and product level. |
| Priority | High |
| Blueprint Reference | Ch 04: Architecture Styles, Section L.10A.4, BRD Module 5 (Setup & Configuration) |
Justification:
BRD Module 5 spans 3,000+ lines of setup and configuration requirements. The system must support hierarchical configuration at multiple levels:
| Configuration Layer | Scope | Examples |
|---|---|---|
| Global | All tenants, all channels | System defaults, tax engine rules |
| Tenant | Per-tenant overrides | Feature toggles, branding, business rules |
| Channel | Per-channel per-tenant | Safety buffer modes, sync frequency, listing rules |
| Product | Per-product per-channel | Override safety buffer, pricing rules, visibility |
Key configuration complexity drivers:
- Safety Buffers: 4-level priority resolution (Product → Category → Channel → Global) with 3 calculation modes (FIXED, PERCENTAGE, MIN_RESERVE) per BRD Section 6.7.2
- Integration YAML: Section 6.12 defines 400+ lines of declarative integration configuration
- Feature Toggles: Per-tenant inventory sync strategy (Safe vs. Aggressive), channel enablement
- Tax Jurisdictions: Modular jurisdiction support with geographic expansion (ADR-BRD-006)
Architectural Implications:
- Hierarchical configuration resolution with 4-level priority
- YAML-driven integration rules (machine-readable, version-controlled)
- Per-tenant per-channel safety buffer settings
- Runtime configuration hot-reload without service restart
- Configuration validation engine preventing invalid combinations
K.2.9 Performance (Rank 9)
| Attribute | Value |
|---|---|
| Definition | The amount of time it takes for the system to process a business request. |
| Priority | High |
| Blueprint Reference | Ch 09: Indexes & Performance |
Justification:
Low latency scanning/checkout is required to prevent queues during high traffic. See also Ch 04 Section L.6 for TPS targets and latency budgets.
Architectural Implications:
- Optimized database indexes
- Read replicas for query-heavy operations
- Caching strategies (Redis)
- Async processing for non-critical operations
Multi-Tenant Performance Considerations
Row-Level Security isolation (from Ch 04 Section L.10A.4) has specific performance implications for connection pooling and query execution:
Connection Pooling:
Connection Pool Strategy (Row-Level Security)
==============================================
+------------------+
| Connection Pool |
| (PgBouncer) |
+--------+---------+
|
+--------------------+--------------------+
| | |
v v v
+-------+-------+ +--------+------+ +---------+-----+
| Connection 1 | | Connection 2 | | Connection 3 |
| Shared pool | | Shared pool | | Shared pool |
| All tenants | | All tenants | | All tenants |
+---------------+ +---------------+ +---------------+
Tenant context set per-transaction via middleware:
SET LOCAL app.current_tenant_id = '<tenant-uuid>';
Use transaction pooling mode in PgBouncer.
Connections are shared across all tenants (no per-tenant pools).
Query Performance:
Row-Level Security uses composite indexes on (tenant_id, ...) so that the RLS policy filter and application query merge into a single efficient index scan:
-- Composite index ensures tenant_id filter is nearly free
CREATE INDEX idx_products_tenant_sku ON products(tenant_id, sku);
CREATE INDEX idx_sales_tenant_date ON sales(tenant_id, sale_date);
-- RLS policy automatically appends tenant filter
-- Application writes simple queries:
SELECT * FROM products WHERE sku = 'NXP0001';
-- PostgreSQL rewrites to:
SELECT * FROM products
WHERE sku = 'NXP0001'
AND tenant_id = current_setting('app.current_tenant_id')::uuid;
The composite index prefix on tenant_id ensures the RLS predicate adds negligible overhead — PostgreSQL’s query planner merges the policy filter with application predicates for a single index scan. Shared tables also improve cache utilization for common catalog data.
Tenant Performance Isolation
Row-Level Security provides logical performance isolation between tenants. Composite indexes on (tenant_id, ...) ensure each tenant’s queries scan only their own rows, so a tenant with a large product catalog does not degrade query performance for other tenants. Maintenance operations can target tenant-specific data using partial operations:
-- Analyze statistics for tenant-heavy tables
VACUUM ANALYZE products;
VACUUM ANALYZE sales;
-- Partial reindex targeting specific indexes
REINDEX INDEX CONCURRENTLY idx_products_tenant_sku;
-- Monitor per-tenant table bloat via pg_stat_user_tables
-- Alert when dead_tup_ratio exceeds threshold
K.3 Implicit Characteristics
These characteristics are inherently required but not explicitly driving architectural decisions.
| Characteristic | Definition | Justification |
|---|---|---|
| Developer Experience (DevEx) | The ease with which developers can interact with the system’s tools, code, and processes. | Security Enabler: High “False Positive” rates from surface-level scanners cause developers to bypass security. We prioritize Deep SAST (accuracy) and AI-Remediation to ensure security does not degrade velocity. |
| Idempotency | The guarantee that repeating the same operation produces the same result without side effects. | BRD Section 6.2.5 mandates an idempotency framework with 24-hour deduplication windows and SHA-256 keying. Shopify @idempotent mutations become mandatory 2026-04. Critical for retry-safe integration operations. |
| Testability | The degree to which the system supports testing at all levels. | BRD v18.0 defines 36 user stories with Gherkin acceptance criteria. Three platform sandboxes (Shopify Dev Store, Amazon SP-API Sandbox, Google Merchant test account) require contract testing. Architecture must support isolation for unit, integration, and E2E tests. |
| Observability | The ability to understand system state from external outputs (logs, metrics, traces). | Multi-platform monitoring across 3 external channels requires first-class treatment. Integration-specific metrics: circuit breaker state, DLQ depth, sync latency, safety buffer violations, disapproval rate. LGTM stack (Loki, Grafana, Tempo, Prometheus). |
| Modularity | Degree to which a system is composed of discrete components. | Update tax logic without breaking inventory system. Module boundaries must be clean enough for independent Claude Code agent development. |
| Fault Tolerance | When fatal errors occur, other parts of the system continue to function. | Local client survival is required; POS must function if cloud crashes. Integration circuit breaker prevents external API failures from cascading to core POS operations. |
| Adaptability | Degree to which a product can be adapted for new environments. | Rapid adoption of new retail trends (social commerce). Module 6 designed as Extractable Integration Gateway for future independent deployment. |
K.4 Others Considered
These characteristics were evaluated but not prioritized as driving characteristics:
| Characteristic | Why Not Selected |
|---|---|
| Recoverability | Covered by Availability + Event Sourcing (replay capability) |
| Safety & Code Quality | Addressed through Security characteristic (6-gate Security Test Pyramid) and DevSecOps pipeline |
K.5 Characteristic Trade-offs
Understanding trade-offs between characteristics is critical for making consistent architectural decisions.
Trade-off Matrix
+------------------+------------------+------------------+------------------+------------------+
| | AVAILABILITY | CONSISTENCY | COMPLIANCE | CONFIGURABILITY |
+------------------+------------------+------------------+------------------+------------------+
| AVAILABILITY | - | TENSION | NEUTRAL | SUPPORTS |
| (Online+Fallback)| | (Eventual Sync) | | (Local config) |
+------------------+------------------+------------------+------------------+------------------+
| CONSISTENCY | TENSION | - | SUPPORTS | TENSION |
| (Data Sync) | (Offline Mode) | | (Audit Trail) | (Config changes) |
+------------------+------------------+------------------+------------------+------------------+
| COMPLIANCE | NEUTRAL | SUPPORTS | - | SUPPORTS |
| (Regulations) | | (Audit Trail) | | (Jurisdiction) |
+------------------+------------------+------------------+------------------+------------------+
| CONFIGURABILITY | SUPPORTS | TENSION | SUPPORTS | - |
| (Multi-level) | (Local config) | (Config changes) | (Jurisdiction) | |
+------------------+------------------+------------------+------------------+------------------+
| PERFORMANCE | SUPPORTS | TENSION | TENSION | TENSION |
| (Low Latency) | (Local Cache) | (Sync Overhead) | (Validation) | (Resolution) |
+------------------+------------------+------------------+------------------+------------------+
| SECURITY | TENSION | SUPPORTS | SUPPORTS | NEUTRAL |
| (Deep Scans) | (Scan Time) | (Audit Trail) | (PCI-DSS) | |
+------------------+------------------+------------------+------------------+------------------+
Key Trade-off Decisions
| Trade-off | Decision | Rationale |
|---|---|---|
| Availability vs. Consistency | Accept Eventual Consistency | Online-first with offline fallback (ADR-048); inventory sync can tolerate short delays during brief outages |
| Performance vs. Security | 6-gate Security Pyramid with CI/CD gates | Security gates run in CI/CD pipeline, not at runtime; only contract tests add deployment time |
| Performance vs. Compliance | Async platform validation | Cross-platform validation runs asynchronously before channel publication; does not block POS checkout |
| Scalability vs. Simplicity | Row-Level Isolation with RLS in Modular Monolith | Full tenant isolation via PostgreSQL RLS without schema-per-tenant or microservices complexity |
| Compliance vs. Performance | Strictest-rule-wins cached validation | Validation rules cached and applied at publish-time, not checkout-time |
K.6 Characteristic-to-Chapter Mapping
Quick reference for finding characteristic implementations in the blueprint:
| Characteristic | Primary Chapters | Key Sections |
|---|---|---|
| Availability | Ch 04(L.10A.1) | Online-First with Offline Fallback (ADR-048) |
| Interoperability | Ch 05 Module 6 | Integration Patterns, ACL, Provider Abstraction |
| Data Consistency | Ch 04(L.4A), Ch 07 | Event Sourcing, Schema Design |
| Security | Ch 04(L.8) | 6-Gate Security Pyramid, PCI-DSS Compliance |
| Compliance | Ch 05 Module 6 | Platform Policy Validation, Regulatory Compliance |
| Modifiability | Ch 04(L.9A) | Plugin Architecture, Hardware Layer |
| Scalability | Ch 04(L.10A.4) | Multi-Tenancy (RLS) |
| Configurability | Ch 04(L.10A.4), Ch 05 Module 5 | Feature Toggles, Safety Buffers, YAML Config |
| Performance | Ch 09, Ch 04(L.6) | Indexes, TPS targets and latency budgets |
| DevEx | Ch 04(L.6) | QA & Testing Strategy |
| Idempotency | Ch 05 Module 6 | Idempotency Framework, Dedup Windows |
| Testability | Ch 04(L.6) | Contract Testing, Platform Sandboxes |
| Observability | Ch 04(L.7) | LGTM Stack, Integration Metrics |
| Modularity | Ch 04(L.9A), Ch 04(L.9C) | Domain Model, Module Boundaries |
| Fault Tolerance | Ch 04(L.10A.1) | Online-First with Offline Fallback, Circuit Breaker |
| Adaptability | Ch 05 Module 6 | Integration Adapters, Extractable Gateway |
K.7 Review Schedule
| Review Type | Frequency | Trigger Events |
|---|---|---|
| Scheduled Review | Quarterly | - |
| Event-Driven Review | As needed | New integration requirements, Security incidents, Performance degradation, New tenant requirements |
K.8 Non-Functional Requirements (NFRs)
This section defines measurable targets that validate the architecture characteristics. All NFRs are traceable to BRD requirements.
K.8.1 Performance Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-PERF-001 | Checkout Latency | < 500ms p99 | BRD-v20 (implied) | Load testing |
| NFR-PERF-002 | RFID Bulk Lookup | < 200ms for 50 tags | BRD-v20 §1.1 | E2E testing |
| NFR-PERF-003 | Price Calculation | < 100ms | BRD-v20 §1.2 | Unit testing |
| NFR-PERF-004 | Tax Calculation | < 50ms | BRD-v20 §1.17 | Unit testing |
| NFR-PERF-005 | Product Search | < 300ms | Implicit | Load testing |
| NFR-PERF-006 | Receipt Generation | < 200ms | Implicit | E2E testing |
Performance Budget:
Total Checkout Time Budget: 500ms
├── Item Lookup: 100ms
├── Price Calculation: 100ms
├── Tax Calculation: 50ms
├── Payment Processing: 150ms (excluding terminal wait)
└── Receipt/Finalize: 100ms
K.8.2 Availability Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-AVAIL-001 | Cloud API Uptime | 99.9% (8.76 hrs/year downtime) | Implicit | Monitoring |
| NFR-AVAIL-002 | Offline Queue Size | Max 100 transactions | BRD-v20 §1.16.2 | Configuration |
| NFR-AVAIL-003 | Sync Interval | 30 seconds | BRD-v20 §1.16.2 | Configuration |
| NFR-AVAIL-004 | Parked Sale TTL | 4 hours | BRD-v20 §1.1.1 | Configuration |
| NFR-AVAIL-005 | Parked Sales per Terminal | Max 5 | BRD-v20 §1.1.1 | Configuration |
| NFR-AVAIL-006 | Payment Terminal Timeout | 60 seconds | BRD-v20 §1.18.2 | Configuration |
| NFR-AVAIL-007 | Connection Timeout | 10 seconds | BRD-v20 §1.18.2 | Configuration |
Availability Tiers:
Cloud Services: 99.9% (allows ~8.76 hrs downtime/year)
POS Terminal: 99.99% (via online-first architecture with thin offline fallback per ADR-048 — brief network outages handled by 2-table SQLite cache and FIFO sales queue)
Database (Primary): 99.95% (with automatic failover)
K.8.3 Scalability Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-SCALE-001 | Concurrent Users | 500 (Black Friday peak) | BRD-v20 (implied) | Load testing |
| NFR-SCALE-002 | Transactions per Second | 1,000 TPS | Chapter 04 L.6 | Load testing |
| NFR-SCALE-003 | Tenant Count | 100+ tenants | Chapter 03 | Architecture |
| NFR-SCALE-004 | Export Row Limit | 1,000 rows max | BRD-v20 §2.5 | Configuration |
| NFR-SCALE-005 | Date Range for Reports | 365 days max | BRD-v20 YAML | Configuration |
| NFR-SCALE-006 | RFID Tags per Request | 50 max | BRD-v20 §1.1 | Configuration |
Scaling Strategy:
Stateless API Layer: Horizontal scaling (Kubernetes HPA)
Database: Vertical scaling + Read replicas + RLS per tenant
Event Stream: PostgreSQL event tables (v1.0), Kafka partitioning (v2.0)
File Storage: Object storage (S3-compatible)
K.8.4 Integration & Timeout Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-INT-001 | Payment Timeout | 60 seconds | BRD-v20 §1.18.2 | Configuration |
| NFR-INT-002 | Connection Timeout | 10 seconds | BRD-v20 §1.18.2 | Configuration |
| NFR-INT-003 | Multi-Store Data Staleness | Max 5 minutes | BRD-v20 §1.7 | Monitoring |
| NFR-INT-004 | Batch Close Time | 23:00 daily | BRD-v20 §1.18.2 | Configuration |
| NFR-INT-005 | External API Retry | 3 attempts with backoff | Implicit | Configuration |
| NFR-INT-006 | Webhook Delivery | At-least-once | Implicit | Architecture |
Integration Patterns:
Synchronous: REST APIs with circuit breaker
Asynchronous: Event-driven via PostgreSQL Events + LISTEN/NOTIFY (v1.0)
Webhooks: Inbound (Shopify/Amazon) + Outbound with Transactional Outbox
File Transfer: SFTP/S3 for bulk imports
K.8.5 Data & Compliance Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-DATA-001 | Consent Audit Retention | 7 years | BRD-v20 YAML | Policy |
| NFR-DATA-002 | Privacy Request Response | 30 days | BRD-v20 §2.5 | Process |
| NFR-DATA-003 | Transaction Data Retention | 7 years (tax compliance) | Implicit | Policy |
| NFR-DATA-004 | Gift Card Minimum Expiry | 5 years (Virginia) | BRD-v20 §1.5.2 | Configuration |
| NFR-DATA-005 | Auto-Anonymize Inactive | Configurable (0 = never) | BRD-v20 YAML | Configuration |
Data Classification:
Level 1 (Restricted): Card data (prohibited storage)
Level 2 (Sensitive): Customer PII, credentials
Level 3 (Internal): Transaction data, inventory
Level 4 (Public): Product catalog, store hours
K.8.6 Security Requirements
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-SEC-001 | PCI Scope | SAQ-A (no card data stored) | BRD-v20 §1.18 | PCI Audit |
| NFR-SEC-002 | Payment Data Storage | Token only, no PAN | BRD-v20 §1.18.1 | Code review |
| NFR-SEC-003 | Manager Auth for Overrides | PIN required | BRD-v20 §1.2 | E2E testing |
| NFR-SEC-004 | Blind Count | Expected not shown | BRD-v20 §1.12 | UI testing |
| NFR-SEC-005 | Variance Tolerance | Configurable ($5 default) | BRD-v20 §1.12 | Configuration |
| NFR-SEC-006 | Session Timeout | 15 minutes inactivity | Implicit | Configuration |
| NFR-SEC-007 | Password Policy | Min 12 chars, complexity | Implicit | Configuration |
SAQ-A Compliance - Data Storage Rules:
STORED (Allowed):
- Payment tokens
- Approval codes
- Masked card number (****1234)
- Card brand (Visa, MC, etc.)
- Terminal ID
PROHIBITED (Never store):
- Full card number (PAN)
- CVV/CVC
- Track data
- PIN block
- EMV cryptogram (raw)
K.8.7 Integration Requirements (BRD v18.0)
| Requirement ID | Category | Target | Source | Validation Method |
|---|---|---|---|---|
| NFR-INTG-001 | Shopify Sync Latency | < 5 seconds (webhook processing) | BRD-v18 §6.3 | Integration testing |
| NFR-INTG-002 | Amazon Sync Latency | < 2 minutes (polling interval) | BRD-v18 §6.4 | Integration testing |
| NFR-INTG-003 | Google Batch Sync | 2x/day + real-time local inventory | BRD-v18 §6.5 | Integration testing |
| NFR-INTG-004 | Circuit Breaker Threshold | 5 failures / 60 seconds → OPEN | BRD-v18 §6.2.4 | Unit testing |
| NFR-INTG-005 | DLQ Retry Policy | 3 attempts, exponential backoff | BRD-v18 §6.2.3 | Integration testing |
| NFR-INTG-006 | Safety Buffer Calculation | < 100ms per product per channel | BRD-v18 §6.7.2 | Performance testing |
| NFR-INTG-007 | Integration Health Check | Every 60 seconds per provider | BRD-v18 §6.11 | Monitoring |
| NFR-INTG-008 | Idempotency Window | 24-hour dedup with SHA-256 key | BRD-v18 §6.2.5 | Unit testing |
| NFR-INTG-009 | Credential Rotation | Automated every 90 days | BRD-v18 §6.2.2 | Operations |
Integration Performance Budget:
Shopify Webhook Processing: < 5s
├── Receive + Validate Signature: 100ms
├── Deserialize + Map to Domain: 200ms
├── Business Logic Processing: 2,000ms
├── Database Persistence: 500ms
└── Outbox Event Publication: 200ms
(Buffer): 2,000ms
Amazon SP-API Polling Cycle: < 2min
├── OAuth Token Refresh (if needed): 500ms
├── API Call (paginated): 5,000ms
├── Response Mapping: 1,000ms
├── Inventory Delta Calculation: 2,000ms
└── Database + Outbox: 1,500ms
(Buffer): 110,000ms
K.8.8 NFR Traceability Matrix
This matrix links NFRs to Architecture Characteristics:
| Characteristic | Related NFRs |
|---|---|
| Availability | NFR-AVAIL-001 through NFR-AVAIL-007 |
| Performance | NFR-PERF-001 through NFR-PERF-006 |
| Scalability | NFR-SCALE-001 through NFR-SCALE-006 |
| Security | NFR-SEC-001 through NFR-SEC-007 |
| Interoperability | NFR-INT-001 through NFR-INT-006 |
| Data Consistency | NFR-DATA-001 through NFR-DATA-005 |
| Compliance | NFR-DATA-001 through NFR-DATA-005, NFR-INTG-001 through NFR-INTG-009 |
| Configurability | NFR-INTG-006 (Safety Buffer), platform-specific targets |
NFR Validation Schedule
| NFR Category | Validation Frequency | Responsible Team |
|---|---|---|
| Performance | Every release + quarterly load test | QA + DevOps |
| Availability | Continuous monitoring | DevOps |
| Scalability | Quarterly load test | DevOps |
| Security | Annual PCI audit + continuous scans | Security |
| Integration | Every release + monthly provider sync | QA + Integration Team |
| Compliance | Annual audit | Compliance |
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2026-01-24 |
| Updated | 2026-03-02 |
| Source | Architecture Characteristics Worksheet v2.0, BRD-v18.0, Chapters 02/03/05/06 |
| Author | Claude Code |
| Reviewer | Expert Panel (Marcus Chen, Sarah Rodriguez, James O’Brien, Priya Patel) |
| Status | Active |
| Part | II - Architecture |
| Chapter | 03 of 9 |
| Previous | Chapter 11 v1.1.0 (pre-restructure numbering; backup at Chapter-11-Architecture-Characteristics.md.backup-v18.0) |
Change Log
| Version | Date | Changes |
|---|---|---|
| 1.0.0 | 2026-01-24 | Initial document |
| 1.1.0 | 2026-01-26 | Added Section K.8 (Non-Functional Requirements) with 37 NFRs from BRD-v20 |
| 2.0.0 | 2026-02-19 | Expert panel review (6.50/10): Expanded to Top 9 driving characteristics (added Compliance, elevated Configurability); rewrote Interoperability with 6 provider families, 3 auth models, 3 rate-limiting paradigms; rewrote Security with 5 concrete sub-domains and 6-gate Security Test Pyramid; added Idempotency, Testability, Observability as implicit; added K.8.7 Integration Requirements (9 NFRs from BRD v18.0 Module 6); updated multi-tenancy from Schema-Per-Tenant to Row-Level with RLS; updated event infrastructure from Kafka to PostgreSQL Events (v1.0) |
| 3.0.0 | 2026-02-22 | Enriched with cross-chapter evidence from former Ch 05/06/08/09; all chapter references renumbered for v3.0.0 (39-chapter to 34-chapter consolidation) |
| 5.2.0 | 2026-02-27 | Fixed 6 schema-per-tenant references → Row-Level Security (RLS) with tenant_id + current_setting('app.current_tenant_id'). Updated connection pooling diagram, query performance, tenant isolation, security evidence, and compliance sections |
| 7.0.0 | 2026-03-02 | Unified web app pivot: Nexus Admin → Nexus POS in API gateway IP whitelisting diagram. All footers to 7.0.0 |
Next Chapter: Chapter 04: Architecture Styles Analysis
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 04: Architecture Styles Analysis
Purpose
This chapter documents the formal architecture styles evaluation for the Nexus POS Platform. It provides the decision rationale for selecting the primary architecture style and supporting patterns, updated per expert panel review against BRD v18.0.
Source: Architecture Styles Worksheet v2.0 (Expert Panel-Reviewed) Project: POS Platform (Nexus) Architect/Team: Cloud AI Architecture Agents Date: February 19, 2026 Panel Review Score: 6.50/10 → Updated per 4-member expert panel recommendations
L.1 Candidate Architecture Styles
Based on the identified driving characteristics (Availability, Interoperability, Data Consistency), the following architecture styles were evaluated.
L.1.1 Event-Driven Architecture (EDA)
| Attribute | Value |
|---|---|
| Description | A distributed asynchronous architecture pattern used to produce highly scalable and high-performance applications. |
| Relevance to Nexus | Deeply aligned with “Interoperability” and “Data Consistency” (Sync) requirements. External channels (Amazon, Shopify) and local POS terminals produce disjointed events that must be reconciled eventually. |
| Decision | Selected (Communication Layer) |
| Key Technology | PostgreSQL Event Tables + LISTEN/NOTIFY (v1.0); Apache Kafka (v2.0, when scale justifies) |
v18.0 Update: BRD designs around PostgreSQL tables for
idempotency_recordsandintegration_dead_letters(not Kafka topics). Amazon SP-API polls every 2 minutes; Google Merchant batches 2x/day. Streaming infrastructure is not required at launch. PostgreSQL event tables with LISTEN/NOTIFY provide sufficient event notification for v1.0. Kafka adoption deferred to v2.0 when transaction volume or real-time analytics requirements justify the operational overhead (ZooKeeper/KRaft cluster management).
L.1.2 Microservices Architecture
| Attribute | Value |
|---|---|
| Description | An architecture style that structures an application as a collection of loosely coupled services, each with its own database. |
| Relevance to Nexus | Evaluated for “Scalability,” but rejected as the primary style for the Core API. |
| Decision | Rejected |
| Rationale | The operational complexity of managing separate databases for 50+ services is unnecessary for the current scale. |
L.1.3 Microkernel (Plugin) Architecture
| Attribute | Value |
|---|---|
| Description | A core system with a plugin interface to add additional features. |
| Relevance to Nexus | Directly addresses the “Modifiability” requirement. The Blueprint specifies “Integration Adapters” (Payment, Tax) and a “Hardware Layer” in the client, fitting this pattern. |
| Decision | Selected (Client) |
L.1.4 Modular Monolith (Layered) Architecture
| Attribute | Value |
|---|---|
| Description | A single deployable unit (“Central API”) structured into distinct, loosely coupled modules (Catalog, Sales, Inventory) that enforce strict boundaries. |
| Relevance to Nexus | High Fit. The Blueprint describes a “Central API Layer” (Stateless) containing all core services. This offers the modularity of microservices without the distributed complexity, aligning with the “Simplicity” and “Maintenance” goals. |
| Decision | Selected (Core API) |
v18.0 Update — Extractable Integration Gateway: Module 6 (Integrations, 4,800+ lines) is designed as a logically separate module within the monolith with explicit boundary contracts:
IIntegrationProviderinterface, async messaging via Transactional Outbox, and dedicated error handling (ERR-6xxx range). This module can be extracted to a separate service when scale demands independent deployment, without changing the core POS modules. Circuit breaker isolation ensures external API failures (Amazon, Google, Shopify) cannot cascade to POS checkout operations.
L.1.5 Service-Based Architecture
| Attribute | Value |
|---|---|
| Description | A hybrid style with coarse-grained services (e.g., Inventory, Sales, HR) often sharing a database. |
| Relevance to Nexus | Offers a middle ground. The Blueprint’s “Service Layer” within the Central API follows this structure logically. |
| Decision | Middle ground (influences internal structure) |
L.1.6 Space-Based Architecture
| Attribute | Value |
|---|---|
| Description | Designed for high scalability and concurrency using tuple spaces (distributed caching/in-memory grids). |
| Relevance to Nexus | Could handle “Black Friday” spikes, but data consistency (synchronization to persistent storage) is too complex for the strict financial audit requirements. |
| Decision | Rejected |
| Rationale | Too complex for financial audit requirements |
L.1.7 Event Sourcing (Architecture Pattern)
| Attribute | Value |
|---|---|
| Description | A data persistence pattern where state transitions are stored as a sequence of immutable events (e.g., ItemAdded, PaymentAuthorized) rather than just the current state. |
| Relevance to Nexus | Critical. The Blueprint (Section L.4A below) mandates this for the “Sales” and “Inventory” domains to enable “Offline Conflict Resolution,” “Complete Audit Trails,” and “Temporal Queries” (Time Travel). |
| Decision | Selected (Sales & Inventory Domains) |
| Key Technology | PostgreSQL 16 (Append-Only Event Table), Apache Kafka (Streaming Platform) |
L.1.8 Online-First with Offline Fallback (Architecture Pattern)
| Attribute | Value |
|---|---|
| Description | POS terminals connect directly to the Central API when online (99.99% of time). A thin SQLite fallback (2 tables: product cache + sales queue) ensures sales continue during rare, brief outages. |
| Relevance to Nexus | Critical. Sales must never be blocked. Online-first provides real-time data consistency while preserving offline resilience. |
| Decision | Selected (Client) — supersedes offline-first (ADR-048) |
| Key Technology | React Query (online), SQLite WASM via sql.js + OPFS (offline fallback) |
L.1.9 Integration Patterns (BRD v18.0 Module 6)
BRD v18.0 Section 6.2 mandates 5 integration patterns that are architecturally significant. These were evaluated during the expert panel review and all selected.
| Pattern | Description | Decision | BRD Reference |
|---|---|---|---|
| Circuit Breaker | State machine (CLOSED → OPEN → HALF_OPEN) that prevents cascading failures from external APIs. Trips after 5 failures within 60 seconds; 30-second cooldown. | Selected | §6.2.4 |
| Transactional Outbox | Atomic write of business data + outbox event in the same database transaction. A relay process polls the outbox and publishes events, guaranteeing at-least-once delivery without distributed transactions. | Selected | §6.2.3, §6.7.3 |
| Provider Abstraction (Strategy) | IIntegrationProvider interface with 5 standard methods (Connect, Sync, Validate, Publish, HealthCheck) implemented per provider. Enables uniform handling regardless of provider protocol. | Selected | §6.2.1 |
| Anti-Corruption Layer (ACL) | Per-provider translation layer preventing external schema changes from leaking into core domain models. Each provider maps external DTOs to internal domain events. | Selected | §6.2.7 |
| Saga / Orchestration | Cross-platform inventory sync orchestrated as a saga with compensation actions. If a Shopify inventory update succeeds but Amazon fails, the saga compensates by rolling back the Shopify change. | Selected (cross-platform flows) | §6.7 |
Circuit Breaker State Machine:
┌──────────────────────────────────────────────────────────┐
│ CIRCUIT BREAKER STATE MACHINE │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ 5 failures ┌──────────┐ │
│ │ CLOSED │ ──────────────►│ OPEN │ │
│ │ (Normal) │ in 60 sec │ (Reject) │ │
│ └────┬─────┘ └────┬─────┘ │
│ ▲ │ │
│ │ success │ 30 sec cooldown │
│ │ ▼ │
│ │ ┌───────────┐ │
│ └────────────────────│ HALF_OPEN │ │
│ │ (1 probe) │ │
│ failure ──────────└───────────┘──► OPEN │
│ │
└──────────────────────────────────────────────────────────┘
L.2 Style Evaluation Matrix
Ratings: 1 (Poor) to 5 (Excellent)
Monolithic Styles
| Style | Availability | Interoperability | Data Consistency | Overall Fit |
|---|---|---|---|---|
| Layered (Traditional) | ★★☆☆☆ | ★★☆☆☆ | ★★★★☆ | Backend only |
| Modular Monolith | ★★★☆☆ | ★★★☆☆ | ★★★★☆ | Selected (Core) |
| Microkernel (Plugin) | ★★★☆☆ | ★★★★★ | ★★★☆☆ | Selected (Client) |
v18.0 Note: Modular Monolith Interoperability reduced from 4★ to 3★. Module 6 requires 6 provider families with different scaling needs — a monolith cannot independently scale individual providers. Mitigated by Extractable Integration Gateway design.
Distributed Styles
| Style | Availability | Interoperability | Data Consistency | Overall Fit |
|---|---|---|---|---|
| Service-Based | ★★★★☆ | ★★★★☆ | ★★★☆☆ | Eventual |
| Event-Driven (EDA) | ★★★★★ | ★★★★★ | ★★☆☆☆ | Selected (Comm Layer) |
| Space-Based | ★★★★★ | ★★★☆☆ | ★☆☆☆☆ | Too Complex |
| Microservices | ★★★★☆ | ★★★★☆ | ★☆☆☆☆ | Hard Sync |
v18.0 Note: Service-Based Interoperability raised from 3★ to 4★. Coarse-grained services can independently deploy integration providers.
Patterns
| Pattern | Availability | Interoperability | Data Consistency | Overall Fit |
|---|---|---|---|---|
| Event Sourcing | ★★★☆☆ | ★★★★☆ | ★★★★★ | Selected (Audit/Sync) |
| Online-First + Offline Fallback | ★★★★★ | ★★★★☆ | ★★★★☆ | Selected (Client) |
| Integration Patterns | ★★★★☆ | ★★★★★ | ★★★★☆ | Selected (Module 6) |
L.3 Key Trade-off Analysis
Trade-off 1: Availability vs. Consistency
| Aspect | Decision |
|---|---|
| Conflict | The online-first strategy requires real-time API access; brief outages create eventual consistency windows. |
| Resolution | Accept Eventual Consistency during rare offline periods (minutes/year). Online 99.99% of the time provides near-immediate consistency. |
| Mitigation | Flag-on-sync detects price discrepancies; safety buffers protect channel inventory; idempotent sales queue flush prevents duplicates. |
Trade-off 2: Complexity (Event Sourcing + PostgreSQL Events)
| Aspect | Decision |
|---|---|
| Conflict | Event Sourcing adds complexity compared to standard CRUD. Original design included Apache Kafka for streaming, adding operational burden (ZooKeeper/KRaft). |
| Resolution | Event Sourcing retained for Sales and Inventory domains. Kafka deferred to v2.0. v1.0 uses PostgreSQL event tables with LISTEN/NOTIFY for event notification and Transactional Outbox for guaranteed delivery. |
| Benefit | Preserves event replay capability and audit trail while eliminating Kafka operational complexity. PostgreSQL event tables match BRD’s existing idempotency_records and integration_dead_letters table designs. |
Trade-off 3: Deployment Simplicity (Modular Monolith)
| Aspect | Decision |
|---|---|
| Conflict | Microservices offer independent scaling but add operational overhead. |
| Resolution | Choosing a Modular Monolith (“Central API”) over Microservices. Row-Level Isolation with RLS for multi-tenancy. |
| Benefit | Reduces deployment complexity (one container vs. dozens). Module 6 designed as Extractable Integration Gateway — can be split into a separate service when scale demands it, without changing core POS modules. |
L.4 Selected Architecture Strategy
Primary Declaration
| Attribute | Selection |
|---|---|
| Primary Style | Event-Driven Modular Monolith (Central API) |
| Key Patterns | Event Sourcing (scoped), CQRS (scoped), Online-First + Offline Fallback, Row-Level Isolation with RLS |
| Event Infrastructure | PostgreSQL Event Tables + LISTEN/NOTIFY (v1.0); Apache Kafka (v2.0) |
| Integration Strategy | Extractable Integration Gateway (Module 6) |
| Credential Management | HashiCorp Vault |
Architecture Layer Mapping
| Layer | Style/Pattern | Technology |
|---|---|---|
| Nexus POS | Microkernel (Plugin) + Online-First with Offline Fallback | React/TypeScript (Vite), React Query, SQLite WASM (fallback) |
| Central API | Modular Monolith | Node.js + Express/Fastify (TypeScript) |
| Communication | Event-Driven | PostgreSQL Events + LISTEN/NOTIFY (v1.0) |
| Real-time | WebSocket Push | Socket.io |
| Data Persistence | Event Sourcing (scoped) + CQRS (scoped) | PostgreSQL 16 |
| Multi-Tenancy | Row-Level Isolation with RLS | PostgreSQL RLS + tenant_id |
| Integration | Extractable Integration Gateway | Module 6, IIntegrationProvider |
| Secrets | Credential Vault | HashiCorp Vault (Docker) |
L.4A CQRS & Event Sourcing Scope
The expert panel identified that CQRS and Event Sourcing scope was undefined. This section clarifies which modules use which patterns, per user decision.
| Module | CQRS | Event Sourcing | Pattern Description |
|---|---|---|---|
| Module 1: Sales | Full CQRS | Full Event Sourcing | Separate read/write models. Events: SaleCreated, PaymentProcessed, ReturnInitiated, VoidExecuted. Event replay for audit and conflict resolution. |
| Module 2: Customers | Standard CRUD | None | Direct query against current-state tables. Simple read/write through repository pattern. |
| Module 3: Catalog | Standard CRUD | None | Read-heavy workload optimized with caching (Redis). Product data served from current-state tables. |
| Module 4: Inventory | Materialized read model | ES for audit trail | Current inventory levels maintained in materialized view. Event Sourcing captures all stock movements for audit trail and conflict resolution (offline sync). |
| Module 5: Setup | Standard CRUD | None | Configuration data accessed directly. Changes logged but not event-sourced. |
| Module 6: Integrations | Standard CRUD | Audit-trail-only ES | Sync logs stored as event stream for debugging and compliance. No event replay for operational queries — current sync state maintained in tables. |
| Section 7: State Machines | N/A | Events drive transitions | 16 state machines powered by domain events. State transitions recorded as events. Database-driven implementation (see below). |
State Machine Implementation: Database-driven pattern using a state column on the entity table plus a state_transitions reference table. This approach provides:
- State column: Each stateful entity (e.g.,
orders.status,returns.status) stores current state directly - Transition table:
state_transitions(from_state, to_state, event, guard_condition, action)defines allowed transitions per entity type - Validation: Application layer validates transitions against the table before applying (preventing invalid state changes)
- Audit: Every transition logged with timestamp, actor, and triggering event
- Benefits: Declarative (non-code) transition rules, easy to modify without deployment, queryable transition history
Design Note: State machines are NOT implemented via Event Sourcing replay. The
statecolumn holds current truth; ES events record the history. This separation keeps state lookups O(1) while maintaining full audit trail.
Event Sourcing vs. Audit Log Relationship: Event Sourcing and the audit log serve separate concerns and are complementary:
- Event Sourcing (Modules 1, 4, 6): Domain events that represent business state changes. Used for: event replay (Sales), conflict resolution (Inventory), sync debugging (Integrations). Stored in event store tables.
- Audit Log: Cross-cutting compliance record of who did what and when. Captures: user identity, IP address, action performed, timestamp, before/after values. Stored in dedicated
audit_logtable. - Relationship: ES events feed INTO the audit log (via event handlers) but the audit log also captures non-ES actions (e.g., login attempts, configuration changes, report generation). The audit log is the compliance artifact; ES is the domain modeling tool.
Event Sourcing Implementation Pattern:
┌──────────────────────────────────────────────────────────┐
│ EVENT SOURCING PATTERN (Sales Module) │
├──────────────────────────────────────────────────────────┤
│ │
│ Command ──► Aggregate ──► Domain Events ──► Event Store │
│ │ │
│ ▼ │
│ Event Handlers │
│ ┌─────────────┐ │
│ │ Read Model │ (CQRS) │
│ │ Projections │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Audit Log │ │
│ │ (Immutable) │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Integration │ │
│ │ Outbox │ │
│ └─────────────┘ │
│ │
│ Queries ──► Read Model (Materialized View) ──► Response │
│ │
└──────────────────────────────────────────────────────────┘
L.4A.1 Event Store Implementation
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Event Store Schema (PostgreSQL)
The append-only event store is the source of truth:
-- Event Store Schema
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
event_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL, -- 'Sale', 'Inventory', 'Customer'
aggregate_id UUID NOT NULL, -- The entity this event belongs to
event_type VARCHAR(100) NOT NULL, -- 'SaleCreated', 'ItemAdded'
event_data JSONB NOT NULL, -- Full event payload
metadata JSONB NOT NULL DEFAULT '{}', -- Correlation, causation IDs
version INTEGER NOT NULL, -- Aggregate version (for optimistic concurrency)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID, -- Employee who caused the event
-- Optimistic concurrency: aggregate_id + version must be unique
UNIQUE (aggregate_type, aggregate_id, version)
);
-- Indexes for common queries
CREATE INDEX idx_events_aggregate ON events (aggregate_type, aggregate_id);
CREATE INDEX idx_events_type ON events (event_type);
CREATE INDEX idx_events_created_at ON events USING BRIN (created_at);
CREATE INDEX idx_events_metadata ON events USING GIN (metadata);
-- Snapshots table (for performance on long event streams)
CREATE TABLE snapshots (
id BIGSERIAL PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id UUID NOT NULL,
version INTEGER NOT NULL,
state JSONB NOT NULL, -- Serialized aggregate state
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (aggregate_type, aggregate_id)
);
-- Outbox table (for reliable event publishing)
CREATE TABLE event_outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(event_id),
destination VARCHAR(100) NOT NULL, -- 'socketio', 'webhook', 'sync'
status VARCHAR(20) DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
Event Sourcing Architecture Diagram
Event Sourcing Architecture
===========================
+-------------------------------------------------------------------------+
| NEXUS POS (React Web App) |
| |
| +------------------+ +-------------------+ +-----------------+ |
| | Command Handler | | Event Store | | Projector | |
| | | | (SQLite WASM / | | (Read Model) | |
| | | | sql.js + OPFS) | | | |
| | CreateSale |--->| |--->| | |
| | VoidSale | | SaleCreated | | sale_summaries | |
| | AddPayment | | ItemAdded | | inventory_view | |
| +------------------+ | PaymentReceived | +-----------------+ |
| +-------------------+ |
| | |
+-------------------------------------------------------------------------+
| Sync
v
+-------------------------------------------------------------------------+
| CENTRAL API |
| |
| +------------------+ +-------------------+ +-----------------+ |
| | Command Handler | | Event Store | | Projector | |
| | (Validates) | | (PostgreSQL) | | (Read Model) | |
| | |<---| |--->| | |
| | Deduplication | | All tenant events | | sales | |
| | Conflict Check | | Append-only | | inventory_items | |
| +------------------+ | Immutable | | customers | |
| +-------------------+ +-----------------+ |
+-------------------------------------------------------------------------+
CQRS Pattern
CQRS Pattern
============
+----------------------+
| User Action |
+----------+-----------+
|
+----------------------+----------------------+
| |
v v
+-------------------+ +-------------------+
| COMMAND | | QUERY |
| (Write) | | (Read) |
+-------------------+ +-------------------+
| |
v v
+-------------------+ +-------------------+
| Command Handler | | Query Handler |
| - Validate | | - No validation |
| - Business rules | | - Fast lookup |
| - Generate events | | - Denormalized |
+-------------------+ +-------------------+
| ^
v |
+-------------------+ +-------------------+
| Event Store |----------------------->| Read Models |
| (Append-only) | Projections | (Optimized) |
+-------------------+ +-------------------+
Write Side (Commands)
// Commands - Express intent
interface CreateSaleCommand {
saleId: string; // UUID
locationId: string; // UUID
employeeId: string; // UUID
customerId?: string; // UUID
lineItems: SaleLineItemDto[];
}
interface VoidSaleCommand {
saleId: string; // UUID
employeeId: string; // UUID
reason: string;
}
interface AddPaymentCommand {
saleId: string; // UUID
paymentMethod: string;
amount: number; // Decimal as number (use Prisma.Decimal for DB)
reference?: string;
}
Read Side (Queries)
// Queries - Request data
interface GetSaleByIdQuery { saleId: string; }
interface GetDailySalesQuery { locationId: string; date: Date; }
interface GetInventoryLevelQuery { sku: string; locationId: string; }
// Read models - Optimized for queries
interface SaleSummaryView {
id: string;
saleNumber: string;
customerName: string; // Denormalized
employeeName: string; // Denormalized
total: number;
status: string;
createdAt: Date;
}
L.4A.2 Event Streaming (Apache Kafka) — v2.0 Future
v2.0 FUTURE: This entire section describes the Kafka-based event streaming architecture planned for v2.0. For v1.0, the platform uses PostgreSQL event tables + LISTEN/NOTIFY as the event infrastructure (see ADR in L.10A.4 and Ch 02 ADR-001). The Kafka architecture below is preserved as the migration target when the platform outgrows PostgreSQL-based events.
Note: Code samples in sections L.4A.2–L.4A.3 retain C# syntax from the pre-v6.1.0 architecture. These will be converted to TypeScript (using kafkajs) when the Kafka v2.0 migration is planned. The patterns and architecture remain valid regardless of implementation language.
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Technology Selection
| Attribute | Selection |
|---|---|
| Platform | Apache Kafka |
| Version | 3.6+ (with KRaft mode) |
| Primary Rationale | Replayability |
Why Kafka over alternatives?
| Alternative | Why Not Selected |
|---|---|
| RabbitMQ | No native replay; messages deleted after consumption |
| Redis Streams | Less durable; not designed for long-term event storage |
| AWS SQS | No replay capability; messages expire |
| PostgreSQL LISTEN/NOTIFY | Not scalable; no persistence |
Kafka Replayability
+------------------------------------------------------------------+
| KAFKA REPLAYABILITY |
+------------------------------------------------------------------+
| |
| Event Log (Immutable, Ordered): |
| |
| Partition 0: [E1] -> [E2] -> [E3] -> [E4] -> [E5] -> ... |
| ^ ^ |
| | | |
| Consumer Group A: ─────┘ | (Processed up to E2) |
| Consumer Group B: ────────────────────┘ (Processed up to E4) |
| |
| NEW Consumer Group C can start from E1 and replay ALL events! |
| |
+------------------------------------------------------------------+
Kafka Topics Architecture
POS Kafka Topics
================
┌────────────────────────────────────────────────────────────────┐
│ TOPIC STRUCTURE │
├────────────────────────────────────────────────────────────────┤
│ │
│ pos.events.sales - All sale-related events │
│ ├── Partition 0 (Location A) │
│ ├── Partition 1 (Location B) │
│ └── Partition N (Location N) │
│ │
│ pos.events.inventory - Inventory movements │
│ ├── Partition 0-N (By SKU hash) │
│ │
│ pos.events.customers - Customer activity │
│ ├── Partition 0-N (By customer hash) │
│ │
│ pos.sync.outbound - Events to sync to external systems │
│ ├── Shopify, Amazon, etc. │
│ │
│ pos.sync.inbound - Events from external systems │
│ ├── Online orders, inventory updates │
│ │
└────────────────────────────────────────────────────────────────┘
Kafka Configuration (Docker Compose)
# docker-compose.kafka.yml
services:
kafka:
image: confluentinc/cp-kafka:7.5.0
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LOG_RETENTION_HOURS: 168 # 7 days
KAFKA_LOG_RETENTION_BYTES: 10737418240 # 10GB per partition
KAFKA_AUTO_CREATE_TOPICS_ENABLE: false
ports:
- "9092:9092"
volumes:
- kafka_data:/var/lib/kafka/data
kafka-ui:
image: provectuslabs/kafka-ui:latest
environment:
KAFKA_CLUSTERS_0_NAME: pos-cluster
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
ports:
- "8090:8080"
Event Publishing Pattern
Note: These C# examples illustrate v2.0 Kafka event-sourcing patterns. TypeScript equivalents (using kafkajs) will replace these when Kafka is adopted.
// KafkaEventPublisher.cs
public class KafkaEventPublisher : IEventPublisher
{
private readonly IProducer<string, string> _producer;
private readonly ILogger<KafkaEventPublisher> _logger;
public async Task PublishAsync<T>(T @event, CancellationToken ct = default)
where T : IDomainEvent
{
var topic = GetTopicForEvent(@event);
var key = GetPartitionKey(@event); // e.g., LocationId for ordering
var message = new Message<string, string>
{
Key = key,
Value = JsonSerializer.Serialize(@event),
Headers = new Headers
{
{ "event-type", Encoding.UTF8.GetBytes(@event.GetType().Name) },
{ "correlation-id", Encoding.UTF8.GetBytes(@event.CorrelationId.ToString()) },
{ "tenant-id", Encoding.UTF8.GetBytes(@event.TenantId.ToString()) }
}
};
var result = await _producer.ProduceAsync(topic, message, ct);
_logger.LogDebug(
"Published {EventType} to {Topic}:{Partition}@{Offset}",
@event.GetType().Name,
result.Topic,
result.Partition.Value,
result.Offset.Value
);
}
private string GetTopicForEvent(IDomainEvent @event) => @event switch
{
SaleCreated or SaleCompleted or SaleVoided => "pos.events.sales",
InventoryReceived or InventorySold => "pos.events.inventory",
CustomerCreated or LoyaltyPointsEarned => "pos.events.customers",
_ => "pos.events.general"
};
}
Schema Registry & Event Versioning
Overview
As the POS platform evolves, event schemas will change. Schema Registry provides:
- Schema Validation: Prevent incompatible events from being published
- Schema Evolution: Safe migrations without breaking consumers
- Schema History: Version tracking for all event types
| Attribute | Selection |
|---|---|
| Tool | Confluent Schema Registry |
| Format | Avro (Primary) or Protobuf |
| Strategy | BACKWARD compatibility |
Schema Registry Architecture
┌─────────────────────────────────────────────────────────────────┐
│ SCHEMA REGISTRY FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │
│ │ Producer │ │ Schema Registry │ │ Consumer │ │
│ │ (POS API) │ │ (Confluent) │ │ (Analytics) │ │
│ └──────┬──────┘ └────────┬─────────┘ └──────┬──────┘ │
│ │ │ │ │
│ 1. Register/Get Schema │ │ │
│ │ ─────────────────> │ │ │
│ │ │ │ │
│ 2. Schema ID returned │ │ │
│ │ <───────────────── │ │ │
│ │ │ │ │
│ 3. Publish event with │ │ │
│ schema ID prefix │ │ │
│ │ ─────────────────────────────────────────> │ │
│ │ │ │ │
│ │ 4. Consumer fetches │ │
│ │ schema by ID │ │
│ │ <─────────────────── │ │
│ │ │ │
│ │ 5. Deserialize with │ │
│ │ correct schema │ │
│ │
└─────────────────────────────────────────────────────────────────┘
Avro Schema Definition (SaleCreated)
// schemas/sale-created.avsc
{
"type": "record",
"name": "SaleCreated",
"namespace": "io.posplatform.events.sales",
"doc": "Event fired when a new sale is initiated",
"fields": [
{
"name": "eventId",
"type": { "type": "string", "logicalType": "uuid" },
"doc": "Unique event identifier"
},
{
"name": "saleId",
"type": { "type": "string", "logicalType": "uuid" },
"doc": "Sale aggregate identifier"
},
{
"name": "tenantId",
"type": { "type": "string", "logicalType": "uuid" }
},
{
"name": "locationId",
"type": { "type": "string", "logicalType": "uuid" }
},
{
"name": "employeeId",
"type": { "type": "string", "logicalType": "uuid" }
},
{
"name": "customerId",
"type": ["null", { "type": "string", "logicalType": "uuid" }],
"default": null,
"doc": "Optional customer for loyalty"
},
{
"name": "saleNumber",
"type": "string"
},
{
"name": "createdAt",
"type": { "type": "long", "logicalType": "timestamp-millis" }
},
{
"name": "metadata",
"type": {
"type": "map",
"values": "string"
},
"default": {}
}
]
}
Schema Evolution Rules (BACKWARD Compatibility)
| Change | Allowed? | Notes |
|---|---|---|
| Add field with default | Yes | New consumers can read old messages |
| Remove field with default | Yes | Old consumers ignore missing field |
| Add field without default | No | Old messages fail validation |
| Remove required field | No | New messages fail for old consumers |
| Change field type | No | Type mismatch errors |
| Rename field | No | Use aliases instead |
Schema Evolution Example (v2)
// schemas/sale-created-v2.avsc (BACKWARD COMPATIBLE)
{
"type": "record",
"name": "SaleCreated",
"namespace": "io.posplatform.events.sales",
"fields": [
// ... existing fields ...
// NEW FIELD - Added with default value (BACKWARD COMPATIBLE)
{
"name": "channel",
"type": "string",
"default": "in_store",
"doc": "Sales channel: in_store, online, mobile"
},
// NEW OPTIONAL FIELD (BACKWARD COMPATIBLE)
{
"name": "referralCode",
"type": ["null", "string"],
"default": null
}
]
}
Producer Configuration with Schema Registry
Note: These C# examples illustrate v2.0 Kafka event-sourcing patterns. TypeScript equivalents (using kafkajs) will replace these when Kafka is adopted.
// Infrastructure/Messaging/SchemaRegistryProducer.cs
using Confluent.Kafka;
using Confluent.SchemaRegistry;
using Confluent.SchemaRegistry.Serdes;
public class SchemaRegistryProducer<TKey, TValue> : IEventPublisher
where TValue : ISpecificRecord
{
private readonly IProducer<TKey, TValue> _producer;
public SchemaRegistryProducer(
string bootstrapServers,
string schemaRegistryUrl)
{
var schemaRegistryConfig = new SchemaRegistryConfig
{
Url = schemaRegistryUrl
};
var schemaRegistry = new CachedSchemaRegistryClient(schemaRegistryConfig);
var producerConfig = new ProducerConfig
{
BootstrapServers = bootstrapServers,
Acks = Acks.All, // Wait for all replicas
EnableIdempotence = true
};
_producer = new ProducerBuilder<TKey, TValue>(producerConfig)
.SetKeySerializer(new AvroSerializer<TKey>(schemaRegistry))
.SetValueSerializer(new AvroSerializer<TValue>(schemaRegistry, new AvroSerializerConfig
{
// Fail if schema is not compatible
AutoRegisterSchemas = false,
SubjectNameStrategy = SubjectNameStrategy.TopicRecord
}))
.Build();
}
public async Task PublishAsync(
string topic,
TKey key,
TValue value,
CancellationToken ct = default)
{
var result = await _producer.ProduceAsync(topic, new Message<TKey, TValue>
{
Key = key,
Value = value
}, ct);
_logger.LogDebug(
"Published {EventType} to {Topic} with schema ID {SchemaId}",
typeof(TValue).Name,
result.Topic,
result.Value
);
}
}
CI/CD Schema Validation
# .github/workflows/schema-validation.yml
name: Schema Validation
on:
pull_request:
paths:
- 'schemas/**'
jobs:
validate-schemas:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start Schema Registry
run: |
docker compose -f docker/docker-compose.kafka.yml up -d schema-registry
sleep 10
- name: Test Schema Compatibility
run: |
for schema in schemas/*.avsc; do
subject=$(basename "$schema" .avsc)-value
echo "Testing compatibility for $subject"
# Check if schema is BACKWARD compatible with existing
curl -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d @"$schema" \
"http://localhost:8081/compatibility/subjects/$subject/versions/latest" \
| jq -e '.is_compatible == true' || exit 1
done
- name: Register Schemas (on merge to main)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
for schema in schemas/*.avsc; do
subject=$(basename "$schema" .avsc)-value
curl -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
-d "{\"schema\": $(cat "$schema" | jq -Rs .)}" \
"http://localhost:8081/subjects/$subject/versions"
done
Docker Compose with Schema Registry
# docker/docker-compose.kafka.yml (updated)
services:
schema-registry:
image: confluentinc/cp-schema-registry:7.5.0
container_name: pos-schema-registry
depends_on:
- kafka
ports:
- "8081:8081"
environment:
SCHEMA_REGISTRY_HOST_NAME: schema-registry
SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: kafka:9092
SCHEMA_REGISTRY_LISTENERS: http://0.0.0.0:8081
# Enforce BACKWARD compatibility by default
SCHEMA_REGISTRY_SCHEMA_COMPATIBILITY_LEVEL: BACKWARD
L.4A.3 Dead Letter Queue Pattern
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Overview
When event processing fails (malformed data, business rule violations, transient errors), messages go to a Dead Letter Queue for investigation and replay.
| Attribute | Selection |
|---|---|
| Purpose | Capture failed messages without blocking main flow |
| Retention | 30 days |
| Monitoring | Alert when DLQ depth > threshold |
DLQ Architecture
┌─────────────────────────────────────────────────────────────────┐
│ DLQ PATTERN │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ pos.events. │ │ Consumer │ │ Handler │ │
│ │ sales │───>│ Group │───>│ Logic │ │
│ │ (Main Topic) │ │ │ │ │ │
│ └───────────────┘ └───────────────┘ └───────┬───────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ Success? │ │
│ └───────┬───────┘ │
│ Yes ┌───────┴───────┐ No │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌───────────┐ │
│ │ Commit │ │ Retry │ │
│ │ Offset │ │ Logic │ │
│ └──────────┘ └─────┬─────┘ │
│ │ │
│ ┌──────┴─────┐ │
│ │ Max Retries│ │
│ │ Exceeded? │ │
│ └──────┬─────┘ │
│ No ┌────────┴──────┐│
│ │ ││
│ ▼ ▼│
│ ┌──────────┐ ┌────────┴──┐
│ │ Retry │ │ DLQ │
│ │ Topic │ │ Topic │
│ └──────────┘ └───────────┘
│ pos.events.
│ sales.dlq
└─────────────────────────────────────────────────────────────────┘
DLQ Consumer Implementation
Note: These C# examples illustrate v2.0 Kafka event-sourcing patterns. TypeScript equivalents (using kafkajs) will replace these when Kafka is adopted.
// Infrastructure/Messaging/DlqAwareConsumer.cs
public class DlqAwareConsumer<TKey, TValue>
{
private readonly IConsumer<TKey, TValue> _consumer;
private readonly IProducer<string, DeadLetterMessage> _dlqProducer;
private readonly ILogger _logger;
private const int MAX_RETRIES = 3;
private readonly TimeSpan[] _retryDelays = new[]
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(30)
};
public async Task ConsumeWithDlqAsync(
string topic,
Func<ConsumeResult<TKey, TValue>, Task> handler,
CancellationToken ct)
{
_consumer.Subscribe(topic);
while (!ct.IsCancellationRequested)
{
var result = _consumer.Consume(ct);
var retryCount = GetRetryCount(result.Message.Headers);
try
{
await handler(result);
_consumer.Commit(result);
}
catch (TransientException ex) when (retryCount < MAX_RETRIES)
{
_logger.LogWarning(
ex,
"Transient error processing message. Retry {Retry}/{Max}",
retryCount + 1,
MAX_RETRIES
);
await Task.Delay(_retryDelays[retryCount], ct);
await PublishToRetryTopicAsync(result, retryCount + 1);
_consumer.Commit(result);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process message after {Retries} retries. Sending to DLQ.",
retryCount
);
await PublishToDlqAsync(result, ex, retryCount);
_consumer.Commit(result);
}
}
}
private async Task PublishToDlqAsync(
ConsumeResult<TKey, TValue> result,
Exception exception,
int retryCount)
{
var dlqMessage = new DeadLetterMessage
{
OriginalTopic = result.Topic,
OriginalPartition = result.Partition.Value,
OriginalOffset = result.Offset.Value,
Key = result.Message.Key?.ToString(),
Value = SerializeValue(result.Message.Value),
Headers = ExtractHeaders(result.Message.Headers),
ErrorType = exception.GetType().FullName,
ErrorMessage = exception.Message,
StackTrace = exception.StackTrace,
RetryCount = retryCount,
FirstFailedAt = GetFirstFailedAt(result.Message.Headers),
LastFailedAt = DateTime.UtcNow,
ConsumerGroup = _consumerGroup,
ConsumerInstance = Environment.MachineName
};
var dlqTopic = $"{result.Topic}.dlq";
await _dlqProducer.ProduceAsync(dlqTopic, new Message<string, DeadLetterMessage>
{
Key = result.Message.Key?.ToString(),
Value = dlqMessage
});
}
}
DLQ Message Structure
Note: These C# examples illustrate v2.0 Kafka event-sourcing patterns. TypeScript equivalents (using kafkajs) will replace these when Kafka is adopted.
// Domain/Events/DeadLetterMessage.cs
public record DeadLetterMessage
{
/// <summary>Original Kafka topic</summary>
public string OriginalTopic { get; init; }
/// <summary>Original partition</summary>
public int OriginalPartition { get; init; }
/// <summary>Original offset</summary>
public long OriginalOffset { get; init; }
/// <summary>Original message key</summary>
public string Key { get; init; }
/// <summary>Original message value (base64 if binary)</summary>
public string Value { get; init; }
/// <summary>Original headers</summary>
public Dictionary<string, string> Headers { get; init; }
/// <summary>Error details</summary>
public string ErrorType { get; init; }
public string ErrorMessage { get; init; }
public string StackTrace { get; init; }
/// <summary>Processing metadata</summary>
public int RetryCount { get; init; }
public DateTime FirstFailedAt { get; init; }
public DateTime LastFailedAt { get; init; }
public string ConsumerGroup { get; init; }
public string ConsumerInstance { get; init; }
}
DLQ Monitoring & Alerting
# prometheus/alerts/dlq-alerts.yml
groups:
- name: kafka-dlq-alerts
rules:
- alert: DLQMessagesAccumulating
expr: kafka_consumer_group_lag{topic=~".*\\.dlq"} > 100
for: 15m
labels:
severity: warning
annotations:
summary: "DLQ has {{ $value }} unprocessed messages"
description: "Topic {{ $labels.topic }} has accumulated messages"
- alert: DLQCriticalBacklog
expr: kafka_consumer_group_lag{topic=~".*\\.dlq"} > 1000
for: 5m
labels:
severity: critical
annotations:
summary: "CRITICAL: DLQ backlog exceeds 1000 messages"
runbook_url: "https://wiki.internal/runbooks/dlq-overflow"
DLQ Replay Tool
Note: These C# examples illustrate v2.0 Kafka event-sourcing patterns. TypeScript equivalents (using kafkajs) will replace these when Kafka is adopted.
// Tools/DlqReplayService.cs
public class DlqReplayService
{
public async Task ReplayMessagesAsync(
string dlqTopic,
DateTime? from = null,
DateTime? to = null,
Func<DeadLetterMessage, bool>? filter = null)
{
var consumer = CreateDlqConsumer(dlqTopic);
var producer = CreateMainTopicProducer();
var messages = await ReadDlqMessagesAsync(consumer, from, to);
foreach (var dlqMessage in messages)
{
if (filter != null && !filter(dlqMessage))
{
_logger.LogDebug("Skipping message by filter: {Key}", dlqMessage.Key);
continue;
}
_logger.LogInformation(
"Replaying message from DLQ: Topic={Topic}, Offset={Offset}",
dlqMessage.OriginalTopic,
dlqMessage.OriginalOffset
);
// Publish back to original topic
await producer.ProduceAsync(dlqMessage.OriginalTopic, new Message<string, string>
{
Key = dlqMessage.Key,
Value = dlqMessage.Value,
Headers = new Headers
{
{ "x-dlq-replay", Encoding.UTF8.GetBytes("true") },
{ "x-dlq-original-offset", Encoding.UTF8.GetBytes(dlqMessage.OriginalOffset.ToString()) }
}
});
}
_logger.LogInformation("Replayed {Count} messages from DLQ", messages.Count);
}
}
# CLI usage for DLQ replay
npx tsx tools/dlq-replay.ts \
--topic pos.events.sales.dlq \
--from "2026-01-20T00:00:00Z" \
--filter "ErrorType contains 'Transient'"
L.4A.4 Domain Events Catalog
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Sale Aggregate Events
Sale Events
===========
SaleCreated
+-----------------------+----------------------------------------+
| Field | Description |
+-----------------------+----------------------------------------+
| sale_id | UUID of the new sale |
| sale_number | Human-readable sale number |
| location_id | Where the sale occurred |
| register_id | Which register |
| employee_id | Who created the sale |
| customer_id | Customer (if any) |
| created_at | Timestamp |
+-----------------------+----------------------------------------+
SaleLineItemAdded
+-----------------------+----------------------------------------+
| sale_id | Parent sale |
| line_item_id | UUID of the line item |
| product_id | Product being sold |
| variant_id | Variant (if any) |
| sku | SKU at time of sale |
| name | Product name at time of sale |
| quantity | Quantity sold |
| unit_price | Price per unit |
| discount_amount | Line discount |
| tax_amount | Line tax |
+-----------------------+----------------------------------------+
SaleLineItemRemoved
+-----------------------+----------------------------------------+
| sale_id | Parent sale |
| line_item_id | UUID of removed item |
| reason | Why removed |
+-----------------------+----------------------------------------+
PaymentReceived
+-----------------------+----------------------------------------+
| sale_id | Parent sale |
| payment_id | UUID of payment |
| payment_method | cash, credit, debit, etc. |
| amount | Payment amount |
| reference | Card last 4, check #, etc. |
| auth_code | Authorization code |
+-----------------------+----------------------------------------+
SaleCompleted
+-----------------------+----------------------------------------+
| sale_id | The sale being completed |
| subtotal | Final subtotal |
| discount_total | Total discounts |
| tax_total | Total tax |
| total | Final total |
| completed_at | Timestamp |
+-----------------------+----------------------------------------+
SaleVoided
+-----------------------+----------------------------------------+
| sale_id | The voided sale |
| voided_by | Employee who voided |
| reason | Void reason |
| voided_at | Timestamp |
+-----------------------+----------------------------------------+
Inventory Aggregate Events
Inventory Events
================
InventoryReceived
+-----------------------+----------------------------------------+
| location_id | Where received |
| product_id | Product |
| variant_id | Variant (if any) |
| quantity | Amount received |
| cost | Unit cost |
| reference | PO number, transfer # |
| received_by | Employee |
+-----------------------+----------------------------------------+
InventoryAdjusted
+-----------------------+----------------------------------------+
| location_id | Location |
| product_id | Product |
| variant_id | Variant (if any) |
| quantity_change | +/- amount |
| new_quantity | New on-hand quantity |
| reason | count, damage, theft, return |
| adjusted_by | Employee |
| notes | Additional context |
+-----------------------+----------------------------------------+
InventorySold
+-----------------------+----------------------------------------+
| location_id | Where sold |
| product_id | Product |
| variant_id | Variant (if any) |
| quantity | Amount sold (positive) |
| sale_id | Related sale |
+-----------------------+----------------------------------------+
InventoryTransferred
+-----------------------+----------------------------------------+
| transfer_id | Transfer document |
| from_location_id | Source location |
| to_location_id | Destination location |
| product_id | Product |
| variant_id | Variant (if any) |
| quantity | Amount transferred |
| transferred_by | Employee |
+-----------------------+----------------------------------------+
InventoryCounted
+-----------------------+----------------------------------------+
| location_id | Location |
| product_id | Product |
| variant_id | Variant |
| expected_quantity | System quantity before count |
| actual_quantity | Physical count |
| variance | Difference |
| counted_by | Employee |
| count_session_id | Batch count session |
+-----------------------+----------------------------------------+
Customer Aggregate Events
Customer Events
===============
CustomerCreated
+-----------------------+----------------------------------------+
| customer_id | New customer UUID |
| customer_number | Human-readable ID |
| first_name | First name |
| last_name | Last name |
| email | Email address |
| phone | Phone number |
| created_by | Employee |
+-----------------------+----------------------------------------+
CustomerUpdated
+-----------------------+----------------------------------------+
| customer_id | Customer UUID |
| changes | Map of field -> {old, new} |
| updated_by | Employee |
+-----------------------+----------------------------------------+
LoyaltyPointsEarned
+-----------------------+----------------------------------------+
| customer_id | Customer |
| points | Points earned |
| sale_id | Related sale |
| new_balance | Updated balance |
+-----------------------+----------------------------------------+
LoyaltyPointsRedeemed
+-----------------------+----------------------------------------+
| customer_id | Customer |
| points | Points redeemed |
| sale_id | Related sale |
| new_balance | Updated balance |
+-----------------------+----------------------------------------+
StoreCreditIssued
+-----------------------+----------------------------------------+
| customer_id | Customer |
| credit_id | Credit UUID |
| amount | Credit amount |
| reason | Why issued |
| issued_by | Employee |
+-----------------------+----------------------------------------+
Employee Aggregate Events
Employee Events
===============
EmployeeClockIn
+-----------------------+----------------------------------------+
| employee_id | Employee UUID |
| location_id | Where clocking in |
| shift_id | New shift UUID |
| clocked_in_at | Timestamp |
+-----------------------+----------------------------------------+
EmployeeClockOut
+-----------------------+----------------------------------------+
| employee_id | Employee UUID |
| shift_id | Shift being closed |
| clocked_out_at | Timestamp |
| break_minutes | Total break time |
+-----------------------+----------------------------------------+
EmployeeBreakStarted
+-----------------------+----------------------------------------+
| employee_id | Employee UUID |
| shift_id | Current shift |
| started_at | Break start time |
+-----------------------+----------------------------------------+
EmployeeBreakEnded
+-----------------------+----------------------------------------+
| employee_id | Employee UUID |
| shift_id | Current shift |
| ended_at | Break end time |
| duration_minutes | Break duration |
+-----------------------+----------------------------------------+
CashDrawer Aggregate Events
Cash Drawer Events
==================
DrawerOpened
+-----------------------+----------------------------------------+
| drawer_id | Drawer UUID |
| register_id | Register UUID |
| employee_id | Who opened |
| opening_balance | Starting cash amount |
| opened_at | Timestamp |
+-----------------------+----------------------------------------+
DrawerCashDrop
+-----------------------+----------------------------------------+
| drawer_id | Drawer UUID |
| amount | Amount dropped to safe |
| employee_id | Who dropped |
| dropped_at | Timestamp |
+-----------------------+----------------------------------------+
DrawerPaidIn
+-----------------------+----------------------------------------+
| drawer_id | Drawer UUID |
| amount | Amount added |
| reason | Why (petty cash, etc.) |
| employee_id | Who added |
+-----------------------+----------------------------------------+
DrawerPaidOut
+-----------------------+----------------------------------------+
| drawer_id | Drawer UUID |
| amount | Amount removed |
| reason | Why (vendor payment, etc.) |
| employee_id | Who removed |
+-----------------------+----------------------------------------+
DrawerClosed
+-----------------------+----------------------------------------+
| drawer_id | Drawer UUID |
| employee_id | Who closed |
| closing_balance | Actual cash counted |
| expected_balance | System calculated |
| variance | Difference (over/short) |
| closed_at | Timestamp |
+-----------------------+----------------------------------------+
L.4A.5 Event Projection Patterns
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Projection Architecture
=======================
+-------------------+
| Event Stream |
| |
| SaleCreated |
| ItemAdded |
| ItemAdded |
| PaymentReceived |
| SaleCompleted |
+--------+----------+
|
| Projector reads events
v
+-------------------+ +-------------------+ +-------------------+
| Sale Projector | |Inventory Projector| |Customer Projector |
| | | | | |
| - Build sale view | | - Update stock | | - Update stats |
| - Calculate totals| | - Track movements | | - Loyalty points |
+--------+----------+ +--------+----------+ +--------+----------+
| | |
v v v
+-------------------+ +-------------------+ +-------------------+
| sale_summaries | | inventory_levels | | customer_stats |
| (Read Model) | | (Read Model) | | (Read Model) |
+-------------------+ +-------------------+ +-------------------+
Sale Projector Implementation
// sale-projector.ts
import { PrismaClient } from '@prisma/client';
import type { SaleCreated, SaleLineItemAdded, SaleCompleted, SaleVoided } from './domain-events';
const prisma = new PrismaClient();
export async function handleSaleCreated(event: SaleCreated): Promise<void> {
await prisma.saleSummary.create({
data: {
id: event.saleId,
saleNumber: event.saleNumber,
locationId: event.locationId,
employeeId: event.employeeId,
customerId: event.customerId ?? null,
status: 'draft',
subtotal: 0,
total: 0,
createdAt: event.createdAt,
},
});
}
export async function handleSaleLineItemAdded(event: SaleLineItemAdded): Promise<void> {
const sale = await prisma.saleSummary.findUnique({ where: { id: event.saleId } });
if (!sale) return;
const lineTotal = event.quantity * event.unitPrice - event.discountAmount;
await prisma.saleSummary.update({
where: { id: event.saleId },
data: {
subtotal: { increment: lineTotal },
itemCount: { increment: event.quantity },
},
});
}
export async function handleSaleCompleted(event: SaleCompleted): Promise<void> {
await prisma.saleSummary.update({
where: { id: event.saleId },
data: {
status: 'completed',
discountTotal: event.discountTotal,
taxTotal: event.taxTotal,
total: event.total,
completedAt: event.completedAt,
},
});
}
export async function handleSaleVoided(event: SaleVoided): Promise<void> {
await prisma.saleSummary.update({
where: { id: event.saleId },
data: {
status: 'voided',
voidedAt: event.voidedAt,
voidedBy: event.voidedBy,
voidReason: event.reason,
},
});
}
L.4A.6 Temporal Queries
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
Event sourcing enables powerful temporal queries:
-- What was inventory on a specific date?
SELECT
product_id,
SUM(CASE
WHEN event_type = 'InventoryReceived' THEN (event_data->>'quantity')::int
WHEN event_type = 'InventorySold' THEN -(event_data->>'quantity')::int
WHEN event_type = 'InventoryAdjusted' THEN (event_data->>'quantity_change')::int
ELSE 0
END) as quantity
FROM events
WHERE aggregate_type = 'Inventory'
AND (event_data->>'location_id')::uuid = '...'
AND created_at <= '2025-12-15 15:00:00'
GROUP BY product_id;
-- Sales trend for specific product
SELECT
date_trunc('day', created_at) as date,
SUM((event_data->>'quantity')::int) as units_sold
FROM events
WHERE event_type = 'InventorySold'
AND (event_data->>'product_id')::uuid = '...'
AND created_at >= NOW() - INTERVAL '30 days'
GROUP BY date_trunc('day', created_at)
ORDER BY date;
-- Audit trail for specific sale
SELECT
event_type,
event_data,
created_at,
created_by
FROM events
WHERE aggregate_type = 'Sale'
AND aggregate_id = '...'
ORDER BY version;
L.4A.7 Snapshots for Performance
Detailed Implementation Reference (from former Event Sourcing & CQRS chapter, now consolidated here):
For aggregates with many events, snapshots prevent replaying the entire stream:
Snapshot Strategy
=================
Without Snapshots:
Event 1 -> Event 2 -> ... -> Event 5000 -> Current State
(Slow for aggregates with many events)
With Snapshots:
Event 1 -> ... -> Event 1000 -> [Snapshot @ v1000]
|
-> Event 1001 -> ... -> Event 1050 -> Current State
(Load snapshot, then only replay 50 events)
Snapshot Implementation
// aggregate-repository.ts
import { PrismaClient } from '@prisma/client';
import type { AggregateRoot, DomainEvent } from './types';
const prisma = new PrismaClient();
const SNAPSHOT_THRESHOLD = 100;
export async function loadAggregate<T extends AggregateRoot>(
id: string,
factory: () => T
): Promise<T> {
const aggregate = factory();
// 1. Try to load snapshot
const snapshot = await prisma.snapshot.findUnique({
where: { aggregateType_aggregateId: { aggregateType: aggregate.type, aggregateId: id } },
});
let fromVersion = 0;
if (snapshot) {
aggregate.restoreFromSnapshot(snapshot.state as Record<string, unknown>);
fromVersion = snapshot.version;
}
// 2. Load events after snapshot
const events = await prisma.event.findMany({
where: { aggregateId: id, version: { gt: fromVersion } },
orderBy: { version: 'asc' },
});
for (const event of events) {
aggregate.apply(event as unknown as DomainEvent);
}
return aggregate;
}
export async function saveAggregate<T extends AggregateRoot>(aggregate: T): Promise<void> {
const newEvents = aggregate.getUncommittedEvents();
// 1. Append events
await prisma.event.createMany({
data: newEvents.map((event, i) => ({
aggregateType: aggregate.type,
aggregateId: aggregate.id,
eventType: event.eventType,
eventData: event as unknown as Record<string, unknown>,
version: aggregate.version + i + 1,
createdBy: event.createdBy,
})),
});
// 2. Create snapshot if threshold reached
if (aggregate.version % SNAPSHOT_THRESHOLD === 0) {
const snapshotState = aggregate.createSnapshot();
await prisma.snapshot.upsert({
where: { aggregateType_aggregateId: { aggregateType: aggregate.type, aggregateId: aggregate.id } },
create: { aggregateType: aggregate.type, aggregateId: aggregate.id, version: aggregate.version, state: snapshotState },
update: { version: aggregate.version, state: snapshotState },
});
}
aggregate.clearUncommittedEvents();
}
L.4B Integration Architecture Patterns
BRD v18.0 Module 6 defines integration patterns that are architecturally significant. This section documents their implementation strategy.
Transactional Outbox Pattern
Guarantees atomic business data persistence + event publication without distributed transactions.
┌──────────────────────────────────────────────────────────┐
│ TRANSACTIONAL OUTBOX PATTERN │
├──────────────────────────────────────────────────────────┤
│ │
│ Application Outbox Relay │
│ ┌─────────────────┐ ┌──────────────────┐ │
│ │ BEGIN TRANSACTION│ │ Poll outbox table│ │
│ │ │ │ every 5 seconds │ │
│ │ 1. Write to │ └────────┬─────────┘ │
│ │ business table│ │ │
│ │ │ ▼ │
│ │ 2. Write to │ ┌──────────────────┐ │
│ │ outbox table │ │ Publish event │ │
│ │ │ │ via LISTEN/NOTIFY│ │
│ │ COMMIT │ └────────┬─────────┘ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Mark as published│ │
│ │ (idempotent) │ │
│ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Provider Abstraction (Strategy Pattern)
┌──────────────────────────────────────────────────────────┐
│ PROVIDER ABSTRACTION PATTERN │
├──────────────────────────────────────────────────────────┤
│ │
│ IIntegrationProvider │
│ ┌──────────────────┐ │
│ │ + Connect() │ │
│ │ + SyncProducts() │ │
│ │ + SyncInventory()│ │
│ │ + ValidateData() │ │
│ │ + HealthCheck() │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌─────────────┼─────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐┌────────────┐┌─────────────────┐ │
│ │ Shopify ││ Amazon ││ Google │ │
│ │ Provider ││ Provider ││ Merchant │ │
│ │ ││ ││ Provider │ │
│ │ GraphQL ││ REST/LWA ││ REST/Service Acct│ │
│ │ 50pts/sec ││ Burst+Tok ││ Quota-based │ │
│ │ Webhooks ││ 2min Poll ││ 2x/day Batch │ │
│ └────────────┘└────────────┘└─────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Safety Buffer Computation
Per BRD Section 6.7.2, channel-available quantity is calculated as:
Channel Available = POS Available - Safety Buffer
┌──────────────────────────────────────────────────────────┐
│ SAFETY BUFFER COMPUTATION │
├──────────────────────────────────────────────────────────┤
│ │
│ 4-Level Priority Resolution: │
│ 1. Product-Level Override (highest priority) │
│ 2. Category-Level Default │
│ 3. Channel-Level Default │
│ 4. Global Default (lowest priority) │
│ │
│ 3 Calculation Modes: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ FIXED: Buffer = fixed_quantity │ │
│ │ PERCENTAGE: Buffer = pos_available * percentage │ │
│ │ MIN_RESERVE: Buffer = pos_available - min_reserve │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Example (FIXED mode, buffer = 2): │
│ POS Available: 10 → Channel Available: 8 │
│ │
│ Example (PERCENTAGE mode, 20%): │
│ POS Available: 10 → Buffer: 2 → Channel Available: 8 │
│ │
└──────────────────────────────────────────────────────────┘
L.5 Architecture Documentation & Traceability
To ensure “soft architecture” matches the code and enables rapid root-cause analysis.
| Aspect | Selection |
|---|---|
| Strategy | “Diagrams as Code” to prevent documentation drift |
| Tooling | Structurizr (C4 Model) or Mermaid.js |
| Implementation | Architecture diagrams committed to Git repository alongside source code |
| Automation | Use Claude Code CLI to auto-generate updates to diagrams during refactoring |
C4 Model Levels
+-------------------------------------------------------------------+
| C4 MODEL HIERARCHY |
+-------------------------------------------------------------------+
| |
| Level 1: System Context |
| +------------------+ +------------------+ +-------------+ |
| | Nexus POS |<--->| Central API |<--->| Shopify | |
| | (Terminals) | | (Cloud) | | Amazon | |
| +------------------+ +------------------+ +-------------+ |
| |
| Level 2: Container Diagram |
| +------------------+ +------------------+ +-------------+ |
| | POS App | | API Gateway | | Kafka | |
| | (SQLite) | | Auth Service | | Cluster | |
| +------------------+ | Sales Module | +-------------+ |
| | Inventory Mod | +-------------+ |
| +------------------+ | PostgreSQL | |
| +-------------+ |
| |
| Level 3: Component Diagram (per module) |
| Level 4: Code Diagram (class/sequence) |
| |
+-------------------------------------------------------------------+
L.6 Quality Assurance (QA) & Testing Strategy
To ensure end-to-end reliability for financial transactions.
E2E (End-to-End) Testing
| Attribute | Selection |
|---|---|
| Tool | Cypress or Playwright |
| Scope | Full simulation: Cashier login → Scan Item → Process Payment → Print Receipt |
Example Test Flow:
1. Cashier authenticates with PIN
2. Scan barcode (NXJ1078)
3. Apply discount (if applicable)
4. Select payment method (Cash/Card)
5. Process payment
6. Print/email receipt
7. Verify inventory decremented
8. Verify domain event appended to events table (PostgreSQL) and NOTIFY dispatched
Load Testing
| Attribute | Selection |
|---|---|
| Tool | k6 or JMeter |
| Scope | Simulate “Black Friday” traffic (500 concurrent transactions) |
Black Friday Scenario:
Concurrent Users: 500
Duration: 30 minutes
Target TPS: 1000 transactions/second
Acceptable Latency: p99 < 500ms
Code Management
| Attribute | Selection |
|---|---|
| Platform | GitHub/GitLab |
| Versioning | Semantic Versioning (tags v1.x.x) |
| Traceability | Exact code version deployed to each POS terminal |
L.7 Observability & Monitoring Strategy
Primary Pattern
| Attribute | Selection |
|---|---|
| Pattern | OpenTelemetry (OTel) “Trace-to-Code” Pipeline |
| Rationale | Industry-standard OTel protocol prevents vendor lock-in and enables tracing an error from a specific store directly to the line of code |
Technology Stack (The “LGTM” Stack)
| Component | Tool | Purpose |
|---|---|---|
| L - Logs | Loki | Log aggregation |
| G - Grafana | Grafana | Visualization dashboards |
| T - Traces | Tempo (or Jaeger) | Distributed tracing |
| M - Metrics | Prometheus | Metrics collection |
Instrumentation
| Layer | Instrumentation |
|---|---|
| API | OpenTelemetry auto-instrumentation (@opentelemetry/sdk-node) |
| Database | Query tracing, slow query logging |
| Events | PostgreSQL event tables with LISTEN/NOTIFY (v1.0), correlation IDs for tracing |
| Nexus POS | Local telemetry buffer, sync on reconnect |
L.8 Security & Compliance Strategy
Primary Pattern
| Attribute | Selection |
|---|---|
| Pattern | 6-Gate Security Test Pyramid with DevSecOps for PCI Compliance |
| Rationale | Claude Code agents generate the full codebase. A single SonarQube gate is insufficient to catch missing authorization checks, incorrect OAuth implementation, SAQ-A violations, architecture drift, or insecure CORS/CSP headers. The 6-gate pyramid ensures defense-in-depth for AI-generated code. |
6-Gate Security Test Pyramid
| Gate | Tool | Purpose | Blocks Merge? |
|---|---|---|---|
| 1. SAST | SonarQube / CodeQL | Static code vulnerability scanning (SQLi, XSS, hardcoded secrets) | Yes |
| 2. SCA | Snyk / OWASP Dependency-Check | Package vulnerability scanning + SBOM generation (PCI-DSS 4.0 Req 6.3.2) | Yes |
| 3. Secrets Detection | GitLeaks / TruffleHog | Credential leak prevention in source code and commit history | Yes |
| 4. Architecture Conformance | dependency-cruiser | Module boundary enforcement, dependency rules (e.g., Module 6 cannot directly access Module 1 internals) | Yes |
| 5. Contract Tests | Pact | Shopify/Amazon/Google sandbox API contract verification; webhook signature validation | Yes |
| 6. Manual Security Review | Human reviewer | Security-critical paths: payment flows, credential vault access, OAuth token handling, PCI boundary | Yes (tagged PRs only) |
┌──────────────────────────────────────────────────────────┐
│ 6-GATE SECURITY TEST PYRAMID │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ Manual │ Gate 6 │
│ │ Review │ (Security-critical PRs) │
│ ┌─┴─────────┴─┐ │
│ │ Contract │ Gate 5 │
│ │ Tests │ (Pact + Sandboxes) │
│ ┌─┴─────────────┴─┐ │
│ │ Architecture │ Gate 4 │
│ │ Conformance │ (dep-cruiser) │
│ ┌─┴─────────────────┴─┐ │
│ │ Secrets Detection │ Gate 3 │
│ │ (GitLeaks) │ │
│ ┌─┴─────────────────────┴─┐ │
│ │ SCA (Snyk + SBOM) │ Gate 2 │
│ ┌─┴─────────────────────────┴─┐ │
│ │ SAST (SonarQube / CodeQL) │ Gate 1 │
│ └─────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
FIM (File Integrity Monitoring) - PCI Requirement
| Attribute | Selection |
|---|---|
| Tool | Wazuh or OSSEC |
| Action | Monitors POS terminals and servers for unauthorized file changes |
| PCI Reference | PCI-DSS 4.0 Req 11.5.1 |
| Criticality | Essential for detecting skimmers, tampering, and supply chain compromise |
Credential Vault Architecture
| Attribute | Selection |
|---|---|
| Technology | HashiCorp Vault (Docker container) |
| Deployment | Single Vault instance with auto-unseal; Docker Compose alongside PostgreSQL |
Key Hierarchy:
Master Encryption Key (Vault auto-unseal)
└── Tenant-Specific Keys
├── tenant_nexus_key
│ ├── Shopify OAuth tokens
│ ├── Amazon LWA credentials
│ ├── Google Service Account key
│ ├── Payment processor tokens
│ ├── SMTP credentials
│ └── Webhook signing keys
└── tenant_acme_key
└── ... (same structure)
6 Credential Types:
| # | Credential Type | Provider | Auth Method | Rotation |
|---|---|---|---|---|
| 1 | Shopify OAuth token | Shopify | OAuth 2.0 / PKCE | On expiry + 90-day forced |
| 2 | Amazon LWA credentials | Amazon | Login with Amazon (OAuth) | On expiry + 90-day forced |
| 3 | Google Service Account | Service Account JSON key | 90-day rotation | |
| 4 | Payment processor token | Various | API key / OAuth | 90-day rotation |
| 5 | SMTP credentials | Email provider | Username/password | 90-day rotation |
| 6 | Webhook signing keys | All providers | HMAC-SHA256 | On compromise + 90-day |
Access Policy: Least privilege; application-role-based access. Integration services can only read their own provider credentials. Credential writes require admin role with MFA.
DevSecOps Pipeline
┌───────────────────────────────────────────────────────────────────┐
│ DEVSECOPS PIPELINE (v2.0) │
├───────────────────────────────────────────────────────────────────┤
│ │
│ Developer / Claude Code Agent │
│ │ │
│ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Pre-commit │──►│ Gate 1: │──►│ Gate 2: │ │
│ │ Hooks │ │ SAST │ │ SCA + SBOM │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │
│ ┌──────────────────────────────────┘ │
│ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Gate 3: │──►│ Gate 4: │──►│ Gate 5: │ │
│ │ Secrets │ │ dep-cruise │ │ Pact Tests │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │ │
│ ┌──────────────────────────────────┘ │
│ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ E2E Tests │──►│ Gate 6: │──►│ Deploy │ │
│ │(Playwright)│ │ Manual │ │ + Wazuh │ │
│ └────────────┘ │ (if tagged)│ │ FIM │ │
│ └────────────┘ └────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
Offline Queue Security
POS terminals operating offline accumulate queued transactions that must be protected against tampering, interception, and replay attacks.
| Control | Implementation | Purpose |
|---|---|---|
| Queue Encryption | AES-256-GCM with device-specific key | Protects queued transactions at rest on SQLite |
| Tamper Detection | HMAC-SHA256 over each queued transaction | Detects modification of queued data before sync |
| Transaction Signing | Device certificate signs each transaction | Non-repudiation; proves transaction originated from authorized terminal |
| Replay Prevention | Monotonic sequence number + timestamp | Prevents re-submission of previously synced transactions |
| Key Storage | Device secure enclave / TPM where available | Protects encryption keys from extraction |
┌──────────────────────────────────────────────────────────┐
│ OFFLINE QUEUE SECURITY MODEL │
├──────────────────────────────────────────────────────────┤
│ │
│ Transaction Created (Offline) │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Serialize │───►│ HMAC-SHA256 │───►│ AES-256 │ │
│ │ Transaction │ │ (Integrity) │ │ Encrypt │ │
│ └─────────────┘ └──────────────┘ └──────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ SQLite │ │
│ │ Queue │ │
│ └───────────┘ │
│ │ │
│ Network Restored │ │
│ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Verify │◄───│ Decrypt │◄───│ Read from │ │
│ │ HMAC + Seq │ │ AES-256 │ │ Queue │ │
│ └──────┬──────┘ └──────────────┘ └────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Sync to │ │
│ │ Central API │ │
│ └─────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
L.9 Diagrammatic Overview
System Architecture (Mermaid)
graph TD
subgraph Client_Device ["Nexus POS"]
UI[UI Layer]
SL[Service Layer]
DB_Local[(SQLite)]
SL --> DB_Local
end
subgraph Cloud_Infrastructure ["Cloud Infrastructure"]
LB[Load Balancer]
subgraph Central_API ["Central API (Modular Monolith)"]
Auth[Auth Module]
Sales[Sales Module]
Inv[Inventory Module]
end
subgraph Data_Layer ["Data Layer"]
PG[(PostgreSQL)]
Events[(PG Events)]
end
end
subgraph DevOps_Pipeline ["DevSecOps & Traceability"]
Git[GitHub - Semantic Ver]
Struct[Structurizr - Docs]
Sonar[SonarQube - SAST]
Cypress[Cypress - E2E]
Wazuh[Wazuh - FIM/PCI]
end
SL --> LB
LB --> Auth
Auth --> Sales
Sales --> Events
Sales --> PG
Git --> Sonar
Sonar --> Cypress
Cypress --> Struct
Wazuh -.-> Central_API
Wazuh -.-> Client_Device
ASCII Version
+------------------------------------------------------------------+
| NEXUS POS ARCHITECTURE |
+------------------------------------------------------------------+
| |
| ┌─────────────────────────────────────────────────────────────┐ |
| │ NEXUS POS CLIENT (STORE) │ |
| │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ |
| │ │ UI │───▶│ Service Layer│───▶│ SQLite (Local) │ │ |
| │ │ (React │ │ (Plugins) │ │ (Offline Data) │ │ |
| │ │ Web App)│ │ │ │ (sql.js + OPFS) │ │ |
| │ └──────────┘ └──────────────┘ └──────────────────┘ │ |
| └──────────────────────────┬──────────────────────────────────┘ |
| │ |
| ▼ (Sync when online) |
| ┌─────────────────────────────────────────────────────────────┐ |
| │ CLOUD INFRASTRUCTURE │ |
| │ │ |
| │ ┌──────────────────────────────────────────────────────┐ │ |
| │ │ CENTRAL API (Modular Monolith) │ │ |
| │ │ ┌────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐ │ │ |
| │ │ │ Auth │ │ Sales │ │Inventory │ │ Catalog │ │ │ |
| │ │ └────────┘ └────────┘ └──────────┘ └──────────┘ │ │ |
| │ └──────────────────────┬───────────────────────────────┘ │ |
| │ │ │ |
| │ ┌───────────────┼───────────────┐ │ |
| │ ▼ ▼ ▼ │ |
| │ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │ |
| │ │PostgreSQL │ │ HashiCorp │ │ External │ │ |
| │ │(Events + │ │ Vault │ │ Systems │ │ |
| │ │ State) │ │(Secrets) │ │(Shopify, etc.)│ │ |
| │ └───────────┘ └───────────┘ └───────────────┘ │ |
| └─────────────────────────────────────────────────────────────┘ |
| |
+------------------------------------------------------------------+
L.9A System Architecture Reference
Detailed Implementation Reference (from former High-Level Architecture chapter, now consolidated here):
Complete System Architecture Diagram
+===========================================================================+
| CLOUD LAYER |
| +------------------+ +------------------+ +------------------+ |
| | Shopify API | | Payment Gateway | | Tax Service | |
| | (E-commerce) | | (Stripe/Square) | | (TaxJar) | |
| +--------+---------+ +--------+---------+ +--------+---------+ |
| | | | |
+===========|=====================|=====================|====================+
| | |
v v v
+===========================================================================+
| API GATEWAY LAYER |
| +---------------------------------------------------------------------+ |
| | Kong / NGINX Gateway | |
| | +-------------+ +-------------+ +-------------+ +-------------+ | |
| | | Rate Limit | | Auth | | Routing | | Logging | | |
| | +-------------+ +-------------+ +-------------+ +-------------+ | |
| +---------------------------------------------------------------------+ |
+===========================================================================+
|
v
+===========================================================================+
| CENTRAL API LAYER |
| (Node.js + Express/Fastify — TypeScript) |
| |
| +------------------+ +------------------+ +------------------+ |
| | Catalog Service | | Sales Service | |Inventory Service| |
| | | | | | | |
| | - Products | | - Transactions | | - Stock Levels | |
| | - Categories | | - Receipts | | - Adjustments | |
| | - Pricing | | - Refunds | | - Transfers | |
| | - Variants | | - Layaways | | - Counts | |
| +------------------+ +------------------+ +------------------+ |
| |
| +------------------+ +------------------+ +------------------+ |
| |Customer Service | |Employee Service | | Sync Service | |
| | | | | | | |
| | - Profiles | | - Users | | - Shopify Sync | |
| | - Loyalty | | - Roles | | - Offline Sync | |
| | - History | | - Permissions | | - Event Queue | |
| | - Credits | | - Shifts | | - Conflict Res | |
| +------------------+ +------------------+ +------------------+ |
| |
+===========================================================================+
|
v
+===========================================================================+
| DATA LAYER |
| +---------------------------------------------------------------------+ |
| | PostgreSQL 16 Cluster | |
| | | |
| | +-----------------+ +----------------------------------------------+ |
| | | shared schema | | public schema (RLS) | |
| | | (platform) | | All tenant data with tenant_id + RLS | |
| | +-----------------+ +----------------------------------------------+ |
| | | |
| +---------------------------------------------------------------------+ |
| +------------------+ +------------------+ |
| | Redis | | Event Store | |
| | (Cache/Queue) | | (Append-Only) | |
| +------------------+ +------------------+ |
+===========================================================================+
+===========================================================================+
| CLIENT APPLICATIONS |
| |
| +-------------------------------+ +------------------+ |
| | Nexus POS | | Nexus Raptag | |
| | (React Web App — Vite) | | (React Native + | |
| | | | Expo) | |
| | - Sales Terminal (Cashier) | | - RFID Scanning | |
| | - Dashboard / Reports (Mgr) | | - Inventory | |
| | - Configuration (Admin) | | - Quick Counts | |
| | - Offline SQLite WASM | | - Transfers | |
| | - Role-based feature access | | | |
| +-------------------------------+ +------------------+ |
| |
+===========================================================================+
Three-Tier Architecture Detail
Tier 1: Cloud Layer (External Services)
| Service | Purpose | Protocol | Data Flow |
|---|---|---|---|
| Shopify API | E-commerce sync | REST/GraphQL | Bidirectional |
| Payment Gateway | Card processing | REST + Webhooks | Request/Response |
| Tax Service | Tax calculation | REST | Request/Response |
| Email Service | Notifications | SMTP/API | Outbound only |
| SMS Service | Alerts | API | Outbound only |
Cloud Integration Flow
======================
Shopify Payment Gateway Tax Service
| | |
| Products, Orders | Authorization | Rate Lookup
| Inventory | Capture | Calculation
| | Refund |
v v v
+----------------------------------------------------------------+
| Integration Adapters |
| +---------------+ +------------------+ +------------------+ |
| |ShopifyAdapter | | PaymentAdapter | | TaxAdapter | |
| +---------------+ +------------------+ +------------------+ |
+----------------------------------------------------------------+
|
v
[Central API Services]
Tier 2: Central API Layer (Application Services)
API Gateway
Request Flow Through Gateway
============================
Client Request
|
v
+--------------------------------------------------+
| API GATEWAY |
| |
| 1. [Rate Limiting] -----> 100 req/min/client |
| | |
| v |
| 2. [Authentication] ----> JWT Validation |
| | |
| v |
| 3. [Tenant Resolution] -> Extract tenant_id |
| | |
| v |
| 4. [Request Logging] ---> Correlation ID |
| | |
| v |
| 5. [Route to Service] --> /api/v1/sales/* |
| |
+--------------------------------------------------+
|
v
Service Handler
Core Services
| Service | Responsibilities | Key Endpoints |
|---|---|---|
| Catalog Service | Products, categories, pricing, variants | /api/v1/products/* |
| Sales Service | Transactions, receipts, refunds, holds | /api/v1/sales/* |
| Inventory Service | Stock levels, adjustments, transfers | /api/v1/inventory/* |
| Customer Service | Profiles, loyalty, purchase history | /api/v1/customers/* |
| Employee Service | Users, roles, permissions, shifts | /api/v1/employees/* |
| Sync Service | Offline sync, conflict resolution | /api/v1/sync/* |
Tier 3: Data Layer (Persistence)
Data Layer Architecture
=======================
+------------------+ +------------------+ +------------------+
| PostgreSQL | | Redis | | Event Store |
| (Primary DB) | | (Cache/Queue) | | (Append-Only) |
+------------------+ +------------------+ +------------------+
| | |
| | |
+-------v------------------------v------------------------v--------+
| |
| Schema: shared Cache Keys Events |
| +--------------+ +------------+ +-------------+ |
| | tenants | | product: | | SaleCreated | |
| | plans | | {id} | | ItemAdded | |
| | features | | session: | | PaymentRcvd | |
| +--------------+ | {token} | | StockAdj | |
| | inventory: | +-------------+ |
| Schema: public (RLS) | {sku} | |
| +--------------+ +------------+ |
| | products | (all tables have tenant_id + RLS policies) |
| | sales | |
| | inventory | |
| | customers | |
| +--------------+ |
| |
+-------------------------------------------------------------------+
Client Applications
Nexus POS (Web Application)
Nexus POS Architecture
======================
+-------------------------------------------------------------------+
| NEXUS POS (React Web App — Vite / TypeScript) |
| |
| +-----------------------+ +---------------------------+ |
| | UI Layer | | Local Storage | |
| | (React + Zustand + | | +--------------------+ | |
| | React Query) | | | SQLite WASM | | |
| | +----------------+ | | | (sql.js + OPFS) | | |
| | | Sales Screen | | | | | | |
| | +----------------+ | | | - product_cache | | |
| | | Product Grid | | | | - sales_queue | | |
| | +----------------+ | | +--------------------+ | |
| | | Cart Panel | | | | |
| | +----------------+ | +---------------------------+ |
| | | Payment Dialog | | |
| | +----------------+ | |
| | | Dashboard/Admin| | (Role-based: Cashier sees sales, |
| | | (role-gated) | | Manager sees reports, Admin sees |
| | +----------------+ | configuration — single app) |
| +-----------------------+ |
| |
| +-----------------------+ +---------------------------+ |
| | Service Layer | | Hardware Layer | |
| | (React Query + | | (Web APIs / SDKs) | |
| | Zustand stores) | | +--------------------+ | |
| | +----------------+ | | | Star WebPRNT | | |
| | | SaleService | | | | (Receipt Printer) | | |
| | +----------------+ | | +--------------------+ | |
| | | SyncService | | | | USB HID Wedge | | |
| | +----------------+ | | | (Barcode Scanner) | | |
| | | OfflineService | | | +--------------------+ | |
| | +----------------+ | | | Kick-out Cable | | |
| +-----------------------+ | | (via Printer Port) | | |
| | +--------------------+ | |
| | | Stripe Terminal SDK| | |
| | | (Card Reader) | | |
| | +--------------------+ | |
| +---------------------------+ |
+-------------------------------------------------------------------+
Note: Nexus Admin was merged into Nexus POS in v7.0.0 (ADR-052). All admin features (Dashboard, Products, Reports, Settings, User Management) are now role-gated screens within the single Nexus POS web application. See the Nexus POS architecture diagram above.
Nexus Raptag (Mobile RFID)
Nexus Raptag Architecture
=========================
+-------------------------------------------------------------------+
| NEXUS RAPTAG (React Native + Expo) |
| |
| +------------------------+ +---------------------------+ |
| | RFID Layer | | UI Layer | |
| | +------------------+ | | (React Native + | |
| | | Zebra SDK | | | React Query + Zustand) | |
| | | (Native Module) | | | +---------------------+ | |
| | +------------------+ | | | Scan Screen | | |
| | | Tag Parser | | | +---------------------+ | |
| | +------------------+ | | | Inventory Count | | |
| | | Batch Processor | | | +---------------------+ | |
| | +------------------+ | | | Transfer Screen | | |
| +------------------------+ | +---------------------+ | |
| +---------------------------+ |
| |
| +------------------------+ +---------------------------+ |
| | Local Storage | | API Client | |
| | +------------------+ | | +---------------------+ | |
| | | SQLite | | | | fetch / axios | | |
| | | (expo-sqlite) | | | +---------------------+ | |
| | +------------------+ | | | Offline Queue | | |
| | | Scan Buffer | | | +---------------------+ | |
| | +------------------+ | | | |
| +------------------------+ +---------------------------+ |
+-------------------------------------------------------------------+
Service Boundaries
Service Boundary Diagram
========================
+-------------------+ +-------------------+ +-------------------+
| Catalog Service | | Sales Service | |Inventory Service |
| | | | | |
| OWNS: | | OWNS: | | OWNS: |
| - products | | - sales | | - inventory_items |
| - categories | | - line_items | | - stock_levels |
| - pricing_rules | | - payments | | - adjustments |
| - product_variants| | - refunds | | - transfers |
| - product_images | | - holds | | - count_sessions |
| | | | | |
| REFERENCES: | | REFERENCES: | | REFERENCES: |
| (none) | | - product_id | | - product_id |
| | | - customer_id | | - location_id |
| | | - employee_id | | |
+-------------------+ +-------------------+ +-------------------+
+-------------------+ +-------------------+
| Customer Service | | Employee Service |
| | | |
| OWNS: | | OWNS: |
| - customers | | - employees |
| - loyalty_cards | | - roles |
| - store_credits | | - permissions |
| - addresses | | - shifts |
| | | - time_entries |
| REFERENCES: | | |
| (none) | | REFERENCES: |
| | | - location_id |
+-------------------+ +-------------------+
Technology Stack Summary
| Layer | Technology | Justification |
|---|---|---|
| API Gateway | Kong or NGINX | Proven, scalable, plugin ecosystem |
| Central API | Node.js + Express/Fastify (TypeScript) | Unified TypeScript stack, async I/O, rich npm ecosystem |
| ORM | Prisma | Type-safe queries, auto-generated client, declarative migrations |
| Validation | Zod | Runtime + compile-time schema validation, TypeScript-native |
| Database | PostgreSQL 16 | Multi-tenant support, JSON support, reliability |
| Cache | Redis (ioredis) | Session storage, real-time features |
| Event Store | PostgreSQL (append-only) | Simplicity, same DB engine |
| Nexus POS | React/TypeScript (Vite) + TailwindCSS + shadcn/ui | Single web app for all roles (cashier, manager, admin). Hardware via web APIs (Star WebPRNT, USB HID wedge, Stripe Terminal SDK). Offline fallback via SQLite WASM (sql.js + OPFS). React Query + Zustand for state. |
| Nexus Raptag | React Native + Expo | Cross-platform mobile, Zebra RFID SDK (native module) |
| Real-time | Socket.io | Inventory broadcasts, notifications, WebSocket with fallback |
| Auth | jose + argon2 | RS256 JWT signing, Argon2id password hashing |
| Logging | Pino | Structured JSON logging, high performance |
| Testing | Vitest | Fast unit/integration testing, TypeScript-native |
| Telemetry | @opentelemetry/sdk-node | Traces, metrics, logs — vendor-neutral |
| Package Manager | pnpm | Fast installs, strict dependency resolution, workspace support |
Deployment Topology
Production Deployment
=====================
+------------------+
| Load Balancer |
| (HAProxy/ALB) |
+--------+---------+
|
+----------------------+----------------------+
| | |
+---------v--------+ +---------v--------+ +---------v--------+
| API Server 1 | | API Server 2 | | API Server 3 |
| | | | | |
| - Central API | | - Central API | | - Central API |
| - Stateless | | - Stateless | | - Stateless |
+--------+---------+ +---------+--------+ +---------+--------+
| | |
+----------+------------+-----------+----------+
| |
+---------v--------+ +---------v--------+
| PostgreSQL | | Redis |
| (Primary) | | (Cluster) |
+--------+---------+ +------------------+
|
+--------v---------+
| PostgreSQL |
| (Replica) |
+------------------+
Store Locations (5 stores):
+----------------+ +----------------+ +----------------+
| GM Store | | HM Store | | LM Store |
| +------------+ | | +------------+ | | +------------+ |
| |Nexus POS 1| | | |Nexus POS 1| | | |Nexus POS 1| |
| +------------+ | | +------------+ | | +------------+ |
| |Nexus POS 2| | | +------------+ | +----------------+
| +------------+ | | |Nexus POS 2| |
+----------------+ | +------------+ |
+----------------+
Security Architecture
Security Layers
===============
+------------------------------------------------------------------+
| INTERNET |
+---------------------------+--------------------------------------+
|
v
+---------------------------+--------------------------------------+
| TLS TERMINATION |
| (Let's Encrypt) |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| API GATEWAY |
| +-----------------------+ +-----------------------+ |
| | Rate Limiting | | IP Whitelisting | |
| | 100 req/min/client | | IP Whitelisting | |
| +-----------------------+ +-----------------------+ |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| AUTHENTICATION |
| +-----------------------+ +-----------------------+ |
| | JWT Validation | | PIN Verification | |
| | - Signature check | | - Employee clock-in | |
| | - Expiry check | | - Sensitive actions | |
| | - Tenant claim | +-----------------------+ |
| +-----------------------+ |
+---------------------------+--------------------------------------+
|
v
+------------------------------------------------------------------+
| AUTHORIZATION |
| +-----------------------+ +-----------------------+ |
| | Role-Based (RBAC) | | Permission Policies | |
| | - Admin | | - can:create_sale | |
| | - Manager | | - can:void_sale | |
| | - Cashier | | - can:view_reports | |
| +-----------------------+ +-----------------------+ |
+------------------------------------------------------------------+
L.9B Data Flow Reference
Detailed Implementation Reference (from former High-Level Architecture chapter, now consolidated here):
Pattern 1: Online Sale Flow
Online Sale Flow
================
[Nexus POS] [Central API] [Database]
| | |
| 1. POST /sales | |
|------------------------------>| |
| | 2. Validate request |
| |------------------------------>|
| | |
| | 3. Begin transaction |
| |------------------------------>|
| | |
| | 4. Create sale record |
| |------------------------------>|
| | |
| | 5. Decrement inventory |
| |------------------------------>|
| | |
| | 6. Log sale event |
| |------------------------------>|
| | |
| | 7. Commit transaction |
| |------------------------------>|
| | |
| 8. Return sale confirmation | |
|<------------------------------| |
| | |
| 9. Print receipt | |
| | |
Pattern 2: Offline Sale Flow
Offline Sale Flow
=================
[Nexus POS] [Local SQLite] [Sync Queue]
| | |
| 1. Create sale locally | |
|------------------------------>| |
| | 2. Generate local UUID |
| | |
| 3. Decrement local inventory | |
|------------------------------>| |
| | |
| 4. Queue for sync | |
|-------------------------------------------------------------->|
| | |
| 5. Print receipt | |
| | |
--- Later, when online ---
[Sync Service] [Central API] [Database]
| | |
| 1. Pop from queue | |
| | |
| 2. POST /sync/sales | |
|------------------------------>| |
| | 3. Validate (check for dupe) |
| |------------------------------>|
| | |
| | 4. Insert with local UUID |
| |------------------------------>|
| | |
| 5. Mark synced | |
|<------------------------------| |
Pattern 3: Inventory Sync Flow
Inventory Sync from Shopify
===========================
[Shopify] [Webhook Handler] [Inventory Svc]
| | |
| 1. inventory_levels/update | |
|------------------------------>| |
| | 2. Validate webhook |
| | |
| | 3. Parse inventory update |
| |------------------------------>|
| | |
| | 4. Update stock level |
| |------------------------------>|
| | |
| | 5. Log inventory event |
| |------------------------------>|
| | |
| | 6. Broadcast to POS clients |
| |------------------------------>|
| | (Socket.io) |
L.9C Domain Model Reference
Domain Model Overview (from former Domain Model chapter, now consolidated here): NOTE: Only bounded contexts, aggregates, and ER diagram included here. Detailed entity field definitions are in Part III Database chapters.
Bounded Contexts Overview
Domain Bounded Contexts
=======================
+------------------------------------------------------------------+
| POS PLATFORM |
| |
| +-------------+ +-------------+ +-------------+ |
| | CATALOG | | SALES | | INVENTORY | |
| | | | | | | |
| | Products | | Sales | | StockLevels | |
| | Variants | | LineItems | | Adjustments | |
| | Categories | | Payments | | Transfers | |
| | Pricing | | Refunds | | Counts | |
| +-------------+ +-------------+ +-------------+ |
| |
| +-------------+ +-------------+ +-------------+ |
| | CUSTOMER | | EMPLOYEE | | LOCATION | |
| | | | | | | |
| | Customers | | Employees | | Locations | |
| | Addresses | | Roles | | Registers | |
| | Loyalty | | Permissions | | Settings | |
| | Credits | | Shifts | | TaxRates | |
| +-------------+ +-------------+ +-------------+ |
| |
+------------------------------------------------------------------+
Context Summary Table
| Context | Entities | Purpose |
|---|---|---|
| Catalog | Product, Variant, Category, PricingRule | Product management |
| Sales | Sale, LineItem, Payment, Refund | Transaction processing |
| Inventory | InventoryItem, Adjustment, Transfer | Stock management |
| Customer | Customer, Address, Credit, Loyalty | Customer management |
| Employee | Employee, Role, Permission, Shift | Staff management |
| Location | Location, Register, Drawer, TaxRate | Store configuration |
Entity Relationship Diagram
Entity Relationships
====================
+----------+
| Category |
+----+-----+
|
| 1:N
v
+----------+ 1:N +----------+ 1:N +----------------+
| Location |<-------------| Product |------------->| ProductVariant |
+----+-----+ +----+-----+ +-------+--------+
| | |
| | |
| 1:N | |
v | |
+----------+ | |
| Register | v v
+----+-----+ +----------+ +----------------+
| |Inventory | | Adjustment |
| | Item | | Item |
| 1:N +----------+ +----------------+
v
+----------+
|CashDrawer|
+----------+
+----------+ 1:N +----------+ 1:N +----------+
| Customer |------------->| Sale |------------->| LineItem |
+----+-----+ +----+-----+ +----------+
| |
| | 1:N
| 1:N v
v +----------+
+----------+ | Payment |
| Credit | +----------+
+----------+
+----------+ N:1 +----------+ 1:N +----------+
| Employee |------------->| Role |------------->|Permission|
+----+-----+ +----------+ +----------+
|
| 1:N
v
+----------+
| Shift |
+----------+
Aggregate Boundaries
Each aggregate has a root entity and encapsulates related entities:
Aggregate Definitions
=====================
SALE Aggregate
+------------------------------------------+
| Sale (Root) |
| +-- SaleLineItem[] (owned) |
| +-- Payment[] (owned) |
| +-- Refund[] (reference: sale_id) |
+------------------------------------------+
INVENTORY_ADJUSTMENT Aggregate
+------------------------------------------+
| InventoryAdjustment (Root) |
| +-- InventoryAdjustmentItem[] (owned) |
+------------------------------------------+
INVENTORY_TRANSFER Aggregate
+------------------------------------------+
| InventoryTransfer (Root) |
| +-- InventoryTransferItem[] (owned) |
+------------------------------------------+
CUSTOMER Aggregate
+------------------------------------------+
| Customer (Root) |
| +-- CustomerAddress[] (owned) |
| +-- StoreCredit[] (reference) |
| +-- LoyaltyTransaction[] (reference) |
+------------------------------------------+
PRODUCT Aggregate
+------------------------------------------+
| Product (Root) |
| +-- ProductVariant[] (owned) |
+------------------------------------------+
L.10 Risks & Mitigations
| Risk | Mitigation Strategy |
|---|---|
| Sync Conflicts | Use Event Sourcing to replay conflicting events deterministically. First-commit-wins for inventory with backorder escalation. |
| Observability Overload | LGTM stack with integration-specific dashboards: circuit breaker state, DLQ depth, sync latency, safety buffer violations, disapproval rate per channel. |
| GenAI Code Risks | 6-Gate Security Pyramid: SAST + SCA + Secrets + dependency-cruiser + Pact + Manual Review. Architecture conformance tests prevent module boundary violations. |
| PCI-DSS Non-Compliance | FIM via Wazuh agents on all POS nodes. SCA via Snyk. SBOM generation. Session management with 15-minute timeout. |
| Supply Chain Attacks | Package firewall at proxy level. Real-time SBOM. Automated dependency updates with vulnerability scanning. |
| External API Cascade Failure | Circuit breaker (5 failures/60s → OPEN). Module 6 as Extractable Integration Gateway with failure isolation. Bulkheaded thread pools. |
| Credential Compromise | HashiCorp Vault with key hierarchy. 90-day automated rotation. Emergency rotation procedures. Least-privilege access policies. |
| Overselling Across Channels | Safety buffer computation with 4-level priority resolution. Transactional Outbox for atomic inventory + event. First-commit-wins with backorder escalation. |
L.10A Key Architecture Decisions (BRD-v12)
This section documents critical architecture decisions derived from BRD-v12 requirements analysis. Each decision follows the Architecture Decision Record (ADR) format.
L.10A.1 Online-First with Offline Fallback
| Attribute | Value |
|---|---|
| Decision ID | ADR-048 |
| Context | POS terminals operate in retail environments with reliable internet (outages measured in minutes/year). The original offline-first design (ADR-002) created daily complexity for a rare event. |
| Decision | Online-First with thin offline safety net (2-table SQLite fallback) |
| Alternatives Considered | 1) Offline-first with 6-table SQLite + CRDTs (ADR-002, superseded), 2) Online-first with thin fallback (selected), 3) Online-only, no offline capability (rejected) |
| Rationale | Online-first optimizes for 99.99% case; 2-table SQLite provides minimum viable offline sales; eliminates CRDTs, platform-aware hooks, and sync priority tiers |
| Reference | ADR-048, BRD §1.16 |
Data Access Strategy:
┌─────────────────────────────────────────────────────────────┐
│ ONLINE-FIRST DATA ACCESS │
├─────────────────────────────────────────────────────────────┤
│ │
│ UI Component → useProduct(barcode) │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ONLINE │ │ DEGRADED │ │ OFFLINE │ │
│ │ │ │ │ │ │ │
│ │ React │ │ Try API │ │ SQLite │ │
│ │ Query → │ │ (2s) → │ │ product │ │
│ │ Central │ │ fallback │ │ _cache │ │
│ │ API │ │ SQLite │ │ │ │
│ └─────────┘ └──────────┘ └──────────┘ │
│ │
│ Writes: │
│ ONLINE → POST to Central API directly │
│ DEGRADED → POST to API + queue locally as backup │
│ OFFLINE → Append to sales_queue (flush on recovery) │
└─────────────────────────────────────────────────────────────┘
Operations During Offline:
online_first:
# All operations go through Central API when online.
# During offline fallback, only these are available:
allowed_offline:
- sale_new # Prices from product_cache, sale to sales_queue
- return_with_receipt # Receipt data available locally
- price_check # From product_cache
- parked_sale_create # Local cart state
- parked_sale_retrieve # Local cart state
blocked_offline:
- customer_create # Requires uniqueness check
- credit_limit_check # Requires real-time balance
- on_account_payment # Risk of exceeding limit
- multi_store_inventory # Requires network
- gift_card_activation # Must register immediately
- gift_card_reload # Risk of double-load
- transfer_request # Requires other store
- reservation_create # Requires other store
staleness_warning:
threshold_minutes: 60 # Show "prices may be outdated" banner
L.10A.1A Nexus POS Architecture (Online-First)
Online-first architecture: POS terminal connects directly to Central API via React Query. SQLite provides offline fallback only.
Nexus POS Client Architecture (Online-First)
=============================================
+-----------------------------------------------------------------------+
| NEXUS POS (React Web App — Vite/TypeScript) |
| |
| +------------------------+ +-------------------------------+ |
| | Presentation | | Offline Fallback (SQLite) | |
| | | | | |
| | +------------------+ | | +-------------------------+ | |
| | | Sales Screen | | | | product_cache | | |
| | +------------------+ | | | (read-only, server- | | |
| | | Product Grid | | | | authoritative) | | |
| | +------------------+ | | +-------------------------+ | |
| | | Cart Panel | | | | sales_queue | | |
| | +------------------+ | | | (append-only, flush | | |
| | | Payment Dialog | | | | on recovery) | | |
| | +------------------+ | | +-------------------------+ | |
| | | Receipt Print | | | | |
| | +------------------+ | +-------------------------------+ |
| +------------------------+ ^ |
| | | (OFFLINE/DEGRADED |
| v | fallback only) |
| +------------------------+ | |
| | Data Access Layer |--------------------+ |
| | | |
| | useProduct(barcode) | Routes transparently based on |
| | useCompleteSale() | connection state. Components |
| | useInventory() | never know which path was taken. |
| +------------------------+ |
| | (ONLINE: primary path) |
| v |
| +------------------------+ +-------------------------------+ |
| | React Query + Cache | | Connection Monitor (3-State) | |
| | | | | |
| | - In-memory cache |<------>| - ONLINE: API + WebSocket | |
| | - Stale-while-revali- | | - DEGRADED: try API, fall- | |
| | date pattern | | back to SQLite cache | |
| | - Background refetch | | - OFFLINE: SQLite only | |
| +------------------------+ +-------------------------------+ |
| | |
+-------------|----------------------------------------------------------+
| (always, when online)
v
+-----------------------------------------------------------------------+
| CENTRAL API |
| - WebSocket push (config changes, inventory updates) |
| - REST API (reads + writes) |
| - Redis cache (product lookups <5ms server-side) |
+-----------------------------------------------------------------------+
L.10A.1B Local Database Schema (SQLite — 2 Tables)
Minimal offline fallback: Only 2 tables needed — a read-only product cache for pricing lookups and an append-only sales queue for offline transactions. All other data (inventory, customers, settings) is accessed via Central API in real-time.
-- SQLite Schema for Nexus POS Offline Fallback (sql.js WASM + OPFS)
-- Only 2 tables — minimal footprint for rare offline events
-- Product cache (read-only, server-authoritative)
-- Pre-warmed on startup, updated by WebSocket push events
CREATE TABLE product_cache (
id TEXT PRIMARY KEY,
sku TEXT UNIQUE NOT NULL,
barcode TEXT,
name TEXT NOT NULL,
category_name TEXT,
price REAL NOT NULL,
cost REAL,
tax_code TEXT,
is_taxable INTEGER DEFAULT 1,
variants_json TEXT, -- JSON array of variants
last_refreshed TEXT NOT NULL -- For staleness detection
);
CREATE INDEX idx_product_cache_barcode ON product_cache(barcode);
CREATE INDEX idx_product_cache_sku ON product_cache(sku);
-- Sales queue (append-only, offline transactions)
-- Written only during OFFLINE/DEGRADED states
-- Flushed to Central API on recovery (FIFO, oldest first)
CREATE TABLE sales_queue (
sale_id TEXT PRIMARY KEY, -- UUID for idempotent upsert
sale_number TEXT UNIQUE NOT NULL,
location_id TEXT NOT NULL,
register_id TEXT NOT NULL,
employee_id TEXT NOT NULL,
customer_id TEXT,
subtotal REAL NOT NULL,
discount_total REAL DEFAULT 0,
tax_total REAL DEFAULT 0,
total REAL NOT NULL,
line_items_json TEXT NOT NULL, -- JSON array of line items
payments_json TEXT NOT NULL, -- JSON array of payments
created_at TEXT DEFAULT (datetime('now')),
synced_at TEXT, -- NULL until synced
sync_error TEXT -- Last error if sync failed
);
CREATE INDEX idx_sales_queue_pending ON sales_queue(synced_at) WHERE synced_at IS NULL;
Why only 2 tables (down from 6 in ADR-002):
inventory_cacheremoved — inventory levels queried from API in real-time; not critical for completing a sale during brief offlinecustomers_cacheremoved — customer lookup via API; offline sales can proceed without customer associationevent_queueremoved — replaced bysales_queue(only sales need offline queuing; no priority tiers needed)sync_statusremoved — cache freshness tracked byproduct_cache.last_refreshed; no multi-entity sync timestamps needed
Cache pre-warming (on POS startup while online):
// On POS application startup (sql.js WASM + OPFS)
import type { Database } from 'sql.js';
async function warmProductCache(db: Database, api: ApiClient): Promise<void> {
const locationId = getLocationConfig().locationId;
const products = await api.getProducts({ locationId, includeVariants: true });
db.run('BEGIN TRANSACTION');
const stmt = db.prepare(`
INSERT OR REPLACE INTO product_cache
(id, sku, barcode, name, category_name, price, cost, tax_code, is_taxable, variants_json, last_refreshed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const p of products) {
stmt.run([p.id, p.sku, p.barcode, p.name, p.categoryName, p.price, p.cost,
p.taxCode, p.isTaxable ? 1 : 0, JSON.stringify(p.variants), new Date().toISOString()]);
}
stmt.free();
db.run('COMMIT');
// Persist to OPFS
const data = db.export();
await persistToOPFS(data);
}
Incremental cache updates (via WebSocket during the day):
// Listen for product changes pushed by Central API (sql.js WASM)
socket.on('product.updated', async (product: ProductUpdate) => {
const stmt = db.prepare(`
INSERT OR REPLACE INTO product_cache
(id, sku, barcode, name, category_name, price, cost, tax_code, is_taxable, variants_json, last_refreshed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run([product.id, product.sku, product.barcode, product.name, product.categoryName,
product.price, product.cost, product.taxCode, product.isTaxable ? 1 : 0,
JSON.stringify(product.variants), new Date().toISOString()]);
stmt.free();
// Persist to OPFS (debounced in production)
await persistToOPFS(db.export());
});
L.10A.1C Sales Queue Flush Design
Simple FIFO flush: No priority tiers needed. Only sales are queued during offline. Flush in order when connectivity restores.
Sales Queue Flush (OFFLINE → ONLINE Recovery)
=============================================
Connection Restores
|
v
┌─────────────────────────────────────────┐
│ 1. Read sales_queue WHERE synced_at │
│ IS NULL ORDER BY created_at ASC │
│ (oldest first, FIFO) │
│ │
│ 2. For each queued sale: │
│ POST /api/sales │
│ Body: { sale_id, items, total, ... }│
│ ↓ │
│ 3. API processes sale: │
│ - Upsert by sale_id (idempotent) │
│ - Compare unit_price vs current │
│ - Flag discrepancy if different │
│ - Adjust inventory │
│ - Fire domain events │
│ ↓ │
│ 4. On success: │
│ UPDATE sales_queue │
│ SET synced_at = NOW() │
│ WHERE sale_id = ? │
│ ↓ │
│ 5. On failure: │
│ UPDATE sales_queue │
│ SET sync_error = error_message │
│ WHERE sale_id = ? │
│ (retry on next cycle) │
└─────────────────────────────────────────┘
|
v
┌─────────────────────────────────────────┐
│ 6. Refresh product_cache │
│ (prices may have changed) │
│ │
│ 7. Resume WebSocket subscription │
│ │
│ 8. Switch to ONLINE mode │
└─────────────────────────────────────────┘
Idempotency guarantee: Each sale has a UUID sale_id generated at creation time. The Central API uses this as an idempotency key — if the same sale_id is submitted twice (e.g., partial flush + retry), the API returns success without creating a duplicate.
L.10A.1D Data Consistency (No Conflicts by Design)
Online-first eliminates traditional conflict resolution. The product cache is read-only (server-authoritative). The sales queue is append-only (UUID-keyed). No two-way data merge is needed.
Why CRDTs are not needed:
| Data Type | Online-First Approach | Why No Conflict |
|---|---|---|
| Products | Read-only cache, server pushes updates | POS never writes to product data |
| Inventory | API query in real-time (online), not tracked locally (offline) | No local inventory state to conflict |
| Customers | API query in real-time (online), not cached locally | No local customer state to conflict |
| Sales | Append-only queue with UUID keys | Each sale is unique; idempotent upsert prevents duplicates |
| Settings | API query in real-time, pushed via WebSocket | POS never writes settings |
Contrast with offline-first (ADR-002, superseded):
- Offline-first required conflict resolution because multiple data types (products, inventory, customers) were cached locally and could diverge from the server
- Online-first eliminates this: only the product cache exists locally, and it’s read-only (server always wins)
- The sales queue is append-only and uses UUID-based idempotent processing — no conflicts possible
L.10A.1E Flag-on-Sync Price Discrepancy Detection
Problem: During offline mode, the POS uses cached product prices. If an admin changed a price while the terminal was offline, the sale was recorded at the stale price. Flag-on-sync catches this automatically.
Flag-on-Sync Workflow:
Price Discrepancy Detection (during sales queue flush)
=====================================================
For each queued sale being synced:
┌─────────────────────────────────────────────────────┐
│ 1. API receives sale with line items │
│ Each item has: { sku, unit_price, quantity } │
│ │
│ 2. For each line item: │
│ Compare sale.unit_price vs product.current_price│
│ │
│ ├── Prices MATCH → accept normally │
│ └── Prices DIFFER → accept + flag: │
│ { │
│ price_discrepancy: true, │
│ sold_price: 29.99, │
│ current_price: 24.99, │
│ difference: +5.00, │
│ reason: "offline_cache_stale" │
│ } │
│ │
│ 3. Fire event: sale.price_discrepancy │
│ → Admin notification in Nexus POS │
└─────────────────────────────────────────────────────┘
Admin Discrepancy View (in Nexus POS manager/admin dashboard):
| Sale ID | Product | Sold Price | Current Price | Diff | Time | Action |
|---|---|---|---|---|---|---|
| abc-123 | Blue T-Shirt | $29.99 | $24.99 | +$5.00 | 2:10 PM | [Issue Credit] [Dismiss] |
Sales Queue Flush Service:
// sales-queue-flush.ts (sql.js WASM)
import type { Database } from 'sql.js';
import pino from 'pino';
import type { ApiClient, ConnectionState } from './types';
const logger = pino({ name: 'SalesQueueFlush' });
interface QueuedSale {
sale_id: string;
sale_number: string;
location_id: string;
register_id: string;
employee_id: string;
customer_id: string | null;
subtotal: number;
discount_total: number;
tax_total: number;
total: number;
line_items_json: string;
payments_json: string;
created_at: string;
}
export class SalesQueueFlush {
private isFlushing = false;
constructor(
private db: Database,
private api: ApiClient,
) {}
async flush(): Promise<{ synced: number; errors: number }> {
if (this.isFlushing) return { synced: 0, errors: 0 };
this.isFlushing = true;
let synced = 0;
let errors = 0;
try {
// sql.js: query pending sales from WASM SQLite
const pending = queryAll<QueuedSale>(this.db,
'SELECT * FROM sales_queue WHERE synced_at IS NULL ORDER BY created_at ASC'
);
for (const sale of pending) {
try {
// POST with idempotent sale_id
await this.api.post('/api/sales', {
saleId: sale.sale_id,
saleNumber: sale.sale_number,
locationId: sale.location_id,
registerId: sale.register_id,
employeeId: sale.employee_id,
customerId: sale.customer_id,
subtotal: sale.subtotal,
discountTotal: sale.discount_total,
taxTotal: sale.tax_total,
total: sale.total,
lineItems: JSON.parse(sale.line_items_json),
payments: JSON.parse(sale.payments_json),
createdAt: sale.created_at,
source: 'offline_queue',
});
// Mark as synced
this.db.run(
'UPDATE sales_queue SET synced_at = ? WHERE sale_id = ?',
[new Date().toISOString(), sale.sale_id]
);
synced++;
logger.info({ saleId: sale.sale_id }, 'Queued sale synced');
} catch (err) {
// Record error but continue with next sale
this.db.run(
'UPDATE sales_queue SET sync_error = ? WHERE sale_id = ?',
[String(err), sale.sale_id]
);
errors++;
logger.error({ saleId: sale.sale_id, err }, 'Failed to sync sale');
}
}
// After flush: refresh product cache (prices may have changed)
if (synced > 0) {
logger.info({ synced, errors }, 'Queue flush complete, refreshing cache');
await persistToOPFS(this.db.export());
}
} finally {
this.isFlushing = false;
}
return { synced, errors };
}
}
L.10A.1F Sale Creation Flow (Online-First with Offline Fallback)
Online path (99.99%): Sale goes directly to Central API. Offline path (rare): Sale saved to local SQLite queue, flushed on recovery.
Sale Flow (Online-First)
========================
1. Cashier scans items
┌────────────────────────────────────────────┐
│ ONLINE: React Query → Central API │
│ (cached after first scan) │
│ DEGRADED: Try API (2s) → SQLite cache │
│ OFFLINE: SQLite product_cache │
└────────────────────────────────────────────┘
|
v
2. Add to cart (in-memory, no network needed)
+----------------+
| In-Memory Cart |
+----------------+
|
v
3. Customer pays
+----------------+
| Payment Dialog |
| (card or cash) |
+----------------+
|
v
4. Complete sale
┌────────────────────────────────────────────┐
│ ONLINE: POST /api/sales → Central API │
│ (immediate, real-time) │
│ │
│ OFFLINE: INSERT INTO sales_queue │
│ (local SQLite, flush later) │
└────────────────────────────────────────────┘
|
v
5. Print receipt
+----------------+
| Receipt ready |
| (no waiting) |
+----------------+
Sale Service Implementation (Online-First)
// sale-service.ts (sql.js WASM)
import type { Database } from 'sql.js';
import type { ApiClient, ConnectionState, ReceiptPrinter, Cart, Payment, Sale } from './types';
export class SaleService {
constructor(
private api: ApiClient,
private db: Database,
private connectionState: ConnectionState,
private printer: ReceiptPrinter
) {}
async completeSale(cart: Cart, payments: Payment[]): Promise<Sale> {
const saleId = crypto.randomUUID(); // Web Crypto API (browser-native)
const saleNumber = this.generateSaleNumber();
const sale: Sale = {
id: saleId,
saleNumber,
locationId: this.getLocationId(),
registerId: this.getRegisterId(),
employeeId: this.getEmployeeId(),
customerId: cart.customerId ?? null,
subtotal: cart.subtotal,
discountTotal: cart.discountTotal,
taxTotal: cart.taxTotal,
total: cart.total,
lineItems: cart.items,
payments,
createdAt: new Date(),
};
if (this.connectionState.isOnline()) {
// ONLINE: send directly to Central API
await this.api.post('/api/sales', {
saleId: sale.id,
saleNumber: sale.saleNumber,
locationId: sale.locationId,
registerId: sale.registerId,
employeeId: sale.employeeId,
customerId: sale.customerId,
subtotal: sale.subtotal,
discountTotal: sale.discountTotal,
taxTotal: sale.taxTotal,
total: sale.total,
lineItems: sale.lineItems,
payments: sale.payments,
createdAt: sale.createdAt.toISOString(),
source: 'online',
});
} else {
// OFFLINE: queue locally for later flush (sql.js WASM)
this.db.run(`
INSERT INTO sales_queue
(sale_id, sale_number, location_id, register_id, employee_id,
customer_id, subtotal, discount_total, tax_total, total,
line_items_json, payments_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
sale.id, sale.saleNumber, sale.locationId, sale.registerId,
sale.employeeId, sale.customerId, sale.subtotal, sale.discountTotal,
sale.taxTotal, sale.total, JSON.stringify(sale.lineItems),
JSON.stringify(sale.payments), sale.createdAt.toISOString()
]);
// Persist to OPFS immediately (critical: don't lose offline sales)
await persistToOPFS(this.db.export());
}
// Print receipt regardless of online/offline
void this.printer.printReceipt(sale);
return sale;
}
private generateSaleNumber(): string {
const location = this.getLocationCode();
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const sequence = this.getNextSequence();
return `${location}-${date}-${String(sequence).padStart(4, '0')}`;
}
private getLocationId(): string { /* from localStorage/session config */ return ''; }
private getRegisterId(): string { /* from localStorage/session config */ return ''; }
private getEmployeeId(): string { /* from auth context */ return ''; }
private getLocationCode(): string { /* from localStorage/session config */ return ''; }
private getNextSequence(): number { /* from SQLite WASM sequence or API */ return 1; }
}
L.10A.1G Connection Monitor (3-State)
3-state model prevents rapid flapping between online and offline during spotty internet. The DEGRADED state tries API first with cache fallback.
| State | Detection | Data Reads | Data Writes | UI Indicator |
|---|---|---|---|---|
| ONLINE | WebSocket connected + health ping OK | React Query → API | POST → API | Green dot |
| DEGRADED | WebSocket dropped, ping intermittent | Try API (2s timeout) → SQLite cache | POST → API + local backup | Yellow dot |
| OFFLINE | 3 consecutive pings fail (~15s) | SQLite product_cache | SQLite sales_queue | Red dot + banner |
Detection layers (fastest → most reliable):
- Socket.io events —
connect/disconnectcallbacks (instant) - Health ping — HTTP GET
/healthevery 5 seconds (catches stale WebSocket state) navigator.onLine— browser API (instant hint, verified by ping)
// connection-monitor.ts
import { EventEmitter } from 'eventemitter3'; // Browser-compatible EventEmitter
import pino from 'pino';
import type { ApiClient } from './types';
const logger = pino({ name: 'ConnectionMonitor' });
export type ConnectionState = 'ONLINE' | 'DEGRADED' | 'OFFLINE';
export class ConnectionMonitor extends EventEmitter {
private pingTimer: ReturnType<typeof setInterval> | null = null;
private consecutiveFailures = 0;
private state: ConnectionState = 'ONLINE';
constructor(
private apiClient: ApiClient,
private socket: SocketIOClient.Socket,
) {
super();
}
get currentState(): ConnectionState {
return this.state;
}
isOnline(): boolean {
return this.state === 'ONLINE';
}
start(): void {
// Layer 1: Socket.io events (instant signal)
this.socket.on('connect', () => {
this.consecutiveFailures = 0;
this.transition('ONLINE');
});
this.socket.on('disconnect', () => {
this.transition('DEGRADED');
});
// Layer 2: Health ping every 5 seconds
this.pingTimer = setInterval(() => void this.healthCheck(), 5_000);
void this.healthCheck();
}
private async healthCheck(): Promise<void> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2_000);
const response = await this.apiClient.ping({ signal: controller.signal });
clearTimeout(timeout);
if (response.ok) {
this.consecutiveFailures = 0;
if (this.state !== 'ONLINE' && this.socket.connected) {
this.transition('ONLINE');
} else if (this.state === 'OFFLINE') {
this.transition('DEGRADED');
}
} else {
this.handlePingFailure();
}
} catch {
this.handlePingFailure();
}
}
private handlePingFailure(): void {
this.consecutiveFailures++;
if (this.consecutiveFailures >= 3 && this.state !== 'OFFLINE') {
this.transition('OFFLINE');
} else if (this.consecutiveFailures >= 1 && this.state === 'ONLINE') {
this.transition('DEGRADED');
}
}
private transition(newState: ConnectionState): void {
if (this.state === newState) return;
const previous = this.state;
this.state = newState;
logger.info({ from: previous, to: newState }, 'Connection state changed');
this.emit('stateChanged', newState, previous);
// Trigger sales queue flush when returning to ONLINE
if (newState === 'ONLINE' && previous !== 'ONLINE') {
this.emit('recoveryStarted');
}
}
stop(): void {
if (this.pingTimer) clearInterval(this.pingTimer);
}
}
Connection Status UI
Connection Status Indicator
===========================
ONLINE (green dot):
+-----------------------------------------------------------------------+
| [=] NEXUS POS [●] Connected [GM Store]|
+-----------------------------------------------------------------------+
DEGRADED (yellow dot):
+-----------------------------------------------------------------------+
| [=] NEXUS POS [●] Unstable [GM Store] |
+-----------------------------------------------------------------------+
OFFLINE (red dot + banner):
+-----------------------------------------------------------------------+
| [=] NEXUS POS [●] OFFLINE [GM Store] |
| +-----------------------------------------------------------------+ |
| | Working offline. 3 sales queued. Prices may be outdated. | |
| +-----------------------------------------------------------------+ |
+-----------------------------------------------------------------------+
RECOVERING (yellow dot + progress):
+-----------------------------------------------------------------------+
| [=] NEXUS POS [●] Syncing 2/3... [GM Store] |
+-----------------------------------------------------------------------+
L.10A.1H Removed: CRDTs (No Longer Required)
This section previously contained CRDT (Conflict-free Replicated Data Type) implementations for offline conflict resolution. With the pivot to online-first (ADR-048), CRDTs are no longer needed for the POS platform. The product cache is read-only (server-authoritative) and the sales queue is append-only (UUID-keyed idempotent) — neither requires conflict-free merge logic. See L.10A.1D for the simplified data consistency model.
The previous CRDT content (G-Counter, PN-Counter, LWW-Register, OR-Set, MV-Register implementations, sync protocol, and reference libraries) has been removed. For historical reference, see the
v6.1.0tag.Note: CRDTs may still be relevant for the Nexus Raptag mobile RFID app (ADR-047), which retains full offline-first capability for counting sessions. If CRDT-based dedup is needed for multi-operator RFID scanning, it would be scoped to Raptag only — not the POS platform.
L.10A.2 Tax Engine Decision
| Attribute | Value |
|---|---|
| Decision ID | ADR-BRD-002 |
| Context | Need flexible tax calculation supporting multiple jurisdictions |
| Decision | Custom-Built Tax Engine with modular jurisdiction support |
| Alternatives Considered | 1) Third-party service (Avalara/TaxJar), 2) Custom-built (selected) |
| Rationale | Full control over rules; no per-transaction fees; offline support; expansion flexibility |
| Reference | BRD-v12 §1.17 |
Tax Calculation Hierarchy (Priority Order):
┌─────────────────────────────────────────────────────────────┐
│ TAX CALCULATION HIERARCHY │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. PRODUCT-LEVEL OVERRIDE (Highest Priority) │
│ └── Example: "Grocery Food - 1.5%" │
│ └── Example: "Prepared Food - 10%" │
│ └── Example: "Prescription Drugs - 0%" │
│ │
│ 2. CUSTOMER-LEVEL EXEMPTION │
│ └── Example: "Reseller Certificate" │
│ └── Example: "Non-Profit 501(c)(3)" │
│ └── Example: "Diplomatic Status" │
│ │
│ 3. LOCATION-BASED TAX (Default) │
│ └── State Tax + County Tax + City Tax + District Tax │
│ └── Based on store physical address │
│ │
└─────────────────────────────────────────────────────────────┘
Virginia Initial Configuration:
tax_jurisdictions:
virginia:
state_rate: 4.3
default_local_rate: 1.0
# Regional additional taxes
regions:
hampton_roads:
counties: ["Norfolk", "Virginia Beach", "Newport News", "Hampton"]
additional_rate: 0.7
northern_virginia:
counties: ["Arlington", "Fairfax", "Loudoun", "Prince William"]
additional_rate: 0.7
central_virginia:
counties: ["Henrico", "Chesterfield", "Richmond City"]
additional_rate: 0.0
# Product exemptions/reduced rates
exemptions:
- category: "grocery_food"
rate: 1.5 # Reduced rate
- category: "prescription_drugs"
rate: 0.0
- category: "medical_equipment"
rate: 0.0
Expansion Roadmap:
jurisdiction_modules:
virginia: { status: "active" }
california: { status: "planned", notes: "Complex district taxes, no gift card expiry" }
oregon: { status: "planned", notes: "No sales tax state" }
canada: { status: "planned", notes: "GST/PST/HST complexity" }
european_union: { status: "planned", notes: "VAT with reverse charge" }
L.10A.3 Payment Integration Decision
| Attribute | Value |
|---|---|
| Decision ID | ADR-BRD-003 |
| Context | Need PCI-compliant card payment processing with minimal compliance burden |
| Decision | SAQ-A Semi-Integrated terminals (no card data touches our system) |
| Alternatives Considered | 1) Full integration SAQ-D, 2) Semi-integrated SAQ-A (selected), 3) Redirect-only |
| Rationale | Simplest PCI compliance; card data never in our scope; supports offline void via token |
| Reference | BRD-v12 §1.18 |
Payment Flow Architecture:
┌─────────────────────────────────────────────────────────────┐
│ SAQ-A PAYMENT ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ POS UI │────►│ Backend │────►│ Terminal │ │
│ │ │ │ API │ │ │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ ▲ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────────┐ │
│ │ │ PAYMENT PROCESSOR │ │
│ │ │ (Card data ONLY here) │ │
│ │ └─────────────────────────────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌─────────────────────────────────────┐ │
│ └───────────│ Token + Approval + Masked Card │ │
│ │ (NO full PAN, CVV, or track data) │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Data Storage Rules:
┌─────────────────────────────────────────────────────────────┐
│ PAYMENT DATA STORAGE RULES │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ STORED (Allowed): ❌ PROHIBITED (Never): │
│ ├── Payment token ├── Full card number │
│ ├── Approval code ├── CVV/CVC │
│ ├── Masked card (****1234) ├── Track data │
│ ├── Card brand (Visa/MC/Amex) ├── PIN block │
│ ├── Entry method (chip/tap) ├── EMV cryptogram (raw) │
│ ├── Terminal ID │ │
│ └── Timestamp │ │
│ │
└─────────────────────────────────────────────────────────────┘
L.10A.4 Multi-Tenancy Decision
| Attribute | Value |
|---|---|
| Decision ID | ADR-BRD-004 (Revised) |
| Context | Platform must support multiple retail tenants with strong data isolation |
| Decision | Row-Level Isolation with PostgreSQL RLS |
| Alternatives Considered | 1) Database-per-tenant, 2) Schema-per-tenant, 3) Row-level isolation with RLS (selected) |
| Rationale | Matches BRD v18.0 data models (135 occurrences of tenant_id FK across all modules). Simpler operations — no per-tenant schema migration tooling. RLS enforces isolation at the database level, preventing accidental cross-tenant data access. |
| Reference | BRD-v18.0, Chapter 05 (Architecture Components) |
v18.0 Update: The original Architecture Styles Worksheet v1.6 specified Schema-Per-Tenant. Expert panel review identified a contradiction: every data model table in BRD v18.0 includes
tenant_id UUID FK(row-level isolation pattern, 135 occurrences). This revision aligns the architecture decision with the actual BRD data models.
Database Structure:
database: pos_production
│
├── schema: shared
│ ├── tax_rates (global, no tenant_id)
│ ├── system_config (global)
│ └── tenant_registry (tenant metadata)
│
└── schema: public (all tenant data)
├── orders (tenant_id UUID FK + RLS)
├── customers (tenant_id UUID FK + RLS)
├── inventory (tenant_id UUID FK + RLS)
├── products (tenant_id UUID FK + RLS)
├── integration_providers (tenant_id UUID FK + RLS)
└── ... (all other tables with tenant_id + RLS)
RLS Policy Implementation:
-- Enable RLS on every tenant table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Create isolation policy
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Force RLS for non-superuser roles
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
Connection Pattern:
// Tenant resolution via Express middleware
import type { Request, Response, NextFunction } from 'express';
import { prisma } from './prisma-client';
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
const tenantId = resolveTenantFromJwt(req);
// Set PostgreSQL session variable for RLS
await prisma.$executeRaw`SET app.current_tenant = ${tenantId}`;
next();
}
Benefits:
- Simpler connection pooling (shared pool, not per-schema)
- Standard query patterns (no search_path manipulation)
- Easier migrations (single schema, applied once)
- RLS enforcement at database level (defense-in-depth)
- Matches BRD v18.0 data model conventions
Trade-offs:
- Less physical isolation than schema separation (mitigated by RLS)
- All tenants share same table structure (flexibility limited)
- RLS policies must be applied to every table (automated via migration scripts)
L.10A.4A Multi-Tenancy Strategies Comparison
Detailed Implementation Reference (from former Multi-Tenancy Design chapter, now consolidated here):
Multi-Tenancy Strategies
========================
Strategy 1: Shared Tables (Row-Level)
+----------------------------------+
| products |
| +--------+--------+------------+ |
| | tenant | id | name | |
| +--------+--------+------------+ |
| | nexus | 1 | T-Shirt | |
| | acme | 2 | Jacket | |
| | nexus | 3 | Jeans | |
| +--------+--------+------------+ |
+----------------------------------+
Pros: Simple, low overhead
Cons: Risk of data leakage, complex queries, no isolation
Strategy 2: Separate Databases
+-------------+ +-------------+ +-------------+
| nexus_db | | acme_db | | beta_db |
| +--------+ | | +--------+ | | +--------+ |
| |products| | | |products| | | |products| |
| +--------+ | | +--------+ | | +--------+ |
| |sales | | | |sales | | | |sales | |
| +--------+ | | +--------+ | | +--------+ |
+-------------+ +-------------+ +-------------+
Pros: Complete isolation
Cons: Connection overhead, backup complexity, cost at scale
Strategy 3: Schema-Per-Tenant
+-----------------------------------------------------+
| pos_platform database |
| |
| +-----------+ +--------------+ +--------------+ |
| | shared | | tenant_nexus | | tenant_acme | |
| +-----------+ +--------------+ +--------------+ |
| | tenants | | products | | products | |
| | plans | | sales | | sales | |
| | features | | inventory | | inventory | |
| +-----------+ | customers | | customers | |
| +--------------+ +--------------+ |
+-----------------------------------------------------+
Pros: Isolation + efficiency, easy backup/restore per tenant
Cons: More complex migrations (but manageable)
Decision Matrix
| Requirement | Shared Tables | Separate DBs | Schema-Per-Tenant |
|---|---|---|---|
| Data Isolation | Poor | Excellent | Excellent |
| Performance | Good | Excellent | Very Good |
| Backup/Restore | Complex | Simple | Simple |
| Connection Overhead | Low | High | Low |
| Query Complexity | High | Low | Low |
| Compliance (SOC2) | Difficult | Easy | Easy |
| Cost at Scale | Low | High | Medium |
| Migration Complexity | Low | Low | Medium |
Note: The Architecture Styles analysis (L.10A.4 above) selected Row-Level Isolation with PostgreSQL RLS as the production strategy, which aligns with BRD v18.0 data models (135 occurrences of
tenant_id). The Schema-Per-Tenant comparison above is preserved for reference and as an alternative should physical isolation requirements change.
L.10A.4B Tenant Resolution & Middleware
Detailed Implementation Reference (from former Multi-Tenancy Design chapter, now consolidated here):
Tenant Resolution Flow
Tenant Resolution Flow (Row-Level Security)
=============================================
+---------------------------+
| Incoming Request |
| nexus.pos-platform.com |
+-------------+-------------+
|
v
+---------------------------+
| Extract Subdomain |
| subdomain = "nexus" |
+-------------+-------------+
|
v
+---------------------------+
| Lookup in shared.tenants|
| WHERE subdomain = ? |
+-------------+-------------+
|
+----------------------+----------------------+
| |
[Found] [Not Found]
| |
v v
+---------------------------+ +---------------------------+
| SET LOCAL | | Return 404 |
| app.current_tenant_id | | "Tenant not found" |
| = '<tenant-uuid>' | +---------------------------+
+-------------+-------------+
|
v
+---------------------------+
| Continue with request |
| RLS policies filter all |
| queries by tenant_id |
+---------------------------+
Express Tenant Middleware (RLS)
// src/middleware/tenant-middleware.ts
import type { Request, Response, NextFunction } from 'express';
import pino from 'pino';
import { prisma } from '../prisma-client';
const logger = pino({ name: 'TenantMiddleware' });
export function createTenantMiddleware(tenantService: TenantService) {
return async (req: Request, res: Response, next: NextFunction) => {
// 1. Extract subdomain from host
const host = req.hostname;
const subdomain = extractSubdomain(host);
if (!subdomain) {
res.status(400).json({ error: 'Invalid tenant' });
return;
}
// 2. Lookup tenant in shared schema (cached in Redis)
const tenant = await tenantService.getBySubdomain(subdomain);
if (!tenant) {
res.status(404).json({ error: 'Tenant not found' });
return;
}
if (tenant.status === 'suspended') {
res.status(403).json({ error: 'Account suspended' });
return;
}
// 3. Store tenant in request for downstream use
req.tenant = tenant;
req.tenantId = tenant.id;
logger.debug({ tenantSlug: tenant.slug, tenantId: tenant.id }, 'Resolved tenant');
// 4. Set RLS context on the Prisma connection
await prisma.$executeRaw`SET app.current_tenant = ${tenant.id}`;
next();
};
}
function extractSubdomain(host: string): string | null {
// nexus.pos-platform.com -> nexus
// localhost:5000 -> null (development fallback)
const parts = host.split('.');
if (parts.length >= 3) {
return parts[0];
}
// Development fallback: check X-Tenant-Id header
return null;
}
// src/services/tenant-service.ts
import { PrismaClient, type Tenant } from '@prisma/client';
interface CreateTenantRequest {
slug: string;
name: string;
subdomain: string;
planId: string;
}
export class TenantService {
constructor(private prisma: PrismaClient) {}
async getBySubdomain(subdomain: string): Promise<Tenant | null> {
return this.prisma.tenant.findUnique({ where: { subdomain } });
}
async getBySlug(slug: string): Promise<Tenant | null> {
return this.prisma.tenant.findUnique({ where: { slug } });
}
async createTenant(request: CreateTenantRequest): Promise<string> {
// 1. Create tenant record (RLS — no schema creation needed)
const tenant = await this.prisma.tenant.create({
data: {
slug: request.slug,
name: request.name,
subdomain: request.subdomain,
planId: request.planId,
status: 'active',
},
});
// 2. Seed default data with tenant context
await this.seedTenantDefaults(tenant.id);
logger.info({ slug: request.slug, tenantId: tenant.id }, 'Created tenant');
return tenant.id;
}
private async seedTenantDefaults(tenantId: string): Promise<void> {
// Set RLS context for seeding
await this.prisma.$executeRaw`SET app.current_tenant = ${tenantId}`;
// Seed default roles, permissions, subscription features
// All rows automatically scoped by tenant_id
}
}
Prisma Client with RLS Tenant Context
// src/prisma-client.ts
import { PrismaClient } from '@prisma/client';
// Base Prisma client
export const prisma = new PrismaClient();
// Prisma client extended with tenant context via middleware
prisma.$use(async (params, next) => {
// Defense-in-depth: ensure tenant_id is always set on creates
// RLS handles query filtering at the database level
if (params.action === 'create' && params.args.data && !params.args.data.tenantId) {
// tenantId should be set by the service layer
// This middleware logs a warning if it's missing
console.warn(`Missing tenantId on ${params.model} create`);
}
return next(params);
});
RLS Session Variable via Prisma Extension
// src/prisma-tenant.ts
import { PrismaClient } from '@prisma/client';
/**
* Creates a tenant-scoped Prisma client that sets the RLS session variable
* on every query. Use this in request handlers after tenant resolution.
*/
export function createTenantPrisma(tenantId: string): PrismaClient {
const prisma = new PrismaClient();
// Set RLS session variable before each query
prisma.$use(async (params, next) => {
await prisma.$executeRaw`SET app.current_tenant = ${tenantId}`;
return next(params);
});
return prisma;
}
L.10A.4C Tenant Provisioning (RLS)
Detailed Implementation Reference — Row-Level Security provisioning (no schema creation):
New Tenant Signup Flow (RLS)
=============================
[Nexus POS] [API] [Database]
| | |
| 1. POST /tenants | |
| { name, slug, plan } | |
|------------------------------>| |
| | |
| | 2. Validate slug uniqueness |
| |--------------------------------->|
| | |
| | 3. INSERT INTO tenants |
| | (returns tenant_id UUID) |
| |--------------------------------->|
| | |
| | 4. SET app.current_tenant = |
| | '{tenant_id}' |
| |--------------------------------->|
| | |
| | 5. Seed default data |
| | (roles, permissions) |
| | All rows get tenant_id via RLS |
| |--------------------------------->|
| | |
| | 6. Create admin user |
| | (tenant_id set automatically) |
| |--------------------------------->|
| | |
| 7. Return tenant details | |
| { id, subdomain, status } | |
|<------------------------------| |
| | |
| 8. Redirect to tenant portal | |
| nexus.pos-platform.com | |
| | |
Key difference from schema-per-tenant: No
CREATE SCHEMAstep. All tenant data lives in thepublicschema withtenant_idcolumns. RLS policies enforce isolation at the database level viaSET app.current_tenant.
L.10A.4D Migration Strategy (Single Schema + RLS)
Detailed Implementation Reference — With RLS, all tenants share the same schema. Migrations apply once to the
publicschema:
Prisma Migrate (Single Schema)
# Generate migration from schema changes
npx prisma migrate dev --name add_loyalty_tier
# Apply in production (called at deploy time or server startup)
npx prisma migrate deploy
Migration Script Example
-- Migration: Add loyalty_tier to customers
-- File: prisma/migrations/20250115_add_loyalty_tier/migration.sql
-- Note: Single ALTER TABLE — RLS means all tenant rows are in one table
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20) DEFAULT 'bronze';
-- Backfill existing rows (optional)
UPDATE customers SET loyalty_tier = 'bronze' WHERE loyalty_tier IS NULL;
Key advantage of RLS over schema-per-tenant: Migrations run once against the
publicschema instead of looping through N tenant schemas. Prisma Migrate handles this natively with a single migration directory.
Tenants Table SQL Reference
-- Tenant Registry (public schema — RLS exempted, admin-only access)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(50) UNIQUE NOT NULL, -- 'nexus', 'acme'
name VARCHAR(255) NOT NULL, -- 'Nexus Clothing'
subdomain VARCHAR(100) UNIQUE NOT NULL, -- 'nexus.pos-platform.com'
plan_id UUID REFERENCES subscription_plans(id),
status VARCHAR(20) DEFAULT 'active', -- active, suspended, trial
trial_ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Subscription Plans (admin-only, no tenant_id — global config)
CREATE TABLE subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL, -- 'Starter', 'Professional'
code VARCHAR(50) UNIQUE NOT NULL, -- 'starter', 'pro', 'enterprise'
price_monthly DECIMAL(10,2),
price_yearly DECIMAL(10,2),
max_locations INTEGER DEFAULT 1,
max_registers INTEGER DEFAULT 2,
max_employees INTEGER DEFAULT 5,
max_products INTEGER DEFAULT 1000,
features JSONB DEFAULT '{}', -- Feature flags
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Feature Flags (admin-only, no tenant_id — global config)
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(100) UNIQUE NOT NULL, -- 'loyalty_program'
name VARCHAR(255) NOT NULL,
description TEXT,
default_enabled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert default plans
INSERT INTO subscription_plans (name, code, price_monthly, max_locations, max_registers, max_employees, max_products) VALUES
('Starter', 'starter', 49.00, 1, 2, 5, 1000),
('Professional', 'pro', 149.00, 3, 10, 25, 10000),
('Enterprise', 'enterprise', 499.00, -1, -1, -1, -1); -- -1 = unlimited
L.10A.5 Commission Reversal Decision
| Attribute | Value |
|---|---|
| Decision ID | ADR-BRD-005 |
| Context | Need fair commission adjustment when sales are voided or items are returned |
| Decision | Proportional Reversal on returns, Full Reversal on voids |
| Alternatives Considered | 1) Full reversal always, 2) Proportional (selected), 3) No reversal |
| Rationale | Fair to employees; maintains incentive alignment; distinguishes mistakes (voids) from returns |
| Reference | BRD-v12 §1.8 |
Commission Reversal Rules:
┌─────────────────────────────────────────────────────────────┐
│ COMMISSION REVERSAL LOGIC │
├─────────────────────────────────────────────────────────────┤
│ │
│ VOID (Same day, before drawer close): │
│ ├── Reversal: 100% (full) │
│ ├── Rationale: Mistake correction, sale didn't happen │
│ └── Example: $6 commission → reverse $6 │
│ │
│ RETURN (After sale completed): │
│ ├── Reversal: Proportional to returned value │
│ ├── Formula: Original Commission × (Returned / Original) │
│ └── Example: │
│ Sale: $120, Commission: $6 (5%) │
│ Return: $80 of items │
│ Reversal: $6 × ($80/$120) = $4.00 │
│ Net Commission: $6 - $4 = $2.00 │
│ │
└─────────────────────────────────────────────────────────────┘
Configuration:
commissions:
default_rate_percent: 2.0
category_rates:
electronics: 3.0
services: 5.0
# Reversal rules
reverse_on_void: true
void_reversal_method: "full" # 100%
reduce_on_return: true
return_reversal_method: "proportional" # Based on value
L.10A.6 Geographic Expansion Strategy
| Attribute | Value |
|---|---|
| Decision ID | ADR-BRD-006 |
| Context | Initial deployment in Virginia with planned expansion to other US states and international |
| Decision | Virginia-First with modular jurisdiction architecture |
| Phases | 1) Virginia (Day 1), 2) US expansion (Year 2), 3) International (Year 3+) |
| Reference | BRD-v12 §1.17.3 |
Expansion Strategy:
┌─────────────────────────────────────────────────────────────┐
│ GEOGRAPHIC EXPANSION ROADMAP │
├─────────────────────────────────────────────────────────────┤
│ │
│ PHASE 1: Virginia (Day 1) │
│ ├── Tax: State 4.3% + Local 1% + Regional 0.7% │
│ ├── Gift Cards: 5-year minimum expiry allowed │
│ └── Compliance: Virginia Consumer Protection Act │
│ │
│ PHASE 2: US Expansion │
│ ├── California: No gift card expiry, $10 cash-out rule │
│ ├── Oregon: No sales tax │
│ ├── New York: Complex local taxes │
│ └── Florida: No income tax, tourism taxes │
│ │
│ PHASE 3: International │
│ ├── Canada: GST/HST/PST provincial variations │
│ ├── EU: VAT with reverse charge for B2B │
│ └── UK: Post-Brexit VAT rules │
│ │
└─────────────────────────────────────────────────────────────┘
Design Principle: Always design for the most restrictive jurisdiction (California for US), then enable features where permitted.
Gift Card Jurisdiction Matrix:
| Jurisdiction | Expiry Allowed | Inactivity Fee | Cash-Out Required |
|---|---|---|---|
| Virginia | Yes (5yr min) | Yes (after 12mo) | No |
| California | No | No | Yes ($10 threshold) |
| New York | No | No | No |
| Default | No | No | No |
L.10A.7 Decision Dependency Graph
┌─────────────────────────────────────────────────────────────┐
│ ARCHITECTURE DECISION DEPENDENCIES │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ │
│ │ Geographic Scope │ │
│ │ (ADR-BRD-006) │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Tax Engine │ │ Gift Card │ │ Compliance │ │
│ │(ADR-BRD-002)│ │ Rules │ │ Rules │ │
│ └──────┬─────┘ └────────────┘ └────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────┐ │
│ │ Offline │───────────────────────────┐ │
│ │(ADR-BRD-001)│ │ │
│ └──────┬─────┘ │ │
│ │ ▼ │
│ ▼ ┌────────────┐ │
│ ┌────────────┐ │ Payment │ │
│ │ Multi- │ │(ADR-BRD-003)│ │
│ │ Tenancy │ └────────────┘ │
│ │(ADR-BRD-004)│ │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
L.11 Style Decision Summary
Final Selection
+------------------------------------------------------------------+
| ARCHITECTURE DECISION SUMMARY |
| (v2.0 - Panel Reviewed) |
+------------------------------------------------------------------+
| |
| QUESTION: What is the primary architecture style? |
| ANSWER: Event-Driven Modular Monolith |
| |
| ┌─────────────────────────────────────────────────────────────┐ |
| │ SELECTED PATTERNS │ |
| ├─────────────────────────────────────────────────────────────┤ |
| │ ✅ Modular Monolith → Central API │ |
| │ ✅ Microkernel (Plugin) → Nexus POS │ |
| │ ✅ Event-Driven → PostgreSQL Events (v1.0) │ |
| │ Kafka (v2.0, when justified) │ |
| │ ✅ Event Sourcing → Sales (Full) + Inventory (Audit)│ |
| │ + Integrations (Audit-trail) │ |
| │ ✅ CQRS → Sales Module (Read/Write split) │ |
| │ ✅ Online-First+Fallback → Nexus POS (API + SQLite cache) │ |
| │ ✅ Row-Level with RLS → Multi-Tenant Isolation │ |
| │ ✅ Integration Gateway → Module 6 (Extractable) │ |
| │ ✅ Circuit Breaker → External API Resilience │ |
| │ ✅ Transactional Outbox → Guaranteed Event Delivery │ |
| │ ✅ Provider Abstraction → IIntegrationProvider Interface │ |
| │ ✅ Credential Vault → HashiCorp Vault │ |
| └─────────────────────────────────────────────────────────────┘ |
| |
| ┌─────────────────────────────────────────────────────────────┐ |
| │ REJECTED PATTERNS │ |
| ├─────────────────────────────────────────────────────────────┤ |
| │ ❌ Microservices → Too complex for current scale │ |
| │ ❌ Space-Based → Too complex for financial audit │ |
| │ ❌ Schema-Per-Tenant → Replaced by Row-Level with RLS │ |
| │ ❌ Kafka (v1.0) → Deferred to v2.0 │ |
| └─────────────────────────────────────────────────────────────┘ |
| |
+------------------------------------------------------------------+
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2026-01-24 |
| Updated | 2026-03-02 |
| Source | Architecture Styles Worksheet v2.0, BRD-v18.0, Chapters 02-06 |
| Author | Claude Code |
| Reviewer | Expert Panel (Marcus Chen, Sarah Rodriguez, James O’Brien, Priya Patel) |
| Status | Active |
| Part | II - Architecture |
| Chapter | 04 of 9 |
| Previous | Chapter 12 v2.0.0 (pre-restructure numbering) |
Change Log
| Version | Date | Changes |
|---|---|---|
| 7.0.0 | 2026-03-02 | Unified web app pivot: Tauri desktop wrapper removed, Nexus POS is now a single React web application (Vite). ADR-046 (dual deployment) superseded by ADR-052 (unified web app). Nexus Admin merged into Nexus POS as role-gated screens. better-sqlite3 replaced by SQLite WASM (sql.js + OPFS) for browser-based offline fallback. Hardware layer rewritten: Tauri Rust bridge removed, replaced by Star WebPRNT (receipts), USB HID keyboard wedge (scanners), Stripe Terminal SDK (payments), kick-out cable via printer port (cash drawers). All code samples updated to sql.js WASM API patterns. L.9A client application diagrams consolidated (POS+Admin→single app). Technology stack summary merged. All Tauri, better-sqlite3, and Nexus Admin references updated throughout. ADR-048 (online-first) remains active with WASM runtime change only. |
| 6.3.0 | 2026-03-01 | Comprehensive review: 50 findings resolved. 3 new ADRs (049-051). ADR-015/037 superseded. 6 missing tables added (69 total). 13 FK type fixes. 9 RFID RLS fixes. Ch 03 K.2.1 rewritten. Ch 05: 8 offline locations rewritten, state machine 7.12 updated. 25 BRD-v12→v20 NFR citations. Appendix F: Module 7 traceability added. 51 ADRs total. |
| 6.2.0 | 2026-03-01 | Online-first pivot (ADR-048): Rewrote L.10A.1 from “Offline-First Strategy” to “Online-First with Offline Fallback”. SQLite schema reduced from 6 tables to 2 (product_cache + sales_queue). Removed L.10A.1H CRDTs entirely (~350 lines). Replaced complex sync queue with simple FIFO sales flush. Upgraded connection monitor from binary to 3-state (ONLINE/DEGRADED/OFFLINE). Added flag-on-sync price discrepancy detection. Updated all code samples (SyncService→SalesQueueFlush, SaleService→online-first, ConnectionMonitor→3-state). |
| 6.1.0 | 2026-02-28 | Tech stack pivot from .NET/C# to TypeScript/Node.js. Rebranded to “Nexus”. Central API: Node.js + Express/Fastify (TypeScript) with Prisma ORM. POS Client: Tauri 2.0 + React/TypeScript. Admin Portal: React + TailwindCSS + shadcn/ui. Raptag Mobile: React Native + Expo. All C# implementation blocks converted to TypeScript (SyncService, SaleService, ConnectionMonitor, CRDTs, Projectors, Tenant Middleware, DbContext). Kafka v2.0 C# blocks annotated for future kafkajs conversion. Naming pass: SignalR→Socket.io, EF Core→Prisma, FluentValidation→Zod, Serilog→Pino, xUnit→Vitest, StackExchange.Redis→ioredis, ArchUnit→dependency-cruiser. Full tech stack summary table rewritten. |
| 1.0.0 | 2026-01-24 | Initial document |
| 1.1.0 | 2026-01-26 | Added Section L.10A (Key Architecture Decisions from BRD-v12) with 6 ADRs |
| 2.0.0 | 2026-02-19 | Expert panel review (6.50/10): Replaced Schema-Per-Tenant with Row-Level RLS; deferred Kafka to v2.0 (PostgreSQL Events for v1.0); added Extractable Integration Gateway for Module 6; added L.1.9 Integration Patterns (Circuit Breaker, Transactional Outbox, Provider Abstraction, ACL, Saga); added L.4A CQRS/ES Scope per module; added L.4B Integration Architecture Patterns with diagrams; replaced SonarQube-only security with 6-Gate Security Test Pyramid; added HashiCorp Vault credential architecture; updated Style Evaluation Matrix scores; added integration-specific risks and mitigations |
| 3.0.0 | 2026-02-22 | Consolidated implementation references from Chapters 05-09: Added L.4A.1-7 (Event Store schema, Kafka architecture, Schema Registry, DLQ pattern, Domain Events catalog, Projections, Temporal Queries, Snapshots from Ch 08); Added L.9A-9B (System Architecture diagrams, Data Flow patterns from Ch 05); Added L.9C (Domain Model bounded contexts, aggregates, ER diagram from Ch 07); Added L.10A.1A-1H (POS Client architecture, SQLite schema, Sync Queue, Conflict Resolution, Sync Processor, Sale Creation Flow, Connection Monitor, CRDTs from Ch 09); Added L.10A.4A-4D (Multi-Tenancy strategies comparison, Tenant Middleware, Provisioning workflow, Migration strategy from Ch 06) |
Next Chapter: Chapter 05: Architecture Components (BRD v20.0)
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 05: Architecture Components (BRD v20.0)
5.0 About This Chapter
This chapter contains the complete Business Requirements Document (BRD) v20.0 for the POS Platform. It serves as the authoritative source for all functional requirements, business rules, state machines, and module specifications.
Authority Rule: When any other chapter in this Blueprint conflicts with content in this chapter, this chapter (BRD) wins.
Module Overview
| Module | Scope | BRD Sections |
|---|---|---|
| 1. Sales | POS workflows, payments, returns, offline operations | 1.1-1.20 |
| 2. Customers | Profiles, groups, tiers, communication | 2.1-2.8 |
| 3. Catalog | Products, variants, pricing, multi-channel | 3.1-3.15 |
| 4. Inventory | Stock management, transfers, counting, RFID | 4.1-4.19 |
| 5. Setup & Configuration | Tenant config, roles, registers, RFID config | 5.1-5.21 |
| 6. Integrations | Shopify, Amazon, Google, payments, shipping | 6.1-6.13 |
| 7. State Machines | All 16+ state machine definitions | 7.1-7.16 |
How to Use This Chapter
- For implementation: Find the relevant Module and Section number
- For business rules: Check the YAML business rules in each module
- For state machines: See Module 7 (State Machine Reference)
- For decisions: 113 decisions documented throughout, summarized in Appendix
- For user stories: Gherkin acceptance criteria at the end of each module
This document consolidates all features into a modular architecture.
- Module 1 (Sales): Covers Scanner, Financials, Discounts, Post-Sale corrections, Gift Cards, Exchanges, Special Orders, Multi-Store, Commissions, Cash Management, Offline Operations, Tax Engine, Payment Integration, and more.
- Module 2 (Customers): Covers Profiles, Merging, Tax Logic, Data Management, Customer Groups, Notes, Communication Preferences, and Privacy Compliance.
- Module 3 (Catalog): Covers Product Types & Data Model (with retail attributes, custom fields, UoM, shipping, templates, matrix management), Product Lifecycle, Pricing Engine (price hierarchy, price books, promotions, markdowns), Barcode Management, Categories/Seasons/Collections, Multi-Channel Management, Shopify Integration, Vendor Management, Search & Discovery, Label Printing, Media Management, Notes & Attachments, Permissions & Approvals, Product Analytics, and comprehensive User Stories with Gherkin acceptance criteria.
- Module 4 (Inventory): Covers Inventory Status Model, Purchase Orders & Procurement, Receiving & Inspection, Reorder Management, Inventory Counting & Auditing, Inventory Adjustments, Inter-Store Transfers, Vendor RMA & Returns, Serial & Lot Tracking, Landed Cost & Costing, Product Movement History, POS & Sales Integration, Online Order Fulfillment, Offline Operations, Alerts & Notifications, Dashboard & Reports, Business Rules, and comprehensive User Stories with Gherkin acceptance criteria.
- Module 5 (Setup & Configuration): Covers System Settings, Multi-Currency, Location Management, Supplier Configuration, User Profiles & Permissions, Time Tracking (Clock-In/Clock-Out), Register Management, Printers & Peripherals, Tax Configuration, UoM Management, Payment Methods, Custom Fields, Approval Workflows, Receipt Configuration, Email Templates, Loyalty Settings, Audit Logging, Consolidated Business Rules (YAML), Tenant Onboarding Wizard, Field Specifications Reference with Technical User Stories, and comprehensive User Stories with Gherkin acceptance criteria.
- Module 6 (Integrations & External Systems): Covers Integration Architecture (provider abstraction, retry/backoff, circuit breaker, idempotency, rate limiting, webhook pipeline), Shopify Integration (enhanced with GraphQL, bulk operations, third-party POS rules, BOPIS, omnichannel), Amazon SP-API Integration (OAuth/LWA, catalog/listings/orders/FBA APIs, FBA+FBM fulfillment, compliance, safety buffers), Google Merchant API Integration (product data management, local inventory, disapproval prevention, image requirements, GBP integration), Cross-Platform Product Data Requirements (unified validation matrix, strictest-rule-wins), Cross-Platform Inventory Sync Rules (safety buffers, oversell prevention, channel-specific rules), Payment Processor Integration, Email Provider Integration, Carrier & Shipping Integration, Integration Hub, Integration Business Rules (YAML), and comprehensive User Stories with Gherkin acceptance criteria.
1. Sales Module
1.1 Core Sales Workflow (Item Entry & Logic)
Scope: Scanner/Manual Entry, Inventory Checks, Parking, and Customer Association.
Cross-Reference: See Module 4, Section 4.13 for inventory reservation model during sales.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant SC as Scanner
participant API as Backend
participant DB as DB
participant PROMO as Promo Engine
Note over U, PROMO: Phase 1: Initiation & Entry
loop Action Loop
U->>UI: Toggle Mode (Sale / Return / Exchange)
alt Input Methods
U->>UI: Scan Barcode / Search SKU
UI->>API: GET /product/{sku}
else Scanner Broadcast
SC->>UI: Tags Detected (Array)
UI->>API: POST /products/bulk-lookup
Note right of API: Max 50 tags per request
end
API->>DB: Fetch Price, Stock, Tax
DB-->>UI: Return Product Data
alt Stock Validation
opt Mode == Sale
UI->>UI: Check Stock > 0
alt Low Stock
UI-->>U: Warning / Block Item
end
end
end
alt Mode Check
opt Mode == Exchange
U->>UI: Load Original Sale for Exchange
UI->>API: GET /orders/{id}
API-->>UI: Return Original Sale Items
UI->>UI: Select Items to Exchange OUT
UI->>UI: Scan/Add New Items IN
UI->>UI: Calculate Price Difference
Note right of UI: Links to Exchange flow in Section 1.4
end
end
UI->>UI: Add to Cart
par Intelligence & Context
UI->>PROMO: Analyze Cart Context
PROMO-->>UI: Trigger Upsell Alert ("Buy 1 more for 10% off")
and Customer Attachment
opt Attach Customer
UI->>API: GET /customer/{id} (Loyalty/Debt/Price Tier)
API-->>UI: Return Profile (Credit Limit, Tax Class, Price Level)
UI->>UI: Recalculate Prices (if Price Tier applies)
UI->>UI: Recalculate Tax (if Exempt)
end
end
end
opt Session Management
U->>UI: Click "Park Sale"
UI->>API: POST /sales/park
API->>DB: Serialize State & Release Locks
U->>UI: Click "Retrieve Sale"
UI->>API: GET /sales/parked
API-->>UI: Restore Cart
end
1.1.1 Parked Sale State Machine
stateDiagram-v2
[*] --> ACTIVE: Cart Created
ACTIVE --> PARKED: Staff Parks Sale
PARKED --> ACTIVE: Staff Retrieves Sale
PARKED --> EXPIRED: TTL Exceeded (4 hours)
EXPIRED --> [*]: Inventory Released
ACTIVE --> PENDING: Proceed to Payment
Parked Sale Rules:
- Maximum parked sales per terminal: 5
- TTL (Time-to-Live): 4 hours
- Inventory is soft-reserved while parked (visible to other terminals with warning)
- Expired parked sales auto-release inventory and log reason
1.1.2 Reports: Core Sales
| Report | Purpose | Key Data Fields |
|---|---|---|
| Daily Sales Summary | End-of-day overview of all transactions | Date, total sales, transaction count, avg transaction value, payment method breakdown |
| Item Entry Method Report | Track how items are entered into the system | Scanner vs manual entry counts, scanner success rate, failed scan count |
| Parked Sales Report | Monitor parked sales activity and expirations | Parked count, retrieved count, expired count, avg park duration |
| Hourly Sales Heatmap | Identify peak sales hours for staffing | Hour, transaction count, total revenue, avg items per transaction |
1.2 Discounts & Pricing Logic
Scope: Line-item overrides, Global discounts, Promotion application, Price Tiers, and Loyalty Redemptions.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Phase 2: Modification & Pricing
loop Pricing Actions
alt Line Item Modification
U->>UI: Select Item -> Override Price
opt Manager Auth
UI-->>U: Prompt PIN
U->>UI: Enter PIN
end
UI->>UI: Apply New Price & Reason Code (e.g., "Damaged")
end
alt Discounting
U->>UI: Apply Global Discount / Promo Code / Coupon
UI->>API: Validate Code (Expiry/Stacking/Single-Use)
API-->>UI: Valid
Note right of UI: Critical Calculation Order:
UI->>UI: 1. Apply Price Tier (Wholesale/VIP)
UI->>UI: 2. Apply Line Discounts
UI->>UI: 3. Apply Global % (Excluding Non-Discountable)
UI->>UI: 4. Apply Coupons
UI->>UI: 5. Calculate Tax (on discounted subtotal)
UI->>UI: 6. Apply Loyalty Redemptions (after tax)
end
end
1.2.1 Discount Calculation Order (Definitive)
The system applies discounts in this strict order:
| Order | Type | Example | Applies To |
|---|---|---|---|
| 1 | Price Tier | Wholesale pricing | Base price replacement |
| 2 | Line Discounts | “Damaged - 20% off” | Individual items |
| 3 | Automatic Promos | “Buy 2 Get 1 Free” | Qualifying items |
| 4 | Global Discount | “10% off entire order” | Subtotal (excl. non-discountable) |
| 5 | Coupons | “SAVE10” code | After global discount |
| 6 | Tax Calculation | State + Local tax | On final discounted amount |
| 7 | Loyalty Redemption | “500 points = $5 off” | Final subtotal after tax |
Non-Discountable Items: Gift cards, deposits, and items flagged is_discountable = false are excluded from global discounts.
1.2.2 Reports: Discounts & Pricing
| Report | Purpose | Key Data Fields |
|---|---|---|
| Discount Usage Report | Track all discounts applied | Discount type, frequency, total value, avg discount %, top discounted items |
| Promotion Effectiveness | Measure promo campaign success | Promo code, redemption count, revenue impact, items sold via promo |
| Coupon Performance | Track coupon redemption rates | Coupon code, issued count, redeemed count, expired count, revenue impact |
| Price Override Audit | Monitor manual price changes | Override count, avg override %, reason codes, authorizing manager |
1.3 Financial Settlement (Payments, Layaway, Third-Party Financing)
Scope: Split tenders, Credit Limits, Gift Cards, Third-Party Financing (Affirm), and Finalization.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
participant PAY as Payment Gateway
Note over U, DB: Phase 3: Settlement
U->>UI: Click "Pay"
loop Tender Processing
U->>UI: Select Method
Note right of UI: Multiple tenders allowed: cash + card(s), multiple cards, etc.
alt Cash
UI-->>U: Show "Collect: $20.03"
U->>UI: Enter Amount Received
UI->>UI: Calculate Change Due
else Gift Card
U->>UI: Scan/Enter Gift Card Number
UI->>API: GET /giftcards/{number}/balance
API-->>UI: Return Balance & Expiry
alt Sufficient Balance
UI->>UI: Deduct from Gift Card
UI->>UI: Add to Tender List
else Insufficient Balance
UI-->>U: "Card Balance: $X. Apply partial?"
U->>UI: Apply Partial Amount
end
else On-Account (Store Credit)
UI->>API: Check Credit Limit (Balance + Pending Layaways + Cart)
alt Exceeds Limit
API-->>UI: Block Transaction
else Approved
UI->>UI: Add to Tender List
end
else Layaway Deposit
U->>UI: Select Layaway Mode
UI->>UI: Validate Min Deposit %
Note right of UI: Sale Status -> LAYAWAY
else Credit Card (SAQ-A Flow)
UI->>API: POST /payments/initiate {amount, order_id}
API->>PAY: Send amount to terminal
Note over PAY: Customer taps/inserts card
PAY-->>API: Token + Approval Code
API->>DB: Store token (never card data)
API-->>UI: Payment Approved
else Third-Party Financing (Affirm)
U->>UI: Select "Pay with Affirm"
UI->>API: POST /payments/affirm/initiate {amount, order_id, customer}
API->>PAY: Create Affirm Checkout Session
PAY-->>API: Redirect URL / QR Code
API-->>UI: Display QR Code or Redirect
Note over PAY: Customer completes Affirm application on their device
PAY-->>API: Webhook: Loan Approved + Charge ID
API->>DB: Store Affirm charge_id, loan_id, status
API-->>UI: Payment Approved (Affirm)
Note right of API: Full amount received from Affirm; customer pays Affirm directly
end
UI->>UI: Update Remaining Balance
end
UI->>API: POST /orders/finalize
par Backend Operations
API->>DB: Write Order Record
API->>DB: Update Inventory (Decrement Sale / Increment Return)
API->>DB: Update Customer (Loyalty Points / Increase Debt)
API->>DB: Update Gift Card Balance (if used)
API->>DB: Record Commission (Employee ID + Amount)
end
API-->>UI: Transaction Success
opt Receipt Printing
U->>UI: Select Template (Thermal / A4 Invoice / Gift Receipt)
UI->>U: Print / Email Receipt
end
1.3.1 Order State Machine
stateDiagram-v2
[*] --> DRAFT: Cart Created
DRAFT --> PENDING: Click Pay
PENDING --> PARTIAL_PAID: Partial Payment
PARTIAL_PAID --> PENDING: More Payment Needed
PENDING --> PAID: Full Payment
PARTIAL_PAID --> PAID: Full Payment
PAID --> COMPLETED: Finalized
PAID --> HOLD_FOR_PICKUP: Hold Requested
HOLD_FOR_PICKUP --> READY_FOR_PICKUP: Items Staged
READY_FOR_PICKUP --> COMPLETED: Customer Picked Up
READY_FOR_PICKUP --> HOLD_EXPIRED: Deadline Passed
HOLD_EXPIRED --> CONTACT_CUSTOMER: Staff Notified
CONTACT_CUSTOMER --> READY_FOR_PICKUP: Deadline Extended
CONTACT_CUSTOMER --> REFUNDED: Customer Wants Refund
COMPLETED --> VOIDED: Void Action (Same Day)
COMPLETED --> PARTIALLY_RETURNED: Partial Return
PARTIALLY_RETURNED --> FULLY_RETURNED: All Items Returned
VOIDED --> [*]
FULLY_RETURNED --> [*]
REFUNDED --> [*]
1.3.2 Layaway State Machine
stateDiagram-v2
[*] --> DEPOSIT_PAID: Min Deposit Received
DEPOSIT_PAID --> RESERVED: Inventory Reserved
RESERVED --> RESERVED: Additional Payment
RESERVED --> PAID_IN_FULL: Final Payment
PAID_IN_FULL --> COMPLETED: Items Released
RESERVED --> CANCELLED: Customer Cancels
RESERVED --> FORFEITED: Payment Deadline Missed
CANCELLED --> [*]
FORFEITED --> [*]
COMPLETED --> [*]
1.3.3 Credit Limit Calculation
When checking if a customer can use On-Account payment:
Available Credit = Credit Limit - (Current Debt + Pending Layaway Balances + Current Cart Total)
Example:
- Credit Limit: $500
- Current Debt: $150
- Pending Layaway (remaining balance): $100
- Current Cart: $200
- Available Credit: $500 - ($150 + $100 + $200) = $50
- Result: Blocked (cart exceeds available credit by $150)
1.3.4 Reports: Financial Settlement
| Report | Purpose | Key Data Fields |
|---|---|---|
| Payment Method Breakdown | Analyze tender mix | Cash total, card total, gift card total, on-account total, Affirm total, split tender count |
| Affirm Financing Summary | Track third-party financing usage | Affirm transaction count, total financed, avg loan amount, approval rate |
| Layaway Status Report | Monitor active layaways | Active count, total deposits, total remaining balances, overdue count |
| On-Account Aging Report | Track customer credit usage | Customer, balance, credit limit, days outstanding, aging buckets (30/60/90) |
1.4 Post-Sale Management (History, Void, Returns, Exchanges)
Scope: Corrections, History, Returns, Dedicated Exchanges, Receipt Reprinting, and Data Export.
sequenceDiagram
autonumber
participant U as Manager
participant UI as POS UI
participant API as Backend
participant DB as DB
U->>UI: Open Sales History
U->>UI: Apply Filters (Date, User, Status)
UI->>API: GET /sales/history
alt Action: Void (Same-Day Correction Only)
Note right of U: "Oops, wrong item - same day"
UI->>API: GET /orders/{id}/void-eligibility
API-->>UI: Check if same business day & drawer still open
alt Eligible for Void
U->>UI: Click Void -> Confirm
UI->>API: POST /sales/{id}/void
par Reversal
API->>DB: Reverse Inventory & Loyalty
API->>DB: Reverse Commission (Full)
API->>DB: Set Status "VOIDED"
end
UI-->>U: Alert: "Manually Refund Card Terminal"
else Not Eligible (Different Day)
UI-->>U: "Cannot void - use Return instead"
end
else Action: Return (with Policy Check)
Note right of U: "Customer brought it back"
U->>UI: Scan Receipt Barcode
UI->>API: POST /receipts/validate {barcode_data}
API->>DB: Verify receipt authenticity & match to order
alt Receipt Valid
API-->>UI: Receipt Verified - Load Original Sale
else Receipt Invalid
API-->>UI: "Invalid Receipt - Cannot Process Return"
UI-->>U: "Receipt validation failed. Verify receipt."
end
UI->>API: GET /sales/{id}/return-eligibility
API-->>UI: Return Policy Result
alt Within Policy (Receipt + Time)
UI->>UI: Select Items to Return
UI->>UI: Process Refund to Original Payment
API->>DB: Reverse Commission (Proportional)
else Outside Policy (No Receipt / Expired)
UI-->>U: "Policy Exception - Store Credit Only"
opt Manager Override
UI-->>U: Prompt Manager PIN
U->>UI: Enter PIN + Reason Code
end
UI->>UI: Issue Store Credit
end
else Action: Exchange (Dedicated Flow)
Note right of U: "Customer wants different size"
U->>UI: Load Original Sale -> Click "Exchange"
UI->>UI: Select Item(s) to Exchange OUT
UI->>UI: Scan/Add New Item(s) IN
UI->>UI: Calculate Price Difference
alt Customer Owes Money
UI-->>U: "Collect: $15.00 difference"
U->>UI: Process Payment
else Store Owes Refund
UI-->>U: "Refund: $10.00 to customer"
U->>UI: Process Refund
else Even Exchange
UI->>UI: No Payment Required
end
UI->>API: POST /sales/exchange
API->>DB: Create Exchange Record (Links Old & New)
API->>DB: Update Inventory (Both Directions)
API->>DB: Adjust Commission (if price difference)
else Action: Reprint Receipt
U->>UI: Click "Reprint Receipt"
UI->>API: GET /orders/{id}/receipt
API-->>UI: Return Receipt Data
U->>UI: Select Format (Thermal / A4 / Email)
opt Email to Different Address
U->>UI: Enter Email Address
UI->>API: POST /orders/{id}/email-receipt
end
else Action: Pay Off Layaway
U->>UI: Retrieve Layaway
UI->>UI: Pay Remaining Balance
UI->>API: Finalize (Status: COMPLETED)
API->>DB: Release Reserve -> Sold
end
Cross-Reference: See Module 4, Section 4.13 for POS inventory integration details.
1.4.1 Void vs. Return Rules
| Aspect | Void | Return |
|---|---|---|
| When allowed | Same business day, drawer open | Any time within policy |
| Inventory | Reversed immediately | Reversed on completion |
| Commission | Full reversal | Proportional reversal |
| Card refund | Manual on terminal only | Staff chooses: Manual on terminal OR Automatic via token |
| Cash refund | Cash returned from drawer | Cash returned from drawer |
| Audit trail | “VOIDED” status | “RETURNED” line items |
| Use case | Cashier mistake | Customer return |
Card Refund Method Selection (Returns Only):
- Manual on terminal: Customer presents physical card. Staff processes refund on the payment terminal. Use when customer is present with their card.
- Automatic via token: System uses the stored payment token to process refund without card present. Use when customer does not have their card or for remote returns.
- Cash payments: Refund is always cash from drawer. No token option available for cash transactions.
1.4.2 Reports: Post-Sale
| Report | Purpose | Key Data Fields |
|---|---|---|
| Void Summary | Track voided transactions | Void count, total value, reason codes, voiding employee, authorizing manager |
| Return Summary | Track returns by period | Return count, total refund value, refund method breakdown, top returned items |
| Exchange Summary | Track exchange activity | Exchange count, price difference (net), upgrade vs downgrade ratio |
| Refund Method Report | Analyze refund processing | Manual on terminal count, automatic via token count, cash refund count |
Email Template: TMPL-REFUND-CONFIRMATION
| Field | Value |
|---|---|
| Trigger | Refund processed (card or store credit) |
| Recipient | Customer (if email on file) |
| Content | Refund amount, method, expected processing time, original order reference |
1.5 Gift Card Management
Scope: Selling, Activating, Redeeming, Balance Management, and Jurisdiction Compliance.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Gift Card Operations
alt Sell New Gift Card
U->>UI: Scan Gift Card (or Enter Number)
U->>UI: Enter Load Amount
UI->>API: POST /giftcards/activate
API->>DB: Create Gift Card Record (Number, Balance, Expiry)
API->>DB: Set Jurisdiction Rules (based on store location)
API-->>UI: Activation Success
UI->>UI: Add Gift Card to Cart as Product
Note right of UI: Proceeds through normal checkout
end
alt Check Balance
U->>UI: Click "Gift Card Balance"
U->>UI: Scan/Enter Card Number
UI->>API: GET /giftcards/{number}/balance
API-->>UI: Display Balance & Expiry Date (if applicable)
end
alt Reload Existing Card
U->>UI: Scan Gift Card
UI->>API: GET /giftcards/{number}
API-->>UI: Card Found - Current Balance
U->>UI: Enter Reload Amount
UI->>UI: Add Reload to Cart
Note right of UI: Balance updated after payment
end
alt Cash Out (California Compliance)
U->>UI: Scan Gift Card
UI->>API: GET /giftcards/{number}
API-->>UI: Return Balance & Jurisdiction
alt Balance <= Cash Out Threshold
UI-->>U: "Eligible for Cash Out: $8.50"
U->>UI: Process Cash Out
API->>DB: Zero Balance, Record Cash Out
else Balance > Threshold
UI-->>U: "Not eligible for cash out"
end
end
1.5.1 Gift Card State Machine
stateDiagram-v2
[*] --> INACTIVE: Card Manufactured
INACTIVE --> ACTIVE: Activated at POS (Sold)
ACTIVE --> ACTIVE: Partial Redemption
ACTIVE --> ACTIVE: Reload
ACTIVE --> DEPLETED: Balance = $0.00
ACTIVE --> EXPIRED: Past Expiry Date (where allowed)
DEPLETED --> ACTIVE: Reloaded
DEPLETED --> CASHED_OUT: Cash Out Processed
EXPIRED --> [*]
CASHED_OUT --> [*]
note right of ACTIVE
Balance > $0
Within expiry (if applicable)
end note
note right of DEPLETED
Balance = $0
Can be reloaded
end note
1.5.2 Gift Card Jurisdiction Compliance
| Rule | Virginia | California | New York | Default |
|---|---|---|---|---|
| Expiry Allowed | Yes (5yr min) | No | No | No |
| Inactivity Fees | Yes (after 12mo) | No | No | No |
| Cash Out Threshold | None | $10.00 | None | None |
| Cash Out Required | No | Yes | No | No |
Implementation: Store location determines which jurisdiction rules apply. System defaults to most restrictive (California-style) and enables features where permitted.
1.5.3 Reports: Gift Cards
| Report | Purpose | Key Data Fields |
|---|---|---|
| Gift Card Liability Report | Track outstanding gift card balances | Total active cards, total outstanding balance, avg balance per card |
| Gift Card Activity Report | Monitor gift card transactions | Activations, reloads, redemptions, cash-outs, expired cards by period |
| Gift Card Aging | Identify dormant cards | Card number, last activity date, balance, days inactive |
1.6 Special Orders & Back Orders
Scope: Ordering out-of-stock items with customer deposits.
sequenceDiagram
autonumber
participant U as Staff
participant C as Customer
participant UI as POS UI
participant API as Backend
participant DB as DB
participant INV as Inventory/Purchasing
Note over U, INV: Special Order Flow
U->>UI: Search Product -> Out of Stock
UI-->>U: "Available for Special Order"
U->>UI: Click "Create Special Order"
UI->>UI: Attach Customer (Required)
UI->>UI: Enter Quantity Needed
UI->>UI: Calculate Deposit (Min 50% or Full)
C->>U: Agrees to Deposit
U->>UI: Process Deposit Payment
UI->>API: POST /special-orders/create
par Order Creation
API->>DB: Create Special Order Record
API->>DB: Link Customer & Deposit Payment
API->>DB: Set Status: DEPOSIT_PAID
API->>INV: Notify Purchasing Team
end
API-->>UI: Order #SO-12345 Created
UI->>U: Print Special Order Receipt
Note over INV, DB: Later - Item Arrives
INV->>API: POST /special-orders/{id}/received
API->>DB: Update Status: READY_FOR_PICKUP
API-->>C: Send Notification (SMS/Email)
Note over U, C: Customer Pickup
U->>UI: Retrieve Special Order
UI->>UI: Show Deposit Already Paid
UI->>UI: Calculate Remaining Balance
U->>UI: Collect Remaining Payment
UI->>API: POST /special-orders/{id}/complete
API->>DB: Status: COMPLETED
1.6.1 Special Order State Machine
stateDiagram-v2
[*] --> CREATED: Order Initiated
CREATED --> DEPOSIT_PAID: Deposit Received
DEPOSIT_PAID --> ORDERED: Sent to Vendor
ORDERED --> RECEIVED: Item Arrived at Store
RECEIVED --> READY_FOR_PICKUP: Inspected & Staged
READY_FOR_PICKUP --> COMPLETED: Customer Picked Up
READY_FOR_PICKUP --> ABANDONED: No Pickup (30+ days)
CREATED --> CANCELLED: Customer Cancels (No Deposit)
DEPOSIT_PAID --> CANCELLED_REFUND: Customer Cancels (Refund Deposit)
CANCELLED --> [*]
CANCELLED_REFUND --> [*]
COMPLETED --> [*]
ABANDONED --> [*]
1.6.2 Reports: Special Orders
| Report | Purpose | Key Data Fields |
|---|---|---|
| Special Order Status Report | Track all special orders by status | Order ID, customer, item, status, deposit amount, days in current status |
| Special Order Aging | Identify overdue or stalled orders | Orders past expected date, orders approaching abandonment threshold |
Email Template: TMPL-SPECIAL-ORDER-READY
| Field | Value |
|---|---|
| Trigger | Special order status changes to READY_FOR_PICKUP |
| Recipient | Customer |
| Content | Order details, remaining balance, store address, pickup deadline |
1.7 Multi-Store Inventory & Transfers
Scope: Cross-store inventory lookup, transfers, and reservations (requires full payment).
Cross-Reference: See Module 4, Section 4.8 for inter-store transfer details.
sequenceDiagram
autonumber
participant U as Staff
participant C as Customer
participant UI as POS UI
participant API as Backend
participant DB as DB
participant S2 as Store B
Note over U, S2: Multi-Store Inventory Check
U->>UI: Search Product -> Low/No Stock
U->>UI: Click "Check Other Stores"
UI->>API: GET /inventory/multi-store/{sku}
Note right of API: Eventually consistent (max 5 min stale)
API-->>UI: Return Stock Levels (All Locations)
UI-->>U: Display: "Store B: 5 units, Store C: 2 units"
alt Request Transfer to This Store
U->>UI: Select Store B -> "Request Transfer"
UI->>UI: Enter Quantity
UI-->>U: "Customer must pay in full to process"
C->>U: Agrees to Pay
U->>UI: Add Item to Cart (Status: TRANSFER_PENDING)
U->>UI: Complete Full Payment
UI->>API: POST /transfers/request
API->>DB: Create Transfer Record (PAID)
API->>S2: Notify Store B to Ship
API-->>UI: Transfer #TRF-789 Created
UI-->>U: "Item will arrive in 2-3 days"
UI->>U: Print Transfer Receipt for Customer
else Reserve at Other Store (Customer Pickup)
U->>UI: Select Store B -> "Reserve for Pickup"
UI-->>U: "Customer must pay in full to reserve"
C->>U: Agrees to Pay
U->>UI: Process Full Payment
UI->>API: POST /reservations/create
API->>DB: Create Reservation (PAID)
API->>S2: Reserve Item at Store B
API-->>UI: Reservation #RES-456 Created
UI-->>U: "Reserved at Store B until [date]"
UI->>U: Print Pickup Voucher for Customer
end
Note over S2, DB: Store B Fulfillment
S2->>API: POST /transfers/{id}/picked
API->>DB: Update Status: PICKING
S2->>API: POST /transfers/{id}/shipped
API->>DB: Update Status: SHIPPED
Note right of DB: Carrier scan triggers IN_TRANSIT
API-->>U: Notification: "Transfer Shipped"
1.7.1 Transfer State Machine
stateDiagram-v2
[*] --> REQUESTED: Transfer Initiated
REQUESTED --> PAID: Customer Paid in Full
PAID --> PICKING: Source Store Processing
PICKING --> SHIPPED: Handed to Carrier
SHIPPED --> IN_TRANSIT: Carrier Scan Confirmed
IN_TRANSIT --> RECEIVED: Arrived at Destination
RECEIVED --> COMPLETED: Customer Notified & Picked Up
REQUESTED --> CANCELLED: Cancelled Before Payment
PAID --> CANCELLED_REFUND: Cancelled After Payment
CANCELLED --> [*]
CANCELLED_REFUND --> [*]
COMPLETED --> [*]
1.7.2 Reservation State Machine
stateDiagram-v2
[*] --> REQUESTED: Reservation Initiated
REQUESTED --> PAID: Customer Paid in Full
PAID --> RESERVED: Item Held at Store
RESERVED --> PICKED_UP: Customer Collected
RESERVED --> EXPIRED: Reservation Deadline Passed
EXPIRED --> REFUND_PENDING: Auto-Refund Triggered
REFUND_PENDING --> REFUNDED: Refund Processed
REQUESTED --> CANCELLED: Cancelled Before Payment
CANCELLED --> [*]
PICKED_UP --> [*]
REFUNDED --> [*]
1.7.3 Ship to Customer from Other Location
Scope: Direct shipping from a source store to the customer’s address, with carrier integration for real-time shipping cost calculation.
sequenceDiagram
autonumber
participant U as Staff
participant C as Customer
participant UI as POS UI
participant API as Backend
participant DB as DB
participant S2 as Source Store
participant SHIP as Carrier API
Note over U, SHIP: Ship to Customer from Another Store
U->>UI: Search Product -> Low/No Stock
U->>UI: Click "Check Other Stores"
UI->>API: GET /inventory/multi-store/{sku}
API-->>UI: Return Stock Levels (All Locations)
U->>UI: Select Source Store -> "Ship to Customer"
UI-->>U: "Enter Customer Shipping Address"
C->>U: Provides Shipping Address
U->>UI: Enter Address Details
UI->>API: POST /shipping/calculate
Note right of API: {origin_store, destination_address, items, weight}
API->>SHIP: Request Shipping Rates
SHIP-->>API: Return Shipping Options
API-->>UI: Display Shipping Options
UI-->>U: "Standard (3-5 days): $8.99 | Express (1-2 days): $15.99"
C->>U: Selects Shipping Option
U->>UI: Add Item + Shipping to Cart
UI->>UI: Total = Item Price + Shipping Cost
UI-->>U: "Customer must pay in full"
C->>U: Pays Full Amount (Item + Shipping)
U->>UI: Process Payment
UI->>API: POST /shipments/create
API->>DB: Create Shipment Record (PAID)
API->>S2: Notify Source Store to Pack & Ship
API-->>UI: Shipment #SHP-101 Created
UI-->>U: "Item will be shipped to customer"
UI->>U: Print Shipment Receipt for Customer
Note over S2, SHIP: Source Store Fulfillment
S2->>API: POST /shipments/{id}/packed
API->>DB: Update Status: PACKED
S2->>SHIP: Request Shipping Label
SHIP-->>S2: Return Label + Tracking Number
S2->>API: POST /shipments/{id}/shipped {tracking_number}
API->>DB: Update Status: SHIPPED
API-->>C: Send Tracking Email to Customer
Note over SHIP, DB: Delivery
SHIP->>API: Webhook: Delivered
API->>DB: Update Status: DELIVERED
API-->>C: Send Delivery Confirmation Email
1.7.4 Ship-to-Customer State Machine
stateDiagram-v2
[*] --> REQUESTED: Shipment Initiated
REQUESTED --> PAID: Customer Paid (Item + Shipping)
PAID --> PICKING: Source Store Processing
PICKING --> PACKED: Items Packed
PACKED --> SHIPPED: Label Generated & Handed to Carrier
SHIPPED --> IN_TRANSIT: Carrier Pickup Confirmed
IN_TRANSIT --> DELIVERED: Delivery Confirmed
REQUESTED --> CANCELLED: Cancelled Before Payment
PAID --> CANCELLED_REFUND: Cancelled After Payment (Full Refund)
CANCELLED --> [*]
CANCELLED_REFUND --> [*]
DELIVERED --> [*]
1.7.5 Reports: Multi-Store & Shipping
| Report | Purpose | Key Data Fields |
|---|---|---|
| Transfer Status Report | Track inter-store transfers | Transfer ID, source/destination, status, days in transit, customer |
| Shipping Fulfillment Report | Monitor ship-to-customer orders | Shipment ID, carrier, tracking, status, delivery date, shipping cost |
| Reservation Report | Track cross-store reservations | Reservation ID, store, item, status, expiry date, customer |
| Multi-Store Inventory Discrepancy | Flag stock mismatches after sync | SKU, expected vs actual, location, last sync time |
Email Template: TMPL-SHIPMENT-TRACKING
| Field | Value |
|---|---|
| Trigger | Shipment status changes to SHIPPED |
| Recipient | Customer |
| Content | Tracking number, carrier, estimated delivery, order details |
Email Template: TMPL-DELIVERY-CONFIRMATION
| Field | Value |
|---|---|
| Trigger | Shipment status changes to DELIVERED |
| Recipient | Customer |
| Content | Delivery confirmation, order summary, return/exchange policy link |
1.8 Sales Commissions
Scope: Track employee sales for commission calculation with proportional reversal on returns.
Cross-Reference: See Module 5, Section 5.5 for user commission rate configuration.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Commission Tracking (Per Transaction)
U->>UI: Login to POS (Employee ID Captured)
Note right of UI: Throughout Sale...
UI->>UI: Employee ID attached to session
U->>UI: Complete Sale
UI->>API: POST /orders/finalize
par Commission Recording
API->>DB: Calculate Commission Amount
Note right of API: Based on: Sale Total, Product Categories, Employee Tier
API->>DB: Insert Commission Record
Note right of DB: {employee_id, order_id, amount, date, line_items[]}
end
Note over U, DB: Commission Reversal on Return
U->>UI: Process Return (2 of 3 items)
UI->>API: POST /returns/create
par Proportional Reversal
API->>DB: Calculate Return Value / Original Sale Value
Note right of API: $80 returned / $120 sale = 66.7%
API->>DB: Reverse 66.7% of Commission
API->>DB: Update Commission Record
end
Note over U, DB: Commission Reporting
U->>UI: Manager -> Reports -> Commissions
UI->>API: GET /reports/commissions?date_range&employee
API->>DB: Aggregate Commission Data
API-->>UI: Return Commission Summary
UI-->>U: Display: Employee | Sales | Returns | Net Commission
1.8.1 Commission Calculation Rules
commission_calculation:
# Base calculation
base_method: "percentage_of_sale"
# Reversal rules
void_reversal: "full" # 100% reversal on void
return_reversal: "proportional" # Based on returned value
# Proportional calculation
# Commission Adjustment = Original Commission × (Returned Value / Original Sale Value)
# Example:
# Original Sale: $120, Commission: $6.00 (5%)
# Return: $80 worth of items
# Reversal: $6.00 × ($80/$120) = $4.00
# Net Commission: $6.00 - $4.00 = $2.00
1.8.2 Reports: Commissions
| Report | Purpose | Key Data Fields |
|---|---|---|
| Commission Summary | Period overview of all commissions | Total sales, total commissions, avg commission rate, top earners |
| Commission by Employee | Individual employee performance | Employee, sales count, sales total, commission earned, returns impact |
| Commission Reversal Log | Track commission adjustments | Order ID, original commission, reversal amount, reversal type (void/return), date |
1.9 Return Policy Engine
Configuration Note: Store return and exchange policies are manually configured in the application’s Settings/Setup module. Policies are NOT hardcoded in the application. Each tenant can configure different policies per store location and per sales channel (online vs in-store).
Scope: Configurable return rules based on receipt, time, and item type.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Return Policy Evaluation
U->>UI: Start Return
alt Has Receipt
U->>UI: Scan Receipt Barcode
UI->>API: POST /receipts/validate {barcode_data}
API->>DB: Verify receipt authenticity & match to order
alt Receipt Valid
API-->>UI: Receipt Verified
else Receipt Invalid
API-->>UI: "Invalid Receipt"
UI-->>U: "Receipt validation failed"
end
UI->>API: GET /orders/{id}
API-->>UI: Return Original Sale Data
UI->>UI: Calculate Days Since Purchase
alt Within 30 Days
UI-->>U: "Full Refund Eligible"
UI->>UI: Refund to Original Payment Method
else 31-90 Days
UI-->>U: "Store Credit Only"
UI->>UI: Issue Store Credit
else Beyond 90 Days
UI-->>U: "Manager Approval Required"
U->>UI: Enter Manager PIN
U->>UI: Select Exception Reason
end
else No Receipt
U->>UI: Scan Item Barcode
UI->>API: GET /products/{sku}/current-price
API-->>UI: Return Current Selling Price
UI-->>U: "No Receipt - Store Credit at Current Price"
UI-->>U: "Manager Approval Required"
U->>UI: Enter Manager PIN
UI->>UI: Issue Store Credit (Current Price)
end
alt Item-Specific Rules
opt Final Sale Item
UI-->>U: "BLOCKED: Final Sale - No Returns"
end
opt Opened Electronics
UI-->>U: "Restocking Fee: 15%"
UI->>UI: Deduct Restocking Fee
end
end
1.9.1 Default Policy Configuration Examples
Note: These are example default values only. Actual policies are configured per tenant in the application’s Settings/Setup module and are NOT hardcoded.
Online Sales Policy
| Policy | Timeframe | Refund Method | Conditions |
|---|---|---|---|
| Return | 30 days from delivery | Original payment method minus shipping & processing fees | Item in original condition, receipt required |
| Exchange | 30 days from delivery | Price difference applies; shipping & processing fees excluded from refund | Same category items preferred |
In-Store Sales Policy
| Policy | Timeframe | Refund Method | Conditions |
|---|---|---|---|
| Return | 24 hours from purchase | Original payment method | Receipt required (scanned for validation) |
| Exchange | 24 hours from purchase | Price difference applies | Receipt required (scanned for validation) |
Policy Configuration Fields (Settings/Setup):
- Return window (days/hours per channel)
- Exchange window (days/hours per channel)
- Refund method options (original method, store credit, etc.)
- Restocking fee percentage and applicable categories
- Final sale categories
- Manager override permissions
- Online shipping/processing fee exclusion rules
1.9.2 Reports: Return Policy
| Report | Purpose | Key Data Fields |
|---|---|---|
| Return Policy Exception Report | Track manager overrides on return policy | Order ID, exception type, reason code, authorizing manager, refund amount |
| Return Reason Analysis | Understand why customers return items | Reason code, frequency, product categories, avg refund value |
| Channel Return Comparison | Compare online vs in-store returns | Channel, return count, return rate, avg processing time |
1.10 Serial Number Tracking
Scope: Capture serial numbers for designated high-value items.
Cross-Reference: See Module 4, Section 4.10 for serial number tracking lifecycle.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Serial Number Capture at Sale
U->>UI: Scan Product (Serial Required Flag)
UI-->>U: "Enter Serial Number"
U->>UI: Scan/Enter Serial Number
UI->>API: POST /serials/validate
alt Serial Already Sold
API-->>UI: "ERROR: Serial already in system"
UI-->>U: Block Item - Investigate
else Serial Valid/New
API-->>UI: Valid
UI->>UI: Attach Serial to Line Item
end
U->>UI: Complete Sale
UI->>API: POST /orders/finalize
API->>DB: Store Serial with Order Line
Note right of DB: {order_id, line_id, serial_number, product_sku}
Note over U, DB: Serial Lookup (Returns/Warranty)
U->>UI: Search by Serial Number
UI->>API: GET /serials/{number}
API-->>UI: Return Purchase History
UI-->>U: "Sold on [date] to [customer] - Order #123"
1.10.1 Reports: Serial Number Tracking
| Report | Purpose | Key Data Fields |
|---|---|---|
| Serial Number Audit Trail | Full history of serial-tracked items | Serial number, product SKU, sale date, customer, return status |
| Missing Serial Report | Flag transactions missing required serials | Order ID, product, serial required flag, serial captured (Y/N) |
1.11 Hold for Pickup (Including BOPIS)
Scope: Fully paid items held for customer pickup at the CURRENT store. This includes both in-store holds and BOPIS (Buy Online, Pick Up In Store) orders. Different from Layaway (partial payment) and Reservation (item at another store).
Cross-Reference: See Module 4, Section 4.13 for inventory reservation model.
Reservation vs Hold for Pickup - Key Distinction:
Aspect Reservation (Section 1.7.2) Hold for Pickup (Section 1.11) What it is Reserve item at a DIFFERENT store for customer pickup Pay for items at THIS store, pick up later Origin In-store POS (staff-initiated) In-store POS or Online order (BOPIS) Payment Full payment at originating store Full payment required upfront Inventory location At the remote store At the current store Customer picks up at The remote store The same store (or originating store for BOPIS) BOPIS No Yes - this is the BOPIS flow Use case “Store B has it, I’ll drive there to get it” “I’ll pay now, come back Saturday” or “Order online, pick up in store” Examples:
Reservation Example: Customer is at Store A. Item is out of stock. Store B has 3 units. Customer pays at Store A. Item is reserved at Store B. Customer drives to Store B to pick it up with their pickup voucher.
Hold for Pickup Example 1 (In-Store): Customer at Store A buys a large piece of furniture. Pays in full. Asks store to hold it until Saturday when they can bring a truck. Store stages the item. Customer returns Saturday to pick up.
Hold for Pickup Example 2 (Online/BOPIS): Customer browses the online store. Selects “Pick Up at Store A.” Pays online. Store A receives the order, stages the items. Customer receives “Ready for Pickup” notification. Customer walks into Store A and picks up.
sequenceDiagram
autonumber
participant U as Staff
participant C as Customer
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Hold for Pickup Flow
U->>UI: Add Items to Cart
U->>UI: Attach Customer
U->>UI: Click "Hold for Pickup"
UI->>UI: Set Pickup Deadline (Default: 7 days)
UI-->>U: "Customer must pay in full"
C->>U: Pays Full Amount
U->>UI: Process Payment
UI->>API: POST /orders/finalize
API->>DB: Create Order (Status: HOLD_FOR_PICKUP)
API->>DB: Set Pickup Deadline
API->>DB: Reserve Inventory
API-->>UI: Order Complete
UI->>U: Print Pickup Slip
Note over U, DB: Customer Returns to Pickup
U->>UI: Retrieve Held Order
UI->>API: GET /orders/held/{id}
API-->>UI: Display Order Details
U->>UI: Verify Customer ID
U->>UI: Click "Release to Customer"
UI->>API: PATCH /orders/{id}/pickup-complete
API->>DB: Status: COMPLETED
API->>DB: Release Inventory Hold
Note over API, DB: Expiry Handling (Background Job)
API->>DB: Check Overdue Holds Daily
alt Hold Expired
API->>DB: Status: HOLD_EXPIRED
API-->>U: Alert: "Hold #123 expired - contact customer"
end
1.11.1 Hold for Pickup State Machine
stateDiagram-v2
[*] --> HOLD_FOR_PICKUP: Full Payment + Hold Request
HOLD_FOR_PICKUP --> READY_FOR_PICKUP: Items Staged
READY_FOR_PICKUP --> COMPLETED: Customer Picked Up
READY_FOR_PICKUP --> HOLD_EXPIRED: Deadline Passed
HOLD_EXPIRED --> CONTACT_CUSTOMER: Staff Notified
CONTACT_CUSTOMER --> READY_FOR_PICKUP: Deadline Extended
CONTACT_CUSTOMER --> REFUNDED: Customer Wants Refund
REFUNDED --> [*]
COMPLETED --> [*]
1.12 Cash Drawer Management
Scope: Opening float, blind counts, variance tracking, X-reports (mid-shift), and Z-reports (end-of-shift).
Cross-Reference: See Module 5, Section 5.7 for register configuration and Section 5.6 for clock-in/clock-out time tracking.
sequenceDiagram
autonumber
participant U as Staff
participant M as Manager
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Shift Start - Open Drawer
M->>UI: Open Cash Drawer Session
M->>UI: Enter Opening Float Amount
UI->>API: POST /cash-drawer/open
API->>DB: Create Drawer Session (opening_float, start_time)
API-->>UI: Drawer Session Started
Note over U, DB: During Shift - Transactions
loop Cash Transactions
U->>UI: Process Cash Sale/Refund
UI->>DB: Record Cash In/Out
end
opt Mid-Shift Check (X-Report)
U->>UI: Click "X-Report"
UI->>API: GET /cash-drawer/x-report
API->>DB: Calculate Current Expected Amount
Note right of API: Expected = Float + Cash Sales So Far - Cash Refunds - Payouts
API-->>UI: Return X-Report Data
UI->>U: Print/Display X-Report
Note right of UI: X-Report does NOT close the drawer
Note right of UI: Can be run multiple times per shift
end
Note over U, DB: Shift End - Close Drawer
U->>UI: Click "Close Drawer"
UI-->>U: "Perform Blind Count"
U->>UI: Enter Counted Cash (Blind - no expected shown)
UI->>API: POST /cash-drawer/count
API->>DB: Calculate Expected Amount
Note right of API: Expected = Float + Cash Sales - Cash Refunds - Payouts
API->>DB: Calculate Variance (Counted - Expected)
alt Variance Within Tolerance
API-->>UI: "Drawer Balanced"
else Variance Outside Tolerance
API-->>UI: "Variance: -$5.00 - Manager Approval Required"
M->>UI: Enter PIN + Variance Reason
end
API->>DB: Close Drawer Session
API->>DB: Record Final Counts & Variance
UI->>U: Print Z-Report
Note over M, DB: Z-Report Contents
Note right of UI: - Opening Float
Note right of UI: - Cash Sales Total
Note right of UI: - Cash Refunds Total
Note right of UI: - Expected Cash
Note right of UI: - Counted Cash
Note right of UI: - Variance (+/-)
Note right of UI: - Transaction Count
1.12.1 Cash Drawer State Machine
stateDiagram-v2
[*] --> CLOSED: Drawer Secured
CLOSED --> OPENING: Manager Opens
OPENING --> OPEN: Float Entered
OPEN --> OPEN: Transactions Processed
OPEN --> COUNTING: Close Initiated
COUNTING --> BALANCED: Variance Within Tolerance
COUNTING --> VARIANCE_DETECTED: Variance Outside Tolerance
VARIANCE_DETECTED --> MANAGER_REVIEW: Awaiting Approval
MANAGER_REVIEW --> BALANCED: Manager Approved
BALANCED --> CLOSED: Z-Report Printed
CLOSED --> [*]
1.12.2 X-Report vs Z-Report
| Aspect | X-Report | Z-Report |
|---|---|---|
| When | Any time during shift | End of shift only |
| Closes Drawer | No | Yes |
| Resets Counters | No | Yes |
| Frequency | Unlimited per shift | Once per shift |
| Use Cases | Mid-shift audit, shift handoff check, manager spot-check | End-of-day close, final reconciliation |
| Content | Same as Z-Report (opening float, sales, refunds, expected cash) | Same content + final blind count + variance |
X-Report Use Cases:
- Manager wants to verify cash during a busy period
- Shift handoff between employees (outgoing staff checks before handing off)
- Routine mid-day audit required by store policy
- Investigating a suspected cash handling issue
1.12.3 Reports: Cash Drawer
| Report | Purpose | Key Data Fields |
|---|---|---|
| X-Report | Mid-shift cash snapshot (does not close drawer) | Opening float, cash sales, cash refunds, payouts, expected cash |
| Z-Report | End-of-shift final reconciliation | Same as X-Report + blind count, variance, manager approval |
| Variance History Report | Track cash variances over time | Date, shift, employee, expected, counted, variance, reason code |
| Cash Movement Log | Detailed cash in/out record | Timestamp, type (sale/refund/payout/float), amount, employee |
1.13 Price Check Mode
Scope: Quick price lookup without adding to cart.
sequenceDiagram
autonumber
participant U as Staff
participant C as Customer
participant UI as POS UI
participant API as Backend
C->>U: "How much is this?"
U->>UI: Click "Price Check" Mode
UI->>UI: Switch to Price Check Display
U->>UI: Scan Item Barcode
UI->>API: GET /products/{sku}
API-->>UI: Return Product Info
UI-->>U: Display Large Price
UI-->>U: Show: Name, SKU, Price, Stock Level
opt Promotion Active
UI-->>U: "ON SALE: Was $50, Now $39.99"
end
U->>UI: Press Any Key / Timeout
UI->>UI: Return to Normal Sale Mode
1.14 Coupon System
Scope: Single-use and multi-use coupons (separate from promo codes).
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Coupon Application
U->>UI: Click "Apply Coupon"
U->>UI: Scan/Enter Coupon Code
UI->>API: POST /coupons/validate
API->>DB: Lookup Coupon
alt Single-Use Coupon (e.g., Birthday)
API->>DB: Check if Already Redeemed
alt Already Used
API-->>UI: "Coupon Already Redeemed"
else Valid
API-->>UI: Return Discount Details
end
else Multi-Use Coupon (e.g., SAVE10)
API->>DB: Check Usage Limit & Expiry
API-->>UI: Return Discount Details
end
alt Valid Coupon
UI->>UI: Apply Discount
UI->>UI: Display Savings
Note right of UI: Coupon marked for redemption at finalize
end
U->>UI: Complete Sale
UI->>API: POST /orders/finalize
par Coupon Processing
API->>DB: Mark Single-Use Coupon as REDEEMED
API->>DB: Increment Multi-Use Coupon Counter
API->>DB: Link Coupon to Order
end
1.14.1 Coupon State Machine
stateDiagram-v2
[*] --> CREATED: Coupon Generated
CREATED --> ACTIVE: Published/Distributed
state ACTIVE {
[*] --> AVAILABLE
AVAILABLE --> APPLIED: Added to Cart
APPLIED --> AVAILABLE: Removed from Cart
APPLIED --> REDEEMED: Order Finalized
}
ACTIVE --> EXPIRED: Past Expiry Date
ACTIVE --> DEPLETED: Usage Limit Reached (Multi-Use)
REDEEMED --> [*]: Single-Use Complete
EXPIRED --> [*]
DEPLETED --> [*]
1.15 Flexible Loyalty Programs
Scope: Configurable loyalty: points-per-dollar, punch cards, spend thresholds.
Cross-Reference: See Module 5, Section 5.17 for loyalty program settings configuration (point rates, tier thresholds, gift card settings).
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Loyalty Program Types
U->>UI: Attach Customer to Sale
UI->>API: GET /customers/{id}/loyalty
API-->>UI: Return Loyalty Profile & Active Programs
alt Points Per Dollar Program
Note right of UI: Earn 1 point per $1 spent
UI->>UI: Calculate Points to Earn
UI-->>U: "Customer earns 45 points"
opt Redeem Points
U->>UI: Click "Redeem Points"
UI-->>U: "500 points = $5 off"
U->>UI: Apply Redemption
end
else Punch Card Program
Note right of UI: Buy 10, Get 1 Free
UI->>UI: Check Qualifying Items in Cart
UI-->>U: "Coffee Purchase: Punch 3 of 10"
opt Card Complete
UI-->>U: "FREE ITEM EARNED!"
UI->>UI: Auto-Apply Free Item Discount
end
else Spend Threshold Program
Note right of UI: Spend $100, Get $10 Off
UI->>UI: Check Customer's Period Spend
UI-->>U: "Customer has spent $85 this month"
opt Threshold Reached This Sale
UI-->>U: "$10 Reward Unlocked!"
UI->>UI: Apply or Save for Next Visit
end
end
U->>UI: Complete Sale
UI->>API: POST /orders/finalize
par Loyalty Updates
API->>DB: Award Points Earned
API->>DB: Update Punch Card Count
API->>DB: Update Spend Totals
API->>DB: Check Tier Upgrades
end
1.15.1 Customer Tier State Machine
stateDiagram-v2
[*] --> BRONZE: New Customer
BRONZE --> SILVER: Spend >= $1,000/year
SILVER --> GOLD: Spend >= $5,000/year
GOLD --> GOLD: Maintains Spend
GOLD --> SILVER: Annual Spend < $5,000
SILVER --> BRONZE: Annual Spend < $1,000
note right of BRONZE
1x points
Standard pricing
end note
note right of SILVER
1.5x points
5% discount
end note
note right of GOLD
2x points
10% discount
Early access
end note
1.15.2 Reports: Loyalty Programs
| Report | Purpose | Key Data Fields |
|---|---|---|
| Loyalty Points Summary | Track points economy | Total points issued, redeemed, expired, outstanding balance |
| Tier Distribution Report | Customer breakdown by tier | Tier, customer count, avg spend, upgrade/downgrade count |
| Points Expiry Forecast | Predict upcoming point expirations | Customer, points expiring, expiry date, days remaining |
| Punch Card Activity | Track punch card completions | Program, cards started, cards completed, avg completion time |
| Loyalty ROI Analysis | Measure loyalty program value | Points cost, additional revenue from loyalty customers, retention rate |
1.16 Offline Fallback Operations
Scope: Online-first architecture with offline fallback for network resilience per ADR-048.
BRD Amendment (v6.3.0): Rewritten per ADR-048 (Online-First with Offline Fallback). The POS operates API-primary via React Query. Offline mode is a degraded fallback, not the default operating mode. CRDTs, conflict resolution engines, and fixed queue limits have been removed.
Cross-Reference: See Module 4, Section 4.15 for offline inventory operations. See Ch 04, Section L.10A.1G for the 3-state connectivity model.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI (React Query)
participant SQ as sales_queue (SQLite)
participant API as Backend API
participant DB as PostgreSQL
Note over U, DB: Normal Operation (ONLINE)
U->>UI: Process Sale
UI->>API: POST /orders (API-primary)
API->>DB: Validate & Write
API-->>UI: Order Confirmed
UI-->>U: "Sale Complete"
Note over U, DB: Network Degrades → DEGRADED / OFFLINE
UI->>UI: Connectivity monitor detects loss
UI-->>U: Display "OFFLINE MODE" banner
U->>UI: Process Sale
UI->>SQ: Write to sales_queue (FIFO)
Note right of SQ: product_cache provides read-only prices
SQ-->>UI: Queued (#1)
UI-->>U: "Sale Complete (Pending Sync)"
UI->>U: Print Receipt with "OFFLINE" watermark
loop Continue Offline Sales
U->>UI: Process More Sales
UI->>SQ: Append to sales_queue (FIFO, unlimited)
UI-->>U: Show Queue Count (e.g., "3 pending")
end
Note over UI, DB: Network Restored → SYNCING
UI->>UI: Connectivity monitor detects restoration
UI-->>U: Display "SYNCING..." Indicator
loop Flush sales_queue (FIFO order)
SQ->>API: POST /orders/sync (oldest first)
API->>DB: Apply current server prices & write
alt Price Discrepancy
API-->>UI: Flagged — cached price differs from current
Note right of UI: Logged for manager review (no block)
end
API-->>UI: Synced
SQ->>SQ: Remove from queue
end
UI-->>U: "All transactions synced" → ONLINE
1.16.1 Connectivity State Machine
stateDiagram-v2
[*] --> ONLINE: App Start (API reachable)
ONLINE --> DEGRADED: Intermittent connectivity
DEGRADED --> OFFLINE: API unreachable (3 consecutive failures)
DEGRADED --> ONLINE: Stable connection restored
OFFLINE --> SYNCING: API reachable again
SYNCING --> ONLINE: sales_queue empty
SYNCING --> OFFLINE: Connection lost during sync
note right of DEGRADED
API calls attempted first
Falls back to local queue on failure
end note
note right of OFFLINE
All sales written to sales_queue
product_cache for read-only lookups
end note
note right of SYNCING
FIFO flush of sales_queue
Flag price discrepancies
end note
BRD Amendment (v6.3.0): CONFLICT_REVIEW state removed per ADR-048. DEGRADED state added per Ch 04 L.10A.1G 3-state model. Conflicts replaced by flag-on-sync discrepancy detection.
1.16.2 Offline Fallback Rules
offline_mode:
# Architecture: Online-first with offline fallback (ADR-048)
strategy: "online_first"
# Local SQLite tables
local_storage:
product_cache: "read_only" # Stale prices for offline lookups
sales_queue: "write_queue" # FIFO queue, unlimited entries
# Queue processing
queue:
order: "fifo" # Strict first-in-first-out
max_size: null # Unlimited (no artificial cap)
flush_trigger: "reconnection" # Real-time flush when API reachable
# Discrepancy handling (replaces conflict resolution)
discrepancy_handling:
price_changed: "flag_for_review" # Server applies current price, logs difference
item_out_of_stock: "flag_for_review" # Server records sale, flags for manager
strategy: "server_authoritative" # Server state always wins; discrepancies logged
# Operations ALLOWED offline (sales only)
allowed_offline:
- sale_new
- return_with_receipt
- price_check # From product_cache (stale warning shown)
- parked_sale_create
- parked_sale_retrieve
# Operations BLOCKED offline
blocked_offline:
- customer_create # Requires uniqueness check
- credit_limit_check # Requires real-time balance
- on_account_payment # Risk of exceeding limit
- multi_store_inventory # Requires network
- gift_card_activation # Must register immediately
- gift_card_reload # Risk of double-load
- gift_card_balance_check # Cached balance too risky
- gift_card_redemption # Balance could be stale
- transfer_request # Requires other store
- reservation_create # Requires other store
- inventory_adjustment # Requires server (see Section 4.15)
- receiving # Requires server (see Section 4.15)
1.16.3 Offline Sync Discrepancy Handling
When the sales_queue flushes on reconnection, the server applies current state and flags discrepancies for manager review. There is no conflict resolution engine – the server is authoritative.
| Discrepancy Type | Server Action | Manager Review |
|---|---|---|
| Price changed since sale | Apply current server price; log cached vs. server price difference | Review flagged transactions; decide if customer credit is warranted |
| Item out of stock | Record the sale; flag negative inventory | Review and approve negative balance, or adjust |
| Customer deleted | Reassign to “Walk-in Customer” | Informational only |
| Promotion expired | Apply current (non-promotional) price | Review flagged transactions |
| Transfer item sold at source | Flag as unavailable | Customer notification via TMPL-OFFLINE-SOLD |
| Ship-to-customer item sold at source | Flag as unavailable | Customer notification via TMPL-OFFLINE-SOLD |
| Reservation item sold at source | Flag as unavailable | Customer notification via TMPL-OFFLINE-SOLD |
1.16.4 Offline Availability Email Templates
When the system comes back online and discovers that a Transfer, Ship-to-Customer, or Reservation request cannot be fulfilled because the item was sold from the source location during the offline period, the system must automatically notify the customer.
Email Template: TMPL-OFFLINE-SOLD (Item Availability Change Notification)
| Field | Value |
|---|---|
| Template ID | TMPL-OFFLINE-SOLD |
| Trigger | Sync detects item sold from source location for Transfer, Ship-to-Customer, or Reserve requests |
| Recipient | Customer (email on file) |
| Subject | “Update Regarding Your Order - Action Required” |
| Content | Informs customer that the requested item is no longer available at the source location. Asks the customer to contact the store at their earliest convenience to discuss available options (alternative locations, backorder, refund, etc.) |
| Fallback | If no customer email on file, create staff alert for manual outreach |
Offline Availability Notification Rules:
offline_availability_notifications:
# Notify customer when item sold from source location
item_sold_at_source:
notify_customer: true
email_template: "TMPL-OFFLINE-SOLD"
fallback_if_no_email: "staff_alert"
# Staff alert appears in manager dashboard
staff_alert_priority: "high"
1.17 Tax Calculation Engine
Scope: Custom tax engine with jurisdiction support, hierarchy rules, and exemptions.
Cross-Reference: See Module 5, Section 5.9 for tax jurisdiction setup, compound rate configuration (State/County/City), and rate assignment per location.
sequenceDiagram
autonumber
participant UI as POS UI
participant API as Backend
participant TAX as Tax Engine
participant DB as DB
Note over UI, DB: Tax Calculation Flow
UI->>API: Calculate Tax for Cart
API->>TAX: Submit Cart + Store Location + Customer
TAX->>DB: Get Store Tax Jurisdiction
Note right of DB: Store in Virginia: State 4.3% + Local 1%
TAX->>DB: Get Customer Tax Status
Note right of DB: Customer: Regular (no exemption)
loop For Each Line Item
TAX->>DB: Get Product Tax Category
alt Product Override (e.g., Food)
TAX->>TAX: Apply Product Tax Rate (0% for groceries)
else Customer Exempt
TAX->>TAX: Apply 0% Tax
else Standard
TAX->>TAX: Apply Jurisdiction Rate (5.3%)
end
end
TAX-->>API: Return Tax Breakdown
API-->>UI: Display Tax Summary
Note right of UI: Tax Breakdown:
Note right of UI: State Tax (4.3%): $4.30
Note right of UI: Local Tax (1.0%): $1.00
Note right of UI: Total Tax: $5.30
1.17.1 Tax Hierarchy (Priority Order)
1. Product-Level Override (Highest Priority)
└── Example: "Grocery - Tax Exempt", "Prepared Food - 10%"
2. Customer-Level Exemption
└── Example: "Reseller Certificate", "Diplomatic Status", "Non-Profit"
3. Location-Based Tax (Default)
└── Based on store physical address
└── Includes: State + County + City + Special District
1.17.2 Virginia Tax Configuration
tax_jurisdictions:
virginia:
state_rate: 4.3
# Regional taxes (in addition to state)
regions:
hampton_roads:
counties: ["Norfolk", "Virginia Beach", "Newport News", "Hampton"]
additional_rate: 0.7
northern_virginia:
counties: ["Arlington", "Fairfax", "Loudoun", "Prince William"]
additional_rate: 0.7
central_virginia:
counties: ["Henrico", "Chesterfield", "Richmond City"]
additional_rate: 0.0
# Local tax (most localities)
default_local_rate: 1.0
# Product exemptions
exemptions:
- category: "grocery_food"
rate: 1.5 # Reduced rate for groceries
- category: "prescription_drugs"
rate: 0.0
- category: "medical_equipment"
rate: 0.0
tax_exemption_types:
- code: "RESALE"
description: "Reseller Certificate"
requires_certificate: true
certificate_expiry: true
- code: "NONPROFIT"
description: "501(c)(3) Non-Profit"
requires_certificate: true
certificate_expiry: true
- code: "DIPLOMAT"
description: "Diplomatic Exemption"
requires_certificate: true
certificate_expiry: false
- code: "NATIVE"
description: "Native American Tribal Member"
requires_certificate: true
certificate_expiry: false
1.17.3 Tax Engine Design for Expansion
Note: The Virginia compound tax jurisdiction model is the active reference implementation. See Section 5.9 for the
tax_jurisdictionsandtax_ratestables that implement the 3-level compound model (State + County + City).
jurisdiction_modules:
virginia:
status: "active" # Reference implementation
model: "compound_3_level" # State + County/Regional + City
sales_tax: true
use_tax: false
california:
status: "planned"
sales_tax: true
district_taxes: true # Complex district overlay
oregon:
status: "planned"
sales_tax: false # No sales tax state
canada:
status: "planned"
gst: true
pst: true # Varies by province
hst: true # Harmonized in some provinces
european_union:
status: "planned"
vat: true
reverse_charge: true # B2B cross-border
1.18 Payment Integration (SAQ-A)
Scope: Semi-integrated payment terminal architecture with PCI SAQ-A compliance.
Cross-Reference: Payment data storage rules, terminal communication protocol, processor configuration, and failure handling details have been consolidated into Module 6, Section 6.8. The payment flow sequence diagram below remains in this section as it is part of the core sales workflow.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Your Backend
participant TERM as Payment Terminal
participant PROC as Payment Processor
Note over U, PROC: Card Payment Flow (SAQ-A)
U->>UI: Click "Pay by Card"
UI->>API: POST /payments/initiate
Note right of API: {order_id, amount, terminal_id}
API->>TERM: Send Payment Request
Note right of TERM: Amount: $45.00
TERM-->>U: "Insert/Tap Card"
Note over TERM: Customer interacts with terminal only
U->>TERM: Customer taps card
TERM->>PROC: Encrypted Card Data
Note right of PROC: Card data NEVER touches your system
PROC-->>TERM: Authorization Response
TERM-->>API: Token + Approval Code + Masked Card
API->>API: Store Token (NOT card data)
Note right of API: Stored: token, approval_code, ****1234, VISA
API-->>UI: Payment Approved
UI-->>U: "Approved - $45.00"
Note over U, PROC: Refund Flow (Using Token)
U->>UI: Process Refund
UI->>API: POST /refunds/create
Note right of API: {order_id, amount, reason}
API->>API: Retrieve Stored Token
API->>PROC: Refund Request with Token
PROC-->>API: Refund Approved
API-->>UI: Refund Complete
1.18.1 Payment Data Storage Rules
payment_data:
# Data your system STORES
stored:
- transaction_id # Your internal ID
- payment_token # Processor token for refunds
- approval_code # Authorization code
- masked_card_number # Last 4 digits only (****1234)
- card_brand # Visa, Mastercard, Amex, Discover
- entry_method # chip, tap, swipe, manual
- terminal_id # Which terminal processed
- timestamp # When processed
- amount # Transaction amount
# Data your system NEVER stores (PCI prohibited)
prohibited:
- full_card_number # 16-digit PAN
- cvv_cvc # Security code
- track_data # Magnetic stripe data
- pin_block # Encrypted PIN
- emv_data # Chip cryptogram (raw)
1.18.2 Terminal Communication
terminal_integration:
protocol: "cloud_api" # Terminal vendor's cloud service
# Timeout settings
payment_timeout_seconds: 60
connection_timeout_seconds: 10
# Terminal states
states:
- IDLE: "Ready for transaction"
- WAITING_FOR_CARD: "Display amount, await tap/insert"
- PROCESSING: "Communicating with processor"
- APPROVED: "Transaction successful"
- DECLINED: "Transaction declined"
- ERROR: "Communication or hardware error"
- CANCELLED: "Customer or staff cancelled"
# Error handling
on_timeout: "prompt_retry_or_cancel"
on_decline: "display_reason_allow_retry"
on_error: "log_and_alert_manager"
# Void window (same-day before batch)
same_day_void: true
batch_close_time: "23:00" # Auto-batch at 11 PM
1.18.3 Payment Terminal Failure Handling
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant TERM as Payment Terminal
U->>UI: Click "Pay by Card"
UI->>API: POST /payments/initiate
API->>TERM: Send Payment Request
alt Terminal Timeout
TERM--xAPI: No Response (60s)
API-->>UI: "Terminal not responding"
UI-->>U: Options: Retry | Different Terminal | Cash | Cancel
else Terminal Declined
TERM-->>API: Declined (Insufficient Funds)
API-->>UI: "Card Declined: Insufficient Funds"
UI-->>U: Options: Try Another Card | Cash | Cancel
else Terminal Error
TERM-->>API: Error (Hardware Issue)
API-->>UI: "Terminal Error"
UI-->>U: Options: Different Terminal | Cash | Cancel
API->>API: Log Error, Alert Manager
end
1.18.4 Reports: Payment Integration
| Report | Purpose | Key Data Fields |
|---|---|---|
| Payment Terminal Performance | Monitor terminal health | Terminal ID, transaction count, avg response time, error rate, decline rate |
| Decline Rate Report | Track payment failures | Decline reason, frequency, terminal, time of day, retry success rate |
| Batch Settlement Report | Daily batch close summary | Batch date, transaction count, total amount, settlement status |
1.19 Sales User Stories (Epics)
Epic 1.A: Core Sales & Inventory
- Story 1.A.1 (Hybrid Entry): Staff can add items via Scanner (bulk array, max 50 tags) or Barcode (single SKU), with immediate stock validation.
- Story 1.A.2 (Parking): Staff can “Park” a sale (max 5 per terminal, 4-hour TTL) to serve another customer and “Retrieve” it later. Parked items soft-reserve inventory.
- Story 1.A.3 (Mixed Cart): A single transaction can contain both Sales (positive price) and Returns (negative price), calculating a Net Total.
- Story 1.A.4 (Inventory Checks): System validates stock > 0 before adding to cart. Returns trigger
INCREMENTstock event; Sales triggerDECREMENT. - Story 1.A.5 (Price Check Mode): Staff can scan items in “Price Check” mode to display price without adding to cart. Useful for customer inquiries.
Epic 1.B: Pricing & Promotion
- Story 1.B.1 (Smart Promos): The system alerts staff (“Upsell Opportunity”) when a cart is eligible for a promo (e.g., “Buy 2 Get 1 Free”).
- Story 1.B.2 (Granular Discounts): Line-item discounts apply before global discounts. Manager PIN is required for overrides above a certain %.
- Story 1.B.3 (Discount Stacking): System prevents invalid stacking (e.g., cannot use Promo Code on “Clearance” items).
- Story 1.B.4 (Price Tiers): Customers can be assigned price tiers (Retail, Wholesale, VIP, Employee) that apply different base prices before any discounts.
- Story 1.B.5 (Coupon System): System supports both single-use coupons (birthday, referral) and multi-use coupons (promotional codes). Single-use coupons are marked redeemed after use.
- Story 1.B.6 (Discount Order): Discounts apply in strict order: Price Tier → Line Discounts → Auto Promos → Global % → Coupons → Tax → Loyalty Redemptions. Loyalty redemptions apply after tax calculation.
Epic 1.C: Payments & Financials
- Story 1.C.1 (On-Account): Trusted customers can buy on credit. System validates
Current Debt + Pending Layaways + Cart <= Credit Limit. Paying off debt requires Cash/Card (prevents circular credit). - Story 1.C.2 (Layaway): Customers can reserve items with a partial deposit. Inventory is reserved immediately (Status:
RESERVED), but revenue is not fully recognized until completion. - Story 1.C.3 (Split Tender): Transactions support mixed payments (e.g., $20 Cash + Remaining on Card). Customers can use multiple credit cards and combine cash + card(s) in any combination. Each card’s token is stored separately for refund processing.
- Story 1.C.4 (Gift Cards): Staff can sell gift cards, reload existing cards, check balances, and accept gift cards as payment. Partial redemption is supported. Jurisdiction rules apply.
- Story 1.C.5 (Cash Drawer Management): Each shift requires opening float entry, blind cash counts at close, variance tracking, X-report (mid-shift) and Z-report (end-of-shift) generation. Variances outside tolerance require manager approval.
- Story 1.C.6 (Credit Card Payments - SAQ-A): Card payments use semi-integrated terminals. Card data never touches our system. Only tokens, approval codes, and masked card numbers are stored.
- Story 1.C.7 (Payment Failures): When terminal times out, declines, or errors, staff can retry, switch terminals, accept cash, or cancel. All failures are logged.
- Story 1.C.8 (Third-Party Financing - Affirm): Staff can offer Affirm as a payment option. Customer completes financing on their device. Store receives full payment from Affirm immediately. Customer repays Affirm per their loan terms.
- Story 1.C.9 (Multiple Payment Methods): Customers can split payment across multiple credit cards or combine cash + card(s). System tracks each card’s token separately for individual refund processing.
Epic 1.D: Post-Sale & Data
- Story 1.D.1 (Voiding): Voiding is only allowed same business day with drawer open. Voiding reverses inventory, loyalty, and commissions (full reversal). Record is flagged
VOIDED, not deleted. - Story 1.D.2 (Returns): Returns require staff to scan the receipt for system validation before processing. Commission is reversed proportionally based on returned value. Refunds use stored payment tokens or manual terminal processing (staff chooses).
- Story 1.D.3 (Receipts): Staff can choose between Thermal (standard), A4 (invoice), or Gift Receipts (hidden price) at print time.
- Story 1.D.4 (Receipt Reprint): Staff can reprint any historical receipt or email to a different address.
- Story 1.D.5 (History & Export): Managers can filter history by Date/User/Status and export to CSV (limit 1,000 rows for performance).
- Story 1.D.6 (Return Policy Engine): System enforces return and exchange policies that are manually configured in the application’s Settings/Setup module. Policies are per-tenant, per-store, and per-channel (online vs in-store). Example defaults: 30 days full refund with receipt, 31-90 days store credit, no receipt = store credit at current price with manager approval.
- Story 1.D.7 (Dedicated Exchanges): Staff can process exchanges as a single transaction showing item out, item in, and price difference. Exchange records link the original and new items. Commission adjusts for price difference.
Epic 1.E: Special Orders & Transfers
- Story 1.E.1 (Special Orders): Staff can create special orders for out-of-stock items. Customer pays deposit (minimum 50% or configurable). System tracks order status and notifies customer on arrival.
- Story 1.E.2 (Multi-Store Inventory): Staff can view inventory at all store locations (eventually consistent, max 5 min stale). Can request transfers or reserve items at other stores for customer pickup.
- Story 1.E.3 (Transfer/Reserve Payment): Transfers and reservations require customer to pay in full before the request is submitted to the system. This prevents unpaid phantom requests.
- Story 1.E.4 (Hold for Pickup): Fully paid orders can be held for customer pickup with a configurable time limit (default 7 days). System alerts staff when holds expire.
- Story 1.E.5 (Ship to Customer): Staff can ship items from other store locations directly to the customer’s address. System integrates with carrier APIs to calculate real-time shipping costs based on origin store and destination address. Customer pays item price + shipping in full before shipment is initiated. Tracking number is shared with customer via email.
Epic 1.F: Tracking & Commissions
- Story 1.F.1 (Serial Numbers): Designated products require serial number capture at sale. System validates serial hasn’t been previously sold. Serial numbers are searchable for warranty/return lookup.
- Story 1.F.2 (Sales Commissions): Each transaction records the employee ID. Commission amounts are calculated based on sale total and stored for reporting.
- Story 1.F.3 (Commission Reversal): Voided sales reverse commission fully. Returns reverse commission proportionally (returned value / original sale value).
- Story 1.F.4 (Commission Reports): Managers can view commission reports by date range and employee, showing total sales, returns, and net commission earned.
Epic 1.G: Loyalty Programs
- Story 1.G.1 (Points Program): Customers earn points per dollar spent. Points can be redeemed for discounts at configurable rates (e.g., 100 points = $1).
- Story 1.G.2 (Punch Cards): Digital punch cards track qualifying purchases (e.g., 10 coffees = 1 free). Punches are automatically applied based on product categories.
- Story 1.G.3 (Spend Thresholds): Customers earn rewards when spending thresholds are met (e.g., spend $100/month, get $10 off). Rewards can be auto-applied or saved.
- Story 1.G.4 (Loyalty Tiers): Customers automatically upgrade tiers (Bronze → Silver → Gold) based on spend. Higher tiers earn more points or get better rewards.
Epic 1.H: Offline & Resilience
BRD Amendment (v6.3.0): Updated per ADR-048 (Online-First with Offline Fallback).
- Story 1.H.1 (Connectivity Detection): System monitors API reachability via 3-state model (ONLINE / DEGRADED / OFFLINE) with clear visual indicator for each state.
- Story 1.H.2 (Offline Sales): Staff can process sales and returns with receipt while offline. Completed transactions are written to
sales_queue(SQLite, FIFO, unlimited). Product prices come from read-onlyproduct_cache. - Story 1.H.3 (Blocked Offline): System blocks risky operations offline: new customer creation, credit checks, gift card activation/reload/redemption, multi-store operations, inventory adjustments, and receiving.
- Story 1.H.4 (Auto Sync): When API becomes reachable, system automatically flushes
sales_queuein FIFO order. Server applies current prices and flags any discrepancies. - Story 1.H.5 (Discrepancy Review): Manager can review flagged discrepancies (price differences, negative inventory) on the dashboard and take corrective action. No blocking conflict resolution step.
Epic 1.I: Tax Calculation
- Story 1.I.1 (Jurisdiction Taxes): System calculates tax based on store physical address, applying state + county + city + district rates as applicable.
- Story 1.I.2 (Tax Hierarchy): Product-level tax overrides take priority over customer exemptions, which take priority over default store rates.
- Story 1.I.3 (Tax Exemptions): Customers with valid exemption certificates (resale, non-profit, diplomatic) can be flagged for tax-exempt purchases.
- Story 1.I.4 (Tax Display): Receipt shows tax breakdown by jurisdiction (State Tax: $X, Local Tax: $Y).
1.20 Sales Acceptance Criteria (Gherkin)
Feature: Gift Card Operations
Feature: Gift Card Management
As a retail staff member
I need to sell, reload, and redeem gift cards
So that customers can purchase and use store credit
Background:
Given I am logged into the POS system
And the cash drawer is open
Scenario: Sell a new gift card
Given I have a physical gift card with number "GC-000000001234"
And the gift card status is "INACTIVE"
When I scan the gift card
And I enter load amount "$50.00"
And the customer pays "$50.00"
Then the gift card status should be "ACTIVE"
And the gift card balance should be "$50.00"
And a receipt should print showing "Gift Card Activated: $50.00"
Scenario: Check gift card balance
Given a gift card "GC-000000001234" with balance "$35.50"
When I click "Gift Card Balance"
And I scan the gift card
Then the display should show "Balance: $35.50"
And the display should show expiry info based on jurisdiction
Scenario: Partial redemption
Given a gift card "GC-000000001234" with balance "$50.00"
And a cart total of "$30.00"
When I apply the gift card as payment
Then the gift card balance should become "$20.00"
And the order should be marked "PAID"
And the gift card status should remain "ACTIVE"
Scenario: Full redemption depletes card
Given a gift card "GC-000000001234" with balance "$25.00"
And a cart total of "$25.00"
When I apply the gift card as payment
Then the gift card balance should become "$0.00"
And the gift card status should be "DEPLETED"
Scenario: Insufficient balance prompts partial
Given a gift card "GC-000000001234" with balance "$20.00"
And a cart total of "$50.00"
When I apply the gift card as payment
Then the system should display "Card Balance: $20.00. Apply partial?"
When I confirm partial application
Then "$20.00" should be applied from the gift card
And remaining balance should show "$30.00"
Scenario: Reload existing gift card
Given a gift card "GC-000000001234" with balance "$10.00"
When I scan the gift card
And I select "Reload"
And I enter reload amount "$25.00"
And the customer pays "$25.00"
Then the gift card balance should be "$35.00"
Scenario: Cash out in California
Given the store is located in California
And a gift card "GC-000000001234" with balance "$8.50"
When I scan the gift card
Then the system should show "Eligible for Cash Out: $8.50"
When I process cash out
Then the gift card balance should be "$0.00"
And "$8.50" cash should be given to customer
Feature: Exchange Transactions
Feature: Dedicated Exchange Flow
As a retail staff member
I need to process exchanges efficiently
So that customers can swap items in a single transaction
Background:
Given I am logged into the POS system
And order "ORD-001" exists with item "Blue Shirt" at "$40.00"
And the original sale had commission "$2.00"
Scenario: Even exchange - same price items
Given I load order "ORD-001" for exchange
When I select "Blue Shirt" to exchange OUT
And I scan "Red Shirt" priced at "$40.00" to exchange IN
Then the price difference should show "$0.00"
And I should see "Even Exchange - No Payment Required"
When I complete the exchange
Then a new exchange record should link "ORD-001" to the new order
And inventory for "Blue Shirt" should INCREMENT by 1
And inventory for "Red Shirt" should DECREMENT by 1
And commission should remain unchanged
Scenario: Customer owes money - upgrade
Given I load order "ORD-001" for exchange
When I select "Blue Shirt" ($40.00) to exchange OUT
And I scan "Premium Jacket" priced at "$80.00" to exchange IN
Then the price difference should show "Customer Owes: $40.00"
When the customer pays "$40.00"
And I complete the exchange
Then the exchange should be recorded successfully
And additional commission should be recorded for the $40.00 difference
Scenario: Store owes refund - downgrade
Given I load order "ORD-001" for exchange
When I select "Blue Shirt" ($40.00) to exchange OUT
And I scan "Basic Tee" priced at "$25.00" to exchange IN
Then the price difference should show "Refund to Customer: $15.00"
When I process the refund
And I complete the exchange
Then "$15.00" should be refunded to original payment method
And commission should be reduced proportionally
Feature: Multi-Store Transfer (Full Payment Required)
Feature: Multi-Store Inventory Transfer
As a retail staff member
I need to request transfers from other stores
So that customers can get items not available locally
Background:
Given I am logged into POS at "Store A"
And "Store B" has 5 units of "SKU-12345"
And "Store A" has 0 units of "SKU-12345"
Scenario: Transfer request requires full payment
Given a customer wants "SKU-12345" priced at "$75.00"
When I search for "SKU-12345"
And I click "Check Other Stores"
Then I should see "Store B: 5 units"
And I should see "Last updated: X minutes ago"
When I click "Request Transfer" from "Store B"
Then I should see "Customer must pay in full to process"
When the customer pays "$75.00"
Then a transfer record should be created with status "PAID"
And "Store B" should receive a transfer notification
And I should print a transfer receipt for the customer
Scenario: Transfer blocked without payment
Given a customer wants "SKU-12345" priced at "$75.00"
When I click "Request Transfer" from "Store B"
And the customer declines to pay
Then no transfer record should be created
And "Store B" inventory should remain unchanged
Scenario: Reservation at other store requires full payment
Given a customer wants to pick up "SKU-12345" at "Store B"
When I click "Reserve at Store B"
Then I should see "Customer must pay in full to reserve"
When the customer pays "$75.00"
Then a reservation should be created at "Store B"
And the customer should receive a pickup voucher
And the reservation should expire in 7 days
Feature: Return Policy Engine
Feature: Return Policy Enforcement
As a retail staff member
I need the system to enforce return policies
So that returns are handled consistently
Scenario: Full refund within 30 days with receipt
Given order "ORD-001" was placed 15 days ago
And the customer has the receipt
When I scan the receipt
And I select items to return
Then the system should show "Full Refund Eligible"
And the refund should go to the original payment method
And commission should be reversed proportionally
Scenario: Store credit for 31-90 days
Given order "ORD-001" was placed 45 days ago
And the customer has the receipt
When I scan the receipt
And I select items to return
Then the system should show "Store Credit Only"
And I should issue store credit for the return amount
Scenario: Manager override required beyond 90 days
Given order "ORD-001" was placed 120 days ago
And the customer has the receipt
When I scan the receipt
And I select items to return
Then the system should show "Manager Approval Required"
When a manager enters their PIN
And selects exception reason "Customer Goodwill"
Then the return should proceed
Scenario: No receipt - store credit at current price
Given a customer wants to return "Blue Shirt"
And the customer has no receipt
And "Blue Shirt" current price is "$35.00"
When I scan the item barcode
Then the system should show "No Receipt - Store Credit at Current Price"
And the system should show "Manager Approval Required"
When a manager approves
Then store credit for "$35.00" should be issued
Scenario: Final sale items blocked
Given order "ORD-001" contains item "Clearance Item" marked as "Final Sale"
When I attempt to return "Clearance Item"
Then the system should show "BLOCKED: Final Sale - No Returns"
And the return should not proceed
Scenario: Restocking fee for opened electronics
Given order "ORD-001" contains "Headphones" in category "Electronics"
And the item has been opened
When I process the return
Then the system should show "Restocking Fee: 15%"
And the refund should be reduced by 15%
Feature: Void vs Return Distinction
Feature: Void vs Return Rules
As a retail staff member
I need clear rules for when to void vs return
So that corrections are handled properly
Scenario: Void allowed same day with drawer open
Given order "ORD-001" was completed today
And the cash drawer is still open
When I select order "ORD-001"
And I click "Void"
Then the void should be allowed
And inventory should be reversed immediately
And commission should be fully reversed
And the order status should be "VOIDED"
Scenario: Void blocked after drawer close
Given order "ORD-001" was completed today
And the cash drawer has been closed
When I select order "ORD-001"
And I click "Void"
Then the system should show "Cannot void - drawer closed. Use Return instead."
Scenario: Void blocked next day
Given order "ORD-001" was completed yesterday
When I select order "ORD-001"
And I click "Void"
Then the system should show "Cannot void - different business day. Use Return instead."
Scenario: Return uses proportional commission reversal
Given order "ORD-001" has 3 items totaling "$120.00"
And commission was "$6.00" (5%)
When I return 1 item worth "$40.00"
Then commission should be reduced by "$2.00" (40/120 × $6)
And net commission should be "$4.00"
Feature: Special Orders
Feature: Special Order Management
As a retail staff member
I need to create special orders for out-of-stock items
So that customers can order items not currently available
Scenario: Create special order with deposit
Given "SKU-99999" is out of stock
And it is available for special order at "$100.00"
When I create a special order for customer "John Doe"
And I enter quantity "1"
Then the system should calculate deposit as "$50.00" (50%)
When the customer pays "$50.00" deposit
Then special order "SO-12345" should be created
And the status should be "DEPOSIT_PAID"
And the purchasing team should be notified
Scenario: Complete special order on arrival
Given special order "SO-12345" exists with deposit "$50.00"
And the remaining balance is "$50.00"
When the item arrives and status becomes "READY_FOR_PICKUP"
Then customer "John Doe" should receive a notification
When the customer arrives and pays "$50.00"
Then the special order status should be "COMPLETED"
Feature: Cash Drawer Management
Feature: Cash Drawer Operations
As a retail staff member
I need to manage the cash drawer
So that cash is tracked accurately
Scenario: Open drawer with float
Given the cash drawer is closed
When a manager opens the drawer
And enters opening float "$200.00"
Then the drawer session should start
And the drawer status should be "OPEN"
Scenario: Close drawer with balanced count
Given the drawer is open with float "$200.00"
And cash sales today total "$350.00"
And cash refunds today total "$50.00"
When I click "Close Drawer"
And I perform blind count entering "$500.00"
Then expected amount should be "$500.00"
And variance should be "$0.00"
And the system should show "Drawer Balanced"
And a Z-report should print
Scenario: Variance requires manager approval
Given the drawer is open with float "$200.00"
And expected cash is "$500.00"
When I perform blind count entering "$493.00"
Then variance should be "-$7.00"
And the system should show "Variance: -$7.00 - Manager Approval Required"
When a manager enters PIN and reason "Counting Error"
Then the drawer should close
And the variance should be logged
Scenario: Variance within tolerance auto-approves
Given variance tolerance is set to "$5.00"
And expected cash is "$500.00"
When I perform blind count entering "$497.00"
Then variance should be "-$3.00"
And the system should show "Drawer Balanced" (within tolerance)
Scenario: Run X-Report mid-shift
Given the drawer is open with float "$200.00"
And cash sales so far total "$150.00"
And cash refunds so far total "$20.00"
When I click "X-Report"
Then the X-Report should show opening float "$200.00"
And the X-Report should show cash sales "$150.00"
And the X-Report should show cash refunds "$20.00"
And the X-Report should show expected cash "$330.00"
And the drawer should remain OPEN
And I should be able to continue processing transactions
Scenario: Run multiple X-Reports in one shift
Given the drawer is open
When I run an X-Report at 10:00 AM
And I process more sales
And I run another X-Report at 2:00 PM
Then both X-Reports should complete successfully
And the second X-Report should reflect updated totals
And the drawer should remain OPEN
Feature: Coupon System
Feature: Coupon Redemption
As a retail staff member
I need to apply coupons to transactions
So that customers receive their discounts
Scenario: Apply single-use birthday coupon
Given coupon "BDAY-JOHN-2026" exists
And it is a single-use coupon for "$10 off"
And it has not been redeemed
When I scan the coupon
Then "$10.00" discount should apply
When I complete the sale
Then the coupon status should be "REDEEMED"
Scenario: Reject already-used single-use coupon
Given coupon "BDAY-JOHN-2026" has been redeemed
When I scan the coupon
Then the system should show "Coupon Already Redeemed"
And no discount should apply
Scenario: Apply multi-use promotional coupon
Given coupon "SAVE10" exists
And it is a multi-use coupon for "10% off"
And it has been used 50 times (limit 1000)
When I scan the coupon
Then "10% off" discount should apply
When I complete the sale
Then the coupon usage count should be 51
Scenario: Reject expired coupon
Given coupon "SUMMER2025" expired on "2025-08-31"
When I scan the coupon
Then the system should show "Coupon Expired"
And no discount should apply
Feature: Loyalty Programs
Feature: Flexible Loyalty Programs
As a retail staff member
I need to manage customer loyalty
So that customers are rewarded for purchases
Scenario: Earn points per dollar
Given customer "Jane Doe" is attached to the sale
And the loyalty program awards 1 point per $1
And the cart total is "$45.00"
When I complete the sale
Then "Jane Doe" should earn 45 points
And the receipt should show "Points Earned: 45"
Scenario: Redeem points for discount
Given customer "Jane Doe" has 500 points
And redemption rate is 100 points = $1
When I click "Redeem Points"
And I redeem 500 points
Then "$5.00" discount should apply
And "Jane Doe" points should become 0
Scenario: Punch card completion
Given customer "Jane Doe" has a coffee punch card
And she has 9 of 10 punches
And the cart contains 1 coffee
When I complete the sale
Then the punch card should show "FREE ITEM EARNED!"
And the 10th coffee should be free
And a new punch card should start
Scenario: Tier upgrade on spend threshold
Given customer "Jane Doe" is "BRONZE" tier
And she has spent "$950" this year
And the cart total is "$100"
When I complete the sale
Then "Jane Doe" should be upgraded to "SILVER"
And the system should show "Customer upgraded to Silver!"
And she should now earn 1.5x points
Feature: Offline Operations
Feature: Offline Mode Operations
As a retail staff member
I need to continue serving customers when network is down
So that business is not interrupted
Background:
Given I am logged into the POS system
And the cash drawer is open
Scenario: Detect offline and show indicator
Given the network connection is lost
Then the UI should show "OFFLINE MODE" indicator
And the indicator should be prominently visible
Scenario: Process sale while offline
Given I am in offline mode
When I scan items and complete a sale
Then the transaction should be queued locally
And the receipt should print with "OFFLINE" watermark
And I should see "1 transaction pending sync"
Scenario: Block risky operations offline
Given I am in offline mode
When I try to create a new customer
Then the system should show "Not available offline"
When I try to activate a gift card
Then the system should show "Not available offline"
When I try to check multi-store inventory
Then the system should show "Not available offline"
Scenario: Auto-sync when network restores
Given I am in offline mode
And I have 3 queued transactions
When the network connection is restored
Then the UI should show "SYNCING..."
And transactions should sync automatically
When sync completes
Then the UI should show "All transactions synced"
Scenario: Handle sync conflict
Given I am in offline mode
And I sold the last unit of "SKU-123"
When the network restores
And another store also sold the last unit
Then the system should show "Conflict: SKU-123 out of stock"
And I should be prompted for manager review
When the manager approves backorder
Then the transaction should complete with backorder status
Feature: Payment Integration
Feature: SAQ-A Payment Processing
As a retail staff member
I need to process card payments securely
So that customer card data is protected
Scenario: Successful card payment
Given a cart total of "$45.00"
When I click "Pay by Card"
Then the terminal should display "$45.00"
When the customer taps their card
And the payment is approved
Then I should see "Approved - $45.00"
And the receipt should show "VISA ****1234"
And no full card number should be stored
Scenario: Card declined - insufficient funds
Given a cart total of "$200.00"
When I click "Pay by Card"
And the customer's card is declined for insufficient funds
Then I should see "Card Declined: Insufficient Funds"
And I should have options: "Try Another Card" | "Cash" | "Cancel"
Scenario: Terminal timeout
Given a cart total of "$45.00"
When I click "Pay by Card"
And the terminal does not respond within 60 seconds
Then I should see "Terminal not responding"
And I should have options: "Retry" | "Different Terminal" | "Cash" | "Cancel"
Scenario: Refund using stored token
Given order "ORD-001" was paid by card
And the payment token is stored
When I process a refund for "ORD-001"
Then the refund should use the stored token
And I should not need to re-enter card details
And the receipt should show "Refund to VISA ****1234"
Scenario: Pay with Affirm financing
Given a cart total of "$500.00"
When I click "Pay with Affirm"
Then a QR code or redirect should display for the customer
When the customer completes Affirm application on their device
And Affirm approves the loan
Then I should see "Payment Approved (Affirm)"
And the full $500.00 should be received from Affirm
And no card data should be stored
And an Affirm charge_id should be stored
Scenario: Split payment across two credit cards
Given a cart total of "$200.00"
When I click "Pay by Card"
And the customer pays "$100.00" with first card
Then remaining balance should show "$100.00"
When I click "Pay by Card" again
And the customer pays "$100.00" with second card
Then the order should be marked "PAID"
And two separate payment tokens should be stored
Scenario: Combine cash and card payment
Given a cart total of "$150.00"
When I accept "$50.00" cash
Then remaining balance should show "$100.00"
When the customer pays "$100.00" by card
Then the order should be marked "PAID"
Feature: Tax Calculation
Feature: Tax Calculation Engine
As a retail staff member
I need accurate tax calculation
So that the correct tax is collected
Scenario: Standard Virginia tax
Given the store is located in Richmond, Virginia
And the cart contains taxable items totaling "$100.00"
When I calculate tax
Then state tax should be "$4.30" (4.3%)
And local tax should be "$1.00" (1.0%)
And total tax should be "$5.30"
Scenario: Northern Virginia regional tax
Given the store is located in Fairfax, Virginia
And the cart contains taxable items totaling "$100.00"
When I calculate tax
Then state tax should be "$4.30" (4.3%)
And local tax should be "$1.00" (1.0%)
And regional tax should be "$0.70" (0.7%)
And total tax should be "$6.00"
Scenario: Product-level tax override
Given the cart contains "Grocery Item" in tax category "grocery_food"
And "Grocery Item" price is "$20.00"
When I calculate tax
Then tax on "Grocery Item" should be "$0.30" (1.5% reduced rate)
Scenario: Customer tax exemption
Given customer "ABC Nonprofit" has tax exemption status "NONPROFIT"
And the cart contains taxable items totaling "$100.00"
When I attach customer "ABC Nonprofit" to the sale
And I calculate tax
Then total tax should be "$0.00"
And the receipt should show "Tax Exempt: 501(c)(3)"
Scenario: Tax hierarchy - product override takes priority
Given customer "ABC Nonprofit" has tax exemption status
And the cart contains "Prepared Food" (10% tax rate)
When I calculate tax
Then the product-level 10% rate should apply
Because product-level overrides take priority over customer exemption
2. Customers Module
2.1 Customer Management Workflow
Scope: Creating Profiles, Merging Duplicates, Tax Logic, Groups, and Deletion Guards.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Phase 1: Creation & Maintenance
U->>UI: Search Customer (Name / Phone / Email)
UI->>API: GET /customers/search
alt Customer Found
API-->>UI: Return Profile
UI-->>U: Display Customer Details + Group + Price Tier
else Create New
U->>UI: Enter Details (Name, Phone, Email)
U->>UI: Enter Physical Address (Shipping)
U->>UI: Enter Billing Address (if different)
opt Customer Group Assignment
U->>UI: Select Group (Retail/Wholesale/VIP/Staff)
Note right of UI: Group determines Price Tier
end
opt Tax Assignment
U->>UI: Select Custom Tax Rate (e.g., "Tax Exempt")
Note right of UI: Overrides Default Store Tax
end
opt Communication Preferences
U->>UI: Set Email Opt-In (Y/N)
U->>UI: Set SMS Opt-In (Y/N)
U->>UI: Set Preferred Contact Method
end
opt Customer Notes
U->>UI: Enter Size Preferences
U->>UI: Enter Free-Form Notes
end
UI->>API: POST /customers/create
API->>DB: Insert Record
end
2.2 Customer Groups & Tiers
Scope: Automatic and manual group assignment with price tier implications.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Customer Group Management
alt Manual Group Assignment
U->>UI: Open Customer Profile
U->>UI: Click "Change Group"
U->>UI: Select Group (Retail/Wholesale/VIP/Staff)
UI->>API: PATCH /customers/{id}/group
API->>DB: Update Customer Group
API->>DB: Update Price Tier (Based on Group)
API-->>UI: Group Updated
end
Note over API, DB: Automatic Tier Upgrades (Background Job)
API->>DB: Check Customer Spend Totals
alt Spend >= Gold Threshold ($5,000/year)
API->>DB: Upgrade to Gold Tier
API-->>UI: Notify: "Customer upgraded to Gold!"
else Spend >= Silver Threshold ($1,000/year)
API->>DB: Upgrade to Silver Tier
end
Note right of DB: Tier Benefits:
Note right of DB: Bronze: 1x points
Note right of DB: Silver: 1.5x points + 5% discount
Note right of DB: Gold: 2x points + 10% discount
2.3 Customer Notes & Preferences
Scope: Structured fields and free-form notes for customer preferences.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
U->>UI: Open Customer Profile
U->>UI: Click "Notes & Preferences"
Note over UI, DB: Structured Preferences
U->>UI: Enter Clothing Size (S/M/L/XL)
U->>UI: Enter Shoe Size
U->>UI: Select Color Preferences
U->>UI: Enter Brand Preferences
Note over UI, DB: Free-Form Notes
U->>UI: Enter Notes (e.g., "Prefers classic styles, allergic to wool")
UI->>API: PATCH /customers/{id}/preferences
API->>DB: Update Preference Fields
API-->>UI: Saved
Note over U, UI: Notes Display at POS
U->>UI: Attach Customer to Sale
UI->>API: GET /customers/{id}
API-->>UI: Return Profile with Notes
UI-->>U: Display: "Notes: Prefers classic styles, Size M"
2.4 Communication Preferences
Scope: Marketing consent, contact preferences, and opt-out management.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Communication Preference Management
U->>UI: Open Customer Profile -> Communications
UI-->>U: Display Current Settings:
Note right of UI: Email Marketing: [ON/OFF]
Note right of UI: SMS Marketing: [ON/OFF]
Note right of UI: Preferred Contact: [Email/Phone/SMS]
Note right of UI: Do Not Contact: [Y/N]
U->>UI: Toggle Email Marketing ON
U->>UI: Toggle SMS Marketing OFF
U->>UI: Set Preferred: Email
UI->>API: PATCH /customers/{id}/communication-prefs
API->>DB: Update Communication Preferences
API->>DB: Log Consent Change (Audit Trail)
API-->>UI: Preferences Saved
Note over API, DB: Privacy Compliance
Note right of DB: All consent changes logged with timestamp
Note right of DB: Customer can request full data export
Note right of DB: "Do Not Contact" blocks all outreach
2.5 Advanced Customer Actions
Scope: Merge duplicates, safe deletion, data export, and privacy compliance.
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Phase 2: Advanced Actions (Merge & Delete)
alt Action: Merge Duplicates
U->>UI: Select "Source" (Duplicate) & "Target" (Primary)
U->>UI: Click "Merge Customers"
UI->>API: POST /customers/merge
par Data Transfer
API->>DB: Move History, Loyalty, Balance to Target
API->>DB: Merge Notes (Append Source to Target)
API->>DB: Keep Higher Tier
API->>DB: Soft-Delete "Source" Profile
end
API-->>UI: Merge Success
end
alt Action: Delete Customer
U->>UI: Click "Delete Customer"
UI->>API: GET /customers/{id}/balance-check
alt Has Debt or Open Layaway
API-->>UI: Error: "Cannot Delete - Outstanding Balance"
UI-->>U: Alert: "Settle Balance First"
else Safe to Delete
UI->>API: DELETE /customers/{id}
API->>DB: Anonymize Personal Data
API->>DB: Retain Sales History (Linked to "Anonymous")
API-->>UI: Deletion Success
end
end
alt Action: Export Data
U->>UI: Filter List -> Click "Export CSV"
UI->>API: POST /customers/export
Note right of UI: Limit 1000 rows
API-->>UI: Download CSV File
end
alt Action: Data Subject Request (Privacy)
U->>UI: Click "Privacy Request"
U->>UI: Select Type: Export / Delete / Restrict
UI->>API: POST /customers/{id}/privacy-request
API->>DB: Log Request with Timestamp
API-->>UI: Request ID Generated
Note right of API: Must complete within 30 days
end
2.6 Customer Self-Service
Scope: Customer-facing loyalty balance and preference management.
sequenceDiagram
autonumber
participant C as Customer
participant UI as Self-Service Kiosk / App
participant API as Backend
participant DB as DB
Note over C, DB: Customer Self-Service Options
alt Check Loyalty Balance
C->>UI: Enter Phone Number or Scan Card
UI->>API: GET /customers/lookup?phone={phone}
API->>DB: Find Customer
API-->>UI: Return Loyalty Summary
UI-->>C: Display: "Points: 450 | Tier: Silver | $4.50 available"
end
alt Update Preferences
C->>UI: Login (Phone + PIN)
UI->>API: GET /customers/{id}/preferences
API-->>UI: Return Current Preferences
C->>UI: Update Email / SMS Opt-In
UI->>API: PATCH /customers/{id}/communication-prefs
API->>DB: Update & Log Consent Change
API-->>UI: Saved
end
alt Request Data Export
C->>UI: Click "Download My Data"
UI->>API: POST /customers/{id}/data-export
API->>DB: Queue Export Job
API-->>UI: "Export will be emailed within 24 hours"
end
2.6.1 Reports: Customer Module
| Report | Purpose | Key Data Fields |
|---|---|---|
| Customer Activity Report | Track customer engagement | Customer, last purchase date, total spend, visit frequency, loyalty tier |
| New Customer Report | Monitor customer acquisition | New customers by period, acquisition source, first purchase value |
| Customer Merge Audit Log | Track merge operations | Source ID, target ID, merge date, merged by, data transferred |
| Customer Group Distribution | Breakdown by group/tier | Group, customer count, avg spend, revenue contribution |
| Inactive Customer Report | Identify disengaged customers | Customer, last activity, days inactive, lifetime value, tier |
Email Template: TMPL-WELCOME
| Field | Value |
|---|---|
| Trigger | New customer profile created |
| Recipient | Customer (if email provided and opt-in) |
| Content | Welcome message, loyalty program details, store locations |
Email Template: TMPL-TIER-UPGRADE
| Field | Value |
|---|---|
| Trigger | Customer tier upgraded (e.g., Bronze → Silver) |
| Recipient | Customer |
| Content | New tier name, benefits unlocked, points multiplier, discount percentage |
2.7 Customer User Stories (Epics)
Epic 2.A: Profile & Data Management
- Story 2.A.1 (Detailed Profile): Staff can store distinct “Shipping” (Physical) and “Billing” addresses for a customer to support delivery and invoicing.
- Story 2.A.2 (Duplicate Handling / Merge): Managers can merge two customer profiles. The system must transfer all Sales History, Loyalty Points, Account Balances, and Notes to the “Primary” profile and archive the “Duplicate”.
- Story 2.A.3 (Safe Deletion): The system must block deletion if the customer has an active “On Account” debt or an open “Layaway”. If deleted, their historical sales must remain but be anonymized.
- Story 2.A.4 (Export): Staff can export the customer list to CSV for marketing, limited to 1,000 rows per batch for system stability.
- Story 2.A.5 (Customer Notes): Staff can record structured preferences (size, color, brand) and free-form notes on customer profiles. Notes are displayed when customer is attached to a sale.
Epic 2.B: Financial & Tax Settings
- Story 2.B.1 (Custom Tax Rates): Managers can assign a specific tax exemption status (e.g., “Reseller”, “Non-Profit”) to a customer profile. This status overrides the Store Default Tax when the hierarchy allows.
- Story 2.B.2 (Credit Limits): Managers can set a hard “Credit Limit”. The POS must block any “On Account” sale that pushes (Current Debt + Pending Layaways + Cart) over this limit.
- Story 2.B.3 (Loyalty Adjustments): Managers can manually adjust loyalty points (e.g., +50 “Sorry for wait”). A mandatory reason note is required for audit trails.
Epic 2.C: Customer Groups & Tiers
- Story 2.C.1 (Customer Groups): Customers can be assigned to groups (Retail, Wholesale, VIP, Staff). Each group maps to a price tier that determines base pricing.
- Story 2.C.2 (Automatic Tier Upgrades): Customers automatically upgrade tiers (Bronze → Silver → Gold) when spend thresholds are met. Upgrades can also be manually assigned by managers.
- Story 2.C.3 (Tier Benefits): Each tier has configurable benefits: point multipliers, automatic discounts, and early access to sales.
- Story 2.C.4 (Price Tiers): Different customer groups see different base prices (not just discounts). Wholesale customers may see cost+markup pricing while retail sees standard pricing.
Epic 2.D: Communication & Preferences
- Story 2.D.1 (Communication Consent): Customers can opt-in or opt-out of email and SMS marketing. All consent changes are logged for compliance.
- Story 2.D.2 (Preferred Contact): Customers can specify their preferred contact method (Email, Phone, SMS). Staff can see this preference when reaching out.
- Story 2.D.3 (Do Not Contact): Customers can be flagged as “Do Not Contact” which blocks all marketing outreach while preserving transaction notifications.
Epic 2.E: Privacy & Compliance
- Story 2.E.1 (Data Export): Customers can request a full export of their personal data. Export must be provided within 30 days.
- Story 2.E.2 (Right to Deletion): Customers can request deletion of their personal data. System anonymizes records while preserving transaction history for accounting.
- Story 2.E.3 (Consent Audit Trail): All marketing consent changes are logged with timestamp and source for regulatory compliance.
- Story 2.E.4 (Data Retention): Customer data is retained according to configurable retention policies. Inactive customers can be auto-anonymized after retention period.
Epic 2.F: Self-Service
- Story 2.F.1 (Loyalty Balance Check): Customers can check their loyalty points balance via kiosk, app, or website using phone number lookup.
- Story 2.F.2 (Preference Update): Customers can update their communication preferences (opt-in/opt-out) via self-service channels.
- Story 2.F.3 (Data Request): Customers can submit data export or deletion requests via self-service, which are queued for staff processing.
2.8 Customer Acceptance Criteria (Gherkin)
Feature: Customer Groups and Tiers
Feature: Customer Group Management
As a retail manager
I need to assign customers to groups
So that they receive appropriate pricing and benefits
Scenario: Assign customer to wholesale group
Given customer "ABC Corp" exists
When I open the customer profile
And I click "Change Group"
And I select "Wholesale"
Then the customer group should be "Wholesale"
And the price tier should update to "Wholesale Pricing"
Scenario: Automatic tier upgrade on spend
Given customer "Jane Doe" is "BRONZE" tier
And she has spent "$4,900" this year
And annual spend threshold for Silver is "$1,000"
And annual spend threshold for Gold is "$5,000"
When she makes a purchase of "$150"
Then her annual spend becomes "$5,050"
And she should be upgraded to "GOLD" tier
And she should now earn 2x points
And she should receive 10% automatic discount
Feature: Customer Merge
Feature: Merge Duplicate Customers
As a retail manager
I need to merge duplicate customer profiles
So that customer data is consolidated
Scenario: Merge two customer profiles
Given customer "John Doe" (ID: 100) has:
| Loyalty Points | 500 |
| Account Balance | $50.00 |
| Tier | Silver |
And customer "J. Doe" (ID: 101) has:
| Loyalty Points | 200 |
| Account Balance | $25.00 |
| Tier | Bronze |
When I select "J. Doe" as source and "John Doe" as target
And I click "Merge Customers"
Then "John Doe" should have:
| Loyalty Points | 700 |
| Account Balance | $75.00 |
| Tier | Silver |
And "J. Doe" profile should be archived
And sales history from both should be under "John Doe"
Feature: Safe Customer Deletion
Feature: Customer Deletion Guards
As a retail manager
I need deletion to be blocked when unsafe
So that we don't lose important data
Scenario: Block deletion with outstanding debt
Given customer "John Doe" has account balance "$150.00"
When I click "Delete Customer"
Then the system should show "Cannot Delete - Outstanding Balance"
And the deletion should be blocked
Scenario: Block deletion with open layaway
Given customer "John Doe" has an active layaway order
When I click "Delete Customer"
Then the system should show "Cannot Delete - Open Layaway"
And the deletion should be blocked
Scenario: Safe deletion anonymizes data
Given customer "John Doe" has no debt or layaway
And "John Doe" has 5 historical orders
When I click "Delete Customer"
And I confirm the deletion
Then personal data should be anonymized
And the 5 orders should remain linked to "Anonymous Customer"
Feature: Communication Preferences
Feature: Communication Preference Management
As a retail staff member
I need to manage customer communication preferences
So that we comply with privacy regulations
Scenario: Update email opt-in
Given customer "Jane Doe" has email marketing OFF
When I open communications preferences
And I toggle email marketing ON
Then email marketing should be ON
And a consent change should be logged with timestamp
Scenario: Do Not Contact blocks outreach
Given customer "John Doe" is flagged "Do Not Contact"
When the marketing system attempts to send email
Then the email should be blocked
And no marketing should be sent
Scenario: Transaction notifications still sent
Given customer "John Doe" is flagged "Do Not Contact"
When he makes a purchase
Then a receipt email should still be sent
Because transaction notifications are not marketing
Feature: Privacy Compliance
Feature: Customer Privacy Rights
As a customer
I need to exercise my privacy rights
So that my data is protected
Scenario: Request data export
Given I am customer "Jane Doe"
When I request a data export
Then a request should be logged
And I should receive confirmation
And my data should be emailed within 30 days
Scenario: Request data deletion
Given I am customer "Jane Doe"
And I have no outstanding balance or layaway
When I request data deletion
Then my personal data should be anonymized
And my transaction history should be preserved (anonymized)
And I should receive confirmation
Scenario: Deletion blocked with balance
Given I am customer "Jane Doe"
And I have account balance "$50.00"
When I request data deletion
Then the system should show "Please settle outstanding balance first"
And my data should not be deleted
Feature: Customer Self-Service
Feature: Customer Self-Service
As a customer
I want to check my loyalty status
So that I know my rewards
Scenario: Check loyalty balance
Given I am customer with phone "555-1234"
And I have 450 loyalty points
And I am Silver tier
When I enter my phone number at the kiosk
Then I should see "Points: 450"
And I should see "Tier: Silver"
And I should see "$4.50 available for redemption"
Scenario: Update marketing preferences
Given I am logged into self-service
And email marketing is ON
When I toggle email marketing OFF
Then email marketing should be OFF
And a consent change should be logged
3. Catalog Module
3.1 Product Types & Data Model
Scope: Core POS Catalog – all product entries, types, data fields, and the relationships between them. Every item sold, bundled, or serviced flows through the catalog. The data model supports four distinct product types that cover the full range of retail merchandise: individually tracked goods, multi-dimension variants, composite bundles, and non-inventory services. Beyond the standard field set, the model supports tenant-defined custom attributes, product templates for rapid creation, and a matrix management interface for efficient variant operations.
Cross-Reference: See Module 5, Section 5.10 for UoM configuration and Section 5.12 for custom fields.
3.1.1 Product Types
| Type | Description | Inventory Tracked | Example |
|---|---|---|---|
| Standard | Single product, one SKU, one price | Yes | A branded t-shirt, a candle, a phone case |
| Variant (Parent + Children) | Parent product with up to 3 variant dimensions (e.g., Size, Color, Material). Each combination creates a child SKU with independent inventory and optional price overrides | Yes (per child) | “Classic Oxford Shirt” parent with children: S/Blue, M/Blue, L/White, etc. |
| Composite / Bundle | A kit of component products sold together. Bundle price is set independently – it does NOT need to equal the sum of component prices | Yes (per component, decremented on sale) | “Gift Set” = Candle + Soap + Box for $39.99 (components total $48 individually) |
| Service | Non-inventory item representing labor or a service. No stock tracking, no physical attributes | No | Alterations, gift wrapping, custom engraving, hemming |
3.1.2 Product Class Diagram
classDiagram
class Product {
+UUID id
+String sku
+String name
+String product_type
+Decimal base_price
+Decimal cost
+String lifecycle_status
+Boolean shippable
+UUID package_type_id
+Enum selling_uom
+Enum purchasing_uom
+Decimal uom_conversion_factor
+UUID season_id
+UUID brand_id
+DateTime created_at
+DateTime updated_at
}
class StandardProduct {
+String barcode
+String[] alternate_barcodes
+Boolean track_inventory
+Integer low_stock_threshold
}
class VariantParent {
+String[] dimension_names
+Integer dimension_count
+String style_number
+String[] demographics_age_group
+String[] demographics_gender
+String origin
+String fabric
+UUID season_id
}
class VariantChild {
+UUID parent_id
+String dimension_1_value
+String dimension_2_value
+String dimension_3_value
+String barcode
+Decimal price_override
+Decimal msrp
+Boolean track_inventory
+Boolean deletion_protected
}
class CompositeProduct {
+Decimal bundle_price
+Boolean allow_component_substitution
}
class BundleComponent {
+UUID composite_id
+UUID component_product_id
+Integer quantity
+Decimal component_cost_allocation
}
class ServiceProduct {
+Integer estimated_minutes
+Boolean requires_appointment
+String service_category
}
class CustomAttributeDefinition {
+UUID id
+String name
+Enum type
+Boolean required
+UUID tenant_id
}
class ProductCustomAttribute {
+UUID product_id
+UUID definition_id
+String value
}
class PackageType {
+UUID id
+String name
+Decimal length
+Decimal width
+Decimal height
+Decimal empty_weight
+UUID tenant_id
}
Product <|-- StandardProduct
Product <|-- VariantParent
Product <|-- CompositeProduct
Product <|-- ServiceProduct
VariantParent "1" --> "*" VariantChild : has children
CompositeProduct "1" --> "*" BundleComponent : contains
BundleComponent "*" --> "1" Product : references component
Product "0..*" --> "0..*" ProductCustomAttribute : has custom values
ProductCustomAttribute "*" --> "1" CustomAttributeDefinition : defined by
Product "*" --> "0..1" PackageType : ships in
3.1.3 Full Product Data Model
| Group | Field | Type | Required | Description |
|---|---|---|---|---|
| Identity | id | UUID | Yes | Primary key, system-generated |
sku | String | Yes | Unique stock keeping unit per tenant | |
barcode | String | No | Primary barcode (UPC-A, EAN-13, or internal) | |
alternate_barcodes[] | String[] | No | Additional barcodes (vendor SKU, alternate UPC, etc.) | |
| Description | name | String | Yes | Display name (max 255 chars) |
short_description | String | No | Brief summary for POS display (max 500 chars) | |
long_description | String | No | Full marketing description (max 5000 chars) | |
tags[] | String[] | No | Freeform tags for search and filtering | |
category_id | UUID | Yes | Reference to category hierarchy | |
| Pricing | base_price | Decimal(10,2) | Yes | Default selling price |
cost | Decimal(10,2) | No | Cost of goods (landed cost) | |
compare_at_price | Decimal(10,2) | No | Original price for “was/now” display | |
tax_code | String | No | Tax category override (e.g., “grocery_food”, “clothing_exempt”) | |
| Physical | weight | Decimal(8,3) | No | Product weight for shipping calculation |
weight_unit | Enum | No | lb, oz, kg, g | |
length | Decimal(8,2) | No | Package length | |
width | Decimal(8,2) | No | Package width | |
height | Decimal(8,2) | No | Package height | |
dimension_unit | Enum | No | in, cm | |
| Inventory | track_inventory | Boolean | Yes | Whether to track stock levels (default: true) |
allow_negative | Boolean | Yes | Allow sales when stock is zero (default: false) | |
low_stock_threshold | Integer | No | Alert threshold per location (default: 5) | |
| Media | images[] | URL[] | No | Array of image URLs |
primary_image_id | UUID | No | Reference to primary display image | |
| Status | lifecycle_status | Enum | Yes | DRAFT, ACTIVE, DISCONTINUED, ARCHIVED |
| Timestamps | created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp | |
published_at | DateTime | No | When product went Active | |
discontinued_at | DateTime | No | When product was discontinued | |
archived_at | DateTime | No | When product was archived | |
| Audit | created_by | UUID | Yes | User who created the record |
updated_by | UUID | Yes | User who last modified the record | |
| Retail Attributes | style_number | String | No | Manufacturer or internal style identifier (e.g., “NXJ1078”) |
demographics_age_group | String[] | No | Target age groups: Adult, Youth, Kids, Infant, Toddler | |
demographics_gender | String[] | No | Target genders: Men, Women, Unisex, Boys, Girls | |
origin | String | No | Country or region of manufacture: US, Imported, EU, or custom value | |
fabric | String | No | Primary material composition (e.g., “100% Cotton”, “60% Polyester / 40% Cotton”) | |
season_id | UUID | No | FK to Season – assigns product to a buying season (null = year-round core) | |
brand_id | UUID | No | FK to Brand – the brand label on the product (distinct from vendor/supplier) | |
| Shipping | shippable | Boolean | Yes | Whether this product can be shipped (default: false). When true, weight and dimensions become mandatory |
package_type_id | UUID | No | FK to PackageType – pre-defined package dimensions (Box, Envelope, Satchel, etc.) | |
| Unit of Measure | selling_uom | Enum | Yes | Unit customers buy in (default: EACH). Values: EACH, PAIR, SET, YARD, FOOT, METER, LB, KG, OZ, LITER |
purchasing_uom | Enum | No | Unit purchased from vendor – can differ from selling_uom (e.g., buy by CASE, sell by EACH) | |
uom_conversion_factor | Decimal(10,4) | No | Conversion ratio from purchasing_uom to selling_uom (e.g., 12.0000 means 1 case = 12 each) | |
| Variant-Specific (VariantChild) | msrp | Decimal(10,2) | No | Manufacturer’s Suggested Retail Price – used for “Compare at MSRP” display and margin analysis |
Business Rules – Shipping:
- IF
shippable = trueTHENweight,length,width, andheightare all mandatory. The system rejects save attempts where shippable is enabled but physical dimensions are missing. - IF
package_type_idis set, the package dimensions auto-populate from the PackageType record but can be overridden at the product level.
Business Rules – Unit of Measure:
- IF
purchasing_uomdiffers fromselling_uom, thenuom_conversion_factoris mandatory. - Receiving inventory via Purchase Order uses
purchasing_uom; inventory levels and POS transactions useselling_uom. The system automatically converts quantities using the conversion factor.
3.1.4 Custom Attributes
Scope: Tenant-defined key/value custom fields that extend the standard product data model. Custom attributes allow each tenant to capture business-specific data (e.g., “Certification Level”, “Country of Origin Detail”, “Season Year”) without schema changes.
Custom Attribute Definition Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Display label (e.g., “Certification”, “Import Code”) |
type | Enum | Yes | TEXT, NUMBER, LIST, BOOLEAN |
list_values[] | String[] | No | Allowed values when type = LIST (e.g., [“Organic”, “Fair Trade”, “None”]) |
required | Boolean | Yes | Whether this attribute must be filled on every product (default: false) |
searchable | Boolean | Yes | Whether this attribute is indexed for catalog search (default: false) |
filterable | Boolean | Yes | Whether this attribute appears as a filter in the catalog UI (default: false) |
display_order | Integer | Yes | Sort position in the product edit form |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
ProductCustomAttribute Junction Table
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | FK to products table |
definition_id | UUID | Yes | FK to custom_attribute_definitions table |
text_value | String | No | Value when definition type = TEXT |
number_value | Decimal(15,4) | No | Value when definition type = NUMBER |
list_value | String | No | Selected value when definition type = LIST (must match one of list_values[]) |
boolean_value | Boolean | No | Value when definition type = BOOLEAN |
Business Rules:
- Limit: Up to 50 custom attribute definitions per tenant. Attempting to create a 51st returns an error with guidance to archive unused attributes.
- Indexing: All attributes marked
searchable = trueare indexed via a GIN index on a materialized JSONB representation for fast full-text search. - Filtering: Attributes marked
filterable = trueappear as sidebar filters in the Nexus POS product list and can be used in smart collection rules. - Validation: When type = LIST, the system rejects any value not present in
list_values[]. When type = NUMBER, the system validates numeric format. Whenrequired = true, product save is blocked until a value is provided. - Inheritance: Custom attributes are set at the parent product level. Variant children inherit parent custom attributes and cannot override them individually.
3.1.5 Product Templates & Cloning
Scope: Accelerating product creation by cloning existing products and by applying category-based templates with pre-filled default values. Templates reduce repetitive data entry for categories with consistent attributes (e.g., all t-shirts share the same weight range, tax code, and fabric type).
Product Cloning
- Clone any product to create a new DRAFT with a new auto-generated SKU. All fields are copied except identity fields (
id,sku,barcode), timestamps (created_at,updated_at,published_at), and audit fields (created_byis set to the cloning user). - Cloned products always start in
DRAFTstatus regardless of the source product’s status. - For variant products, cloning copies the parent AND all child variants. Each child receives a new auto-generated SKU. Inventory levels are NOT copied (all set to zero).
- Cloned products receive a default name of “{Original Name} (Copy)” which staff must rename before publishing.
Category-Based Templates
Templates save pre-filled default values per category so that new products created in that category start with sensible defaults already populated.
Template Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Template display name (e.g., “Men’s T-Shirt Defaults”) |
category_id | UUID | Yes | FK to product_categories – the category this template applies to |
default_values | JSON | Yes | Key-value map of field names to default values |
tenant_id | UUID | Yes | Owning tenant |
created_by | UUID | Yes | User who created the template |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Example default_values JSON:
{
"fabric": "100% Cotton",
"weight": 0.35,
"weight_unit": "lb",
"tax_code": "clothing_exempt",
"selling_uom": "EACH",
"shippable": true,
"track_inventory": true,
"low_stock_threshold": 5,
"demographics_gender": ["Men"],
"demographics_age_group": ["Adult"],
"origin": "Imported",
"custom_attributes": {
"Certification": "None",
"Care Instructions": "Machine wash cold"
}
}
Business Rules:
- One template per category per tenant. If a template already exists for a category, staff must edit the existing template rather than create a duplicate.
- When a product is created and assigned to a category that has a template, the system auto-fills all fields from
default_values. Staff can override any auto-filled value before saving. - Templates do not retroactively update existing products. They apply only at creation time.
- Template fields that conflict with required product fields are ignored (e.g., a template cannot set
nameorsku).
3.1.6 Product Matrix Management
Scope: A grid-based interface for managing variant products efficiently. The matrix view presents all variant combinations in a spreadsheet-like layout, enabling rapid price/cost/stock editing and bulk variant creation.
Matrix Grid View
For variant products with two dimensions, the matrix displays:
- Rows = Dimension 1 values (e.g., sizes: S, M, L, XL, XXL)
- Columns = Dimension 2 values (e.g., colors: Red, Blue, Navy, Black)
- Each cell shows: price, cost, and current stock level for that combination
| Red | Blue | Navy | Black |
---------------------------------------------------------------------------
S | $29 / $12 | $29 / $12 | $29 / $12 | $29 / $12 |
| Stock: 14 | Stock: 11 | Stock: 9 | Stock: 7 |
---------------------------------------------------------------------------
M | $29 / $12 | $29 / $12 | $29 / $12 | $29 / $12 |
| Stock: 22 | Stock: 18 | Stock: 15 | Stock: 13 |
---------------------------------------------------------------------------
L | $29 / $12 | $29 / $12 | $29 / $12 | $29 / $12 |
| Stock: 19 | Stock: 16 | Stock: 12 | Stock: 10 |
---------------------------------------------------------------------------
XL | $32 / $13 | $32 / $13 | $32 / $13 | $32 / $13 |
| Stock: 8 | Stock: 6 | Stock: 5 | Stock: 4 |
Inline Editing:
- Click any cell to edit price, cost, or stock level directly in the grid.
- Tab key moves to the next cell. Enter confirms the edit.
- Changed cells are highlighted until saved. A single “Save All Changes” action persists all edits in one batch.
Bulk-Create Variants:
- Select dimension value combinations via checkboxes (e.g., check “XXL” row and all color columns) to generate all child SKUs in one operation.
- The system auto-generates SKUs using the configured SKU pattern, assigns the parent’s base price and cost as defaults, and sets initial inventory to zero.
- Staff can review generated variants before confirming creation.
Matrix Import via CSV:
- Upload a CSV file with columns matching dimension values and data fields.
- CSV format:
dimension_1_value, dimension_2_value, price, cost, barcode - The system validates all rows, reports errors (duplicate barcodes, invalid dimension values, non-numeric prices), and applies valid rows in batch.
- Supports both CREATE (new variants) and UPDATE (existing variants matched by dimension values) modes.
Business Rules:
- Matrix view is available only for products with exactly 2 variant dimensions. Products with 1 or 3 dimensions use the standard list editor.
- Cells for non-existent variant combinations display as empty with a “+” icon to create that specific variant on demand.
- Deleting a variant from the matrix is only permitted when the variant has zero inventory across all locations and no open orders referencing it.
3.2 Product Lifecycle
Scope: Managing products from initial creation through active selling, discontinuation, archival, and potential re-creation from archived templates. The lifecycle enforces data completeness before publishing and protects against premature archival while stock remains on hand.
3.2.1 Product Lifecycle State Machine
stateDiagram-v2
[*] --> DRAFT: Product Created
DRAFT --> ACTIVE: Publish
ACTIVE --> DISCONTINUED: Discontinue
DISCONTINUED --> ACTIVE: Reactivate
DISCONTINUED --> ARCHIVED: Archive (stock = 0 all locations)
ARCHIVED --> DRAFT: Clone as New (new SKU)
note right of DRAFT
Incomplete product
Not visible at POS
No sales allowed
end note
note right of ACTIVE
Fully configured
Available for sale
Visible at POS
end note
note right of DISCONTINUED
Sell-through mode
No restock / no new POs
Still sellable until stock = 0
end note
note right of ARCHIVED
Read-only historical record
Not visible at POS
Can clone to create new product
end note
3.2.2 Lifecycle Transition Rules
| Transition | Preconditions | Actions | Post-Conditions |
|---|---|---|---|
| Draft -> Active (Publish) | Name is set, SKU is set, at least one price assigned, category assigned | Set published_at timestamp, make visible at POS, enable inventory tracking | Product appears in POS search and barcode lookup |
| Active -> Discontinued (Discontinue) | Product must be in ACTIVE status | Set discontinued_at timestamp, flag as sell-through only, block from new purchase orders, remove from “New Arrivals” collections | Product remains sellable but will not be restocked |
| Discontinued -> Active (Reactivate) | Product must be in DISCONTINUED status | Clear discontinued_at, re-enable for purchase orders | Product fully available for sale and restocking |
| Discontinued -> Archived (Archive) | qty_on_hand = 0 at ALL locations (stores + warehouse) | Set archived_at timestamp, remove from POS visibility, mark record read-only | Product preserved for historical reporting but invisible to POS operations |
| Archived -> Draft (Clone as New) | Source product must be in ARCHIVED status | Create new product record with new auto-generated SKU, copy all fields except identity and timestamps, set status to DRAFT | New draft product exists independently; original archive unchanged |
Business Rules:
- A product cannot be archived while any location holds stock. The system checks
qty_on_handacross all locations before allowing the transition. - Cloning an archived product does NOT restore the original. It creates a new, separate product that can be edited independently.
- Discontinued products continue to appear in POS barcode lookup and search so that remaining stock can be sold.
3.3 Pricing Engine
Scope: Managing product pricing across channels, customer groups, and promotional periods. The engine supports centralized pricing with cascading overrides, named price books, four promotion types, formal markdown workflows, and best-price conflict resolution. Every price determination is auditable – the system logs which rules were evaluated, which rule won, and the final price applied.
3.3.1 Price Hierarchy
Centralized pricing with cascading override resolution. When determining the price for a product at checkout, the system evaluates five priority levels and applies the highest-priority match:
| Priority | Level | Source | Description |
|---|---|---|---|
| 1 (Highest) | Manual Override | Staff at POS | Staff manually enters a price during the sale. Requires reason selection and manager PIN if discount exceeds configurable threshold |
| 2 | Active Promotion | Promotion Engine | Scheduled or triggered promotions currently in effect for this product |
| 3 | Price Book | Price Book Engine | Customer-group-specific or date-bound pricing from a named price list |
| 4 | Channel Price | Channel Configuration | Per-channel pricing: In-Store, Online, or Wholesale |
| 5 (Lowest) | Global Default | Product Record | The base_price field from the product data model |
flowchart TD
A[Start: Determine Price] --> B{Manual Override?}
B -->|Yes| C[Use Manual Override Price]
B -->|No| D{Active Promotion?}
D -->|Yes| E[Use Promotion Price]
D -->|No| F{Price Book Match?}
F -->|Yes| G[Use Price Book Price]
F -->|No| H{Channel Price Set?}
H -->|Yes| I[Use Channel Price]
H -->|No| J[Use base_price from Product]
C --> K[Log Price Resolution to Audit Trail]
E --> K
G --> K
I --> K
J --> K
K --> L[Return Final Price]
3.3.2 Price Books
Scope: Named price lists that override global pricing for specific customer groups, channels, or date ranges. Price books enable scenarios such as wholesale pricing for B2B customers, employee discount pricing, and seasonal price adjustments without modifying the base product price.
Price Book Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Display name (e.g., “Wholesale”, “Employee Discount”, “Holiday Special”) |
description | String(500) | No | Purpose and scope of this price book |
customer_group_id | UUID | No | FK to customer_groups – restrict this book to a specific customer group. NULL = any customer |
channel | Enum | No | Restrict to channel: IN_STORE, ONLINE, WHOLESALE. NULL = all channels |
start_date | DateTime | Yes | When this price book becomes active |
end_date | DateTime | Yes | When this price book expires |
is_active | Boolean | Yes | Master toggle (default: true). Allows manual deactivation before end_date |
priority | Integer | Yes | When multiple price books match, the highest priority value wins (default: 10) |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
created_by | UUID | Yes | User who created the price book |
Price Book Entry Table
| Field | Type | Required | Description |
|---|---|---|---|
price_book_id | UUID | Yes | FK to price_books table |
product_id | UUID | Yes | FK to products table (parent or standard product) |
override_price | Decimal(10,2) | Yes | The price to charge when this price book is active |
override_cost | Decimal(10,2) | No | Optional cost override for margin reporting accuracy |
Business Rules:
- Exclusivity: Only one price book can be active per customer group per channel at any given time. If a new price book overlaps with an existing active book for the same group/channel combination, the system rejects creation and prompts the user to deactivate the conflicting book first.
- No stacking: Price books do not stack. When multiple price books match (e.g., one by customer group and one by channel), only the book with the highest
priorityvalue applies. - Audit: Every price book activation, deactivation, and entry modification is logged with the acting user and timestamp.
- Bulk entry: Staff can import price book entries via CSV with columns:
sku, override_price, override_cost. The system validates SKU existence and numeric formats before applying.
3.3.3 Promotions
Scope: Four promotion types that cover the most common retail discount scenarios. Promotions can be automatic (applied when conditions are met) or code-based (requiring manual entry at POS).
Promotion Types
| Type | Description | Key Fields |
|---|---|---|
| Basic Discount | Percentage or fixed amount off a single item | discount_type (PERCENT / AMOUNT), discount_value, min_qty (optional), max_uses (optional) |
| Tiered / Volume | Quantity breaks – price per unit decreases as quantity increases | tiers[] array of {min_qty, max_qty, price_per_unit} |
| BOGO / Cross-Item | Buy X get Y at a discount | buy_product_id, buy_qty, get_product_id, get_qty, get_discount_type, get_discount_value |
| Scheduled / Automatic | Activate on date/time without manual intervention | schedule_type (DATE_RANGE / RECURRING), start_datetime, end_datetime, recurrence_pattern |
Tiered / Volume Example:
| Quantity | Price Per Unit |
|---|---|
| 1 | $10.00 |
| 3+ | $8.00 |
| 10+ | $6.00 |
BOGO / Cross-Item Example:
- Buy 2 Shirts (
buy_product_id= shirts category,buy_qty= 2), Get 1 Belt 50% off (get_product_id= belts category,get_qty= 1,get_discount_type= PERCENT,get_discount_value= 50)
Scheduled / Automatic Example:
- Happy Hour:
schedule_type= RECURRING,recurrence_pattern= “MON-FRI 15:00-17:00” – 15% off all drinks every weekday from 3 PM to 5 PM
Promotion Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Display name (e.g., “Summer BOGO”, “Volume Discount”) |
type | Enum | Yes | BASIC, TIERED, BOGO, SCHEDULED |
stackable | Boolean | Yes | Whether this promotion can combine with other promotions (default: false) |
exclusive | Boolean | Yes | When true, this promotion replaces ALL other pricing – no other rules evaluated (default: false) |
product_scope | Enum | Yes | ALL, CATEGORY, PRODUCT_LIST |
product_ids[] | UUID[] | No | Applicable product IDs when scope = PRODUCT_LIST |
category_ids[] | UUID[] | No | Applicable category IDs when scope = CATEGORY |
start_date | DateTime | Yes | Earliest date promotion can activate |
end_date | DateTime | Yes | Latest date promotion remains active |
is_active | Boolean | Yes | Master toggle (default: false – starts as draft) |
priority | Integer | Yes | Tiebreaker when multiple promotions match (higher wins, default: 10) |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
created_by | UUID | Yes | User who created the promotion |
Promotion Lifecycle
stateDiagram-v2
[*] --> DRAFT : Create Promotion
DRAFT --> SCHEDULED : Set dates & activate
DRAFT --> CANCELLED : Cancel before scheduling
SCHEDULED --> ACTIVE : Start date reached
SCHEDULED --> CANCELLED : Cancel before start
ACTIVE --> EXPIRED : End date passed
ACTIVE --> CANCELLED : Manually deactivate
CANCELLED --> [*]
EXPIRED --> [*]
Lifecycle Transition Rules:
- DRAFT: Promotion is being configured. Not visible to POS. Can be edited freely.
- SCHEDULED: Promotion is locked for editing (except cancellation). Awaiting start date.
- ACTIVE: Promotion is live. Applied automatically or via code at POS. Only the
is_activetoggle andend_datecan be modified. - EXPIRED: Terminal state. Promotion has passed its end date. Preserved for reporting. Cannot be reactivated – must be cloned to create a new promotion.
- CANCELLED: Terminal state. Promotion was manually stopped. Preserved for reporting.
3.3.4 Markdown & Clearance Management
Scope: Formal workflows for reducing prices, tracking clearance merchandise, and handling end-of-life write-offs. Every price reduction is accountable – the system enforces approval workflows and logs all changes for audit.
Markdown Request Workflow
sequenceDiagram
autonumber
participant S as Staff
participant UI as Nexus POS
participant API as Backend
participant M as Manager
participant DB as DB
Note over S, DB: Markdown Request Workflow
S->>UI: Create Markdown Request
Note right of UI: Product, new_price, effective_date, reason
UI->>API: POST /markdowns
API->>DB: Save request (status: PENDING)
API-->>UI: Request #MD-001 created
API->>M: Notification: Markdown request pending
M->>UI: Review request details
Note right of M: Current price, proposed price,<br/>margin impact, reason
alt Approved
M->>API: PATCH /markdowns/MD-001 {status: APPROVED}
API->>DB: Update status, schedule price change
Note right of DB: effective_date triggers price update
DB-->>API: Price updated on effective_date
API->>UI: Push updated price to all terminals
API->>DB: Log to price_audit_trail
Note right of DB: who, when, old_price,<br/>new_price, reason, approval_id
else Rejected
M->>API: PATCH /markdowns/MD-001 {status: REJECTED, reject_reason: "..."}
API-->>S: Notification: Markdown rejected
end
Manual Price Changes:
- Staff with appropriate permissions can change a product’s price directly without the formal markdown workflow.
- Every manual change requires selecting a reason from a configurable list:
Damaged,Price Match,Manager Discretion,Competitive Adjustment,Cost Change,Seasonal Reduction,Error Correction. - All manual changes are logged to the price audit trail:
user_id,timestamp,product_id,old_price,new_price,reason,location_id.
Automatic Markdown Rules:
- Configurable rules engine that evaluates nightly and flags or automatically applies markdowns:
| Rule | Condition | Action |
|---|---|---|
| Slow Mover | No sale in X days AND stock > Y units | Reduce price by Z% |
| Aging Inventory | days_since_receive > N AND stock > threshold | Move to clearance collection, apply clearance_price |
| Season End | Season status = CLEARANCE AND product.season_id matches | Apply configured season clearance discount |
- Automatic rules can be configured to either flag for review (creates a pending markdown request) or apply immediately (executes the price change and logs it as auto-rule).
Liquidation / Write-Off:
- When a product cannot sell at any price (damaged beyond sale, expired, recalled), staff create a write-off record.
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Product being written off |
quantity | Integer | Yes | Number of units written off |
write_off_value | Decimal(10,2) | Yes | Total cost value of written-off inventory |
reason | Enum | Yes | DAMAGED, EXPIRED, RECALLED, SHRINKAGE, OBSOLETE |
approved_by | UUID | Yes | Manager who authorized the write-off |
location_id | UUID | Yes | Location where inventory is removed |
notes | String | No | Additional context |
created_at | DateTime | Yes | Timestamp of write-off |
Clearance Rack Tracking:
- Each product can be flagged as clearance on a per-location basis.
- Clearance fields:
is_clearance(Boolean),clearance_price(Decimal),clearance_started_at(DateTime). - Products flagged as clearance appear in a dedicated “Clearance” collection on the POS browse screen and can be filtered in reports.
- Clearance pricing takes precedence over the product’s
base_pricebut is overridden by active promotions and manual overrides per the standard price hierarchy.
3.3.5 Conflict Resolution
Scope: Determining the final price when multiple pricing rules match the same product at checkout. The resolution algorithm ensures predictability, transparency, and customer-friendly outcomes.
Resolution Algorithm:
-
Check exclusivity: If any matched promotion has
exclusive = true, use ONLY that promotion. If multiple exclusive promotions match, the one with the highestprioritywins. All other pricing rules are ignored. -
If no exclusive promotion: Apply the best price for the customer (lowest final price) from among:
- The winning price book entry (if any)
- The winning non-exclusive promotion (if any)
- The channel price (if set)
- The base price
-
Stackable promotions: Stackable promotions combine ONLY when both are marked
stackable = true. The combined discount must not exceed the tenant’smax_discount_percentsetting (default: 75%). If stacking would exceed the cap, the system applies the single best promotion instead. -
Manual override: A manual override entered by staff at the POS always wins regardless of all other rules. It bypasses the algorithm entirely.
Conflict Resolution Flowchart:
flowchart TD
A[Checkout: Evaluate Price] --> B[Gather all matching rules]
B --> C{Any exclusive promotion?}
C -->|Yes| D[Use highest-priority exclusive promo]
C -->|No| E{Multiple stackable promos?}
E -->|Yes| F{Combined discount <= max_discount_percent?}
F -->|Yes| G[Apply stacked promotions]
F -->|No| H[Apply single best promotion]
E -->|No| I[Compare: Promo vs Price Book vs Channel vs Base]
I --> J[Use lowest final price for customer]
D --> K[Log: rules evaluated, winner, reason]
G --> K
H --> K
J --> K
Audit: Every price resolution is logged with the following data:
sale_id,line_item_id,product_idrules_evaluated[]– list of all pricing rules that were consideredwinning_rule_idandwinning_rule_type(MANUAL / PROMOTION / PRICE_BOOK / CHANNEL / BASE)original_price(base_price) andfinal_pricetotal_discount_amountandtotal_discount_percenttimestamp
3.3.6 Reports: Pricing
| Report | Purpose | Key Data Fields |
|---|---|---|
| Price Book Usage | Track which price books are active and how often applied | Price book name, times applied, avg discount %, revenue impact, active date range |
| Promotion Performance | Measure promotion effectiveness against business goals | Promo name, type, redemptions, revenue lift vs. pre-promo period, margin impact, cost of discount given |
| Markdown History | Track all price reductions with full accountability | Product SKU, old price, new price, reason, who requested, who approved, when, approval status |
| Margin Impact Analysis | Understand how pricing decisions affect profitability | Product/category, base margin %, discounted margin %, volume at each price point, total margin dollars lost/gained |
| Conflict Resolution Log | Audit which pricing rules win at checkout | Transaction ID, product, rules evaluated, winning rule, final price, discount applied |
| Clearance Tracking | Monitor clearance inventory and sell-through | Product, original price, clearance price, days on clearance, units remaining, sell-through % |
3.4 Barcode Management
Scope: Supporting multiple barcode formats per product, auto-generating internal barcodes when no manufacturer code exists, enforcing uniqueness per tenant, and enabling fast barcode-to-product lookup from any scanner or manual entry.
3.4.1 Barcode Types
| Type | Format | Source | Example | Use Case |
|---|---|---|---|---|
| UPC-A | 12-digit numeric | Manufacturer-assigned | 012345678901 | Standard North American retail products |
| EAN-13 | 13-digit numeric | Manufacturer-assigned (EU/International) | 4006381333931 | International products, European imports |
| Internal | Configurable prefix + auto-increment sequence | System-generated | INT-000001, INT-000002 | Products without manufacturer barcodes (custom items, local goods) |
| Alternate | Any format, free-form | Manual entry | VENDOR-SKU-X99, OLD-UPC-123 | Vendor SKUs, legacy barcodes, secondary identifiers |
3.4.2 Barcode Features
- Multiple barcodes per product: Each product has one primary barcode and unlimited alternate barcodes. Scanning ANY barcode (primary or alternate) resolves to the same product.
- Auto-generate internal barcode: When a product is created without a manufacturer barcode, the system auto-generates an internal barcode using the tenant’s configured prefix and the next available sequence number.
- Uniqueness enforced per tenant: No two products within the same tenant can share a barcode (primary or alternate). The system rejects duplicates at creation and import time.
- Universal scan resolution: POS barcode lookup checks the primary barcode first, then searches alternate barcodes. The lookup returns the same product regardless of which barcode was scanned.
- Bulk barcode import via CSV: Staff can upload a CSV file mapping barcodes to existing SKUs. The system validates uniqueness, reports conflicts, and applies valid mappings in batch.
3.4.3 Barcode Lookup Flow
sequenceDiagram
autonumber
participant U as Staff
participant SC as Scanner
participant UI as POS UI
participant API as Backend
participant DB as DB
Note over U, DB: Barcode Scan to Product Lookup
alt Barcode Scanner
U->>SC: Scan Item Barcode
SC->>UI: Barcode Value (e.g., "012345678901")
else Manual Entry
U->>UI: Type Barcode / SKU
end
UI->>API: POST /products/barcode-lookup
Note right of API: {barcode: "012345678901", tenant_id: "..."}
API->>DB: SELECT * FROM products WHERE barcode = ?
Note right of DB: Check primary barcode first
alt Primary Barcode Match
DB-->>API: Product Found
else No Primary Match
API->>DB: SELECT * FROM alternate_barcodes WHERE barcode = ?
Note right of DB: Search alternate barcodes
alt Alternate Barcode Match
DB-->>API: Product Found (via alternate)
else No Match Found
DB-->>API: No Results
API-->>UI: "Product Not Found"
UI-->>U: "No product matches this barcode"
Note right of UI: Option: Create new product or re-scan
end
end
API-->>UI: Return Product Data
UI-->>U: Display: Name, SKU, Price, Stock Level
Note right of UI: Product ready to add to cart
3.4.4 Reports: Barcode Management
| Report | Purpose | Key Data Fields |
|---|---|---|
| Barcode Coverage Report | Identify products missing barcodes | Products without primary barcode, products with only internal barcodes, total coverage % |
| Barcode Scan Failure Log | Track unrecognized scans | Scanned value, timestamp, terminal, resolution (created new / manual lookup / abandoned) |
| Duplicate Barcode Audit | Detect and prevent barcode conflicts | Barcode value, conflicting products, resolution status |
3.5 Categories, Seasons & Collections
Scope: Organizing products into a navigable hierarchy for POS browsing, reporting, and rule application (tax defaults, commission rates). The system supports up to four levels of nesting, freeform tags, named collections, auto-tagging rules, formal buying seasons with lifecycle management, and multi-dimensional reporting hierarchies for financial analysis.
3.5.1 Category Hierarchy
The catalog supports a 4-level hierarchy. Products are assigned to the most specific level applicable.
Level 1: Department
|-- Level 2: Category
|-- Level 3: Subcategory
|-- Level 4: Sub-subcategory (max depth)
Example:
Men's Apparel (Department)
|-- Tops (Category)
| |-- T-Shirts (Subcategory)
| | |-- Graphic Tees (Sub-subcategory)
| | |-- Plain Tees (Sub-subcategory)
| |-- Dress Shirts (Subcategory)
| |-- Polos (Subcategory)
|-- Bottoms (Category)
| |-- Jeans (Subcategory)
| |-- Chinos (Subcategory)
|-- Outerwear (Category)
|-- Jackets (Subcategory)
|-- Coats (Subcategory)
Accessories (Department)
|-- Bags (Category)
|-- Hats (Category)
|-- Jewelry (Category)
Services (Department)
|-- Alterations (Category)
|-- Gift Services (Category)
3.5.2 Tags & Collections
Tags: Unlimited freeform tags can be applied to any product. Tags are tenant-scoped and support search filtering.
| Feature | Description | Example |
|---|---|---|
| Freeform Tags | Any text, lowercase normalized, no hierarchy | summer, bestseller, organic, limited-edition |
| Named Collections | Curated product groups, manual or rule-based | “Summer Essentials”, “Staff Picks”, “New Arrivals” |
| Manual Collections | Staff manually adds/removes products | “Staff Picks” – staff curates the list |
| Rule-Based Collections | Auto-populated based on conditions | “New Arrivals” = products where published_at is within last 30 days |
| Auto-Tagging Rules | IF condition THEN add tag automatically | IF category = “Outerwear” AND created_at within last 14 days THEN add tag new-outerwear |
3.5.3 Category Features
- Drag-and-drop reordering: Staff can reorder categories and products within categories via drag-and-drop in the catalog management UI.
- Category-level default tax code: Each category can specify a default tax code (e.g., “clothing_exempt”, “grocery_food”). Products inherit the category tax code unless overridden at the product level.
- Category-level default commission rate: Each category can specify a default commission percentage. Used for commission calculation unless overridden at the product level.
- Bulk move products: Staff can select multiple products and move them to a different category in a single action.
- Category image/icon: Each category can have an assigned image or icon for display in POS browse mode and kiosk interfaces.
3.5.4 Reports: Category Management
| Report | Purpose | Key Data Fields |
|---|---|---|
| Category Sales Report | Revenue breakdown by category | Category path, product count, units sold, revenue, avg margin |
| Uncategorized Products | Find products missing a category | Product SKU, name, created date, status |
| Category Depth Report | Audit hierarchy usage | Depth level, category count, product count per level |
3.5.5 Formal Seasons
Scope: Named buying seasons with lifecycle dates that track merchandise from initial buy planning through active selling, clearance, and close-out. Seasons provide a temporal dimension to inventory analysis – enabling sell-through tracking, carryover identification, and season-over-season comparison.
Season Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Display name (e.g., “Spring 2026”, “Holiday 2025”, “Back to School 2026”) |
start_date | Date | Yes | Season merchandise begins arriving / becomes active |
end_date | Date | Yes | Season officially closes – remaining inventory is carryover |
status | Enum | Yes | PLANNING, ACTIVE, CLEARANCE, CLOSED |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Example Seasons:
| Season Name | Start Date | End Date | Status |
|---|---|---|---|
| Spring 2026 | 2026-02-01 | 2026-07-31 | PLANNING |
| Holiday 2025 | 2025-10-15 | 2026-01-15 | CLEARANCE |
| Fall 2025 | 2025-08-01 | 2025-12-31 | CLOSED |
| Summer 2026 | 2026-05-01 | 2026-09-30 | PLANNING |
Season Lifecycle State Machine
stateDiagram-v2
[*] --> PLANNING : Create Season
PLANNING --> ACTIVE : Season start date reached
ACTIVE --> CLEARANCE : Triggered by staff or auto-rule
CLEARANCE --> CLOSED : Season end date passed
PLANNING --> CLOSED : Cancel season (no products received)
CLOSED --> [*]
Lifecycle Transition Rules:
- PLANNING: Season is being prepared. Products can be assigned to this season. Purchase orders can reference this season. No products are expected on the sales floor yet.
- ACTIVE: Season merchandise is on the sales floor and selling. Transition occurs automatically when
start_dateis reached, or manually by staff. Products assigned to this season appear in season-filtered reports. - CLEARANCE: Triggered manually by a buyer/manager OR by an automatic rule (e.g., “30 days before end_date, move to CLEARANCE”). Products in this season become eligible for automatic markdown rules. The clearance collection auto-populates with this season’s products.
- CLOSED: Terminal state. Season has ended. Remaining inventory is flagged as carryover. No further price changes tied to this season. Season data is preserved for historical reporting.
Product-Season Assignment:
- Products are assigned to seasons via the
season_idFK on the product record. - A product can belong to at most ONE season. Products with
season_id = NULLare year-round core items not tied to any season. - When a season transitions to CLOSED, products still assigned to it can be reassigned to a new season (carryover) or have their
season_idset to NULL (promoted to core).
3.5.6 Reporting Dimensions
Scope: Structured classification hierarchies used exclusively for financial reporting and buying analysis. Reporting dimensions are separate from display categories – a product’s display category determines where it appears in the POS browse screen, while reporting dimensions determine how it appears in financial reports, open-to-buy analysis, and margin summaries.
Brand
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Brand name (e.g., “Nike”, “Levi’s”, “Nexus Premier”) |
logo_url | String(500) | No | URL to brand logo image |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
- Products are assigned to brands via the
brand_idFK on the product record. - Brand is distinct from Vendor. Nike the brand (what the customer sees on the label) is different from Nike Direct the vendor (the company you send purchase orders to). A single brand may be sourced from multiple vendors. A single vendor may supply multiple brands.
Merchandise Hierarchy
A 3-level reporting hierarchy independent of display categories:
Department (Level 1)
|-- Class (Level 2)
|-- Subclass (Level 3)
Example:
Footwear (Department)
|-- Athletic Shoes (Class)
| |-- Running (Subclass)
| |-- Basketball (Subclass)
| |-- Training (Subclass)
|-- Casual Shoes (Class)
| |-- Sneakers (Subclass)
| |-- Loafers (Subclass)
|-- Dress Shoes (Class)
|-- Oxfords (Subclass)
|-- Derby (Subclass)
Merchandise Hierarchy Fields on Product:
| Field | Type | Required | Description |
|---|---|---|---|
merch_department_id | UUID | No | FK to merchandise_departments table |
merch_class_id | UUID | No | FK to merchandise_classes table (must be child of selected department) |
merch_subclass_id | UUID | No | FK to merchandise_subclasses table (must be child of selected class) |
- The merchandise hierarchy enables financial reporting by buying category rather than display category. A “Galaxy V-Neck Tee” might display under “Men’s > Casual > T-Shirts” but report under “Apparel > Knit Tops > V-Necks” for buying analysis.
- Merchandise hierarchy assignment is optional. Products without a merchandise hierarchy assignment are grouped under “Unclassified” in financial reports.
Custom Dimensions
Tenant-defined reporting dimensions for analysis needs beyond brand and merchandise hierarchy.
| Field | Type | Required | Description |
|---|---|---|---|
dimension_id | UUID | Yes | Primary key |
name | String(100) | Yes | Dimension name (e.g., “Buyer”, “Margin Tier”, “Velocity Class”) |
values[] | String[] | Yes | List of allowed values (e.g., [“Sarah”, “Mike”, “Unassigned”] for Buyer dimension) |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
Product-Dimension Junction Table:
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | FK to products table |
dimension_id | UUID | Yes | FK to custom_dimensions table |
dimension_value | String | Yes | Selected value (must be one of values[] from the dimension definition) |
Example Custom Dimensions:
| Dimension | Allowed Values | Purpose |
|---|---|---|
| Buyer | Sarah, Mike, Jenny, Unassigned | Track which buyer selected this product for reporting by buyer performance |
| Margin Tier | High (>60%), Medium (40-60%), Low (<40%) | Quick filtering for margin-focused analysis |
| Velocity Class | A (top 20%), B (middle 60%), C (bottom 20%) | ABC analysis classification for inventory planning |
| Sourcing Region | Domestic, Asia, Europe, South America | Supply chain analysis and lead time planning |
3.5.7 Reports: Seasons & Dimensions
| Report | Purpose | Key Data Fields |
|---|---|---|
| Season Sell-Through | Track sell-through rate per season to evaluate buying accuracy | Season name, total units received, total units sold, sell-through %, revenue, avg margin % |
| Season Carryover | Identify unsold season inventory for markdown or transfer decisions | Season name, product SKU, product name, qty remaining, original cost, current retail value, days since season close |
| Brand Performance | Revenue and margin analysis by brand for vendor negotiation and buying decisions | Brand name, product count, units sold, revenue, margin %, return rate, avg selling price |
| Merchandise Hierarchy Report | Financial reporting by Dept/Class/Subclass for open-to-buy and assortment planning | Department, Class, Subclass, revenue, margin %, inventory value at cost, inventory turns, weeks of supply |
| Custom Dimension Report | Flexible analysis by any tenant-defined dimension | Dimension name, dimension value, product count, revenue, margin %, units sold, avg price |
3.6 Multi-Channel Management
Scope: Managing product visibility, inventory allocation, and pricing across multiple sales channels (In-Store POS, Online/Shopify, Wholesale). Each product can be configured independently per channel, enabling retailers to control where products appear, how much stock each channel can sell, and at what price.
Cross-Reference: See Module 4, Section 4.2 for inventory allocation across channels.
3.6.1 Channel Definition
The system ships with three built-in channels. Tenants can define additional custom channels to match their sales operations.
| Channel | Type | Default | Description |
|---|---|---|---|
| IN_STORE | PHYSICAL | Yes | Point-of-sale transactions at physical store locations |
| ONLINE | DIGITAL | No | E-commerce sales via Shopify or other web storefront |
| WHOLESALE | B2B | No | Bulk/wholesale orders from business customers |
Channel Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
name | String | Yes | Display name (e.g., “In-Store POS”, “Shopify Online”, “Wholesale Portal”) |
type | Enum | Yes | PHYSICAL, DIGITAL, B2B |
is_default | Boolean | Yes | Whether this is the default channel for new products (one per tenant) |
is_system | Boolean | Yes | System-defined channels cannot be deleted |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
3.6.2 Channel Visibility Controls
Each product is independently toggled for visibility on each channel. Visibility can be immediate or scheduled with date windows.
Channel Visibility Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Reference to product |
channel_id | UUID | Yes | Reference to channel |
is_visible | Boolean | Yes | Whether the product appears on this channel |
available_from | DateTime | No | Scheduled visibility start (null = immediately visible when toggled on) |
available_until | DateTime | No | Scheduled visibility end (null = indefinite) |
tenant_id | UUID | Yes | Owning tenant |
Business Rules:
- A product must be visible on at least one channel to remain in
ACTIVElifecycle status. If a product is removed from all channels, the system warns: “Product will be hidden from all sales channels. Consider setting to DISCONTINUED.” - Scheduled visibility windows are evaluated in real time. A product with
available_fromin the future does not appear on the channel until that timestamp. - Bulk operations: staff can select multiple products and toggle channel visibility in batch via the catalog management UI.
Channel Visibility Sequence
sequenceDiagram
autonumber
participant U as Staff
participant UI as Catalog UI
participant API as Backend
participant DB as DB
Note over U, DB: Single Product Channel Toggle
U->>UI: Open Product Detail
UI->>API: GET /products/{id}/channels
API-->>UI: Return Channel Visibility Settings
U->>UI: Toggle "Online" Channel ON
U->>UI: Set available_from "2026-03-01"
U->>UI: Click "Save"
UI->>API: PATCH /products/{id}/channels
API->>DB: Upsert Channel Visibility Record
API-->>UI: Channel Updated
Note over U, DB: Bulk Channel Toggle
U->>UI: Select 25 Products (Checkbox)
U->>UI: Click "Bulk Actions" -> "Channel Visibility"
UI-->>U: Show Channel Toggle Panel
U->>UI: Toggle "Wholesale" ON for All Selected
U->>UI: Click "Apply"
UI->>API: POST /products/bulk-channel-update
Note right of API: {product_ids: [...], channel_id: "...", is_visible: true}
API->>DB: Upsert 25 Channel Visibility Records
API-->>UI: "25 products updated"
3.6.3 Channel Inventory Allocation
Each tenant selects one of two inventory allocation modes. The mode applies globally per tenant.
| Mode | Description | Use Case |
|---|---|---|
| Shared Pool (Default) | All channels sell from the same inventory pool. An online sale decrements the same stock as an in-store sale. | Small-to-medium retailers with unified stock |
| Dedicated Allocation | Reserve specific quantities per channel per location. Each channel has its own available pool. | Large retailers needing channel-specific stock control |
Dedicated Allocation Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Reference to product |
location_id | UUID | Yes | Reference to store/warehouse location |
channel_id | UUID | Yes | Reference to channel |
allocated_qty | Integer | Yes | Quantity reserved for this channel at this location |
sold_qty | Integer | Yes | Quantity sold through this channel (starts at 0) |
available_qty | Integer | Computed | Calculated: allocated_qty - sold_qty |
tenant_id | UUID | Yes | Owning tenant |
last_updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Sum of
allocated_qtyacross all channels at a location cannot exceed total physical inventory at that location. - When one channel sells out (available_qty = 0), the system flags the product as “Channel Stockout” in the dashboard. Staff can manually release allocation from another channel to replenish.
- Auto-reallocation is not automatic by default. Staff must approve reallocation to prevent unintended stock shifts.
- In Shared Pool mode, the dedicated allocation table is not used. All channels reference the location’s total
qty_on_hand.
3.6.4 Channel Pricing
Each product can carry a different price per channel. Channel pricing ties into the Price Tier hierarchy defined in the pricing engine.
Channel Pricing Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Reference to product |
channel_id | UUID | Yes | Reference to channel |
channel_price | Decimal(10,2) | No | Override price for this channel (null = use base_price) |
channel_compare_at_price | Decimal(10,2) | No | “Was” price for this channel (used for strikethrough display) |
tenant_id | UUID | Yes | Owning tenant |
Business Rules:
- If no
channel_priceis set for a product-channel combination, the system falls through to the product’s globalbase_price. - Channel pricing is evaluated BEFORE customer price tiers. The resolution order is: Channel Price -> Base Price -> Price Tier Override.
- Wholesale channel pricing typically represents a cost-plus markup and may be lower than in-store pricing.
3.6.5 Reports: Multi-Channel
| Report | Purpose | Key Data Fields |
|---|---|---|
| Channel Sales Comparison | Compare revenue performance across channels | Channel, units sold, revenue, gross margin, avg order value, period |
| Channel Inventory Status | Stock availability by channel | Product, channel, allocated qty, available qty, sold qty, stockout risk flag |
| Channel Visibility Audit | Identify products missing from channels | Product, channels currently visible, channels not visible, last change date, recommended action |
| Channel Price Variance | Price differences across channels for same product | Product, in-store price, online price, wholesale price, variance % |
3.7 Shopify Integration
MOVED TO MODULE 6: This section has been consolidated into Module 6: Integrations & External Systems, Section 6.3 (Shopify Integration). The full Shopify integration specification – including sync modes, field-level ownership, conflict resolution, sync constraints, GraphQL API preference, @idempotent directive, Bulk Operations API, third-party POS integration rules, omnichannel/BOPIS requirements, and hardware compatibility – is now maintained in Section 6.3.
See: Module 6, Section 6.3 for the complete Shopify integration specification.
3.8 Vendor Management
Scope: Tracking supplier information, payment terms, and the many-to-many relationship between vendors and products. A single product can be sourced from multiple vendors, each with vendor-specific cost, SKU, and lead time.
Cross-Reference: See Module 4, Section 4.3 for purchase orders and Section 4.9 for vendor RMA.
Cross-Reference: See Module 5, Section 5.19 for supplier payment terms and lead time configuration.
3.8.1 Vendor Data Model
| Group | Field | Type | Required | Description |
|---|---|---|---|---|
| Identity | id | UUID | Yes | Primary key, system-generated |
name | String | Yes | Vendor company name | |
code | String | Yes | Short unique code (e.g., “NIKE”, “LEVI”) | |
tax_id | String | No | Vendor tax identification number | |
| Contact | email | String | No | Primary contact email |
phone | String | No | Primary phone number | |
address | Object | No | Full mailing address (street, city, state, zip, country) | |
contact_person | String | No | Name of primary contact | |
| Terms | payment_terms | Enum | Yes | NET_30, NET_60, NET_90, COD, PREPAID |
currency | String | Yes | Default currency code (e.g., “USD”) | |
minimum_order | Decimal(10,2) | No | Minimum order amount required | |
| Logistics | default_lead_time_days | Integer | No | Standard delivery lead time in days |
preferred_carrier | String | No | Default shipping carrier | |
| Status | status | Enum | Yes | ACTIVE, INACTIVE |
| Timestamps | created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
3.8.2 Vendor-Product Relationship
Vendors and products share a many-to-many relationship through the vendor_product junction table. Each link carries vendor-specific pricing, SKU mapping, and lead time overrides.
| Field | Type | Required | Description |
|---|---|---|---|
vendor_id | UUID | Yes | Reference to vendor |
product_id | UUID | Yes | Reference to product |
vendor_sku | String | No | The vendor’s own SKU for this product |
vendor_cost | Decimal(10,2) | Yes | Purchase cost from this vendor |
vendor_barcode | String | No | Vendor-specific barcode (can differ from product barcode) |
is_primary_vendor | Boolean | Yes | Whether this is the preferred vendor for this product (one per product) |
lead_time_override_days | Integer | No | Vendor-product specific lead time (overrides vendor default) |
minimum_order_qty | Integer | No | Minimum quantity per order for this product from this vendor |
last_ordered_at | DateTime | No | Timestamp of most recent PO to this vendor for this product |
3.8.3 Vendor-Product Entity Relationship
erDiagram
VENDOR {
UUID id PK
String name
String code
String tax_id
String email
String phone
String payment_terms
String currency
Decimal minimum_order
Integer default_lead_time_days
String status
}
PRODUCT {
UUID id PK
String sku
String name
String product_type
Decimal base_price
Decimal cost
String lifecycle_status
}
VENDOR_PRODUCT {
UUID vendor_id FK
UUID product_id FK
String vendor_sku
Decimal vendor_cost
String vendor_barcode
Boolean is_primary_vendor
Integer lead_time_override_days
Integer minimum_order_qty
}
VENDOR ||--o{ VENDOR_PRODUCT : "supplies"
PRODUCT ||--o{ VENDOR_PRODUCT : "sourced from"
3.8.4 Reports: Vendor Management
| Report | Purpose | Key Data Fields |
|---|---|---|
| Vendor Product List | All products supplied by a vendor | Vendor, product SKU, vendor SKU, vendor cost, is primary, last ordered |
| Vendor Performance Report | Track delivery and quality | Vendor, PO count, on-time %, avg lead time, variance count, return rate |
| Primary Vendor Coverage | Ensure all products have a primary vendor | Products without primary vendor, products with only one vendor source |
| Vendor Cost Comparison | Compare pricing across vendors for same product | Product SKU, vendor name, vendor cost, lead time, minimum qty |
3.9 Product Search & Discovery
Scope: Enabling fast product lookup at the POS terminal through multiple search methods: full-text search, category browsing, advanced filters, favorites, and product recommendations. Search must return results in under 200ms for responsive POS operation.
3.9.1 Full-Text Search
The POS search engine indexes the following fields for fast retrieval:
| Search Field | Weight (Relevance) | Index Type | Example Match |
|---|---|---|---|
sku | 10 (highest) | Exact match | Search “BLK-TEE-001” returns exact product |
barcode / alternate_barcodes | 10 (highest) | Exact match | Search “012345678901” returns exact product |
name | 8 | Full-text, prefix | Search “Oxford” matches “Oxford Button-Down Shirt” |
brand_name | 6 | Full-text | Search “Nike” matches all Nike products |
vendor_name | 5 | Full-text | Search “Levis” matches products from Levi’s vendor |
tags[] | 4 | Exact token match | Search “bestseller” matches tagged products |
short_description | 3 | Full-text, contains | Search “moisture wicking” matches description |
long_description | 2 | Full-text, contains | Lowest priority, broad match |
custom_attributes | 3 | Key-value match | Search “organic” matches custom attribute values |
Relevance Ranking Order:
1. Exact SKU match
2. Exact barcode match (primary or alternate)
3. Name starts with search term
4. Name contains search term
5. Brand name match
6. Vendor name match
7. Tag exact match
8. Description contains search term
9. Custom attribute value match
Fuzzy Matching:
- Handles typos using Levenshtein distance (edit distance <= 2 for words >= 5 characters)
- Examples: “oxfrd” matches “Oxford”, “clasic” matches “Classic”, “niike” matches “Nike”
- Fuzzy results ranked below exact matches
- Disabled for SKU and barcode fields (exact match only)
Auto-Complete:
- Suggestions appear after 2 characters typed
- Returns top 8 suggestions combining product names, SKUs, and recent searches
- Debounced at 150ms to prevent excessive API calls
- Keyboard navigation supported (arrow keys + Enter to select)
Recent Searches:
| Field | Type | Description |
|---|---|---|
id | UUID | Primary key |
user_id | UUID | Staff member who performed the search |
search_term | String(255) | The search query text |
result_count | Integer | Number of results returned |
selected_product_id | UUID (nullable) | Product selected from results (null if no selection) |
tenant_id | UUID | Tenant scope |
created_at | DateTime | When the search was performed |
- Last 10 searches stored per user, displayed in a dropdown when the search field is focused
- Tapping a recent search re-executes the query
- Clear individual or all recent searches
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant IDX as Search Index
participant DB as DB
U->>UI: Type "oxfrd" in search bar
Note right of UI: Debounce 150ms
UI->>API: GET /products/search?q=oxfrd&limit=20
API->>IDX: Full-text query with fuzzy matching
par Search Execution
IDX->>IDX: Exact SKU/barcode check (no match)
IDX->>IDX: Full-text name search (fuzzy: "oxfrd" → "Oxford")
IDX->>IDX: Brand/vendor/tag search
IDX->>IDX: Description search
end
IDX-->>API: Ranked results (Oxford Button-Down first)
API->>DB: Fetch stock levels for results
API-->>UI: Return products with price, stock, images
UI-->>U: Display results grid (name, SKU, price, stock, image)
U->>UI: Tap "Oxford Button-Down Shirt"
UI->>UI: Add to cart
par Background
UI->>API: POST /search-history
API->>DB: Save recent search "oxfrd" for user
end
3.9.2 Category Browsing
Staff can browse the catalog visually through the 4-level category hierarchy defined in Section 3.4.
Visual Grid View:
- Categories displayed as image tiles (using
category_imagefrom Section 3.4.3) - Each tile shows: category name, product count badge, category image (or placeholder icon)
- Grid layout: configurable 3x3, 4x4, or 5x5 per screen (setting per terminal)
Drill-Down Navigation:
Department → Category → Subcategory → Products
Example:
Men's Apparel [42 items] →
Tops [28 items] →
T-Shirts [15 items] →
[Product Grid: Classic Tee, Graphic Tee, V-Neck, ...]
Breadcrumb Navigation:
Always visible at the top of the browse screen:
Home > Men's Apparel > Tops > T-Shirts
Tapping any breadcrumb segment navigates back to that level.
Sort Within Category:
| Sort Option | Direction | Default |
|---|---|---|
| Name | A-Z, Z-A | A-Z (default) |
| Price | Low-High, High-Low | – |
| Newest | Most recently published first | – |
| Best-Selling | Highest sales velocity first | – |
3.9.3 Advanced Filters
Filters are combinable using AND logic. Each active filter narrows the result set.
| Filter | Type | Values | UI Control |
|---|---|---|---|
| Price range | Range | Min-Max price (tenant currency) | Dual-handle slider with manual entry |
| Brand | Multi-select | List of all brands in tenant catalog | Searchable checkbox list |
| Vendor | Multi-select | List of all active vendors | Searchable checkbox list |
| Stock status | Multi-select | In Stock, Low Stock, Out of Stock | Checkbox group |
| Season | Multi-select | Active season tags/collections | Checkbox list |
| Size | Multi-select | Available sizes (from variant dimension values) | Checkbox grid |
| Color | Multi-select | Available colors (from variant dimension values) | Color swatch grid |
| Channel | Multi-select | In-Store, Online, Wholesale | Checkbox group |
| Lifecycle status | Multi-select | Active, Discontinued | Checkbox group |
| Category | Tree-select | Full category hierarchy | Expandable tree |
| Custom attributes | Dynamic | Based on tenant’s custom attribute definitions | Auto-generated per attribute type |
Saved Filters:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Display name (e.g., “Nike Low Stock”, “Clearance Items”) |
filter_definition | JSON | Yes | Serialized filter state: {"brand": ["Nike"], "stock_status": ["LOW_STOCK"]} |
created_by | UUID | Yes | Staff member who created the filter |
is_shared | Boolean | Yes | Whether other staff can see and use this filter (default: false) |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
- Staff can save current filter combination as a named view
- Maximum 20 saved filters per user, 50 shared filters per tenant
- Shared filters visible to all staff in the tenant
3.9.4 Quick-Add & Favorites
Favorites:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
user_id | UUID | Yes | Staff member |
product_id | UUID | Yes | Favorited product |
sort_order | Integer | Yes | Display position |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | When favorited |
- Each staff member can pin up to 50 products
- One-tap add to cart from favorites panel
- Drag-drop reordering of favorites
- Favorites persist across terminals (tied to user, not device)
Quick-Add Buttons:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
location_id | UUID | Yes | Location this configuration applies to |
product_id | UUID | Yes | Product assigned to the button |
grid_position | Integer | Yes | Position in the grid (1-20) |
button_color | String(7) | No | Hex color for button background (e.g., “#FF5733”) |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
- Configurable grid of up to 20 high-velocity items on POS home screen
- Each button displays: product image thumbnail, product name (truncated), price
- Configurable per location (e.g., store-specific quick items)
- Manager or Admin role required to configure quick-add buttons
Recent Products:
- Last 20 products viewed or added to cart, displayed in a “Recents” side panel
- Per-user, per-session (resets on logout)
- One-tap to add to cart
- Stored in local state (not persisted to database)
Department Quick-Keys:
- Configurable shortcut buttons for top-level categories (departments)
- Up to 8 department quick-keys displayed in a horizontal bar above the search field
- Tapping a quick-key navigates directly to that department’s category browse view
- Configurable per location by Manager or Admin
3.9.5 Product Substitutions & Recommendations
Out-of-Stock Alternatives:
When a scanned or searched product has zero available stock at the current location, the system presents alternatives in priority order:
| Priority | Suggestion Type | Logic | Display |
|---|---|---|---|
| 1 | Same product at another location | Query inventory where product_id matches AND qty_available > 0 at other locations | Location name, stock qty, distance (if configured) |
| 2 | Similar products | Same category_id AND base_price within +/- 20% of original AND lifecycle_status = ACTIVE AND qty_available > 0 | Product name, price, stock qty |
| 3 | Same brand alternatives | Same brand AND lifecycle_status = ACTIVE AND qty_available > 0, ordered by sales velocity | Product name, price, stock qty |
Cross-Sell / Upsell Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Source product |
related_product_id | UUID | Yes | Related product |
relationship_type | Enum | Yes | SUBSTITUTE, CROSS_SELL, UPSELL, ACCESSORY |
priority | Integer | Yes | Display order (1 = highest priority) |
auto_generated | Boolean | Yes | true if system-generated from order history, false if manually defined |
confidence_score | Decimal(3,2) | No | For auto-generated: co-purchase frequency score (0.00-1.00) |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
Manual Relationships:
- Staff defines “related products” per product via the admin product detail page
- Relationship types: SUBSTITUTE (alternative), CROSS_SELL (complementary), UPSELL (upgrade), ACCESSORY (add-on)
- Maximum 10 manual relationships per product per type
Auto-Generated Relationships:
- Background job analyzes completed order history (rolling 90 days)
- Identifies products frequently purchased together (co-purchase frequency >= 3 occurrences)
- Generates
CROSS_SELLrelationships with confidence score based on frequency - Auto-generated relationships are refreshed weekly
- Staff can promote an auto-generated relationship to manual (persists beyond refresh)
POS Display:
- “Customers Also Bought” panel shown during checkout when cart contains products with cross-sell relationships
- Maximum 4 suggestions displayed, ordered by priority then confidence score
- Staff can dismiss suggestions or tap to add to cart
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
U->>UI: Scan barcode for "Classic Tee"
UI->>API: POST /products/barcode-lookup
API->>DB: Fetch product + inventory
DB-->>API: Product found, qty_available = 0
API-->>UI: Product data (OUT OF STOCK)
UI-->>U: "Classic Tee is out of stock at this location"
par Fetch Alternatives
API->>DB: Same product at other locations
DB-->>API: Store B: 12 units, Store C: 5 units
and
API->>DB: Similar products (same category, ±20% price)
DB-->>API: "V-Neck Tee" $27.99 (8 in stock), "Crew Neck" $31.99 (15 in stock)
and
API->>DB: Same brand alternatives
DB-->>API: "Classic Tee V2" $29.99 (20 in stock)
end
UI-->>U: Display alternatives panel
Note right of UI: 1. Same item at Store B (12) / Store C (5)
Note right of UI: 2. V-Neck Tee $27.99 (8) / Crew Neck $31.99 (15)
Note right of UI: 3. Classic Tee V2 $29.99 (20)
alt Staff selects alternative
U->>UI: Tap "V-Neck Tee"
UI->>UI: Add to cart
else Staff initiates transfer
U->>UI: Tap "Request from Store B"
UI->>API: POST /transfers/request
end
3.9.6 Reports: Search & Discovery
| Report | Purpose | Key Data Fields |
|---|---|---|
| Search Failure Log | Searches that returned zero results, identifying catalog gaps | Search term, timestamp, terminal, staff member, location |
| Top Searches | Most frequent search terms for merchandising insight | Search term, frequency, avg results returned, conversion rate (searches that led to cart add) |
| Search Conversion Funnel | Track search-to-sale effectiveness | Total searches, searches with results, searches with cart add, searches leading to sale |
| Substitute Offered vs Accepted | Effectiveness of substitution suggestions | Original product, substitute offered, offered count, accepted count, accepted %, declined % |
| Favorites Usage | Staff utilization of favorites feature | Staff member, favorite count, favorites used in sales, top 10 favorited products |
| Quick-Add Button Performance | Click-through rate of quick-add buttons | Button position, product, click count, resulting sales, revenue from quick-add |
3.10 Label & Price Tag Printing
Scope: Generating and printing barcode labels, shelf price tags, and clearance stickers from the catalog. Supports multiple label formats, batch printing, and integration with standard label printers (Zebra, DYMO, Brother).
3.10.1 Label Types
| Label Type | Content Fields | Use Case | Standard Size |
|---|---|---|---|
| Barcode Label | SKU, barcode (scannable), product name, price | Tagging individual items for checkout scanning | 50mm x 25mm |
| Shelf Price Tag | Product name, price, compare_at_price (if on sale), category, SKU | Shelf-edge display showing current price | 60mm x 40mm |
| Clearance Sticker | Markdown price, original price (strikethrough), discount %, “CLEARANCE” badge | Identifying marked-down clearance items | 40mm x 30mm |
| Variant Label | Parent name, size, color (variant dimensions), SKU, barcode (scannable), price | Per-variant tagging for variant products | 50mm x 25mm |
| Bin Label | SKU, barcode (scannable), product name, location code, bin/shelf position | Warehouse bin identification for inventory management | 100mm x 50mm |
3.10.2 Label Templates
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
name | String(100) | Yes | Template display name (e.g., “Standard Barcode 50x25”) |
label_type | Enum | Yes | BARCODE, SHELF_TAG, CLEARANCE, VARIANT, BIN |
width_mm | Integer | Yes | Label width in millimeters |
height_mm | Integer | Yes | Label height in millimeters |
layout_definition | JSON | Yes | Field positions, font sizes, barcode format, margins (see below) |
barcode_format | Enum | Yes | CODE128, EAN13, UPCA, QR_CODE |
is_default | Boolean | Yes | Default template for this label type (one per type per tenant) |
printer_language | Enum | Yes | ZPL (Zebra), DYMO_XML, BROTHER_ESC, RECEIPT_ESC_POS |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Layout Definition JSON Structure:
{
"fields": [
{ "name": "product_name", "x": 2, "y": 2, "font_size": 10, "max_width": 46, "bold": true },
{ "name": "barcode", "x": 2, "y": 10, "height": 8, "format": "CODE128" },
{ "name": "sku", "x": 2, "y": 20, "font_size": 7, "bold": false },
{ "name": "price", "x": 35, "y": 2, "font_size": 12, "bold": true, "prefix": "$" }
],
"margins": { "top": 1, "right": 1, "bottom": 1, "left": 1 }
}
Supported Printers:
| Printer | Language | Connection | Notes |
|---|---|---|---|
| Zebra ZD/ZT Series | ZPL (Zebra Programming Language) | USB, Network (TCP/IP) | Industry standard for retail labels |
| DYMO LabelWriter | DYMO XML (via DYMO SDK) | USB | Desktop label printing |
| Brother QL Series | Brother ESC/P | USB, Network | Versatile label printing |
| Receipt Printer (fallback) | ESC/POS | USB, Network | Print labels on 80mm receipt paper |
3.10.3 Batch Printing Workflow
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI / Admin
participant API as Backend
participant PS as Print Service
participant PR as Label Printer
Note over U, PR: Batch Label Printing Workflow
U->>UI: Select products (search, category, or bulk select)
UI-->>U: Display selected products (count, names)
U->>UI: Click "Print Labels"
UI-->>U: Show template selection dialog
U->>UI: Choose label template (e.g., "Standard Barcode 50x25")
UI-->>U: Show quantity options
U->>UI: Set quantity per product
Note right of UI: Default: 1 per product
Note right of UI: Option: "Match stock qty" auto-fills
U->>UI: Click "Preview"
UI->>API: POST /labels/preview
API-->>UI: Return rendered label previews (first 5)
UI-->>U: Display label preview grid
U->>UI: Select target printer from configured list
U->>UI: Click "Print"
UI->>API: POST /labels/print
API->>API: Generate label data for all products
API->>PS: Send print job (template + product data)
PS->>PS: Render labels in printer language (ZPL/DYMO/ESC)
PS->>PR: Send to printer
PR-->>PS: Print confirmation
PS-->>API: Job complete (labels_printed count)
API->>API: Log print job to print_log
API-->>UI: "Printed 45 labels on Zebra-Stockroom"
UI-->>U: Success confirmation
Print Job Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
template_id | UUID | Yes | Label template used |
printer_name | String(100) | Yes | Target printer identifier |
product_count | Integer | Yes | Number of distinct products |
label_count | Integer | Yes | Total labels printed (sum of quantities) |
status | Enum | Yes | QUEUED, PRINTING, COMPLETED, FAILED |
error_message | String | No | Error details if status is FAILED |
initiated_by | UUID | Yes | Staff member who started the print job |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | When the job was created |
completed_at | DateTime | No | When printing finished |
3.10.4 Print Triggers
| Trigger Event | Action | Prompt Behavior | Default Setting |
|---|---|---|---|
| On PO Receive | Prompt to print barcode labels for received items | Modal: “Print labels for 48 received items?” with template selection | Enabled (configurable per tenant) |
| On Transfer Receive | Prompt to print barcode labels for transferred items | Same modal as PO receive | Enabled |
| On Price Change | Prompt to print new shelf tags for price-changed products | Modal: “Price changed for 3 products. Print new shelf tags?” | Enabled |
| On Markdown | Prompt to print clearance stickers for marked-down items | Modal: “Print clearance stickers for 12 items?” with clearance template | Enabled |
| On New Product Published | Prompt to print barcode labels for newly published products | Modal: “Product published. Print labels?” | Disabled (configurable) |
| Manual | Staff initiates from product detail, category view, or bulk selection | No prompt – direct template selection | Always available |
Business Rules:
- Print triggers are configurable per tenant (enable/disable each trigger)
- Staff can dismiss any auto-prompt without printing
- All print events are logged regardless of trigger type
- Print triggers fire only at the location where the event occurred
3.10.5 Reports: Label Printing
| Report | Purpose | Key Data Fields |
|---|---|---|
| Label Print Log | Track all label printing activity | Template used, product count, labels printed, printer, staff member, timestamp, trigger type |
| Reprint Needed | Products with price changes since last label print | Product, current price, price at last label print, last print date, price delta |
| Printer Usage Report | Monitor printer workload and failures | Printer name, jobs processed, labels printed, failure count, avg job size |
| Label Cost Estimate | Estimate label media consumption | Label type, labels printed (period), estimated media cost (based on configured cost per label) |
3.11 Product Media
Scope: Managing product images and video content for POS display, catalog browsing, and Shopify synchronization. Each product supports multiple images with one designated primary, plus optional video links. Images are optimized for fast POS terminal rendering.
3.11.1 Image Management
Product Image Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Parent product reference |
variant_id | UUID | No | Variant reference (null = product-level image) |
url | String(500) | Yes | Full-resolution image URL |
thumbnail_urls | JSON | Yes | Generated thumbnails: {"sm": "url_64px", "md": "url_128px", "lg": "url_256px"} |
alt_text | String(255) | No | Accessibility description |
sort_order | Integer | Yes | Display position in gallery (1-based) |
is_primary | Boolean | Yes | Primary display image (one per product, one per variant) |
file_size_bytes | Integer | Yes | Original file size for storage tracking |
width_px | Integer | Yes | Original image width |
height_px | Integer | Yes | Original image height |
uploaded_by | UUID | Yes | Staff member who uploaded |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Upload timestamp |
Image Rules:
| Rule | Specification |
|---|---|
| Primary image | One per product; one per variant (optional). Displayed in POS search results, cart line items, and product detail. |
| Gallery images | Up to 20 additional images per product (max configurable per tenant, default 20) |
| Per-variant images | Variants can have their own images (e.g., different image per color). If no variant image, falls back to parent product primary image. |
| Supported formats | JPEG, PNG, WebP |
| Maximum file size | 5MB per image |
| Minimum resolution | 256 x 256 pixels |
| Maximum resolution | 4096 x 4096 pixels (larger images auto-resized on upload) |
| Drag-drop reordering | Staff reorders gallery images by dragging; sort_order updated in batch |
Thumbnail Generation:
On upload, the system auto-generates three thumbnail sizes:
| Size Key | Dimensions | Use Case |
|---|---|---|
sm | 64 x 64 px | POS cart line item, compact list view |
md | 128 x 128 px | POS search results grid |
lg | 256 x 256 px | POS product detail, category browse tile |
Thumbnails are generated asynchronously via a background job. Original image is stored as-is; thumbnails are derived copies.
3.11.2 Video Support
Product Video Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Parent product reference |
video_url | String(500) | Yes | Video URL (YouTube, Vimeo, or self-hosted) |
video_provider | Enum | No | YOUTUBE, VIMEO, SELF_HOSTED, OTHER |
title | String(255) | Yes | Video display title |
description | String(1000) | No | Brief description of video content |
thumbnail_url | String(500) | No | Custom thumbnail (auto-fetched from provider if not set) |
sort_order | Integer | Yes | Display position |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
Video Rules:
- URL-based video links only (no direct video file upload)
- Use cases: product demos, styling guides, assembly instructions, care guides
- Display: video tab on product detail page in Nexus POS
- Not displayed at POS terminal (bandwidth and performance consideration)
- Maximum 5 videos per product
3.11.3 Media Sync
Shopify Integration:
| Direction | Behavior |
|---|---|
| POS to Shopify (Initial Publish) | Primary image pushed to Shopify on first product publish. Sets as Shopify product featured image. |
| POS to Shopify (Updates) | Primary image updates push to Shopify. Additional gallery images are NOT auto-synced (managed in Shopify separately). |
| Shopify to POS | Not synced. Shopify-managed images remain in Shopify only. POS images are POS-authoritative. |
Image Optimization Pipeline:
sequenceDiagram
autonumber
participant U as Staff
participant UI as Admin UI
participant API as Backend
participant IMG as Image Service
participant CDN as CDN
participant DB as DB
U->>UI: Upload product image (drag-drop or file select)
UI->>UI: Client-side validation (format, size < 5MB)
UI->>API: POST /products/{id}/images (multipart upload)
API->>IMG: Process image
IMG->>IMG: Validate dimensions (min 256x256)
IMG->>IMG: Auto-resize if > 4096x4096
IMG->>IMG: Compress (quality 85%, strip EXIF metadata)
IMG->>IMG: Convert to WebP (if not already)
par Thumbnail Generation
IMG->>IMG: Generate 64x64 thumbnail (sm)
IMG->>IMG: Generate 128x128 thumbnail (md)
IMG->>IMG: Generate 256x256 thumbnail (lg)
end
IMG->>CDN: Upload original + 3 thumbnails
CDN-->>IMG: Return CDN URLs
IMG-->>API: Return URLs (original + thumbnails)
API->>DB: Save image record with URLs
API-->>UI: Image uploaded successfully
UI-->>U: Display new image in gallery
CDN Delivery:
- All images served via CDN URL for fast display across all locations
- CDN cache TTL: 30 days (images are immutable; new upload = new URL)
- Fallback: if CDN is unavailable, images served from origin storage
3.12 Product Notes & Attachments
Scope: Supporting structured internal notes and file attachments on products for buying decisions, vendor communication, quality tracking, and staff communication. Notes and attachments are visible only to MANAGER/OWNER roles in Nexus POS, not at the POS register.
3.12.1 Structured Note Types
| Note Type | Purpose | Typical Authors | Icon |
|---|---|---|---|
| Buying Note | Purchasing decisions, reorder plans, vendor negotiation details | Buyers, Managers | Shopping cart icon |
| Vendor Note | Vendor communication, lead time updates, quality issues, terms changes | Buyers, Admin | Truck icon |
| Quality Note | Quality inspections, defect reports, customer complaints about product | Staff, Managers | Checkmark/shield icon |
| Staff Note | General internal communication about the product | Any staff | Chat bubble icon |
Product Note Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Parent product reference |
note_type | Enum | Yes | BUYING, VENDOR, QUALITY, STAFF |
content | Text | Yes | Note text (max 5,000 characters) |
is_pinned | Boolean | Yes | Pinned notes display at top (default: false) |
created_by | UUID | Yes | Staff member who created the note |
updated_by | UUID | No | Staff member who last edited (null if never edited) |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Only the note creator or an Admin can edit or delete a note
- Pinned notes always sort to the top, regardless of date
- Maximum 1 pinned note per note type per product (pinning a new note of the same type unpins the previous)
- Notes are never hard-deleted; they are soft-deleted with a
deleted_attimestamp for audit
3.12.2 File Attachments
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Parent product reference |
file_name | String(255) | Yes | Original uploaded file name |
file_url | String(500) | Yes | Storage URL for download |
file_type | Enum | Yes | PDF, JPEG, PNG, XLSX, DOCX, CSV |
file_size_bytes | Integer | Yes | File size for storage tracking |
description | String(500) | No | Brief description of the attachment |
uploaded_by | UUID | Yes | Staff member who uploaded |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Upload timestamp |
Attachment Rules:
| Rule | Specification |
|---|---|
| Supported file types | PDF, JPEG, PNG, XLSX, DOCX, CSV |
| Maximum file size | 10MB per attachment |
| Maximum attachments per product | 20 |
| Common use cases | Spec sheets, certificates of authenticity, vendor catalogs, care instructions, import documents |
| Access control | Any staff can view; Buyer, Manager, or Admin can upload/delete |
3.12.3 Display
Admin Product Detail Page – Notes Tab:
┌─────────────────────────────────────────────────────────┐
│ Notes (7) [+ Add Note ▼] │
│ ─────────────────────────────────────────────────────── │
│ Filter: [All Types ▼] [Show Pinned Only ☐] │
│ │
│ 📌 BUYING NOTE — Jan 15, 2026 by Sarah (Buyer) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Reorder 200 units for Spring. Vendor confirmed │ │
│ │ lead time of 21 days. Negotiate 5% volume disc. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ QUALITY NOTE — Jan 12, 2026 by Mike (Manager) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Customer complaint: stitching loose on collar. │ │
│ │ Inspected 5 units from last batch - 2 defective. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ STAFF NOTE — Jan 10, 2026 by Jane (Staff) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ This item sells best when displayed near the │ │
│ │ front entrance. Move to endcap for weekends. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ─────────────────────────────────────────────────────── │
│ Attachments (3) [+ Upload File] │
│ │
│ 📎 Nike-SS26-Spec-Sheet.pdf (1.2 MB) [Download] │
│ 📎 Care-Instructions-EN.pdf (340 KB) [Download] │
│ 📎 Vendor-Quote-Jan2026.xlsx (85 KB) [Download] │
└─────────────────────────────────────────────────────────┘
Product List View:
- Note count badge displayed on product rows (e.g., “3 notes”) as a small indicator
- Badge color: grey for staff notes only, yellow if any buying/vendor notes exist, red if any quality notes exist
3.13 Catalog Permissions & Approvals
Scope: Controlling access to catalog features through role-based permissions, field-level edit restrictions, and approval workflows for sensitive changes (pricing, cost, lifecycle transitions). All permission-governed actions are logged to the audit trail defined in Section 3.13.4.
3.13.1 Role-Based Catalog Access
| Permission | Admin | Buyer | Manager | Staff |
|---|---|---|---|---|
| View Products | Yes | Yes | Yes | Yes |
| Create Products | Yes | Yes | Yes | No |
| Edit Products | Yes | Yes | Yes | No (view only) |
| Change Price | Yes | No | Yes | No |
| Change Cost | Yes | Yes | No | No |
| Change Lifecycle Status | Yes | No | Yes | No |
| Delete Products | Yes | No | No | No |
| Approve Changes | Yes | No | Yes | No |
| Manage Categories | Yes | No | Yes | No |
| Manage Vendors | Yes | Yes | No | No |
| Create Purchase Orders | Yes | Yes | Yes | No |
| Submit Purchase Orders | Yes | Yes | No | No |
| Receive Inventory | Yes | Yes | Yes | No |
| Configure Templates/Buttons | Yes | No | Yes | No |
| Export Catalog Data | Yes | Yes | Yes | No |
| Import Catalog Data | Yes | Yes | No | No |
Role Assignment:
- Roles are assigned per user per tenant (a user can have different roles in different tenants)
- A user can hold exactly one catalog role per tenant
- Role assignment requires Admin permission
- Role changes take effect on the user’s next login (or session refresh)
3.13.2 Field-Level Permissions
Configurable per role per tenant. The tenant Admin can customize which fields each role can edit versus view as read-only.
Default Field-Level Restrictions:
| Field(s) | Admin | Buyer | Manager | Staff |
|---|---|---|---|---|
base_price, compare_at_price | Editable | Read-only | Editable | Read-only |
cost, vendor_cost | Editable | Editable | Read-only | Read-only |
lifecycle_status | Editable | Read-only | Editable | Read-only |
category_id | Editable | Editable | Editable | Read-only |
name, description, tags | Editable | Editable | Editable | Read-only |
barcode, alternate_barcodes | Editable | Editable | Editable | Read-only |
images, media | Editable | Editable | Editable | Read-only |
tax_code | Editable | Read-only | Editable | Read-only |
track_inventory, allow_negative | Editable | Read-only | Editable | Read-only |
low_stock_threshold | Editable | Editable | Editable | Read-only |
| Custom attributes | Editable | Editable | Editable | Read-only |
Field Permission Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
role_id | UUID | Yes | Reference to role |
field_name | String(100) | Yes | Product field name (e.g., “base_price”, “cost”) |
permission | Enum | Yes | READ_ONLY or EDITABLE |
tenant_id | UUID | Yes | Tenant scope |
updated_at | DateTime | Yes | Last modification timestamp |
Constraint: Unique on (role_id, field_name, tenant_id).
3.13.3 Approval Workflows
Configurable approval rules determine which catalog changes require manager or admin sign-off before taking effect.
Default Approval Rules:
| Change Type | Condition | Approval Required From | Priority |
|---|---|---|---|
| Price decrease | > 10% reduction from current price | Manager | Medium |
| Price decrease | > 30% reduction from current price | Admin | High |
| Price increase | > 50% increase from current price | Manager | Medium |
| Cost change | Any modification to cost or vendor_cost | Buyer or Admin | Medium |
| Product activation | Draft to Active transition | Manager | Low |
| Product deactivation | Active to Discontinued transition | Manager | Medium |
| Product deletion | Any deletion of Active or Discontinued product | Admin | High |
| Bulk price change | Any bulk operation affecting base_price | Manager | High |
| Bulk status change | Any bulk operation affecting lifecycle_status | Manager | High |
Approval Rule Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
change_type | Enum | Yes | PRICE_DECREASE, PRICE_INCREASE, COST_CHANGE, STATUS_CHANGE, PRODUCT_DELETION, BULK_PRICE, BULK_STATUS |
condition | JSON | Yes | Threshold definition: {"field": "base_price", "operator": "decrease_pct_gt", "value": 10} |
required_role | Enum | Yes | Minimum role required to approve: MANAGER, ADMIN, BUYER_OR_ADMIN |
is_active | Boolean | Yes | Whether this rule is currently enforced |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Approval Request Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
approval_rule_id | UUID | Yes | Rule that triggered this approval |
change_type | Enum | Yes | Type of change requested |
product_id | UUID | Yes | Product affected |
field_name | String(100) | Yes | Field being changed |
old_value | String(500) | Yes | Current value (serialized) |
new_value | String(500) | Yes | Proposed value (serialized) |
reason | String(1000) | No | Requester’s justification |
requested_by | UUID | Yes | Staff member requesting the change |
approved_by | UUID | No | Staff member who approved (null if pending or rejected) |
status | Enum | Yes | PENDING, APPROVED, REJECTED, EXPIRED |
rejection_reason | String(1000) | No | Why the change was rejected |
expires_at | DateTime | No | Auto-expire if not acted on (default: 7 days) |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | When the request was created |
resolved_at | DateTime | No | When the request was approved or rejected |
Approval Workflow:
sequenceDiagram
autonumber
participant U as Staff / Buyer
participant UI as Admin UI
participant API as Backend
participant DB as DB
participant N as Notification Service
participant A as Approver (Manager/Admin)
U->>UI: Edit product field (e.g., reduce price by 25%)
UI->>API: PUT /products/{id}
API->>API: Check approval rules for this change
alt Approval Required
API->>DB: Create approval_request (status: PENDING)
API->>DB: Store proposed change (do NOT apply yet)
API->>N: Send notification to eligible approvers
N-->>A: "Price change requires your approval"
API-->>UI: "Change submitted for approval"
UI-->>U: "Pending manager approval. Price unchanged until approved."
Note over A, DB: Approver Reviews
A->>UI: Open "Pending Approvals" queue
UI->>API: GET /approvals?status=PENDING
API-->>UI: List of pending approval requests
A->>UI: Review change details
Note right of UI: Shows: product, field, old value, new value, who requested, reason
alt Approve
A->>UI: Click "Approve"
UI->>API: POST /approvals/{id}/approve
API->>DB: Update approval_request (status: APPROVED, approved_by, resolved_at)
API->>DB: Apply the change to the product
API->>DB: Log to audit_trail (with approval_id reference)
API->>N: Notify requester "Your change was approved"
API-->>UI: "Change approved and applied"
else Reject
A->>UI: Click "Reject" + enter reason
UI->>API: POST /approvals/{id}/reject
API->>DB: Update approval_request (status: REJECTED, rejection_reason, resolved_at)
API->>N: Notify requester "Your change was rejected: [reason]"
API-->>UI: "Change rejected"
end
else No Approval Required
API->>DB: Apply change directly
API->>DB: Log to audit_trail
API-->>UI: "Change saved"
end
Business Rules:
- Pending approval requests expire after 7 days (configurable per tenant) and auto-set to
EXPIRED - A requester cannot approve their own change request
- If the product is edited again while a pending approval exists for the same field, the older request is auto-cancelled
- Admin can bypass approval requirements (self-approving)
- Approval rules can be enabled or disabled per tenant without deleting the rule definition
3.13.4 Audit Trail
Every field-level change to any catalog product is logged to an immutable audit trail.
Audit Trail Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Product that was changed |
field_name | String(100) | Yes | Field that changed |
old_value | Text | No | Previous value (null for new records) |
new_value | Text | Yes | New value |
changed_by | UUID | Yes | User who made the change |
change_source | Enum | Yes | MANUAL, IMPORT, SYNC, BULK, SYSTEM, APPROVAL |
approval_id | UUID | No | Reference to approval request (if approval was required) |
ip_address | String(45) | No | IP address of the client |
user_agent | String(500) | No | Client user-agent string |
tenant_id | UUID | Yes | Tenant scope |
created_at | DateTime | Yes | Timestamp of the change |
Audit Trail Rules:
| Rule | Specification |
|---|---|
| Immutability | Audit records cannot be edited or deleted by any user, including Admin |
| Retention | Minimum 7 years (configurable per tenant; can be increased, never decreased) |
| Sensitive field highlighting | Changes to base_price, cost, compare_at_price, and lifecycle_status are visually flagged with a warning icon in the UI |
| Searchable | Audit trail is searchable per product, per user, per field, per date range, and per change source |
| Export | Audit trail exportable as CSV for compliance and external audit |
| Bulk change tracking | Bulk operations create one audit entry per product per field changed (not a single aggregate entry) |
Audit Trail View (Admin Product Detail Page):
┌─────────────────────────────────────────────────────────────────┐
│ Change History (42 changes) [Export CSV] [Filter ▼] │
│ ───────────────────────────────────────────────────────────── │
│ │
│ ⚠ Jan 20, 2026 14:32 — Mike (Manager) — APPROVAL │
│ base_price: $29.99 → $22.99 (approved by Sarah) │
│ │
│ Jan 18, 2026 09:15 — Jane (Buyer) — MANUAL │
│ tags: ["summer"] → ["summer", "clearance"] │
│ │
│ ⚠ Jan 15, 2026 11:00 — SYSTEM — SYNC │
│ lifecycle_status: ACTIVE → DISCONTINUED │
│ │
│ Jan 10, 2026 16:45 — Import Bot — IMPORT │
│ cost: $12.50 → $13.00 │
│ │
│ [Load More...] │
└─────────────────────────────────────────────────────────────────┘
3.13.5 Reports: Permissions & Audit
| Report | Purpose | Key Data Fields |
|---|---|---|
| Pending Approvals | Changes currently awaiting manager/admin approval | Product, change type, field, old value, new value, requested by, requested at, days pending, approver required |
| Approval History | Completed approval decisions with turnaround metrics | Product, change type, requested by, approved/rejected by, resolution time (hours), reason |
| Approval SLA Report | Measure approval response times against targets | Avg resolution time, % resolved within 24h, % expired, by approver |
| Change Audit Log | Complete catalog change history (filterable) | Product, field, old value, new value, changed by, change source, approval ID, timestamp |
| Change Volume Report | Aggregate change counts for workload analysis | Date, change count by source (manual/import/sync/bulk), change count by field, top changers |
| Permission Violations | Attempted unauthorized actions that were blocked | User, role, attempted action, product, timestamp, blocking rule |
3.14 Product Performance Analytics
Scope: Providing real-time product performance metrics embedded on the product detail page and a dedicated catalog analytics dashboard. These metrics drive inventory optimization, markdown decisions, and merchandising strategy.
3.14.1 Embedded Product Metrics
Displayed on each product’s detail page in Nexus POS:
| Metric | Calculation | Display Format | Color Coding |
|---|---|---|---|
| Sell-Through Rate | (Units Sold / Units Received) x 100 over selected period | Percentage with trend arrow (up/down vs prior period) | Green >= 70%, Yellow 40-69%, Red < 40% |
| Days of Supply | Current Stock / Avg Daily Sales (rolling 30 days) | Integer with unit “days” | Red < 14 days, Yellow 14-30 days, Green > 30 days |
| Gross Margin % | ((Selling Price - Weighted Avg Cost) / Selling Price) x 100 | Percentage | Red < 30%, Yellow 30-50%, Green > 50% |
| Sales Velocity | Units sold per week (rolling 4-week average) | Decimal units/week with sparkline chart (8-week trend) | No color coding; sparkline shows trend |
| Inventory Aging | Days since last receive at each location | Days per location | Green < 60 days, Yellow 60-120 days, Red > 120 days |
| ABC Classification | Revenue-based Pareto analysis (see 3.14.2) | Badge: A / B / C / NEW | A = Green, B = Blue, C = Grey, NEW = Purple |
| Stock Turn Rate | (COGS / Avg Inventory Value) annualized | Decimal turns/year | Red < 2, Yellow 2-4, Green > 4 |
| Revenue (Period) | Sum of (qty_sold x selling_price) in selected date range | Currency with period selector | No color coding |
Metric Display Layout (Admin Product Detail):
┌─────────────────────────────────────────────────────────────┐
│ Performance Metrics Period: [Last 30 Days ▼] │
│ ───────────────────────────────────────────────────────── │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Sell-Thru │ │ Days of │ │ Margin │ │ Velocity │ │
│ │ 72% ▲ │ │ Supply │ │ 54.2% │ │ 8.3/wk │ │
│ │ (Green) │ │ 18 days │ │ (Green) │ │ ~~~~~~~~ │ │
│ │ │ │ (Yellow) │ │ │ │ sparkline│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Aging │ │ ABC │ │ Turns │ │ Revenue │ │
│ │ 45 days │ │ [A] │ │ 6.2/yr │ │ $12,450 │ │
│ │ (Green) │ │ (Green) │ │ (Green) │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
3.14.2 ABC Classification
Calculation Method: Revenue-based Pareto analysis, calculated monthly via background job.
| Class | Revenue Contribution | Product Population | Attention Level |
|---|---|---|---|
| A | Top 20% of products generating ~80% of revenue | ~20% of active SKUs | High: frequent cycle counts, optimal stock levels, priority reorder |
| B | Next 30% of products generating ~15% of revenue | ~30% of active SKUs | Moderate: standard stock levels, regular review |
| C | Bottom 50% of products generating ~5% of revenue | ~50% of active SKUs | Low: review for markdown or discontinuation, minimal safety stock |
| NEW | Products active for < 60 days (insufficient data) | Varies | Exempt from classification until data accumulates |
ABC Classification Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | Product reference |
classification | Enum | Yes | A, B, C, NEW |
revenue_total | Decimal(12,2) | Yes | Revenue in the analysis period |
revenue_rank_pct | Decimal(5,2) | Yes | Percentile rank (0-100) by revenue |
analysis_period_start | Date | Yes | Start of the analysis period |
analysis_period_end | Date | Yes | End of the analysis period |
tenant_id | UUID | Yes | Tenant scope |
calculated_at | DateTime | Yes | When this classification was computed |
Business Rules:
- Recalculated on the 1st of each month using the trailing 12-month sales period
- Products with fewer than 60 days since
published_atare classified asNEW - Classification stored on the product record as
abc_classificationfield for fast query - Classification is tenant-scoped (each tenant’s products are ranked independently)
- ABC drives: reorder priority (A items reordered first), count scheduling (A items counted more frequently), display prominence in analytics
ABC Classification Impact on Operations:
| Area | A Items | B Items | C Items |
|---|---|---|---|
| Cycle Count Frequency | Weekly | Monthly | Quarterly |
| Safety Stock | 2 weeks of supply | 1 week of supply | Minimum (3 days) |
| Reorder Priority | First in auto-PO generation | Second priority | Only if manually flagged |
| Stockout Alerting | Immediate alert to Manager | End-of-day alert | Weekly report only |
| Markdown Review | Quarterly review | Semi-annual review | Monthly review (candidates for clearance) |
3.14.3 Catalog Analytics Dashboard
A dedicated dashboard accessible from the Nexus POS navigation.
Summary Cards (Top Row):
| Card | Metric | Calculation |
|---|---|---|
| Total Active SKUs | Count of products where lifecycle_status = ACTIVE | Real-time |
| Total Inventory Value | Sum of (qty_on_hand x weighted_avg_cost) across all locations | Refreshed hourly |
| Avg Gross Margin % | Average margin across all active products (weighted by revenue) | Refreshed daily |
| Avg Days of Supply | Average days of supply across all active products | Refreshed daily |
Charts:
| Chart | Type | Data | Purpose |
|---|---|---|---|
| Top 10 by Revenue | Horizontal bar chart | Top 10 products by revenue in selected period | Identify best sellers |
| Bottom 10 by Sell-Through | Horizontal bar chart | Bottom 10 products by sell-through rate | Candidates for markdown or discontinuation |
| ABC Distribution | Pie chart (dual-ring) | Inner ring: product count per class. Outer ring: revenue per class | Visualize catalog health |
| Inventory Aging Histogram | Stacked bar chart | Buckets: 0-30, 30-60, 60-90, 90-120, 120+ days | Identify aging inventory |
| Category Margin Comparison | Horizontal bar chart | Avg margin by top-level category | Compare category profitability |
| Seasonal Sell-Through | Multi-line chart | Sell-through rate per season over time | Compare seasonal performance |
| Velocity vs Stock Scatter | Scatter plot | X = velocity, Y = stock qty, color = ABC class | Spot overstock/understock |
Dashboard Filters:
| Filter | Type | Scope |
|---|---|---|
| Date Range | Date picker | All charts and metrics |
| Location | Multi-select | Filter to specific store(s) or all |
| Category | Tree-select | Filter to specific category branch |
| Brand | Multi-select | Filter to specific brand(s) |
| Season | Multi-select | Filter to season collection |
| ABC Class | Multi-select | Filter by A, B, C, or NEW |
3.14.4 Reports: Analytics
| Report | Purpose | Key Data Fields |
|---|---|---|
| Product Scorecard | Full performance summary per product, exportable | Product, SKU, revenue, units sold, margin %, velocity, aging, ABC class, sell-through %, days of supply, stock turn |
| Slow Movers | Products with low velocity and high stock (markdown candidates) | Product, stock qty, days of supply, velocity, last sale date, ABC class, recommended action |
| Overstock Alert | Products exceeding target days of supply threshold | Product, location, stock qty, target DOS, actual DOS, excess units, excess value at cost |
| Dead Stock | Zero sales in configurable period (default 90 days) | Product, last sale date, stock qty, stock value at cost, days without sale, ABC class |
| Margin Erosion | Products where margin has declined over time | Product, margin 90 days ago, current margin, margin delta, cause (cost increase / price decrease / promo) |
| ABC Migration | Products that changed classification between periods | Product, previous class, current class, revenue change, velocity change |
3.15 Catalog User Stories & Acceptance Criteria (EXPANDED)
Note: Epics 3.A through 3.E and their existing Gherkin scenarios remain unchanged. The following adds new Epics 3.F through 3.S and new Gherkin acceptance criteria for key features.
Epic 3.F: Pricing Engine
- Story 3.F.1 (Price Hierarchy): System resolves product price using cascading hierarchy: Manual Override > Promotion > Price Book > Channel Price > Global Default. The customer always receives the best applicable price.
- Story 3.F.2 (Price Books): Admin can create named price books restricted by customer group, channel, and date range. Price book entries override base price for matched products.
- Story 3.F.3 (Promotions): Staff can create four promotion types: Basic (% or $ off), Tiered (volume pricing), BOGO (cross-item), and Scheduled (time-based). Promotions follow a Draft > Scheduled > Active > Expired lifecycle.
- Story 3.F.4 (Markdown Workflow): Price reductions follow a formal workflow: request > manager approval > scheduled price change. All price changes logged with who/when/why for accountability.
- Story 3.F.5 (Conflict Resolution): When multiple pricing rules match, system applies best-price-for-customer logic. Exclusive promotions override all other pricing. Stackable promotions combine up to configurable max discount.
Epic 3.G: Multi-Channel
- Story 3.G.1 (Channel Visibility): Staff can toggle product visibility per channel (In-Store, Online, Wholesale). Products must be visible on at least one channel to be Active.
- Story 3.G.2 (Channel Inventory): Admin can choose shared pool (default) or dedicated allocation per channel. Dedicated mode reserves specific quantities per channel.
- Story 3.G.3 (Channel Pricing): Products can have different prices per channel. Channel price overrides base price per the price hierarchy.
Epic 3.H: Shopify Integration
- Story 3.H.1 (POS-Master Sync): Product changes in POS automatically push to Shopify for POS-owned fields. Shopify-only fields (SEO, metafields) are preserved.
- Story 3.H.2 (Bidirectional Mode): Admin can enable bidirectional sync per tenant. POS-priority conflict resolution applies to shared fields.
- Story 3.H.3 (Sync Monitoring): Admin dashboard shows sync status, pending changes, failed syncs, and conflict log.
Epic 3.I: Search & Discovery
- Story 3.I.1 (Full-Text Search): POS search supports name, SKU, barcode, tags, vendor, brand, and custom attributes with fuzzy matching and auto-complete. Results return in under 200ms.
- Story 3.I.2 (Favorites & Quick-Add): Staff can pin up to 50 favorite products for one-tap access and configure up to 20 quick-add buttons on the POS home screen.
- Story 3.I.3 (Substitutions): When a product is out of stock, system suggests alternatives: same product at other locations, similar products in same category, and related products from cross-sell configuration.
Epic 3.J: Labels & Printing
- Story 3.J.1 (Label Printing): Staff can select products and print barcode labels, shelf tags, or clearance stickers using configurable templates. Supports Zebra, DYMO, Brother, and receipt printer output.
- Story 3.J.2 (Auto-Print Triggers): System prompts label printing on PO receive, transfer receive, price change, and markdown events. Triggers are configurable per tenant.
Epic 3.K: Media Management
- Story 3.K.1 (Product Images): Products support one primary image plus a gallery of up to 20 images. Per-variant images are supported. Drag-drop reorder. Auto-generated thumbnails (64px, 128px, 256px).
- Story 3.K.2 (Video): Products can link to video URLs (YouTube, Vimeo, self-hosted) for demos and styling guides. Videos displayed on admin product detail page only (not POS terminal).
Epic 3.L: Permissions & Approvals
- Story 3.L.1 (Role-Based Access): Four catalog roles (Admin, Buyer, Manager, Staff) with configurable field-level permissions. Staff is view-only. Admin has full access.
- Story 3.L.2 (Approval Workflows): Price decreases > 10% require Manager approval. Price decreases > 30% require Admin approval. Cost changes require Buyer/Admin approval. Rules are configurable per tenant.
- Story 3.L.3 (Audit Trail): Every field change logged with who, when, old value, new value, and change source. Searchable per product. Minimum 7-year retention. Exportable as CSV.
Epic 3.M: Analytics
- Story 3.M.1 (Product Metrics): Product detail page shows sell-through rate, days of supply, gross margin, sales velocity (sparkline), inventory aging, ABC classification, stock turn rate, and period revenue.
- Story 3.M.2 (ABC Classification): Monthly Pareto analysis classifies products as A (top 20% by revenue), B (next 30%), C (bottom 50%). New products exempt until 60 days of data.
- Story 3.M.3 (Analytics Dashboard): Dedicated catalog dashboard with summary cards, top/bottom performers, ABC distribution chart, aging histogram, category margin comparison, and seasonal sell-through trends.
Catalog Acceptance Criteria: New Gherkin Scenarios
Feature: Price Hierarchy Resolution
Feature: Price Hierarchy Resolution
As a POS system
I need to resolve the correct price using the pricing hierarchy
So that customers always receive the best applicable price
Background:
Given product "Classic Tee" has base_price "$29.99"
And product "Classic Tee" is in "ACTIVE" lifecycle status
Scenario: Base price used when no overrides exist
Given no channel price, price book, or promotion applies to "Classic Tee"
When any customer adds "Classic Tee" to cart
Then the price should be "$29.99"
Scenario: Channel price overrides base price
Given channel "WHOLESALE" has price "$19.99" for "Classic Tee"
When a wholesale customer adds "Classic Tee" to cart
Then the price should be "$19.99"
Scenario: Price book overrides channel price
Given price book "Employee Discount" is active for customer group "Employees"
And "Employee Discount" has "Classic Tee" at "$14.99"
And channel "IN_STORE" has price "$29.99" for "Classic Tee"
When employee "John" adds "Classic Tee" to cart
Then the price should be "$14.99"
Scenario: Promotion overrides price book when better for customer
Given promotion "Summer Sale" is active with 30% off "Classic Tee"
And price book "Employee Discount" has "Classic Tee" at "$14.99"
When employee "John" adds "Classic Tee" to cart
Then the price should be "$14.99"
And the system should apply best-price-for-customer logic
And the applied pricing source should be "PRICE_BOOK"
Scenario: Promotion wins when it gives the lower price
Given promotion "Flash Sale" is active with 60% off "Classic Tee"
And price book "Employee Discount" has "Classic Tee" at "$14.99"
When employee "John" adds "Classic Tee" to cart
Then the price should be "$12.00"
And the applied pricing source should be "PROMOTION"
Scenario: Manual override beats all other pricing
Given promotion "Summer Sale" is active with 30% off "Classic Tee"
And price book "Employee Discount" has "Classic Tee" at "$14.99"
When manager "Mike" applies manual override price "$10.00" to "Classic Tee"
Then the price should be "$10.00"
And the applied pricing source should be "MANUAL_OVERRIDE"
And the override should be logged to the audit trail
Scenario: Exclusive promotion overrides stackable promotions
Given exclusive promotion "VIP Members Only" is active with 40% off "Classic Tee"
And stackable promotion "Summer Sale" is active with 10% off "Classic Tee"
When a VIP customer adds "Classic Tee" to cart
Then only the exclusive promotion should apply
And the price should be "$17.99"
Scenario: Stackable promotions combine up to max discount
Given stackable promotion "Summer Sale" is active with 10% off "Classic Tee"
And stackable promotion "Newsletter Signup" is active with 5% off "Classic Tee"
And tenant max discount is configured at 50%
When a qualifying customer adds "Classic Tee" to cart
Then both promotions should apply
And the combined discount should be 15%
And the price should be "$25.49"
Feature: Markdown Workflow with Approval
Feature: Markdown Workflow with Approval
As a catalog manager
I need price reductions to follow an approval workflow
So that markdowns are controlled and accountable
Background:
Given product "Slow Seller" has base_price "$49.99"
And approval rule exists: price decrease > 10% requires Manager approval
And approval rule exists: price decrease > 30% requires Admin approval
Scenario: Small price decrease requires no approval
When staff "Jane" changes price to "$47.99" (4% decrease)
Then the price should change immediately to "$47.99"
And the change should be logged to the audit trail
And no approval request should be created
Scenario: Moderate price decrease requires manager approval
When staff "Jane" requests markdown to "$39.99" (20% decrease) with reason "Low sell-through"
Then an approval request should be created with status "PENDING"
And the product price should remain "$49.99" until approved
And manager "Mike" should receive a notification
Scenario: Manager approves the markdown
Given a pending approval exists for "Slow Seller" price change to "$39.99"
When manager "Mike" approves the markdown
Then the product price should change to "$39.99"
And the approval status should be "APPROVED"
And the audit log should record the change with requester "Jane", approver "Mike", and reason "Low sell-through"
And staff "Jane" should receive a notification "Your markdown was approved"
Scenario: Manager rejects the markdown
Given a pending approval exists for "Slow Seller" price change to "$39.99"
When manager "Mike" rejects the markdown with reason "Wait for end-of-season clearance"
Then the product price should remain "$49.99"
And the approval status should be "REJECTED"
And staff "Jane" should receive a notification with the rejection reason
Scenario: Large price decrease escalates to admin
When staff "Jane" requests markdown to "$29.99" (40% decrease) with reason "Clearance"
Then an approval request should be created requiring Admin approval
And manager approval should NOT be sufficient
Scenario: Approval request expires after 7 days
Given a pending approval was created 8 days ago for "Slow Seller"
When the expiration job runs
Then the approval status should change to "EXPIRED"
And the product price should remain unchanged
And the requester should be notified of expiration
Scenario: Requester cannot approve their own change
When staff "Jane" requests markdown to "$39.99"
And "Jane" also has Manager role
Then "Jane" should not be able to approve her own request
And the system should show "Cannot approve your own change request"
Feature: POS-Shopify Catalog Sync
Feature: POS-Shopify Catalog Sync
As a multi-channel retailer
I need product changes to sync between POS and Shopify
So that online and in-store catalogs stay consistent
Background:
Given product "Classic Tee" exists in POS with SKU "BLK-TEE-001"
And product "Classic Tee" is synced with Shopify product ID "shop_12345"
Scenario: POS price change pushes to Shopify in POS-Master mode
Given sync mode is "POS-Master"
When staff changes price from "$29.99" to "$24.99" in POS
Then the price should update in Shopify within the sync interval
And Shopify SEO title should remain unchanged
And Shopify metafields should remain unchanged
And sync log should record: field "price", direction "POS_TO_SHOPIFY", status "SUCCESS"
Scenario: POS-owned fields are protected from Shopify overwrite
Given sync mode is "POS-Master"
When someone edits the price to "$27.99" directly in Shopify
Then the next sync cycle should overwrite Shopify price back to "$24.99"
And a conflict entry should be logged with source "SHOPIFY", rejected value "$27.99"
Scenario: Shopify description change syncs in bidirectional mode
Given sync mode is "Bidirectional with POS Priority"
And "long_description" is configured with sync direction "Configurable"
And "long_description" is set to "Shopify-to-POS" direction
When an SEO agency updates the description in Shopify
Then the new description should sync to POS on the next cycle
And POS-owned fields (price, SKU, variants) should not be affected
And sync log should record: field "long_description", direction "SHOPIFY_TO_POS", status "SUCCESS"
Scenario: Conflict resolution when both sides change the same field
Given sync mode is "Bidirectional with POS Priority"
And "base_price" is a POS-priority field
When POS changes price to "$24.99" between sync cycles
And Shopify changes price to "$27.99" between the same sync cycles
Then the POS price "$24.99" should win
And Shopify should be updated to "$24.99"
And a conflict audit entry should be created
And the conflict log should show: POS value "$24.99" (applied), Shopify value "$27.99" (rejected)
Scenario: New product publish creates Shopify listing
Given product "New Arrival Shirt" is in "DRAFT" status in POS
And the product has a primary image
When staff publishes the product (Draft to Active)
And channel "Online" is enabled for this product
Then a new Shopify product should be created
And the primary image should be pushed to Shopify
And Shopify product status should be set to "active"
And the Shopify product ID should be stored in the sync mapping table
Scenario: Sync failure is logged and retried
Given sync mode is "POS-Master"
When staff changes price in POS
And the Shopify API returns a 500 error
Then the sync should be marked as "FAILED" in the sync log
And the change should be queued for retry
And after 3 failed retries, the sync should be flagged for manual review
And the admin sync dashboard should show the failure
Feature: Label Printing
Feature: Label Printing
As a retail staff member
I need to print barcode labels and price tags
So that products are properly tagged for sale
Scenario: Print barcode labels for received PO items
Given PO "PO-2026-00042" has just been received with 48 units of "Air Max 90"
When the receive is confirmed
Then the system should prompt "Print labels for 48 received items?"
When staff clicks "Print Labels"
And selects template "Standard Barcode 50x25"
And sets quantity to 48
And selects printer "Zebra-Stockroom"
And clicks "Print"
Then 48 labels should be sent to "Zebra-Stockroom"
And the print log should record the job
Scenario: Auto-prompt on price change
Given product "Classic Tee" has a printed shelf tag at "$29.99"
When manager changes price to "$24.99"
Then the system should prompt "Price changed. Print new shelf tags?"
And the "Reprint Needed" report should include "Classic Tee"
Scenario: Batch print clearance stickers
Given 12 products have been marked down for clearance
When staff selects all 12 products from the clearance collection
And clicks "Print Labels"
And selects template "Clearance Sticker 40x30"
Then each label should show markdown price, original price (strikethrough), discount %, and "CLEARANCE" badge
And 12 labels should be printed
Scenario: Print labels on receipt printer fallback
Given no dedicated label printer is configured at "Store C"
When staff attempts to print labels
Then the system should offer the receipt printer as fallback
And labels should be formatted for 80mm receipt paper width
Feature: Product Search at POS
Feature: Product Search at POS
As a POS operator
I need to find products quickly using multiple search methods
So that checkout is fast and efficient
Scenario: Fuzzy search handles typos
Given product "Oxford Button-Down Shirt" exists
When staff types "oxfrd" in the search bar
Then "Oxford Button-Down Shirt" should appear in search results
And results should return within 200ms
Scenario: SKU exact match ranks highest
Given product "Classic Tee" has SKU "BLK-TEE-001"
And product "Black Tee Dress" has name containing "Tee"
When staff searches "BLK-TEE-001"
Then "Classic Tee" should be the first result (exact SKU match)
Scenario: Auto-complete shows suggestions after 2 characters
When staff types "Cl" in the search bar
Then auto-complete should show suggestions including "Classic Tee", "Classic Oxford", "Clearance Items"
And suggestions should appear within 150ms
Scenario: Out-of-stock product shows substitution suggestions
Given product "Classic Tee" has 0 units available at current location
And "Store B" has 12 units of "Classic Tee"
And "V-Neck Tee" is in the same category at "$27.99"
When staff searches and selects "Classic Tee"
Then the system should show "Out of stock at this location"
And suggest: "Available at Store B (12 units)"
And suggest: "Similar: V-Neck Tee $27.99 (8 in stock)"
Scenario: Quick-add button adds product to cart in one tap
Given "Classic Tee" is configured as quick-add button at position 1
When staff taps the quick-add button
Then "Classic Tee" should be added to cart with qty 1
And no search or product detail view should be required
Scenario: Saved filter retrieves matching products
Given staff saved a filter named "Nike Low Stock" with brand "Nike" and stock status "Low Stock"
When staff selects "Nike Low Stock" from saved filters
Then only Nike products with stock below low_stock_threshold should be displayed
Feature: Catalog Permissions
Feature: Catalog Role-Based Permissions
As a tenant admin
I need to control who can edit catalog fields
So that sensitive data is protected from unauthorized changes
Scenario: Staff role is view-only
Given user "Tom" has role "Staff"
When "Tom" opens product "Classic Tee" detail page
Then all fields should be displayed as read-only
And no "Edit" or "Save" buttons should be visible
And "Tom" should not see "Create Product" option in navigation
Scenario: Buyer cannot change price
Given user "Sarah" has role "Buyer"
When "Sarah" edits product "Classic Tee"
Then the "base_price" field should be read-only
And the "compare_at_price" field should be read-only
But the "cost" field should be editable
And the "vendor_cost" field should be editable
Scenario: Manager cannot change cost
Given user "Mike" has role "Manager"
When "Mike" edits product "Classic Tee"
Then the "cost" field should be read-only
And the "vendor_cost" field should be read-only
But the "base_price" field should be editable
And the "lifecycle_status" field should be editable
Scenario: Unauthorized action is blocked and logged
Given user "Tom" has role "Staff"
When "Tom" attempts to call PUT /products/{id} via API
Then the request should return 403 Forbidden
And a permission violation should be logged with user, role, and attempted action
And the violation should appear in the "Permission Violations" report
Feature: Product Analytics
Feature: Product Performance Analytics
As a merchandising manager
I need to see product performance metrics
So that I can make data-driven inventory and pricing decisions
Scenario: Product detail shows embedded metrics
Given product "Classic Tee" has been active for 90 days
And has sold 450 units out of 600 received
And current stock is 150 units across all locations
And avg daily sales is 5 units
When manager opens the product detail page
Then sell-through rate should show "75%"
And days of supply should show "30 days"
And sales velocity should show "35.0/wk" with sparkline
And ABC classification badge should be displayed
Scenario: ABC classification calculated monthly
Given it is the 1st of the month
And the monthly ABC job runs
Then the top 20% of products by trailing 12-month revenue should be classified "A"
And the next 30% should be classified "B"
And the bottom 50% should be classified "C"
And products active less than 60 days should be classified "NEW"
Scenario: Dead stock report identifies zero-sale products
Given product "Forgotten Widget" has had zero sales for 95 days
And product "Forgotten Widget" has 30 units on hand
And the dead stock threshold is configured at 90 days
When manager runs the "Dead Stock" report
Then "Forgotten Widget" should appear with last_sale_date, stock_qty 30, and days_without_sale 95
And recommended action should be "Review for markdown or discontinuation"
Scenario: Overstock alert fires for excess inventory
Given product "Winter Coat" has target days of supply of 30
And "Store A" has 200 units and sells 1/day (200 days of supply)
When the overstock alert report runs
Then "Winter Coat" at "Store A" should be flagged
And excess units should show 170 (200 - 30 days x 1/day)
And excess value should show the cost of 170 units
4. Inventory Module
Module 4: Inventory Management (Sections 4.1 – 4.7)
4.1 Overview & Scope
The Inventory Management module governs the complete lifecycle of physical stock within the multi-tenant POS system – from procurement through vendor purchase orders, to warehouse and store receiving, through internal logistics and transfers, and into auditing via physical counts and manual adjustments. It is the operational backbone that ensures every unit of merchandise is tracked, accounted for, and available for sale at the right location at the right time.
4.1.1 Executive Summary
Retail clothing operations across five stores and one HQ warehouse demand real-time, accurate inventory visibility. A single garment may be ordered from a vendor, received at the warehouse, transferred to a retail store, reserved for a customer’s online order, counted during a cycle count, adjusted after discovery of damage, and ultimately sold at the point of sale. Each of these events must be captured, validated, and reflected in the system’s inventory balances within seconds.
Module 4 provides the business rules, workflows, data models, and integration points that make this possible. It covers seven functional domains:
- Procurement – Creating, approving, submitting, and tracking purchase orders to vendors (Section 4.3).
- Receiving – Inspecting and accepting inbound inventory from any source – PO shipments, transfers, customer returns, and vendor RMA replacements (Section 4.4).
- Logistics – Inter-store and warehouse-to-store transfers are documented in Module 5 (Transfers & Logistics). Module 4 provides the inventory status and reservation primitives that Module 5 depends on.
- Auditing – Physical stock counts and manual adjustments that reconcile system quantities with reality (Sections 4.6 and 4.7).
- Costing – Inventory valuation via weighted average cost is applied at receiving time and propagated through the system. Cost data feeds into Module 1 (Sales) for margin calculation.
- Integration – Inventory events trigger real-time updates to the POS terminals (Module 1), the catalog (Module 3), and the movement history audit trail.
- Operations – Reorder management, dead stock detection, and minimum display quantity monitoring ensure proactive inventory health (Section 4.5).
4.1.2 Module Dependencies
Module 4 does not operate in isolation. It depends on and is depended upon by multiple other modules in the system.
flowchart LR
M1["Module 1\nSales & POS"]
M3["Module 3\nCatalog"]
M4["Module 4\nInventory"]
M5["Module 5\nTransfers & Logistics"]
M6["Module 6\nReporting"]
M3 -->|Product data, variants,\nvendor links, barcodes| M4
M4 -->|Available qty per location,\nreservation status| M1
M1 -->|Sale committed → decrement,\nVoid → release reservation| M4
M4 -->|Inventory status,\nreservation holds| M5
M5 -->|Transfer receive → increment,\nTransfer ship → decrement| M4
M4 -->|Stock levels, velocity,\ncost data| M6
M1 -->|Sales velocity data| M4
style M4 fill:#2d6a4f,stroke:#1b4332,color:#fff
style M1 fill:#264653,stroke:#1d3557,color:#fff
style M3 fill:#264653,stroke:#1d3557,color:#fff
style M5 fill:#264653,stroke:#1d3557,color:#fff
style M6 fill:#264653,stroke:#1d3557,color:#fff
Upstream dependencies (Module 4 consumes):
| Source Module | Data Consumed | Purpose |
|---|---|---|
| Module 3 (Catalog) | Product ID, variant ID, SKU, barcode, vendor-product links, vendor cost | Identify what is being counted, received, or ordered. Vendor cost used for PO line items. |
| Module 1 (Sales) | Sales velocity per product per location, sale events (commit, void, cancel) | Drive reorder point calculations and inventory decrements/releases. |
| Module 5 (Transfers) | Transfer ship and receive events | Trigger IN_TRANSIT status changes and inventory increments at destination. |
Downstream consumers (Module 4 provides):
| Consumer Module | Data Provided | Purpose |
|---|---|---|
| Module 1 (Sales) | Available quantity per product per location, reservation status | POS checks available qty before completing a sale. Displays stock info to staff. |
| Module 5 (Transfers) | Inventory status, available qty, reservation holds | Transfer system checks available qty before allowing outbound shipment. |
| Module 6 (Reporting) | Stock levels, cost data, velocity, count variances, adjustment history | Inventory reports, shrinkage analysis, days-of-supply calculations. |
4.1.3 Functional Scope
The following table enumerates the functional areas covered by Module 4 and their section references.
| Domain | Section | Description |
|---|---|---|
| Inventory Status Model | 4.2 | Six-status state machine governing what can be sold, transferred, or must be held. |
| Reservation Model | 4.2 | Reserve inventory for carts, parked transactions, transfers, online orders, and hold-for-pickup. |
| Minimum Display Quantity | 4.2 | Advisory warnings when stock drops below configured floor display minimums. |
| Purchase Orders | 4.3 | Full PO lifecycle from draft through receiving and close. |
| PO Approval Workflow | 4.3 | Threshold-based approval routing for high-value purchase orders. |
| Receiving & Inspection | 4.4 | Unified receiving workflow for POs, transfers, returns, and RMA replacements. |
| Discrepancy Handling | 4.4 | Triple-approach handling of receiving variances: note, RMA draft, quarantine. |
| Non-PO Receiving | 4.4 | Accept inventory without a purchase order using mandatory reason codes. |
| Return-to-Stock | 4.4 | Customer return items re-enter available inventory. |
| Reorder Management | 4.5 | Velocity-based reorder points with auto-generated draft POs. |
| Static Override | 4.5 | Manager-locked manual reorder points overriding dynamic calculations. |
| Dead Stock Detection | 4.5 | Alert on products with zero sales velocity over configurable period. |
| Inventory Counting | 4.6 | Five count types with configurable freeze/snapshot modes. |
| Inventory Adjustments | 4.7 | Manual corrections with mandatory manager approval and custom reason codes. |
4.1.4 Key Business Rules Summary
The following rules apply across all Module 4 operations:
- Only AVAILABLE stock can be sold at POS. Inventory in any other status (QUARANTINE, DAMAGED, RESERVED, IN_TRANSIT, PENDING_INSPECTION) is excluded from the sellable quantity displayed to cashiers.
- Only AVAILABLE stock can be transferred between locations. Transfer requests that would reduce available stock below zero are rejected.
- Every inventory change is logged. All status transitions, quantity changes, and cost updates create movement records in the audit trail (see Module 3, Section 3.16 – Movement History).
- Inventory is tracked per product, per variant, per location, per status. A single product may have quantities spread across multiple statuses at a single location simultaneously.
- All monetary values use the tenant’s configured currency. Multi-currency is not supported in v1.
- Tenant isolation is enforced at the data layer. Every inventory record carries a
tenant_idforeign key. Cross-tenant queries are impossible by design.
4.1.5 Inventory Balance Equation
The system maintains the following balance equation at all times for each product-variant-location combination:
Available = On-Hand - Reserved - In-Transit - Quarantine - Damaged
Where:
| Term | Definition |
|---|---|
| On-Hand | Total physical units at the location (all statuses combined). This is what you would count if you physically counted every item. |
| Available | Units that can be sold or transferred right now. This is the number displayed to POS staff. |
| Reserved | Units allocated to a pending sale cart, parked transaction, outbound transfer, online order, or hold-for-pickup. Not yet physically moved but committed. |
| In-Transit | Units that have shipped from this location to another location but have not yet been received at the destination. Decremented from source available, not yet incremented at destination. |
| Quarantine | Units held for inspection or quality review. Cannot be sold or transferred. |
| Damaged | Units identified as damaged. Cannot be sold. May be written off or returned to vendor via RMA. |
The system does not store Available as a separate field. It is always computed from the status-based quantity fields. This ensures the balance equation is always consistent and cannot drift due to bugs in update logic.
4.2 Inventory Status Model
4.2.1 Inventory Status State Machine
Each unit of inventory at each location carries a status that controls whether it can be sold, transferred, or must be held for inspection. The system supports six statuses organized into a state machine with well-defined transitions.
stateDiagram-v2
[*] --> AVAILABLE: Stock Received & Inspected
AVAILABLE --> QUARANTINE: Quality Concern Flagged
AVAILABLE --> RESERVED: Allocated to Order/Transfer
AVAILABLE --> DAMAGED: Damage Identified
QUARANTINE --> AVAILABLE: Inspection Passed
QUARANTINE --> DAMAGED: Inspection Failed
PENDING_INSPECTION --> AVAILABLE: Inspection Passed
PENDING_INSPECTION --> QUARANTINE: Needs Further Review
PENDING_INSPECTION --> DAMAGED: Inspection Failed
DAMAGED --> WRITE_OFF: Unrepairable
DAMAGED --> VENDOR_RMA: Return to Vendor
IN_TRANSIT --> AVAILABLE: Transfer Received & OK
IN_TRANSIT --> PENDING_INSPECTION: Received - Needs Inspection
RESERVED --> AVAILABLE: Reservation Released
note right of AVAILABLE
Sellable at POS
Transferable between locations
end note
note right of QUARANTINE
Blocked from sale
Blocked from transfer
Awaiting inspection
end note
note right of DAMAGED
Blocked from sale
Can be written off or returned to vendor
end note
note right of RESERVED
Allocated but not yet shipped/sold
Decremented from available count
end note
note right of IN_TRANSIT
Moving between locations
Not available at source or destination
end note
Status Definitions:
| Status | Sellable | Transferable | Description |
|---|---|---|---|
AVAILABLE | Yes | Yes | Stock is on the shelf and ready for sale or transfer. |
QUARANTINE | No | No | Stock is held pending quality inspection. Triggered by a staff member flagging a quality concern, or by a receiving inspection that requires further review. |
DAMAGED | No | No | Stock is identified as damaged and cannot be sold. Terminal states from here are WRITE_OFF (removed from inventory) or VENDOR_RMA (returned to vendor for credit or replacement). |
PENDING_INSPECTION | No | No | Stock has arrived (from transfer or PO) and needs inspection before it can be placed on the sales floor. |
RESERVED | No | No | Stock is allocated to a specific purpose (sale cart, parked transaction, outbound transfer, online order, or hold-for-pickup) but has not yet been physically moved or sold. |
IN_TRANSIT | No | No | Stock has shipped from the source location but has not yet arrived at the destination location. It is not available at either location during transit. |
Business Rules:
- Only
AVAILABLEstock can be sold at POS. The POS terminal displays only the AVAILABLE quantity as the sellable count. - Only
AVAILABLEstock can be transferred between locations. Transfer requests that would reduce AVAILABLE stock below zero at the source location are rejected. QUARANTINEandDAMAGEDstock is blocked from sale and transfer. It must be inspected and resolved before it can re-enter the sellable pool.RESERVEDstock is decremented from the available count but not yet physically moved. If the reservation is released (e.g., cart abandoned, parked transaction voided), the stock returns to AVAILABLE.- All status changes require a reason code and are logged to the movement history audit trail.
- Status changes can only follow the transitions defined in the state machine above. Any attempt to make an invalid transition (e.g., QUARANTINE directly to RESERVED) is rejected by the API.
Inventory Status Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Reference to product (FK to catalog) |
variant_id | UUID | No | Reference to specific variant, if applicable (FK to catalog) |
location_id | UUID | Yes | Reference to store/warehouse location |
status | Enum | Yes | AVAILABLE, QUARANTINE, DAMAGED, PENDING_INSPECTION, RESERVED, IN_TRANSIT |
qty | Integer | Yes | Quantity in this status at this location |
last_status_change_at | DateTime | Yes | Timestamp of most recent status change |
changed_by | UUID | Yes | User who made the status change |
reason_code | String | Yes | Reason for current status (e.g., QUALITY_CONCERN, TRANSFER_ALLOCATED, TRANSIT_DAMAGE, CART_RESERVE, PARKED_RESERVE) |
tenant_id | UUID | Yes | Owning tenant |
4.2.2 Reservation Model
Reservations temporarily hold inventory for a specific purpose, preventing it from being sold or transferred to another customer or location. The reservation model is central to ensuring that the POS system does not oversell stock in a multi-terminal, multi-channel environment.
When a reservation is created, the specified quantity is moved from AVAILABLE status to RESERVED status. When the reservation is committed (sale completed, transfer shipped), the reserved quantity is decremented from inventory. When the reservation is released (cart abandoned, transaction voided), the reserved quantity returns to AVAILABLE.
Reservation Types
The system supports five distinct reservation types, each with its own lifecycle and rules:
| Type | Trigger | Hold Duration | Behavior | Release Trigger |
|---|---|---|---|---|
| Sale Cart | Item added to POS cart | Until payment or void | Hard reserve. Other terminals see reduced available qty. | Payment completes (commit) or cart voided/abandoned (release). |
| Parked Transaction | Sale saved as parked | Until recalled or expired | Soft reserve. Other terminals see reduced available qty but with a visual warning: “2 units reserved by parked sale P-0045.” Staff can still sell through the soft reserve if they override the warning. | Parked transaction recalled and completed (commit), or voided (release), or expired after configurable timeout (release). |
| Transfer | Transfer approved and picking starts | Until transfer shipped or cancelled | Hard reserve at source location. Items being picked for an outbound transfer are reserved to prevent them from being sold before they ship. | Transfer shipped (status moves to IN_TRANSIT) or transfer cancelled (release). |
| Online Order | Online order placed and allocated to nearest store | Until fulfilled or cancelled | Hard reserve at the assigned store. The nearest-store allocation algorithm (see Module 1, Section 1.10 if applicable) assigns the order to the store with the most available stock. | Order fulfilled (commit) or order cancelled (release). |
| Hold-for-Pickup | Staff places a hold for a customer | Configurable expiry (default: 48 hours) | Hard reserve with auto-release on expiry. Customer has a window to pick up. If not picked up, system auto-releases the hold and notifies the store. | Customer picks up (commit), or expiry timer elapses (auto-release), or staff manually releases. |
Reservation Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | FK to product |
variant_id | UUID | No | FK to variant (if applicable) |
location_id | UUID | Yes | Location where the stock is reserved |
qty | Integer | Yes | Quantity reserved |
type | Enum | Yes | SALE_CART, PARKED_TRANSACTION, TRANSFER, ONLINE_ORDER, HOLD_FOR_PICKUP |
status | Enum | Yes | ACTIVE, COMMITTED, RELEASED, EXPIRED |
source_document_id | UUID | Yes | FK to the source document (sale ID, parked transaction ID, transfer ID, online order ID, or hold ID) |
source_document_type | String | Yes | Type of source document for polymorphic FK resolution |
reserved_by | UUID | Yes | User who created the reservation |
reserved_at | DateTime | Yes | Timestamp when the reservation was created |
expires_at | DateTime | No | Expiry timestamp for time-limited reservations (parked transactions, hold-for-pickup). Null for reservations without expiry. |
committed_at | DateTime | No | Timestamp when the reservation was committed (sale completed, transfer shipped). |
released_at | DateTime | No | Timestamp when the reservation was released (void, cancel, expiry). |
release_reason | String | No | Reason for release: VOID, CANCEL, EXPIRY, OVERRIDE |
tenant_id | UUID | Yes | Owning tenant |
Reservation Lifecycle State Machine
stateDiagram-v2
[*] --> ACTIVE: Reserve Created
ACTIVE --> COMMITTED: Sale Paid / Transfer Shipped / Pickup Completed
ACTIVE --> RELEASED: Void / Cancel / Manual Release
ACTIVE --> EXPIRED: Expiry Timer Elapsed
COMMITTED --> [*]
RELEASED --> [*]
EXPIRED --> [*]
note right of ACTIVE
Qty moved from AVAILABLE to RESERVED
Other terminals see reduced available qty
end note
note right of COMMITTED
Qty decremented from inventory
Reservation fulfilled
end note
note right of RELEASED
Qty moved from RESERVED back to AVAILABLE
Stock returned to sellable pool
end note
note right of EXPIRED
Auto-triggered by background job
Qty returned to AVAILABLE
Notification sent to staff
end note
Business Rules:
- When a reservation is created, the system atomically decrements the AVAILABLE status qty and increments the RESERVED status qty for the product-variant-location combination.
- When a reservation is committed, the RESERVED qty is decremented (stock leaves inventory via sale, or moves to IN_TRANSIT for transfer).
- When a reservation is released or expired, the RESERVED qty is decremented and the AVAILABLE qty is incremented (stock returns to sellable pool).
- Reservations are checked by a background job every 5 minutes for expiry. Expired reservations are auto-released and a notification is sent to the staff member who created the reservation.
- Parked transaction override: If a staff member at another terminal attempts to sell a product that has units reserved by a parked transaction, the system shows a warning: “2 of 5 units reserved by parked sale P-0045 at Terminal 2. Proceed anyway?” If the staff member confirms, the system sells through the available stock and the parked transaction’s reserved quantity is reduced when it is recalled (the system reconciles at recall time).
- Concurrent reservation conflict: If two terminals attempt to reserve the last available unit simultaneously, the first transaction to commit the database write wins. The second terminal receives an error: “Insufficient available stock. 0 units available.” This is enforced by database-level row locking on the inventory status record.
4.2.4 Minimum Display Quantity
Retail clothing stores rely on visual merchandising – an empty rack or sparse display reduces sales. The minimum display quantity feature provides an advisory warning system that alerts store staff when the available inventory at a location drops below a configured floor display minimum.
Key Behaviors:
- Minimum display quantity is configured per product (or variant) per location. Not all products require a minimum display – the field is optional and defaults to null (no warning).
- The warning is advisory only. It does not block sales, transfers, or any other operation. It is a soft alert that appears on the store dashboard and in the inventory list view.
- When available qty at a location drops below the configured minimum display qty, the system creates a dashboard alert: “Product XYZ at Store A has 1 unit remaining (minimum display: 3). Consider replenishment.”
- The alert clears automatically when stock is replenished above the minimum display qty (via receiving, transfer, or adjustment).
- Minimum display qty is distinct from the reorder point (Section 4.5). The reorder point triggers purchase order generation. The minimum display qty triggers a store-level visual merchandising alert.
Minimum Display Quantity Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | FK to product |
variant_id | UUID | No | FK to variant (if applicable). When set, the min display applies to the specific variant. When null, it applies to the product aggregate. |
location_id | UUID | Yes | FK to location. Min display is set per location since different stores may have different display requirements. |
min_display_qty | Integer | Yes | The minimum number of units that should be on display at this location. |
is_active | Boolean | Yes | Whether this minimum display rule is active. Allows temporary disabling without deleting the configuration. |
set_by | UUID | Yes | User who configured the minimum display qty. |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Minimum display quantity alerts appear on the store dashboard under a “Low Display Stock” section.
- Alerts are generated when
AVAILABLE qty < min_display_qtyat a location. The check runs whenever inventory changes at the location (sale, transfer, adjustment, receive). - Alerts include a suggested action: “Request transfer from [location with highest available qty]” with a one-click “Request Transfer” button.
- Minimum display quantity does NOT factor into the reorder point calculation (Section 4.5). They are independent systems.
- Setting a minimum display quantity of 0 is equivalent to disabling the alert for that product-location combination.
4.3 Purchase Orders & Procurement
Scope: Creating, approving, submitting, receiving, and closing purchase orders to replenish inventory from vendors. The PO workflow supports approval routing for high-value orders, partial receives, variance tracking, inspection steps, overdue alerts, and auto-generation from low-stock alerts.
4.3.1 Purchase Order State Machine
stateDiagram-v2
[*] --> DRAFT: PO Created
DRAFT --> PENDING_APPROVAL: Submit for Approval (above threshold)
DRAFT --> SUBMITTED: Submit to Vendor (below threshold / auto-approved)
PENDING_APPROVAL --> SUBMITTED: Manager Approves
PENDING_APPROVAL --> REJECTED: Manager Rejects
REJECTED --> DRAFT: Revise and Resubmit
SUBMITTED --> PARTIALLY_RECEIVED: Partial Shipment Arrived
PARTIALLY_RECEIVED --> PARTIALLY_RECEIVED: Additional Shipment
PARTIALLY_RECEIVED --> FULLY_RECEIVED: All Items Received
SUBMITTED --> FULLY_RECEIVED: Full Shipment Arrived
FULLY_RECEIVED --> CLOSED: PO Closed
DRAFT --> CANCELLED: Cancel Before Submit
SUBMITTED --> CANCELLED: Cancel After Submit
CANCELLED --> [*]
CLOSED --> [*]
note right of DRAFT
Editable line items
No inventory impact
Not sent to vendor
end note
note right of PENDING_APPROVAL
PO total exceeds approval threshold
Awaiting manager/owner approval
Line items locked for review
end note
note right of SUBMITTED
Sent to vendor (email/EDI/manual)
Awaiting shipment
Line items locked
end note
note right of PARTIALLY_RECEIVED
Some items received
Inventory incremented for received qty
Remaining items still expected
end note
note right of FULLY_RECEIVED
All line items received
Pending final review
Ready to close
end note
4.3.2 PO Approval Workflow
Purchase orders above a configurable dollar threshold require manager or owner approval before submission to the vendor. This prevents unauthorized large purchases while allowing routine restocking to flow without friction.
Approval Threshold Configuration:
| Setting | Type | Default | Description |
|---|---|---|---|
po_auto_approve_threshold | Decimal(10,2) | $2,000.00 | PO total value at or below this amount is auto-approved and moves directly to SUBMITTED. |
po_approval_role | Enum | MANAGER | Minimum role required to approve POs above threshold. Options: MANAGER, OWNER. |
po_approval_notify | Boolean | true | Whether to send push notification to approvers when a PO is pending. |
Approval Rules:
- When a staff member clicks “Submit” on a PO whose total value (
SUM of line_total) is at or below thepo_auto_approve_threshold, the PO moves directly from DRAFT to SUBMITTED. No approval step is needed. - When a staff member clicks “Submit” on a PO whose total value exceeds the
po_auto_approve_threshold, the PO moves from DRAFT to PENDING_APPROVAL. A notification is sent to all users with the configured approval role at the PO’s destination location. - The approver can APPROVE (moves to SUBMITTED), REJECT with a reason (moves to REJECTED), or request modifications (the PO creator is notified to revise).
- A REJECTED PO can be revised (returns to DRAFT status with editable line items) and resubmitted.
- The approval threshold is configurable per tenant in tenant settings. Different tenants may have different spending limits.
- Auto-generated draft POs from the reorder engine (Section 4.5) follow the same approval rules – they are not exempt from the threshold check.
Approval Workflow Sequence
sequenceDiagram
autonumber
participant S as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
participant NOTIF as Notification Service
participant M as Manager/Owner
S->>UI: Click "Submit PO"
UI->>API: POST /purchase-orders/{id}/submit
API->>DB: Calculate PO Total (SUM of line_total)
API->>DB: Lookup Tenant's po_auto_approve_threshold
alt PO Total <= Threshold (Auto-Approve)
API->>DB: Update Status: SUBMITTED
API->>DB: Lock Line Items
API-->>UI: "PO Submitted to Vendor"
Note right of API: PO proceeds to vendor submission
else PO Total > Threshold (Requires Approval)
API->>DB: Update Status: PENDING_APPROVAL
API->>DB: Lock Line Items for Review
API->>NOTIF: Send Approval Request to Manager(s)
API-->>UI: "PO Sent for Manager Approval"
NOTIF-->>M: "PO #PO-2026-00042 ($4,500) awaiting your approval"
alt Manager Approves
M->>UI: Review PO -> Click "Approve"
UI->>API: POST /purchase-orders/{id}/approve
API->>DB: Update Status: SUBMITTED
API->>DB: Record approved_by, approved_at
API->>NOTIF: Notify Creator: "PO Approved"
NOTIF-->>S: "Your PO #PO-2026-00042 was approved"
else Manager Rejects
M->>UI: Review PO -> Click "Reject"
M->>UI: Enter Rejection Reason
UI->>API: POST /purchase-orders/{id}/reject
API->>DB: Update Status: REJECTED
API->>DB: Record rejection_reason
API->>NOTIF: Notify Creator: "PO Rejected"
NOTIF-->>S: "Your PO #PO-2026-00042 was rejected: reason"
end
end
4.3.3 Purchase Order Lifecycle
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant DB as DB
participant V as Vendor
Note over U, V: Step 1: Create Purchase Order
U->>UI: Click "New Purchase Order"
UI->>UI: Select Vendor from List
UI->>API: GET /vendors/{id}/products
API-->>UI: Return Vendor's Product Catalog with Vendor Costs
loop Add Line Items
U->>UI: Select Product
UI->>UI: Auto-Fill Vendor SKU, Vendor Cost
U->>UI: Enter Quantity Ordered
U->>UI: Set Expected Delivery Date
UI->>UI: Calculate Line Total (qty x unit_cost)
end
UI->>UI: Display PO Summary (line count, total cost)
U->>UI: Add Notes (optional)
U->>UI: Click "Save Draft"
UI->>API: POST /purchase-orders
API->>DB: Create PO Record (Status: DRAFT)
Note right of DB: Auto-generated PO Number: PO-2026-00042
API-->>UI: PO #PO-2026-00042 Created
Note over U, V: Step 2: Submit to Vendor
U->>UI: Review PO -> Click "Submit"
UI->>API: POST /purchase-orders/{id}/submit
Note over API: Approval check runs here (see Section 4.3.2)
alt Email Submission
API->>V: Send PO via Email (PDF attachment)
Note right of V: Vendor receives PO email
else EDI Submission
API->>V: Transmit PO via EDI
else Manual Submission
API-->>UI: "PO marked Submitted - send manually"
Note right of UI: Staff prints PO and calls/faxes vendor
end
API->>DB: Update Status: SUBMITTED
API->>DB: Lock Line Items (no edits)
API-->>UI: PO Submitted Successfully
Note over U, V: Step 3: Receive Inventory
V-->>U: Shipment Arrives at Store/Warehouse
U->>UI: Open PO #PO-2026-00042 -> Click "Receive"
loop Receive Line Items
U->>UI: Enter Qty Received per Line Item
opt Variance Detected
UI-->>U: "Ordered: 50, Received: 48 - Enter Variance Note"
U->>UI: Enter Note: "2 units damaged in transit"
end
end
U->>UI: Click "Confirm Receive"
UI->>API: POST /purchase-orders/{id}/receive
par Inventory Updates
API->>DB: Increment Inventory (received qty per location)
API->>DB: Update PO Line Items (qty_received)
API->>DB: Record Variance Notes
end
alt All Items Received
API->>DB: Update Status: FULLY_RECEIVED
API-->>UI: "All items received"
else Partial Receive
API->>DB: Update Status: PARTIALLY_RECEIVED
API-->>UI: "Partial receive recorded - awaiting remaining"
end
Note over U, DB: Step 4 (Optional): Inspect Received Goods
opt Quality Inspection
U->>UI: Open Received Items -> Click "Inspect"
U->>UI: Mark Items as Passed / Failed
Note right of UI: Failed items logged for vendor claim
UI->>API: POST /purchase-orders/{id}/inspection
API->>DB: Record Inspection Results
end
Note over U, DB: Step 5: Close PO
U->>UI: Click "Close PO"
UI->>API: POST /purchase-orders/{id}/close
API->>DB: Update Status: CLOSED
API->>DB: Finalize Cost Records
API-->>UI: PO Closed
4.3.4 PO Header Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
po_number | String | Yes | Auto-generated: PO-{YEAR}-{SEQ} per tenant |
vendor_id | UUID | Yes | FK to vendor |
status | Enum | Yes | DRAFT, PENDING_APPROVAL, SUBMITTED, PARTIALLY_RECEIVED, FULLY_RECEIVED, CLOSED, CANCELLED, REJECTED |
destination_location_id | UUID | Yes | FK to the location where goods will be received |
total_value | Decimal(12,2) | Yes | Calculated: SUM of all line_total values |
expected_delivery_date | Date | No | Overall expected delivery date for the PO |
overdue_alert_buffer_days | Integer | No | Number of buffer days after expected_delivery_date before overdue alert triggers. Default: 3 days. |
submission_method | Enum | No | EMAIL, EDI, MANUAL. How the PO is sent to the vendor. |
notes | Text | No | Free-text notes for the PO |
auto_generated | Boolean | Yes | Whether this PO was auto-generated by the reorder engine (Section 4.5). Default: false. |
approved_by | UUID | No | Manager who approved the PO (if approval was required) |
approved_at | DateTime | No | Timestamp of approval |
rejection_reason | Text | No | Reason for rejection (if rejected) |
created_by | UUID | Yes | Staff member who created the PO |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
closed_at | DateTime | No | Timestamp when PO was closed |
4.3.5 PO Line Item Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Line item primary key |
purchase_order_id | UUID | Yes | Reference to parent PO |
product_id | UUID | Yes | Reference to product being ordered |
variant_id | UUID | No | Reference to specific variant (if applicable) |
vendor_sku | String | No | Vendor’s SKU (auto-filled from vendor-product link) |
qty_ordered | Integer | Yes | Quantity ordered from vendor |
qty_received | Integer | Yes | Quantity received so far (starts at 0) |
unit_cost | Decimal(10,2) | Yes | Cost per unit from vendor |
line_total | Decimal(10,2) | Yes | Calculated: qty_ordered x unit_cost |
expected_date | Date | No | Expected delivery date for this line |
received_date | Date | No | Actual date items were received |
variance_notes | String | No | Notes on quantity/quality discrepancies |
inspection_status | Enum | No | PENDING, PASSED, FAILED |
4.3.6 Expected Delivery Date & Overdue Alerts
Each purchase order has an expected_delivery_date field representing when the vendor is expected to deliver the goods. The system uses this date, combined with a configurable buffer period, to generate overdue alerts when a PO has not been received within the expected timeframe.
Overdue Alert Logic:
overdue_trigger_date = expected_delivery_date + overdue_alert_buffer_days
- If the current date exceeds
overdue_trigger_dateand the PO status is stillSUBMITTED(nothing received), the system generates an overdue alert. - If the PO status is
PARTIALLY_RECEIVEDand the current date exceedsoverdue_trigger_date, the system generates a different alert: “PO partially received but remaining items overdue.” - Overdue alerts appear on the purchasing dashboard and are sent as push notifications to the PO creator and the destination location’s manager.
- The
overdue_alert_buffer_daysdefaults to 3 days but can be overridden per PO when the expected delivery date is uncertain (e.g., international shipments). - Overdue alerts auto-clear when the PO reaches
FULLY_RECEIVEDorCLOSEDstatus.
Business Rules:
- If
expected_delivery_dateis not set on the PO, the system falls back to the vendor’s defaultlead_time_days(from the vendor record) plusoverdue_alert_buffer_days. - Overdue POs appear in the Open PO Report with a visual indicator (red highlight) and are sorted to the top by default.
- The background job that checks for overdue POs runs daily at a configurable time (default: 8:00 AM local time per tenant timezone).
4.3.7 Purchase Order Features
- Auto-generate PO from low-stock alerts: When inventory drops below
reorder_pointat any location, the system generates a suggested draft PO with the primary vendor and recommended quantities based on sales velocity (see Section 4.5 for reorder management details). - PO templates: Staff can save frequently ordered product sets as templates (e.g., “Weekly Nike Restock”) and generate new POs from templates with one click. Templates store vendor, product list, and default quantities but not dates or notes.
- Partial receives: Each receive operation records the quantity received per line item. Multiple receives accumulate until all items arrive. Each partial receive increments inventory at the destination location immediately.
- Variance tracking: When received quantity differs from ordered quantity, staff must enter a variance note. Variances are tracked for vendor performance reporting (see Section 4.3.8).
- Auto-increment PO number per tenant: PO numbers follow the format
PO-{YEAR}-{SEQUENCE}and auto-increment per tenant. Example:PO-2026-00001,PO-2026-00002. Sequence resets annually. - Receive to specific location: When receiving, staff selects the destination location (store or warehouse). Inventory increments at that location. The destination is pre-filled from the PO header’s
destination_location_idbut can be overridden during receiving. - PO duplication: Staff can duplicate an existing PO (any status) to create a new DRAFT with the same vendor and line items. Useful for recurring orders.
- Approval threshold: POs above a configurable dollar threshold require manager approval before vendor submission (see Section 4.3.2).
4.3.8 Reports: Purchase Orders
| Report | Purpose | Key Data Fields |
|---|---|---|
| Open PO Report | Track all non-closed purchase orders | PO number, vendor, status, total value, expected date, days since submitted, overdue flag |
| PO Receiving Report | Monitor receiving activity | PO number, line items, qty ordered vs received, variance %, receive date |
| Vendor Lead Time Report | Measure actual vs expected delivery | Vendor, PO count, avg expected lead time, avg actual lead time, on-time %, overdue count |
| PO Variance Report | Track quantity and quality discrepancies | PO number, line item, qty ordered, qty received, variance, variance notes |
| Cost Analysis Report | Review purchasing spend | Vendor, total PO value, product categories, avg unit cost, cost trends over time |
| Approval Pipeline Report | Monitor POs awaiting approval | PO number, total value, created by, created date, days pending, approver assigned |
| Overdue PO Report | Track POs past expected delivery | PO number, vendor, expected date, days overdue, last contact notes, status |
4.4 Receiving & Inspection
Scope: A single unified receiving workflow handles all inbound inventory regardless of source type – PO shipments, inter-store transfers, customer returns, vendor RMA replacements, and non-PO receives. This section documents the open receive mode, discrepancy handling, non-PO receiving, over-shipment handling, return-to-stock processing, and scanner-primary receiving operations.
4.4.1 Receiving Source Types
| Source Type | Origin | Example |
|---|---|---|
PO_RECEIVE | Purchase order from vendor | PO-2026-00042 shipment arrives |
TRANSFER_RECEIVE | Inter-store transfer | Transfer from Store A received at Store B |
RETURN_TO_STOCK | Customer return | Returned item added back to inventory |
RMA_REPLACEMENT | Vendor RMA replacement | Vendor sent replacement items |
NON_PO_RECEIVE | Stock received without a PO | Vendor sample, found stock, replacement |
4.4.2 Receiving Data Model – Header
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
receive_number | String | Yes | Auto-generated: RCV-{YEAR}-{SEQ} |
source_type | Enum | Yes | PO_RECEIVE, TRANSFER_RECEIVE, RETURN_TO_STOCK, RMA_REPLACEMENT, NON_PO_RECEIVE |
source_document_id | UUID | Conditional | FK to source document (PO, transfer, return, RMA). Required for all types except NON_PO_RECEIVE. |
non_po_reason_code | Enum | Conditional | Required when source_type = NON_PO_RECEIVE. See Section 4.4.6. |
location_id | UUID | Yes | Destination location where stock is received |
status | Enum | Yes | PENDING, IN_PROGRESS, COMPLETED |
received_by | UUID | Yes | Staff member processing the receive |
notes | Text | No | General notes for the receiving session |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
completed_at | DateTime | No | Timestamp when receiving was completed |
4.4.3 Receiving Data Model – Line Items
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
receive_id | UUID | Yes | Reference to parent receive record |
product_id | UUID | Yes | Reference to product |
variant_id | UUID | No | Reference to specific variant (if applicable) |
expected_qty | Integer | Yes | Quantity expected from source document. 0 for non-PO receive lines. |
received_qty | Integer | Yes | Actual quantity received |
variance | Integer | Computed | Calculated: received_qty - expected_qty |
condition | Enum | Yes | GOOD, DAMAGED, WRONG_ITEM |
condition_notes | Text | No | Notes describing the condition (especially for DAMAGED or WRONG_ITEM) |
initial_status | Enum | Yes | Inventory status assigned on receive: AVAILABLE (default for GOOD), DAMAGED, PENDING_INSPECTION |
serial_numbers[] | String[] | No | Serial numbers captured (if serial tracked) |
lot_number | String | No | Lot/batch number (if lot tracked) |
notes | Text | No | Notes on received items |
4.4.4 Open Receive Mode
Open receive mode is the primary workflow for receiving inventory against a purchase order. Staff sees the expected quantities from the PO and records the actual received quantities. Variances are automatically calculated and documented.
Workflow:
- Staff opens the PO in the receiving screen and clicks “Start Receiving.”
- The system displays all PO line items with their
qty_orderedand currentqty_received(from any prior partial receives). - For each line item, staff enters (or scans – see Section 4.4.9) the actual quantity received in this shipment.
- The system calculates the variance for each line:
received_qty_this_session - (qty_ordered - qty_previously_received). - If the variance is negative (short-shipped), the system highlights the line and prompts for a variance note.
- If the variance is positive (over-shipped), the system applies over-shipment rules (see Section 4.4.7).
- Staff confirms the receive. Inventory is incremented at the destination location for all GOOD items. DAMAGED items are placed in DAMAGED status. WRONG_ITEM items are flagged for RMA processing.
Open Receive Sequence Diagram
sequenceDiagram
autonumber
participant U as Staff
participant UI as Receiving UI
participant API as Backend
participant DB as DB
U->>UI: Open PO #PO-2026-00042
UI->>API: GET /purchase-orders/{id}/lines
API-->>UI: Return Line Items with Expected Qty
U->>UI: Click "Start Receiving"
UI->>API: POST /receiving/start
API->>DB: Create Receive Record (Status: IN_PROGRESS)
API-->>UI: Receive Session #RCV-2026-00108 Started
Note over U, UI: Staff sees expected qty for each line
loop Receive Each Line Item
alt Scanner Mode (Default)
U->>UI: Scan Item Barcode
UI->>UI: Match to PO Line Item
UI->>UI: Increment Received Qty by 1
Note right of UI: Each scan = +1 unit
else Manual Entry
U->>UI: Enter Received Qty for Line
end
UI->>UI: Calculate Variance (received - expected remaining)
opt Short-Shipped (Negative Variance)
UI-->>U: "Expected: 50, Received: 48 — 2 units short"
U->>UI: Enter Variance Note
end
opt Over-Shipped (Positive Variance)
UI-->>U: "Expected: 50, Received: 55 — 5 units over"
Note right of UI: Over-shipment rules apply (Section 4.4.7)
end
opt Damaged Item Found
U->>UI: Mark Item as DAMAGED
U->>UI: Enter Condition Notes
end
opt Wrong Item Found
U->>UI: Mark Item as WRONG_ITEM
U->>UI: Enter Condition Notes
end
end
U->>UI: Click "Confirm Receive"
UI->>API: POST /receiving/{id}/confirm
par Post-Receive Updates
API->>DB: Increment AVAILABLE Inventory (GOOD items)
API->>DB: Set DAMAGED items to DAMAGED Status
API->>DB: Flag WRONG_ITEM for RMA Processing
API->>DB: Update PO Line Items (qty_received)
API->>DB: Record Variance Notes
API->>DB: Log Movement Records (RECEIVE movement type)
API->>DB: Update Receive Status: COMPLETED
end
alt All PO Items Now Received
API->>DB: Update PO Status: FULLY_RECEIVED
API-->>UI: "PO fully received"
else Remaining Items Outstanding
API->>DB: Update PO Status: PARTIALLY_RECEIVED
API-->>UI: "Partial receive recorded — awaiting remaining items"
end
4.4.5 Discrepancy Handling (Triple Approach)
When receiving reveals discrepancies between expected and actual quantities or conditions, the system applies a triple approach to ensure nothing falls through the cracks:
- Note variance on PO line (always): Every variance is recorded on the PO line item’s
variance_notesfield with the quantity difference and the staff member’s explanation. This is mandatory for all discrepancies regardless of type. - Auto-create RMA draft for wrong/defective items: When items are marked as
WRONG_ITEMorDAMAGEDwith a condition indicating a vendor fault (not transit damage), the system auto-creates an RMA draft record linked to the PO and the vendor. The RMA draft appears in the returns management queue for staff to review and submit to the vendor. - Quarantine damaged items: Items marked as
DAMAGEDduring receiving are placed in theDAMAGEDinventory status at the receiving location. They are blocked from sale and transfer until resolved (write-off or vendor RMA).
Discrepancy Decision Flowchart
flowchart TD
A[Receive Line Item] --> B{Qty Matches Expected?}
B -->|Yes| C{Condition OK?}
B -->|No - Short| D[Note Variance on PO Line]
B -->|No - Over| E[Apply Over-Shipment Rules\nSection 4.4.7]
D --> F{All Items in Good Condition?}
F -->|Yes| G[Accept Short Shipment\nRecord Variance Note]
F -->|No| H{What Condition?}
C -->|Yes - GOOD| I[Accept to AVAILABLE Status]
C -->|No| H
H -->|DAMAGED| J[Move to DAMAGED Status]
H -->|WRONG_ITEM| K[Flag for RMA]
J --> L{Vendor Fault?}
L -->|Yes| M[Auto-Create RMA Draft\nLinked to PO & Vendor]
L -->|No - Transit Damage| N[Record Damage Note\nFile Carrier Claim if Applicable]
K --> M
E --> O{Within Over-Shipment Threshold?}
O -->|Yes - Accept| P[Accept Overage\nNote Variance]
O -->|No - Above Threshold| Q[Require Manager Approval\nto Accept Overage]
G --> R[Log Movement Record]
I --> R
M --> R
N --> R
P --> R
Q --> R
Business Rules:
- Every discrepancy, regardless of type, generates a variance note on the PO line item. This is non-negotiable.
- RMA drafts are auto-created only for vendor-attributable issues (wrong item, defective item). Transit damage is handled separately through carrier claims.
- Damaged items are immediately placed in DAMAGED status. They do not count toward the PO’s “received in good condition” tally.
- The PO Variance Report (Section 4.3.8) aggregates all discrepancies for vendor performance analysis.
- Discrepancy records include: PO number, line item, expected qty, received qty, variance, condition, notes, and whether an RMA was auto-created.
4.4.6 Non-PO Receiving
In some situations, stock arrives at a location without an associated purchase order. The system supports receiving without a PO, provided the staff member selects a mandatory reason code explaining why the stock is being received outside the normal procurement workflow.
Non-PO Reason Codes:
| Reason Code | Description | Example |
|---|---|---|
VENDOR_SAMPLE | Vendor sent sample merchandise for evaluation | New season sample box from Nike |
REPLACEMENT | Vendor sent replacement for previously defective/returned items outside the RMA process | Vendor shipped replacement directly without formal RMA |
RETURN_TO_STOCK | Items being re-entered into inventory after being temporarily removed (not a customer return – use RETURN_TO_STOCK source type for that) | Items returned from a photo shoot or trade show |
FOUND_STOCK | Stock discovered that was not in the system (e.g., found in back room, mislabeled) | Unscanned box found during warehouse cleanup |
OTHER | None of the above. Requires free-text explanation in notes field. | Unusual circumstance requiring documentation |
Non-PO Receive Data Model
Non-PO receives use the same receiving header and line item data models (Section 4.4.2 and 4.4.3) with the following specifics:
source_type=NON_PO_RECEIVEsource_document_id= nullnon_po_reason_codeis required (one of the codes above)expected_qtyon line items is set to 0 (since there is no source document to establish expectations)- Variance is not calculated for non-PO receives (there is no expected baseline)
Business Rules:
- Non-PO receives always require a reason code. The system rejects a non-PO receive without a reason code.
- When
reason_code = OTHER, the notes field on the receive header becomes mandatory. Staff must provide a free-text explanation. - Non-PO receives are flagged in the Receiving Log report for visibility. Management can filter the report to show only non-PO receives for audit purposes.
- Inventory incremented via non-PO receiving is costed at the product’s current weighted average cost (from the catalog). If no cost data exists, the system prompts staff to enter a unit cost.
4.4.7 Over-Shipment Handling
When a vendor ships more units than ordered, the system applies configurable rules to determine whether the overage is automatically accepted or requires manager approval.
Over-Shipment Configuration:
| Setting | Type | Default | Description |
|---|---|---|---|
over_shipment_threshold_pct | Decimal(5,2) | 10.00 | Maximum percentage above ordered qty that can be auto-accepted. |
over_shipment_approval_role | Enum | MANAGER | Role required to approve over-shipments above the threshold. |
Business Rules:
- Within threshold: If the received quantity exceeds the ordered quantity by up to the configured threshold percentage, the overage is auto-accepted. Inventory is incremented for the full received quantity. The variance is noted on the PO line item.
- Example: Ordered 100 units, threshold is 10%. Receiving up to 110 units is auto-accepted.
- Above threshold: If the received quantity exceeds the ordered quantity by more than the configured threshold percentage, the system blocks acceptance of the overage and requires manager approval.
- Example: Ordered 100 units, threshold is 10%. Receiving 115 units triggers manager approval for the 15-unit overage.
- Until approved, the over-threshold units are held in
PENDING_INSPECTIONstatus. The units within the threshold (110) are accepted immediately.
- Manager approval flow: The manager receives a notification: “Over-shipment on PO #PO-2026-00042, line 3: Ordered 100, Received 115 (15% over, threshold 10%). Approve acceptance?” The manager can approve (units move to AVAILABLE) or reject (units are flagged for return to vendor).
- Per-line calculation: The threshold is applied per line item, not per PO total. Each line item’s overage is evaluated independently.
- Cost impact: Over-shipped units accepted at the same unit cost as the PO line item. The PO total value is recalculated to reflect the actual received quantity.
4.4.8 Return-to-Stock
When a customer returns merchandise (processed through Module 1, Sales – Returns), the returned items re-enter the inventory system through the return-to-stock workflow.
Default Behavior:
- Customer returns automatically move to
AVAILABLEstatus. No inspection is required by default. - The rationale: clothing returns in this retail context are typically tried-on garments, not defective products. The default assumption is that returned items are saleable.
- Staff has the option to mark any returned item as
DAMAGEDduring the return process if the item is visibly damaged, soiled, or otherwise unsaleable.
Business Rules:
- Return-to-stock creates a receiving record with
source_type = RETURN_TO_STOCKandsource_document_idpointing to the return/refund transaction. - The returned item is added to inventory at the location where the return was processed (the store where the customer brought the item back).
- If the item is marked DAMAGED during return, it enters
DAMAGEDstatus instead ofAVAILABLE. The staff member must enter a condition note. - Return-to-stock inventory is costed at the original sale’s cost basis (from the sale transaction), not at current weighted average cost. This ensures accurate margin reporting.
- A
RETURN_TO_STOCKmovement record is logged to the audit trail.
4.4.9 Scanner-Primary Receiving
The default receiving workflow is scanner-primary: staff uses a barcode scanner to scan each individual item as it is unpacked. Each scan auto-increments the received count for the matching PO line item by one unit.
Workflow:
- Staff opens the PO receiving screen and clicks “Start Receiving.”
- The system enters scanner mode (default). The cursor focus is on the barcode scan input field.
- Staff scans an item’s barcode. The system:
- Looks up the barcode in the catalog (Module 3).
- Matches it to a PO line item.
- Increments the
received_qtyfor that line by 1. - Plays an audible confirmation beep.
- Displays a running count: “Item XYZ: 23 of 50 received.”
- If the barcode does not match any PO line item, the system shows an alert: “Barcode not found on this PO. Wrong item?” Staff can flag it as WRONG_ITEM or search manually.
- Staff repeats scanning until all items are processed.
- Staff clicks “Confirm Receive” to finalize.
Manual Override:
- For items with damaged or missing barcodes, staff can switch to manual entry mode for individual line items.
- In manual mode, staff selects the product from the PO line item list and enters the quantity directly.
- The system logs whether each line was received via scanner or manual entry (for accuracy tracking).
Business Rules:
- Scanner mode is the default. The receiving screen opens in scanner mode unless the staff member explicitly switches to manual.
- Each barcode scan increments the count by exactly 1. There is no “scan and enter quantity” mode in scanner-primary workflow – every physical unit is scanned individually.
- If the same barcode is scanned more than the expected quantity for that line, over-shipment rules (Section 4.4.7) apply.
- Scanning speed is optimized for high throughput: the system processes each scan in under 200ms and immediately updates the on-screen count.
- The receiving screen shows a progress summary at all times: total items expected, total scanned so far, and lines remaining.
4.4.10 Unified Receiving Sequence (All Source Types)
sequenceDiagram
autonumber
participant U as Staff
participant UI as Receiving UI
participant API as Backend
participant DB as DB
Note over U, DB: Unified Receiving Flow
U->>UI: Select Source Document (PO / Transfer / Return / RMA / Non-PO)
UI->>API: GET /receiving/source/{type}/{id}
API-->>UI: Return Expected Line Items (empty for Non-PO)
U->>UI: Click "Start Receiving"
API->>DB: Create Receive Record (Status: IN_PROGRESS)
loop Receive Each Line Item
alt Scanner Verification (Default)
U->>UI: Scan Item Barcode
UI->>UI: Match to Expected Line Item
UI->>UI: Increment Received Qty
else Manual Entry
U->>UI: Enter Received Qty per Line
end
opt Variance Detected
UI-->>U: "Expected: 50, Received: 48"
U->>UI: Enter Variance Notes
end
opt Damaged Item
U->>UI: Mark Condition: DAMAGED
U->>UI: Enter Damage Notes
end
opt Wrong Item
U->>UI: Mark Condition: WRONG_ITEM
U->>UI: Enter Notes
end
opt Serial Tracked Product
U->>UI: Scan/Enter Serial Number for Each Unit
end
opt Lot Tracked Product
U->>UI: Enter Lot Number
end
end
U->>UI: Click "Confirm Receive"
UI->>API: POST /receiving/{id}/confirm
par Post-Receive Updates
API->>DB: Increment Inventory at Location (Received Qty - GOOD → AVAILABLE)
API->>DB: Set Damaged Items to DAMAGED Status
API->>DB: Flag Wrong Items for RMA Processing
API->>DB: Update Source Document (PO/Transfer/Return/RMA status)
API->>DB: Log Movement Records (per source type)
API->>DB: Update Receive Status: COMPLETED
end
API-->>UI: Receiving Complete
4.4.11 Reports: Receiving & Inspection
| Report | Purpose | Key Data Fields |
|---|---|---|
| Receiving Log | All receiving activity across all source types | Receive number, source type, source document, location, items expected, items received, variances, receive date |
| Non-PO Receiving Report | Track inventory received outside of PO process | Receive number, reason code, products, qty, staff member, date, notes |
| Damaged Goods Report | Track items received in damaged condition | Receive number, source, product, qty damaged, condition notes, RMA created (Y/N) |
| Over-Shipment Report | Track vendor over-shipments and approval outcomes | PO number, line item, qty ordered, qty received, overage %, auto-accepted (Y/N), approval status |
| Receiving Accuracy Report | Measure scanner vs manual receive accuracy | Location, total items received, scanner-received count, manual-received count, variance rate by method |
4.5 Reorder Management
Scope: Automating inventory replenishment through velocity-based reorder points, seasonal demand adjustments, and auto-generated draft purchase orders for staff review and approval. The reorder engine reduces stockouts by proactively identifying when products need replenishment and pre-building purchase orders for staff to review. This section also covers static override of reorder points and dead stock detection.
4.5.1 Velocity-Based Reorder Points
The system calculates dynamic reorder points per product per location using sales velocity, vendor lead time, and configurable safety stock. This ensures that reorder triggers adapt automatically to changing demand patterns without requiring manual intervention.
Formula:
reorder_point = (avg_daily_sales x lead_time_days x seasonal_factor) + safety_stock
Components:
| Component | Source | Description |
|---|---|---|
avg_daily_sales | Calculated from rolling 90-day sales velocity per product per location. | The average number of units sold per day over the trailing 90 days. New products with less than 90 days of history use the available history. Products with zero sales use 0. |
lead_time_days | Sourced from vendor-product relationship (vendor default or per-product override). | The number of days between placing a PO with the vendor and receiving the goods. |
safety_stock | Configurable multiplier applied to the standard deviation of daily sales. Default: 1.5 sigma. | Buffer stock to account for demand variability and supply uncertainty. Higher sigma values provide more protection against stockouts at the cost of higher inventory. |
seasonal_factor | Multiplier derived from historical same-period data (e.g., December velocity in prior years). | Applied to avg_daily_sales before reorder point calculation. A factor of 1.5 means the system expects 50% higher sales than the rolling average suggests. Default: 1.00 (no seasonal adjustment). |
Recalculation: Weekly via background job (configurable schedule per tenant). The job recalculates reorder points for all active products at all locations and updates the reorder point data model.
Reorder Point Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | Reference to product |
location_id | UUID | Yes | Reference to store/warehouse location |
avg_daily_velocity | Decimal(8,3) | Yes | Rolling 90-day average daily sales |
lead_time_days | Integer | Yes | Vendor lead time for this product |
safety_stock_units | Integer | Yes | Calculated safety stock buffer |
reorder_point | Integer | Yes | Stock level that triggers reorder |
reorder_qty | Integer | Yes | Economic order quantity (recommended purchase qty) |
seasonal_factor | Decimal(5,2) | No | Seasonal adjustment multiplier (default: 1.00) |
override_reorder_point | Integer | No | Manager-set manual override. When not null, takes precedence over the calculated reorder_point. See Section 4.5.3. |
override_reason | Text | No | Documentation for why the override was set. Required when override_reorder_point is not null. |
override_set_by | UUID | No | User who set the override. |
override_set_at | DateTime | No | Timestamp when the override was set. |
last_calculated_at | DateTime | Yes | Timestamp of last recalculation |
tenant_id | UUID | Yes | Owning tenant |
4.5.2 Auto-Generated Draft POs
When stock at any location drops below the calculated reorder_point (or the override_reorder_point if set), the system creates a draft purchase order for staff review.
Auto-PO Features:
- Pre-filled with the primary vendor for each product below reorder point.
- Recommended quantity set to
reorder_qty(economic order quantity). - Vendor consolidation: If multiple products from the same vendor hit reorder simultaneously, the system combines them into a single draft PO. This reduces the number of POs and may help reach vendor minimum order values.
- Staff receives notification: “3 draft POs auto-generated for review.”
- Staff can edit quantities, add/remove products, then submit – or discard the draft.
- Auto-generated draft POs are marked with
auto_generated = trueon the PO header (Section 4.3.4) so they can be filtered and reported on separately. - Auto-generated POs follow the same approval threshold rules as manually created POs (Section 4.3.2). They are not exempt from the approval workflow.
Auto-PO Generation Sequence
sequenceDiagram
autonumber
participant JOB as Background Job
participant DB as DB
participant API as Backend
participant NOTIF as Notification Service
participant U as Staff
Note over JOB, U: Reorder Point Check (Runs on Schedule)
JOB->>DB: Query Products Below Reorder Point (or Override)
DB-->>JOB: Return List (Product, Location, Current Qty, Reorder Point)
JOB->>JOB: Exclude products with existing DRAFT/SUBMITTED PO for same vendor
loop For Each Product Below Reorder
JOB->>DB: Lookup Primary Vendor
JOB->>DB: Get Reorder Qty (Economic Order Quantity)
alt Existing Draft PO for Same Vendor
JOB->>DB: Add Line Item to Existing Draft PO
else No Existing Draft PO
JOB->>DB: Create New Draft PO for Vendor
JOB->>DB: Add Line Item
end
end
JOB->>DB: Finalize Draft POs (calculate totals)
JOB->>NOTIF: "3 draft POs auto-generated for review"
NOTIF-->>U: Push Notification / Dashboard Alert
Note over U, DB: Staff Review
U->>API: GET /purchase-orders?status=DRAFT&auto_generated=true
API-->>U: Return Auto-Generated Draft POs
alt Approve as-is
U->>API: POST /purchase-orders/{id}/submit
Note right of API: Approval threshold check applies (Section 4.3.2)
API->>DB: Update Status: SUBMITTED (or PENDING_APPROVAL)
else Modify and Submit
U->>API: PATCH /purchase-orders/{id}
Note right of U: Edit quantities, add/remove lines
U->>API: POST /purchase-orders/{id}/submit
else Discard
U->>API: DELETE /purchase-orders/{id}
API->>DB: Delete Draft PO
end
Business Rules:
- The reorder check job does not create duplicate draft POs. If a product already has an open draft or submitted PO for the same vendor, it is excluded from auto-PO generation.
- The reorder check evaluates the
override_reorder_pointfirst. If set, it uses the override value. If null, it uses the calculatedreorder_point. - Auto-PO generation uses the product’s AVAILABLE quantity (not total on-hand) to determine if reorder is needed. RESERVED, IN_TRANSIT, and other non-available statuses are excluded.
- The system accounts for in-transit stock when calculating whether to reorder. If a product has an open PO with expected delivery within the lead time window, the system may skip reorder to avoid over-ordering (configurable behavior:
account_for_open_possetting, default: true).
4.5.3 Static Override
In some cases, the velocity-based reorder point calculation does not reflect the manager’s knowledge of the business. For example, a product may have low sales velocity (suggesting a low reorder point) but the manager knows it will be featured in an upcoming promotion and needs extra stock. Or a product may have high velocity but the manager knows it is being discontinued and does not want to reorder.
The static override feature allows a manager to lock any product at any location to a manual reorder point that overrides the dynamic velocity calculation.
Behavior:
- When
override_reorder_pointis set (not null), the reorder engine uses this value instead of the calculatedreorder_point. - The calculated
reorder_pointcontinues to be recalculated weekly by the background job, but it is ignored for reorder triggering as long as the override is active. - The override is visible in the product’s inventory detail screen with a visual indicator: “Reorder point manually set to 25 by [Manager Name] on [Date]. Calculated value: 12.”
- Managers can remove the override at any time, returning the product to dynamic reorder point calculation.
Business Rules:
- Only users with MANAGER or OWNER role can set or remove reorder point overrides.
- When setting an override, the
override_reasonfield is mandatory. The manager must document why the override is being set (e.g., “Upcoming Black Friday promotion – need extra stock”, “Discontinuing product – do not reorder”). - Overrides are per product per location. A manager can override the reorder point at Store A without affecting the calculation at Store B.
- The Reorder Alerts report (Section 4.5.5) shows which products are using overridden reorder points vs. calculated reorder points, so management can audit active overrides.
- There is no expiry on overrides. They remain active until manually removed. A periodic review reminder can be configured (e.g., “3 reorder overrides have been active for 90+ days – review?”).
4.5.4 Dead Stock Detection
Dead stock (also called slow-moving or stagnant inventory) represents products that have not sold at a location for an extended period. These items tie up capital, occupy shelf space, and may need to be marked down, transferred to a higher-traffic store, or written off.
Detection Logic:
- The system monitors the sales velocity of every active product at every location.
- When a product’s sales velocity at a location is zero for a configurable number of consecutive days (default: 90 days), it is flagged as dead stock.
- The dead stock flag is recalculated by the same weekly background job that recalculates reorder points.
Alert Behavior:
- Dead stock items appear on the Dead Stock Report (dashboard and exportable).
- A dashboard alert is displayed: “15 products at Store A have had zero sales for 90+ days.”
- The alert is informational only. No automatic action is taken. The manager decides the appropriate action for each item.
Manager Actions (Manual):
| Action | Description |
|---|---|
| Markdown | Reduce the price to accelerate sales. Handled via Module 3 (Catalog) price update. |
| Transfer | Move stock to a location with higher demand. Handled via Module 5 (Transfers). |
| Write-Off | Remove from inventory as a loss. Handled via Section 4.7 (Adjustments) with reason code WRITE_OFF. |
| Dismiss Alert | Acknowledge the alert without taking action. The product remains flagged but the alert is silenced for a configurable period (default: 30 days). |
Business Rules:
- Dead stock detection only applies to products with
lifecycle_status = ACTIVEin the catalog. Discontinued or inactive products are excluded. - The configurable threshold (default 90 days) is set per tenant in tenant settings. Different tenants may have different thresholds based on their product turnover expectations.
- Dead stock is evaluated per location. A product may be dead stock at Store A but selling well at Store B. The system highlights this imbalance and suggests transfer as an action.
- Products that have been in inventory for less than the threshold period (e.g., a new product received 30 days ago) are excluded from dead stock detection regardless of sales velocity.
- The Dead Stock Report includes: product, location, current qty, last sale date, days since last sale, total value at cost, suggested action.
Dead Stock Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | FK to product |
location_id | UUID | Yes | FK to location |
days_since_last_sale | Integer | Yes | Number of days since the last sale of this product at this location |
last_sale_date | Date | No | Date of the most recent sale. Null if never sold at this location. |
qty_on_hand | Integer | Yes | Current AVAILABLE quantity at this location |
value_at_cost | Decimal(10,2) | Yes | qty_on_hand x weighted_avg_cost |
is_flagged | Boolean | Yes | Whether this product-location is currently flagged as dead stock |
flagged_at | DateTime | No | Timestamp when the dead stock flag was set |
alert_dismissed_at | DateTime | No | Timestamp when a manager dismissed the alert. Alert is silenced until dismissed_at + dismiss_duration. |
dismiss_duration_days | Integer | No | Number of days to silence the alert after dismissal. Default: 30. |
tenant_id | UUID | Yes | Owning tenant |
4.5.5 Reports: Reorder Management
| Report | Purpose | Key Data Fields |
|---|---|---|
| Reorder Alerts | Products below reorder point needing attention | Product, location, current qty, reorder point (calculated vs override), days of supply remaining, suggested qty, primary vendor, override active (Y/N) |
| Auto-PO Performance | Effectiveness of automatic reorder system | Auto-generated PO count, submitted as-is count, modified count, cancelled count, avg fill rate, avg time from draft to submit |
| Velocity Trends | Sales velocity changes over time per product | Product, 30-day velocity, 60-day velocity, 90-day velocity, trend direction (increasing/stable/decreasing), seasonal factor |
| Days of Supply | How long current stock will last at current velocity | Product, location, qty on hand, avg daily velocity, days of supply, reorder urgency (Critical/Low/OK) |
| Dead Stock Report | Products with zero velocity over threshold period | Product, location, current qty, last sale date, days since last sale, value at cost, suggested action, alert status |
| Override Audit Report | Active reorder point overrides for review | Product, location, override value, calculated value, override reason, set by, set date, days active |
4.6 Inventory Counting & Auditing
Scope: Maintaining inventory accuracy through structured counting workflows that reconcile system quantities with physical reality. The system supports five counting methods, two count modes (freeze and snapshot), scanner-primary counting, and a complete review-and-approve workflow for count variances.
4.6.1 Count Types
The system supports five counting methods to maintain inventory accuracy. Each method is suited to different operational needs.
| Method | Description | Frequency | Scope |
|---|---|---|---|
| Full Physical Count | All products at a location counted | Annual or semi-annual | Entire location |
| Cycle Count | Rolling partial counts by category | Weekly (configurable schedule) | Category rotation |
| Scanner-Assisted Count | Barcode/RFID scanner used to tally items | As needed | Configurable scope |
| Monthly Scan | Scheduled full-location scan | Auto-created 1st of each month (configurable) | Entire location |
| On-Demand Count | Ad-hoc count triggered by manager | As needed | Specific products |
4.6.2 Count Data Model – Header
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
count_number | String | Yes | Auto-generated: CNT-{YEAR}-{SEQ} |
type | Enum | Yes | FULL, CYCLE, SCANNER, MONTHLY, ON_DEMAND |
location_id | UUID | Yes | Location being counted |
status | Enum | Yes | CREATED, IN_PROGRESS, REVIEW, APPROVED, CANCELLED |
count_mode | Enum | Yes | FREEZE or SNAPSHOT. See Section 4.6.4. |
scope | Enum | Yes | ALL, CATEGORY, PRODUCT_LIST |
category_ids[] | UUID[] | No | Categories included (when scope = CATEGORY) |
product_ids[] | UUID[] | No | Specific products included (when scope = PRODUCT_LIST) |
snapshot_taken_at | DateTime | No | Timestamp when the inventory snapshot was taken (SNAPSHOT mode only). |
created_by | UUID | Yes | Manager who initiated the count |
assigned_to | UUID | No | Staff member assigned to perform the count |
approved_by | UUID | No | Manager who approved adjustments |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
completed_at | DateTime | No | Timestamp when count was approved/cancelled |
4.6.3 Count Data Model – Line Items
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
count_id | UUID | Yes | Reference to parent count |
product_id | UUID | Yes | Product being counted |
variant_id | UUID | No | Specific variant (if applicable) |
expected_qty | Integer | Yes | System’s recorded AVAILABLE quantity at count start (or snapshot time in SNAPSHOT mode) |
counted_qty | Integer | No | Actual quantity counted by staff |
variance | Integer | Computed | Calculated: counted_qty - expected_qty |
variance_pct | Decimal(5,2) | Computed | Calculated: (variance / expected_qty) x 100. Null if expected_qty is 0. |
count_method | Enum | No | SCANNER or MANUAL – indicates how this line was counted |
adjustment_approved | Boolean | No | Whether the variance adjustment was approved by the reviewing manager |
notes | Text | No | Staff notes on variance explanation |
4.6.4 Configurable Count Freeze
When initiating a stock count, the manager chooses one of two counting modes. Each mode has different trade-offs between accuracy and operational impact.
| Aspect | FREEZE Mode | SNAPSHOT Mode |
|---|---|---|
| POS Sales | Blocked at the counting location for the duration of the count. Other locations continue to sell normally. | Continue normally. Sales are recorded and reconciled after the count. |
| Transfers | Outbound transfers blocked. Inbound transfers held until count completes. | Continue normally. Transfers are recorded and reconciled after the count. |
| Accuracy | Highest accuracy – no inventory movement during count ensures perfect reconciliation. | Slightly lower accuracy – reconciliation must account for sales and transfers that occurred during the count window. |
| Business Impact | High – store cannot sell during count. Best for after-hours or slow periods. | Low – store operates normally. Suitable for counts during business hours. |
| Duration Limit | No system limit, but operational pressure to complete quickly since sales are blocked. | No limit. Count can span hours or even days if needed. |
| Reconciliation | Direct comparison: counted_qty vs expected_qty. No adjustment needed for concurrent activity. | System calculates: adjusted_expected = snapshot_qty - sales_during_count + receives_during_count. Variance = counted_qty - adjusted_expected. |
| Recommended For | Full physical counts, high-value inventory sections, annual audits. | Cycle counts, monthly scans, routine counting during business hours. |
Business Rules:
- The count mode is selected at count creation time and cannot be changed after the count starts (status = IN_PROGRESS).
- FREEZE mode: When a count is started in FREEZE mode, the POS at the counting location displays a message: “Inventory count in progress. Sales temporarily suspended at this location.” POS terminals at other locations are unaffected. Transfers to/from the counting location are queued and processed after the count is approved.
- SNAPSHOT mode: When a count is started in SNAPSHOT mode, the system takes a point-in-time snapshot of all in-scope product quantities. This snapshot is stored as the
expected_qtyfor each count line item. Sales and transfers that occur after the snapshot continue normally. At review time, the system recalculates the expected values by applying all inventory movements that occurred between the snapshot time and the count submission time. - Only users with MANAGER or OWNER role can create counts and select the count mode.
- The system defaults to SNAPSHOT mode. FREEZE mode must be explicitly selected.
4.6.5 Scanner-Primary Counting
The default counting workflow is scanner-primary: staff uses a barcode scanner to scan each physical item on the shelf. Each scan increments the count for the matching product by one unit.
Scanner-Assisted Count Workflow
sequenceDiagram
autonumber
participant M as Manager
participant U as Staff
participant SC as Scanner Device
participant UI as Count UI
participant API as Backend
participant DB as DB
Note over M, DB: Step 1: Create Count Session
M->>UI: Click "New Stock Count"
M->>UI: Select Type, Location, Scope, Mode
UI->>API: POST /stock-counts
API->>DB: Create Count Header (Status: CREATED)
alt SNAPSHOT Mode
API->>DB: Snapshot Current Qty for All In-Scope Products
Note right of DB: Snapshot stored as expected_qty per line
else FREEZE Mode
API->>DB: Set Location POS to Count Mode (sales blocked)
end
API->>DB: Create Count Line Items (one per in-scope product-variant)
API-->>UI: Count #CNT-2026-00031 Created
Note over M, DB: Step 2: Assign & Start Counting
M->>UI: Assign Count to Staff Member
API-->>U: Notification: "Count assigned to you"
U->>UI: Open Count -> Click "Start Counting"
API->>DB: Update Status: IN_PROGRESS
Note over U, SC: Step 3: Scan Items
loop For Each Physical Item on Shelf
U->>SC: Scan Item Barcode
SC-->>UI: Barcode Data
UI->>UI: Lookup Product by Barcode
UI->>UI: Increment counted_qty by 1 for Matching Line
alt Barcode Matched
UI-->>U: Beep + "Item XYZ: 23 counted"
else Barcode Not Found
UI-->>U: Alert "Unknown barcode — enter product manually?"
U->>UI: Search Product by SKU/Name
U->>UI: Confirm Match
UI->>UI: Increment counted_qty by 1
Note right of UI: Line marked as count_method = MANUAL
end
end
opt Items with Damaged/Missing Barcodes
U->>UI: Switch to Manual Entry for Specific Line
U->>UI: Enter counted_qty Directly
Note right of UI: Line marked as count_method = MANUAL
end
Note over U, DB: Step 4: Submit for Review
U->>UI: Click "Submit for Review"
UI->>API: POST /stock-counts/{id}/submit
API->>DB: Calculate Variances per Line
API->>DB: Update Status: REVIEW
Note over M, DB: Step 5: Review & Approve
M->>UI: Open Count for Review
UI->>API: GET /stock-counts/{id}/variances
API-->>UI: Return Variance Report
M->>UI: Review Each Variance Line
Note right of M: Accept or reject each line adjustment
M->>UI: Click "Approve Adjustments"
UI->>API: POST /stock-counts/{id}/approve
par Inventory Updates
API->>DB: Apply Approved Adjustments to Inventory
API->>DB: Log Each Adjustment as COUNT_ADJUST Movement
API->>DB: Update Count Status: APPROVED
end
alt FREEZE Mode
API->>DB: Release Location POS from Count Mode (sales resume)
API->>DB: Process Queued Transfers
end
API-->>UI: Count Approved — Inventory Updated
Business Rules:
- Scanner mode is the default. The count screen opens in scanner-listening mode when the count is started.
- Each barcode scan increments the count by exactly 1. Staff scans every physical unit individually.
- Manual quantity entry is available as a fallback for items with damaged or missing barcodes. Staff can switch between scanner and manual mode on a per-line basis.
- Items not scanned during the count have
counted_qty = 0at submission time. If the expected qty was greater than 0, this is flagged as a variance (potential shrinkage or miscount). - Products scanned that are not in the count scope (e.g., wrong category during a cycle count) are flagged with a warning but can be added to the count at the staff member’s discretion.
- Count line items track whether they were counted via scanner or manual entry (
count_methodfield) for accuracy auditing.
4.6.6 Count Workflow Sequence
sequenceDiagram
autonumber
participant M as Manager
participant U as Staff
participant UI as Inventory UI
participant API as Backend
participant DB as DB
Note over M, DB: Step 1: Create Count
M->>UI: Click "New Stock Count"
M->>UI: Select Type (Full/Cycle/On-Demand)
M->>UI: Select Location
M->>UI: Select Count Mode (Freeze/Snapshot)
M->>UI: Define Scope (All / Category / Product List)
UI->>API: POST /stock-counts
API->>DB: Create Count (Status: CREATED)
API->>DB: Snapshot Expected Qty for All In-Scope Products
API-->>UI: Count #CNT-2026-00031 Created
Note over M, DB: Step 2: Assign & Count
M->>UI: Assign Count to Staff Member
API-->>U: Notification: "Count assigned to you"
U->>UI: Open Count -> Start Counting
API->>DB: Update Status: IN_PROGRESS
loop Count Each Product
alt Scanner-Assisted (Default)
U->>UI: Scan Item Barcode
UI->>UI: Increment Counted Qty for Product
else Manual Entry (Fallback)
U->>UI: Enter Counted Qty per Product
end
end
U->>UI: Click "Submit for Review"
UI->>API: POST /stock-counts/{id}/submit
API->>DB: Update Status: REVIEW
Note over M, DB: Step 3: Review & Approve
M->>UI: Open Count for Review
UI->>API: GET /stock-counts/{id}/variances
API-->>UI: Return Variance Report
M->>UI: Review Each Variance
Note right of M: Accept or reject each line adjustment
M->>UI: Click "Approve Adjustments"
UI->>API: POST /stock-counts/{id}/approve
par Inventory Updates
API->>DB: Apply Approved Adjustments to Inventory
API->>DB: Log Each Adjustment as COUNT_ADJUST Movement
API->>DB: Update Status: APPROVED
end
API-->>UI: Count Approved - Inventory Updated
4.6.7 Reports: Inventory Counting
| Report | Purpose | Key Data Fields |
|---|---|---|
| Count Variance Report | Variances discovered during stock counts | Count number, type, mode (Freeze/Snapshot), product, expected qty, counted qty, variance, variance %, count method (Scanner/Manual), adjustment status |
| Count Schedule Report | Upcoming and overdue scheduled counts | Count type, location, scheduled date, status, assigned to, overdue flag |
| Count Accuracy Trend | Track counting accuracy over time | Month, location, total counts, avg variance %, scanner-counted %, manual-counted %, accuracy trend |
| Shrinkage by Location | Inventory loss detected through counts per location | Location, period, total negative variances, value at cost, shrinkage % of total inventory value |
4.6.8 RFID-Assisted Counting (Raptag)
RFID counting operates as a dedicated subsystem separate from barcode-scanner counting. While scanner-primary counting (Section 4.6.5) handles individual barcode scans at the POS, RFID counting enables bulk tag reads using handheld RFID readers via the Raptag mobile application (Raptag Mobile chapter — planned future rewrite).
Scope: RFID counting is for inventory counts ONLY. It does not participate in receiving (Section 4.4), transfers (Section 4.5), or sales (Module 1). Configuration for the RFID subsystem is in Section 5.16.
RFID Count Session Lifecycle
Manager Creates Session → Assigns Sections to Operators → Operators Join via Raptag
→ Parallel Scanning (offline-capable) → Upload Chunks → Server Merges & Deduplicates
→ Variance Calculation → Manager Reviews → Approve / Recount
Multi-Operator Sessions
A single RFID count session can have multiple operators (up to 10), each assigned to a section of the store. This enables parallel counting for enterprise-scale inventories (100,000+ items).
Workflow:
- Manager creates a count session in Nexus POS or the Raptag app, selecting count type (full_inventory, cycle_count, spot_check) and location
- Manager assigns sections to operators (e.g., “Sarah: Men’s Tops”, “James: Women’s Bottoms”)
- Each operator launches Raptag, sees the active session on their Home Dashboard, and taps “Join Session”
- Each operator’s device scans independently using the Zebra reader, recording tag reads locally in SQLite (offline-capable)
- On upload, the system merges all operator reads into one session via chunked upload (5,000 events per chunk)
- Server-side deduplication: If two operators scan the same tag (same EPC), the system keeps the read with the strongest RSSI (closest proximity = most accurate location)
Multi-Operator Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to shared.tenants |
session_id | UUID | Yes | FK to rfid_scan_sessions |
operator_id | UUID | Yes | FK to shared.users |
assigned_section | Text | No | Section/area label assigned by manager |
device_id | UUID | No | FK to devices — reader device used by operator |
joined_at | Timestamp | Yes | When operator joined the session |
left_at | Timestamp | No | When operator left or session ended |
Deduplication Rules:
- Same EPC scanned by multiple operators → keep the read with highest RSSI (strongest signal indicates closest proximity)
- Merge happens server-side during chunk upload processing
rfid_scan_eventsrecords which operator reported each tag (viasession_operatorslink)
RFID-Specific Business Rules:
- Maximum 10 operators per session
- Each operator can only participate in one active session at a time
- Manager must create the session and assign at least one section before operators can join
- Session cannot be completed until all operators have submitted their chunks (or been removed)
- Operators can leave a session (voluntarily or by manager removal) without losing already-uploaded data
- If an operator’s device loses power mid-scan, auto-save preserves data locally; they can resume on the same or different device
RFID Counting vs Scanner Counting
| Aspect | Scanner-Primary (Section 4.6.5) | RFID-Assisted (This Section) |
|---|---|---|
| Input Device | Barcode scanner (USB HID) | Zebra RFID reader (MC3390R, RFD40) |
| Application | POS terminal | Nexus Raptag (React Native) |
| Read Speed | 1 item per scan | 40+ tags per second |
| Tracking Level | SKU-level (barcode → product lookup) | EPC-level (individual item tracking) |
| Offline | Connected to POS (online) | Fully offline with SQLite + sync |
| Operators | Single operator per count | Up to 10 operators per session |
| Data Flow | Real-time via POS API | Batch upload via chunked sync |
| Count Method Value | SCANNER or MANUAL | RFID |
4.7 Inventory Adjustments
Scope: Manual inventory adjustments handle corrections outside of stock counts. Adjustments are used when a staff member discovers a discrepancy between the system quantity and physical reality and needs to correct the system immediately, without waiting for a scheduled count. All adjustments – regardless of direction or size – require manager approval before stock levels change.
4.7.1 Adjustment Approval Workflow
All adjustments require manager approval. Unlike the previous threshold-based approach, this module enforces a universal approval requirement for every inventory adjustment. This ensures that no stock level change bypasses management oversight, which is critical for loss prevention and financial accuracy in a multi-store retail environment.
Workflow:
- A staff member identifies a discrepancy (e.g., found 3 extra units on a shelf, or 2 units are missing and believed stolen).
- The staff member creates an adjustment request in the system, specifying the product, location, quantity change (positive or negative), reason code, and notes.
- The adjustment is created with
approval_status = PENDING. Inventory is NOT changed yet. - A notification is sent to all users with MANAGER or OWNER role at the adjustment’s location.
- The manager reviews the adjustment request. They can:
- Approve: The adjustment is applied to inventory. The status changes to
APPROVED. Inventory quantity is updated. A movement record is logged. - Reject: The adjustment is not applied. The status changes to
REJECTED. The requesting staff member is notified with the rejection reason. Inventory is unchanged.
- Approve: The adjustment is applied to inventory. The status changes to
- Rejected adjustments can be revised and resubmitted as new adjustment requests.
Adjustment Approval Sequence
sequenceDiagram
autonumber
participant S as Staff
participant UI as Inventory UI
participant API as Backend
participant DB as DB
participant NOTIF as Notification Service
participant M as Manager
S->>UI: Identify Discrepancy
S->>UI: Click "New Adjustment"
S->>UI: Select Product, Location
S->>UI: Enter Qty Change (+3 or -2)
S->>UI: Select Reason Code
S->>UI: Enter Notes
UI->>API: POST /adjustments
API->>DB: Create Adjustment (Status: PENDING)
Note right of DB: ADJ-2026-00015 created
Note right of DB: Inventory NOT changed yet
API->>NOTIF: Send Approval Request to Manager(s)
API-->>UI: "Adjustment submitted for approval"
NOTIF-->>M: "Adjustment ADJ-2026-00015: +3 units of SKU NXJ1078 at Store A — awaiting approval"
alt Manager Approves
M->>UI: Review Adjustment -> Click "Approve"
UI->>API: POST /adjustments/{id}/approve
API->>DB: Update Status: APPROVED
API->>DB: Update Inventory Qty (apply qty_change)
API->>DB: Record approved_by, approved_at
API->>DB: Log Movement Record (ADJUSTMENT_UP or ADJUSTMENT_DOWN)
API->>NOTIF: Notify Staff: "Adjustment approved"
NOTIF-->>S: "Your adjustment ADJ-2026-00015 was approved"
API-->>UI: Adjustment Approved — Inventory Updated
else Manager Rejects
M->>UI: Review Adjustment -> Click "Reject"
M->>UI: Enter Rejection Reason
UI->>API: POST /adjustments/{id}/reject
API->>DB: Update Status: REJECTED
API->>DB: Record rejection_reason
API->>NOTIF: Notify Staff: "Adjustment rejected"
NOTIF-->>S: "Your adjustment ADJ-2026-00015 was rejected: reason"
API-->>UI: Adjustment Rejected — Inventory Unchanged
end
4.7.2 Adjustment Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
adjustment_number | String | Yes | Auto-generated: ADJ-{YEAR}-{SEQ} |
product_id | UUID | Yes | Reference to product |
variant_id | UUID | No | Reference to specific variant (if applicable) |
location_id | UUID | Yes | Location where adjustment applies |
qty_change | Integer | Yes | Positive (found stock) or negative (shrinkage/damage). Must not be 0. |
reason_code | String | Yes | Standard reason code or custom reason code (see Section 4.7.3). |
notes | Text | No | Explanation of adjustment. Mandatory for certain reason codes (e.g., OTHER). |
requested_by | UUID | Yes | Staff member who requested the adjustment |
approved_by | UUID | No | Manager who approved (null until approved) |
rejected_by | UUID | No | Manager who rejected (null unless rejected) |
approval_status | Enum | Yes | PENDING, APPROVED, REJECTED |
rejection_reason | Text | No | Manager’s reason for rejection (if rejected) |
cost_impact | Decimal(10,2) | Computed | qty_change x weighted_avg_cost. Positive for found stock, negative for shrinkage. Calculated at approval time. |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
approved_at | DateTime | No | Timestamp of approval |
rejected_at | DateTime | No | Timestamp of rejection |
4.7.3 Custom Reason Codes
The system provides a standard set of reason codes for inventory adjustments. In addition, tenants can define custom reason codes to capture business-specific adjustment scenarios.
Standard Reason Codes
| Code | Direction | Description |
|---|---|---|
DAMAGED | Negative | Item identified as damaged and removed from sellable inventory. |
THEFT | Negative | Item believed to be stolen (shoplifting, employee theft). |
COUNT_CORRECTION | Either | Correction resulting from a stock count variance that was not captured in the count approval process. |
SAMPLE | Negative | Item removed from inventory and given as a sample (to vendor, customer, or for marketing). |
WRITE_OFF | Negative | Item permanently removed from inventory as a loss. Requires cost documentation for accounting. |
FOUND_STOCK | Positive | Stock discovered that was not in the system (e.g., found behind a shelf, unscanned box). |
RETURN_TO_STOCK | Positive | Item returned to inventory outside the normal return-to-stock workflow (e.g., item used for display purposes and now returned to sellable stock). |
OTHER | Either | None of the above. Notes field becomes mandatory. |
Custom Reason Code Data Model
Tenants can create additional reason codes beyond the standard set. Custom reason codes behave identically to standard codes in all workflows – they appear in the reason code dropdown, are logged in movement records, and are available in reports.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
code | String(50) | Yes | Unique code identifier (e.g., EMPLOYEE_PURCHASE, PHOTO_SHOOT, CHARITY_DONATION). Must be uppercase, alphanumeric with underscores. Must not conflict with standard reason codes. |
display_name | String(100) | Yes | Human-readable name shown in the UI dropdown (e.g., “Employee Purchase”, “Photo Shoot”, “Charity Donation”). |
description | Text | No | Optional description of when this reason code should be used. |
direction | Enum | Yes | POSITIVE, NEGATIVE, or BOTH. Controls whether this code can be used for positive adjustments, negative adjustments, or both. |
requires_notes | Boolean | Yes | Whether the notes field is mandatory when this reason code is selected. Default: false. |
is_active | Boolean | Yes | Whether this custom reason code is available for selection. Inactive codes are hidden from the dropdown but preserved in historical records. |
sort_order | Integer | Yes | Display order in the reason code dropdown (after standard codes). |
tenant_id | UUID | Yes | Owning tenant |
created_by | UUID | Yes | User who created the custom reason code |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Custom reason codes are tenant-specific. Each tenant manages their own set of custom codes.
- Custom code identifiers must be unique within a tenant and must not duplicate any standard code.
- Custom codes can be deactivated (soft delete) but not hard-deleted, since historical adjustment records may reference them.
- Only users with MANAGER or OWNER role can create, edit, or deactivate custom reason codes.
- When a custom reason code has
requires_notes = true, the adjustment form enforces a mandatory notes field when that code is selected. - Custom reason codes appear in all reports alongside standard codes. Reports can filter by standard vs. custom codes.
- Standard reason codes cannot be deactivated or modified by tenants. They are system-level constants.
4.7.4 Business Rules Summary
- All adjustments require manager approval. Positive adjustments (found stock), negative adjustments (shrinkage, damage), and zero-net adjustments (reclassification) all require explicit manager approval before inventory quantities change. There is no auto-approval threshold.
- Adjustment status flow:
PENDING(created, awaiting approval) ->APPROVED(manager approved, inventory updated) orREJECTED(manager rejected, inventory unchanged). - Inventory is not changed until approval. The
PENDINGadjustment is a request only. The physical inventory quantity in the system remains unchanged until a manager explicitly approves the adjustment. WRITE_OFFadjustments require cost documentation. When the reason code isWRITE_OFF, the system calculates and records thecost_impactfield for accounting reconciliation. The manager can see the dollar impact before approving.- All approved adjustments are logged as
ADJUSTMENT_UP(positive qty_change) orADJUSTMENT_DOWN(negative qty_change) movements in the movement history audit trail. - Rejected adjustments are preserved. Rejected adjustments remain in the system for audit purposes. They are not deleted. The rejection reason is recorded.
- Concurrent adjustment protection: If two staff members submit adjustments for the same product at the same location, the manager sees both pending adjustments and can approve or reject each independently. The system recalculates the inventory impact at approval time based on the current quantity, not the quantity at request time.
4.7.5 Reports: Inventory Adjustments
| Report | Purpose | Key Data Fields |
|---|---|---|
| Adjustment History | All manual inventory adjustments | Adjustment number, product, location, qty change, reason code (standard/custom), requested by, approved/rejected by, date, cost impact |
| Pending Adjustments | Adjustments awaiting manager approval | Adjustment number, product, location, qty change, reason code, requested by, request date, days pending |
| Shrinkage Report | Track inventory loss by reason code and location | Period, location, reason code, qty lost, value at cost, % of total inventory value |
| Reason Code Analysis | Frequency and impact of each reason code | Reason code, adjustment count, total qty impact, total cost impact, avg approval time, rejection rate |
| Custom Reason Code Usage | Track usage of tenant-defined reason codes | Custom code, display name, adjustment count, total qty impact, last used date |
4.8 Inter-Store Transfers
Scope: Moving inventory between store locations and HQ warehouse with full workflow tracking, variance detection on receipt, and auto-rebalancing recommendations based on sales velocity analysis. This section covers bi-directional transfer initiation (HQ push and store pull), manual allocation for scarce items, and auto-suggest workflows. The customer-facing paid transfer request (from Section 1.7) feeds into this system as the triggering event.
4.8.1 Transfer State Machine
The transfer lifecycle supports 10 states covering the full journey from request through completion, including rejection and cancellation paths.
stateDiagram-v2
[*] --> REQUESTED: Transfer Requested
REQUESTED --> APPROVED: Source Manager Approves
REQUESTED --> REJECTED: Source Manager Rejects
APPROVED --> PICKING: Pick List Generated
PICKING --> SHIPPED: Items Shipped
SHIPPED --> IN_TRANSIT: Carrier Confirmed Pickup
IN_TRANSIT --> RECEIVED: Destination Receives
RECEIVED --> COMPLETED: All Items Verified
APPROVED --> CANCELLED: Cancelled After Approval
REJECTED --> CLOSED: No Further Action
CANCELLED --> CLOSED: No Further Action
note right of REQUESTED
Destination store or HQ requests stock
OR HQ pushes stock to store
Awaiting source approval
end note
note right of APPROVED
Source manager authorized the transfer
Pick list ready for warehouse/store
end note
note right of PICKING
Source store picking items
Inventory not yet decremented
end note
note right of SHIPPED
Items handed to carrier or internal transport
Source inventory decremented
Tracking number recorded
end note
note right of IN_TRANSIT
Carrier confirmed pickup
Items between locations
end note
note right of RECEIVED
Destination received and is verifying
Variances being recorded
end note
note right of COMPLETED
All items accounted for
Destination inventory incremented
end note
4.8.2 Transfer Initiation Directions
Transfers can be initiated in two directions. Both directions enter the state machine at the same REQUESTED state.
Pull Model (Store Requests from Source)
A destination store identifies a need (low stock, customer demand, auto-suggest alert) and creates a transfer request specifying the source location and desired items. The source manager reviews and approves or rejects.
Use cases:
- Store manager sees low stock on a bestseller and requests units from HQ or another store.
- Customer requests an item available at another location (paid transfer from Section 1.7).
- System auto-suggest identifies an imbalance and the destination manager initiates the transfer.
Push Model (HQ Pushes to Stores)
HQ warehouse staff or a regional manager initiates a transfer from HQ to one or more stores. The HQ manager acts as both requester and approver, so the transfer can skip the approval wait if the same user has both roles.
Use cases:
- New seasonal inventory arrives at HQ and is distributed to stores.
- HQ manager reviews rebalancing suggestions and pushes stock to understocked stores.
- Vendor replacement shipment received at HQ needs distribution to affected stores.
Initiation Direction Data
The transfer header includes a direction field to distinguish the two models:
| Field | Type | Required | Description |
|---|---|---|---|
direction | Enum | Yes | PULL (destination requests) or PUSH (source initiates) |
initiated_by_location_id | UUID | Yes | Location that created the transfer (source for PUSH, destination for PULL) |
Business Rules for Direction:
- PULL transfers require source manager approval before proceeding to PICKING.
- PUSH transfers from HQ may be auto-approved if the initiating user holds the
inventory_manageroradminrole at the source location. - PUSH transfers between peer stores (non-HQ) still require source manager approval.
- Both directions produce identical downstream workflow (PICKING through COMPLETED).
4.8.3 Transfer Data Model
Transfer Header
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
transfer_number | String | Yes | Auto-generated: TRF-{YEAR}-{SEQ} (e.g., TRF-2026-00088) |
source_location_id | UUID | Yes | Location sending stock |
destination_location_id | UUID | Yes | Location receiving stock |
direction | Enum | Yes | PULL (destination requests) or PUSH (source initiates) |
initiated_by_location_id | UUID | Yes | Location that created the transfer |
status | Enum | Yes | REQUESTED, APPROVED, REJECTED, PICKING, SHIPPED, IN_TRANSIT, RECEIVED, COMPLETED, CANCELLED, CLOSED |
priority | Enum | No | NORMAL, URGENT, CUSTOMER_REQUEST (default: NORMAL) |
requested_by | UUID | Yes | Staff member who initiated the request |
approved_by | UUID | No | Manager who approved/rejected |
shipped_date | Date | No | Date items were shipped |
received_date | Date | No | Date items were received |
tracking_number | String | No | Carrier tracking number |
carrier | String | No | Carrier name (e.g., internal, UPS, FedEx) |
estimated_arrival | Date | No | Expected delivery date |
auto_suggest_id | UUID | No | FK to auto-suggest recommendation that triggered this transfer (null if manually created) |
notes | Text | No | Transfer notes |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Transfer Line Items
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
transfer_id | UUID | Yes | Reference to parent transfer |
product_id | UUID | Yes | Product being transferred |
variant_id | UUID | No | Specific variant (if applicable) |
qty_requested | Integer | Yes | Quantity requested by destination |
qty_shipped | Integer | No | Actual quantity shipped by source |
qty_received | Integer | No | Quantity verified at destination |
variance | Integer | Computed | Calculated: qty_received - qty_shipped |
variance_notes | Text | No | Explanation of any variance |
condition_on_receive | Enum | No | GOOD, DAMAGED, WRONG_ITEM |
4.8.4 Transfer Workflow (Pull Model)
sequenceDiagram
autonumber
participant DST as Destination Staff
participant SRC_M as Source Manager
participant SRC as Source Staff
participant UI as Transfer UI
participant API as Backend
participant DB as DB
Note over DST, DB: Step 1: Request Transfer (Pull)
DST->>UI: Click "Request Transfer"
DST->>UI: Select Source Location
DST->>UI: Add Products & Quantities
UI->>API: POST /transfers
API->>DB: Create Transfer (Status: REQUESTED, Direction: PULL)
API-->>DST: Transfer #TRF-2026-00088 Created
API-->>SRC_M: Notification: "Transfer request from Store B"
Note over SRC_M, DB: Step 2: Approve/Reject
SRC_M->>UI: Review Transfer Request
SRC_M->>UI: Check Source Stock Availability
alt Approve
SRC_M->>UI: Click "Approve"
UI->>API: POST /transfers/{id}/approve
API->>DB: Update Status: APPROVED
API->>DB: Generate Pick List
else Reject
SRC_M->>UI: Click "Reject" + Enter Reason
UI->>API: POST /transfers/{id}/reject
API->>DB: Update Status: REJECTED -> CLOSED
end
Note over SRC, DB: Step 3: Pick & Ship
SRC->>UI: Open Pick List
SRC->>UI: Pick Items (Scan to Verify)
SRC->>UI: Enter Qty Shipped per Line
SRC->>UI: Enter Tracking Number (if applicable)
SRC->>UI: Click "Ship"
UI->>API: POST /transfers/{id}/ship
API->>DB: Decrement Source Inventory
API->>DB: Update Status: SHIPPED
API->>DB: Log TRANSFER_OUT Movement (Section 4.12)
Note over DST, DB: Step 4: Receive & Verify
DST->>UI: Open Transfer -> Click "Receive"
loop Verify Each Line Item
DST->>UI: Scan/Count Received Items
DST->>UI: Enter Qty Received per Line
opt Variance
UI-->>DST: "Shipped: 20, Received: 18"
DST->>UI: Enter Variance Notes
end
opt Damaged Items
DST->>UI: Mark Condition: DAMAGED
end
end
DST->>UI: Click "Confirm Receive"
UI->>API: POST /transfers/{id}/receive
par Post-Receive
API->>DB: Increment Destination Inventory
API->>DB: Log TRANSFER_IN Movement (Section 4.12)
API->>DB: Record Variances
API->>DB: Update Status: COMPLETED
end
API-->>DST: Transfer Complete
4.8.6 Transfer Workflow (Push Model)
sequenceDiagram
autonumber
participant HQ as HQ Manager
participant SRC as HQ Warehouse Staff
participant UI as Transfer UI
participant API as Backend
participant DB as DB
participant DST as Destination Staff
Note over HQ, DST: Step 1: HQ Initiates Push Transfer
HQ->>UI: Click "Push Stock to Store"
HQ->>UI: Select Destination Store(s)
HQ->>UI: Add Products & Quantities
UI->>API: POST /transfers
API->>DB: Create Transfer (Status: REQUESTED, Direction: PUSH)
alt HQ Manager Has Approval Authority
API->>DB: Auto-Approve (Status: APPROVED)
API->>DB: Generate Pick List
API-->>HQ: Transfer Auto-Approved, Pick List Ready
else Requires Separate Approval
API-->>HQ: Transfer Created, Awaiting Approval
end
Note over SRC, DB: Step 2: Pick & Ship (same as Pull model)
SRC->>UI: Open Pick List
SRC->>UI: Pick Items (Scan to Verify)
SRC->>UI: Enter Qty Shipped per Line
SRC->>UI: Enter Tracking Number
SRC->>UI: Click "Ship"
UI->>API: POST /transfers/{id}/ship
API->>DB: Decrement Source Inventory
API->>DB: Update Status: SHIPPED
API->>DB: Log TRANSFER_OUT Movement (Section 4.12)
API-->>DST: Notification: "Incoming transfer from HQ"
Note over DST, DB: Step 3: Receive & Verify (same as Pull model)
DST->>UI: Open Transfer -> Click "Receive"
DST->>UI: Verify Items, Enter Qty Received
UI->>API: POST /transfers/{id}/receive
API->>DB: Increment Destination Inventory
API->>DB: Log TRANSFER_IN Movement (Section 4.12)
API->>DB: Update Status: COMPLETED
API-->>DST: Transfer Complete
4.8.7 Auto-Suggest Transfers
The system continuously monitors inventory distribution relative to sales velocity across all locations and generates transfer suggestions when significant imbalances are detected.
Auto-Suggest Algorithm
Step 1: Calculate Days of Supply per Product per Location
days_of_supply = qty_on_hand / avg_daily_velocity
Where avg_daily_velocity is the trailing 30-day sales average (configurable per tenant).
Step 2: Detect Imbalances
An imbalance is flagged when:
- One location has > 60 days of supply (overstocked threshold, configurable)
- Another location has < 15 days of supply (understocked threshold, configurable)
- Both locations are active retail stores (HQ warehouse uses separate thresholds)
Step 3: Calculate Suggested Quantity
target_days_of_supply = 30 (configurable per tenant)
qty_needed = (target_days_of_supply - current_days_of_supply) x avg_daily_velocity
qty_available_to_send = qty_on_hand - (target_days_of_supply x avg_daily_velocity)
suggested_qty = MIN(qty_needed at destination, qty_available_to_send from source)
The algorithm ensures the source location retains at least target_days_of_supply worth of stock after the transfer.
Step 4: Generate Suggestion
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | Product with detected imbalance |
variant_id | UUID | No | Specific variant (if applicable) |
source_location_id | UUID | Yes | Overstocked location (sender) |
destination_location_id | UUID | Yes | Understocked location (receiver) |
suggested_qty | Integer | Yes | Recommended transfer quantity |
source_days_of_supply | Decimal(8,1) | Yes | Current days of supply at source |
destination_days_of_supply | Decimal(8,1) | Yes | Current days of supply at destination |
source_velocity | Decimal(8,2) | Yes | Avg daily sales at source |
destination_velocity | Decimal(8,2) | Yes | Avg daily sales at destination |
status | Enum | Yes | PENDING, APPROVED, REJECTED, EXPIRED, CONVERTED |
reviewed_by | UUID | No | Manager who reviewed the suggestion |
reviewed_at | DateTime | No | Timestamp of review |
transfer_id | UUID | No | FK to transfer created from this suggestion (if approved) |
batch_id | UUID | Yes | Groups suggestions from the same analysis run |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Timestamp of suggestion generation |
Auto-Suggest Workflow
sequenceDiagram
autonumber
participant CRON as Scheduler (Weekly/On-Demand)
participant SVC as Rebalancing Service
participant DB as DB
participant MGR as Manager
participant UI as Dashboard UI
participant API as Backend
Note over CRON, API: Phase 1: Generate Suggestions
CRON->>SVC: Trigger Rebalancing Analysis
SVC->>DB: Query qty_on_hand per product per location
SVC->>DB: Query trailing 30-day sales velocity per product per location
SVC->>SVC: Calculate days_of_supply for each product/location
SVC->>SVC: Detect imbalances (>60 days vs <15 days)
SVC->>SVC: Calculate suggested transfer quantities
SVC->>DB: Insert suggestions (Status: PENDING, batch_id: {batch})
SVC-->>MGR: Notification: "12 rebalancing suggestions ready for review"
Note over MGR, API: Phase 2: Manager Review
MGR->>UI: Open Rebalancing Dashboard
UI->>API: GET /transfer-suggestions?status=PENDING
API-->>UI: Return suggestion list with velocity data
loop Review Each Suggestion
UI-->>MGR: Show: Product, Source (85 days supply), Dest (8 days supply), Suggested Qty: 15
alt Approve Suggestion
MGR->>UI: Click "Approve" (may adjust qty)
UI->>API: POST /transfer-suggestions/{id}/approve
API->>DB: Update suggestion status: APPROVED
API->>DB: Create Transfer (Status: REQUESTED, auto_suggest_id: {suggestion_id})
API-->>MGR: Transfer #TRF-2026-00090 Created
else Reject Suggestion
MGR->>UI: Click "Reject" + Enter Reason
UI->>API: POST /transfer-suggestions/{id}/reject
API->>DB: Update suggestion status: REJECTED
end
end
Note over MGR, API: Phase 3: Approve All (Batch)
opt Batch Approve
MGR->>UI: Click "Approve All Remaining"
UI->>API: POST /transfer-suggestions/batch/{batch_id}/approve
API->>DB: Update all PENDING to APPROVED
API->>DB: Create Transfer records for each
API-->>MGR: "8 transfers created from suggestions"
end
Note over SVC, DB: Phase 4: Expiration
SVC->>DB: Expire PENDING suggestions older than 7 days
SVC->>DB: Update status: EXPIRED
Configuration:
| Setting | Default | Description |
|---|---|---|
rebalance_schedule | Weekly (Monday 6:00 AM) | When auto-suggest analysis runs |
overstocked_threshold_days | 60 | Days of supply above which a location is considered overstocked |
understocked_threshold_days | 15 | Days of supply below which a location is considered understocked |
target_days_of_supply | 30 | Target days of supply after rebalancing |
velocity_lookback_days | 30 | Trailing days used to calculate average daily velocity |
suggestion_expiry_days | 7 | Days before unreviewed suggestions expire |
min_suggested_qty | 1 | Minimum quantity for a suggestion to be generated |
hq_overstocked_threshold_days | 90 | Separate overstocked threshold for HQ warehouse |
Business Rules:
- Auto-suggest never creates transfers automatically. All suggestions require manager review.
- Manager can modify the suggested quantity before approving (e.g., reduce from 15 to 10).
- Suggestions expire after
suggestion_expiry_daysif not reviewed. Expired suggestions are excluded from the next run to avoid duplicates. - The algorithm excludes products with zero velocity at both source and destination (dead stock requires manual review, not rebalancing).
- HQ warehouse uses separate thresholds because HQ holds distribution stock, not retail selling stock.
- If multiple destinations need the same product from the same source, the system generates one suggestion per source-destination pair.
4.8.8 Manual Allocation for Scarce Items
When multiple stores need the same scarce item and HQ has limited stock, the system does not attempt to automatically split the available quantity. Instead, a manager manually decides the allocation based on business judgment.
Scarce Item Scenario
A scarce item condition exists when:
- Two or more stores have submitted transfer requests (or auto-suggest has flagged multiple destinations) for the same product.
- The source location (typically HQ) does not have enough stock to fulfill all requests in full.
Allocation Dashboard
When a scarce item condition is detected, the system presents an Allocation Dashboard that consolidates all competing requests:
| Column | Description |
|---|---|
| Product | SKU, name, variant |
| HQ Available Qty | Current on-hand at HQ (source) |
| Store | Each requesting store listed as a row |
| Requested Qty | Quantity each store is requesting |
| Current On-Hand | Quantity each store currently holds |
| Days of Supply | Calculated days of supply at each store |
| 30-Day Velocity | Average daily sales at each store |
| Allocated Qty | Editable field – manager enters allocation per store |
Example:
| Product | HQ Available | Store | Requested | On-Hand | Days of Supply | 30-Day Velocity | Allocated |
|---|---|---|---|---|---|---|---|
| BLK-TEE-M | 20 | Store GM | 12 | 2 | 4 days | 0.5/day | ___ |
| BLK-TEE-M | 20 | Store HM | 15 | 1 | 2 days | 0.5/day | ___ |
| BLK-TEE-M | 20 | Store NM | 8 | 5 | 12 days | 0.4/day | ___ |
| Total Requested | 35 | ___ / 20 |
The manager sees that total requests (35) exceed available stock (20) and enters allocations that sum to at most 20. The system validates that SUM(allocated_qty) <= available_qty.
Business Rules:
- No automated splitting. The system presents data; the human decides.
- Manager can allocate zero to any store (decline that store’s request entirely).
- The allocation creates individual transfers for each store receiving stock.
- Priority field on each transfer request (
NORMAL,URGENT,CUSTOMER_REQUEST) is displayed to help inform the manager’s decision. - Stores with
CUSTOMER_REQUESTpriority (paid customer transfers from Section 1.7) should generally be prioritized to avoid customer disappointment. - The allocation dashboard is accessible from the Transfer Management screen when the system detects competing requests for the same product.
4.8.9 Variance Handling
When the destination receives a different quantity than was shipped, a variance is recorded.
Variance Types:
| Variance | Description | Resolution |
|---|---|---|
| Short | Received less than shipped (e.g., shipped 20, received 18) | Record variance notes. Investigate: lost in transit, miscount at source, or carrier damage. Source location does not get stock back automatically; requires adjustment (Section 4.7) if items are confirmed lost. |
| Over | Received more than shipped (e.g., shipped 20, received 22) | Rare. Likely miscount at source. Record variance. Destination inventory reflects actual received count. |
| Damaged | Items received in damaged condition | Record condition as DAMAGED. Damaged items enter damaged inventory status at destination. May trigger RMA (Section 4.9) or write-off. |
| Wrong Item | Different product received than expected | Record condition as WRONG_ITEM. Requires follow-up: return to source or create adjustment at both locations. |
Business Rules:
- Variance percentage > 10% triggers a notification to both source and destination managers.
- All variances are logged in the Product Movement History (Section 4.12) with reason codes.
- Unresolved variances appear on the Transfer Variance Report for management review.
4.8.10 Carrier Tracking
For transfers shipped via external carriers (not internal transport), tracking information is recorded.
| Field | Type | Required | Description |
|---|---|---|---|
carrier | String | No | Carrier name: INTERNAL, UPS, FEDEX, USPS, DHL, OTHER |
tracking_number | String | No | Carrier tracking number |
estimated_arrival | Date | No | Expected delivery date |
actual_arrival | Date | No | Actual delivery date (set on receive) |
shipping_cost | Decimal(10,2) | No | Cost of shipment (for internal cost tracking) |
Business Rules:
- Carrier and tracking number are required when
carrieris notINTERNAL. INTERNALcarrier indicates the transfer is hand-delivered by staff or via company vehicle.- The system does not integrate with carrier tracking APIs in v1. Tracking numbers are recorded for manual lookup.
4.8.11 Reports: Transfers
| Report | Purpose | Key Data Fields |
|---|---|---|
| Open Transfer Report | Track in-progress transfers | Transfer number, source, destination, direction (Push/Pull), status, item count, total units, days in transit, priority |
| Transfer Variance Report | Discrepancies between shipped and received | Transfer number, product, qty shipped, qty received, variance, variance %, condition, notes |
| Transfer Volume Report | Volume of transfers between locations | Source, destination, transfer count, total units transferred, total value, period, direction breakdown |
| Rebalancing Suggestions | Auto-generated transfer recommendations | Product, source location (days of supply), destination location (days of supply), suggested qty, current velocity data, suggestion status |
| Scarce Item Allocation Log | Audit trail of manual allocation decisions | Product, HQ available qty, stores requesting, qty allocated per store, manager who allocated, date |
| Transfer Lead Time | Average time from request to completion | Source, destination, carrier, avg days requested-to-shipped, avg days shipped-to-received, avg total lead time |
4.9 Vendor RMA & Returns
Scope: Managing the return of defective, damaged, incorrect, or overstock merchandise to vendors. The RMA (Return Merchandise Authorization) workflow tracks the complete lifecycle from initial request through vendor approval, shipment back to the vendor, and receipt of credit or replacement inventory. This section covers both defective/quality RMA returns and overstock returns, which follow distinct workflows.
4.9.1 RMA State Machine
stateDiagram-v2
[*] --> DRAFT: RMA Created
DRAFT --> SUBMITTED: Submit to Vendor
SUBMITTED --> VENDOR_APPROVED: Vendor Approves Return
SUBMITTED --> VENDOR_REJECTED: Vendor Rejects Return
VENDOR_APPROVED --> SHIPPED_BACK: Items Shipped to Vendor
SHIPPED_BACK --> CREDIT_RECEIVED: Vendor Issues Credit Memo
SHIPPED_BACK --> REPLACEMENT_RECEIVED: Vendor Sends Replacement
CREDIT_RECEIVED --> CLOSED: RMA Finalized
REPLACEMENT_RECEIVED --> CLOSED: RMA Finalized
VENDOR_REJECTED --> CLOSED: RMA Closed (No Action)
note right of DRAFT
Staff assembles return list
Line items editable
No inventory impact
end note
note right of SUBMITTED
Sent to vendor for review
Awaiting vendor response
Line items locked
end note
note right of VENDOR_APPROVED
Vendor authorized return
Ready to ship back
end note
note right of SHIPPED_BACK
Items in transit to vendor
Inventory decremented at source
Tracking number recorded
end note
note right of CLOSED
Credit applied or replacement received
Audit trail complete
end note
4.9.2 RMA Data Model
RMA Header
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
rma_number | String | Yes | Auto-generated: RMA-{YEAR}-{SEQ} (e.g., RMA-2026-00015) |
vendor_id | UUID | Yes | Reference to vendor |
source_location_id | UUID | Yes | Location from which items are being returned |
status | Enum | Yes | DRAFT, SUBMITTED, VENDOR_APPROVED, VENDOR_REJECTED, SHIPPED_BACK, CREDIT_RECEIVED, REPLACEMENT_RECEIVED, CLOSED |
reason | Enum | Yes | DEFECTIVE, DAMAGED, WRONG_ITEM, OVERSTOCK |
rma_type | Enum | Yes | DEFECTIVE_RETURN or OVERSTOCK_RETURN (see Section 4.9.6) |
notes | Text | No | Free-form notes about the return |
vendor_agreement_ref | String | No | Reference to vendor agreement or pre-authorization (required for OVERSTOCK returns) |
created_by | UUID | Yes | Staff member who created the RMA |
approved_by | UUID | No | Vendor contact or reference who approved |
ship_date | Date | No | Date items were shipped back to vendor |
tracking_number | String | No | Carrier tracking number for return shipment |
credit_amount | Decimal(10,2) | No | Credit amount issued by vendor |
restocking_fee_pct | Decimal(5,2) | No | Vendor restocking fee percentage (applicable to OVERSTOCK returns) |
restocking_fee_amount | Decimal(10,2) | No | Calculated restocking fee deducted from credit |
net_credit_amount | Decimal(10,2) | No | Calculated: credit_amount - restocking_fee_amount |
replacement_po_id | UUID | No | FK to replacement purchase order (if vendor sends replacement) |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
RMA Line Items
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
rma_id | UUID | Yes | Reference to parent RMA |
product_id | UUID | Yes | Reference to product being returned |
variant_id | UUID | No | Reference to specific variant (if applicable) |
qty | Integer | Yes | Quantity being returned to vendor |
unit_cost | Decimal(10,2) | Yes | Cost per unit (from original PO or weighted avg cost) |
line_total | Decimal(10,2) | Yes | Calculated: qty x unit_cost |
condition_notes | Text | No | Description of item condition |
inspection_result | Enum | No | CONFIRMED_DEFECTIVE, COSMETIC_DAMAGE, NOT_AS_DESCRIBED, NOT_INSPECTED (for overstock items) |
original_po_id | UUID | No | Reference to the original purchase order that delivered this item (for traceability) |
4.9.3 RMA Workflow (Defective Returns)
sequenceDiagram
autonumber
participant U as Staff
participant UI as RMA UI
participant API as Backend
participant DB as DB
participant V as Vendor
Note over U, V: Step 1: Create RMA
U->>UI: Click "New Vendor RMA"
UI->>UI: Select Vendor
UI->>API: GET /vendors/{id}/products
API-->>UI: Return Vendor's Products
loop Add RMA Line Items
U->>UI: Select Product
U->>UI: Enter Quantity to Return
U->>UI: Select Reason (Defective/Damaged/Wrong Item)
U->>UI: Enter Condition Notes
U->>UI: Record Inspection Result
end
U->>UI: Click "Save Draft"
UI->>API: POST /vendor-rma
API->>DB: Create RMA Record (Status: DRAFT, Type: DEFECTIVE_RETURN)
API-->>UI: RMA #RMA-2026-00015 Created
Note over U, V: Step 2: Submit to Vendor
U->>UI: Review RMA -> Click "Submit"
UI->>API: POST /vendor-rma/{id}/submit
alt Email Submission
API->>V: Send RMA via Email (PDF attachment)
else Manual Submission
API-->>UI: "RMA marked Submitted - contact vendor manually"
end
API->>DB: Update Status: SUBMITTED
API-->>UI: RMA Submitted
Note over V, U: Step 3: Vendor Response
alt Vendor Approves
U->>UI: Mark RMA as "Vendor Approved"
UI->>API: POST /vendor-rma/{id}/approve
API->>DB: Update Status: VENDOR_APPROVED
else Vendor Rejects
U->>UI: Mark RMA as "Vendor Rejected"
U->>UI: Enter Rejection Reason
UI->>API: POST /vendor-rma/{id}/reject
API->>DB: Update Status: VENDOR_REJECTED
API->>DB: Update Status: CLOSED
end
Note over U, V: Step 4: Ship Items Back
U->>UI: Enter Tracking Number & Ship Date
UI->>API: POST /vendor-rma/{id}/ship
API->>DB: Decrement Inventory at Source Location
API->>DB: Log RMA_OUT Movement (Section 4.12)
API->>DB: Update Status: SHIPPED_BACK
API-->>UI: Items Shipped
Note over V, DB: Step 5: Vendor Resolution
alt Credit Memo
V-->>U: Vendor Issues Credit Memo
U->>UI: Enter Credit Amount
UI->>API: POST /vendor-rma/{id}/credit
API->>DB: Record Credit Amount
API->>DB: Update Vendor Account Balance
API->>DB: Update Status: CREDIT_RECEIVED
else Replacement Shipment
V-->>U: Vendor Sends Replacement
U->>UI: Click "Receive Replacement"
UI->>API: POST /vendor-rma/{id}/replacement
API->>DB: Create Linked Purchase Order
API->>DB: Increment Inventory (Replacement Items)
API->>DB: Log RMA_IN Movement (Section 4.12)
API->>DB: Update Status: REPLACEMENT_RECEIVED
end
Note over U, DB: Step 6: Close RMA
U->>UI: Click "Close RMA"
UI->>API: POST /vendor-rma/{id}/close
API->>DB: Update Status: CLOSED
API-->>UI: RMA Closed
4.9.4 Business Rules (Defective Returns)
- RMA can only be created for products with an active vendor relationship (exists in
vendor_producttable with anACTIVEvendor). - Items must be in
AVAILABLEorDAMAGEDinventory status to be placed on an RMA. Items inIN_TRANSIT,RESERVED, orQUARANTINEcannot be returned until their status resolves. - Inventory is decremented from the source location when the RMA status changes to
SHIPPED_BACK, not before. This ensures accurate on-hand counts until items physically leave. - Credit amounts are reconciled with the vendor’s account balance. If the tenant tracks payables, the credit reduces the outstanding amount owed to the vendor.
- Replacement purchase orders link back to the original RMA via
replacement_po_idfor complete audit trail. - Auto-increment RMA number per tenant:
RMA-{YEAR}-{SEQUENCE}. - Each RMA line item must have an
inspection_resultrecorded before the RMA can be submitted. This ensures quality documentation accompanies the return request. - All inventory movements (out for RMA, in for replacement) are recorded in the Product Movement History (Section 4.12) with event types
RMA_OUTandRMA_IN.
4.9.5 Reports: Vendor RMA
| Report | Purpose | Key Data Fields |
|---|---|---|
| Open RMA Report | Track outstanding vendor returns | RMA number, vendor, status, rma_type, total value, days open, last action date |
| Vendor Return Rate | Quality tracking per vendor | Vendor, RMA count, units returned, return % of total purchased, top reasons, defective vs overstock breakdown |
| RMA Aging | Identify stalled returns | RMA number, vendor, current status, days in current status, last action, escalation flag |
| RMA Credit Reconciliation | Track credits received vs expected | RMA number, vendor, expected credit, actual credit, restocking fees, net credit, variance, reconciliation status |
| Overstock Return Summary | Track overstock-specific returns | Vendor, period, units returned as overstock, gross credit, restocking fees paid, net credit received |
4.9.6 Overstock Return Workflow
Overstock returns represent a fundamentally different business process from defective RMAs. Overstock returns involve negotiated return of unsold seasonal, end-of-line, or slow-moving merchandise to the vendor. The vendor has pre-agreed to accept the return, often with a restocking fee.
Key Differences from Defective RMA
| Aspect | Defective RMA | Overstock Return |
|---|---|---|
| Trigger | Quality issue discovered in stock | Excess inventory / end of season |
| Inspection | Required – each item inspected for defect | Not required – items are in sellable condition |
| Vendor Pre-Agreement | Not always required (warranty claims) | Always required – must reference agreement |
| Restocking Fee | Typically none (vendor’s quality failure) | Common – vendor charges 10-25% restocking fee |
| Credit Calculation | Full original cost or replacement | Negotiated – may differ from original cost |
| Urgency | High (defective items tie up shelf space) | Moderate (planned seasonal transition) |
| Reason Code | DEFECTIVE, DAMAGED, WRONG_ITEM | OVERSTOCK |
| RMA Type | DEFECTIVE_RETURN | OVERSTOCK_RETURN |
Overstock Return Process
sequenceDiagram
autonumber
participant MGR as Store/HQ Manager
participant UI as RMA UI
participant API as Backend
participant DB as DB
participant V as Vendor
Note over MGR, V: Pre-Condition: Vendor Agreement Exists
MGR->>UI: Click "New Overstock Return"
UI->>UI: Select Vendor
UI->>UI: Prompt: "Enter Vendor Agreement Reference"
MGR->>UI: Enter Agreement Ref (e.g., "Email 2026-01-15, 20% restocking agreed")
loop Add Overstock Items
MGR->>UI: Select Product (filter: in-stock, this vendor)
MGR->>UI: Enter Quantity to Return
UI-->>MGR: Show: Unit Cost, Extended Total
end
MGR->>UI: Enter Restocking Fee % (e.g., 20%)
UI->>UI: Calculate: Gross Credit, Restocking Fee, Net Credit
UI-->>MGR: "Gross: $2,500 | Restocking Fee (20%): $500 | Net Credit: $2,000"
MGR->>UI: Click "Save Draft"
UI->>API: POST /vendor-rma
API->>DB: Create RMA (Status: DRAFT, Type: OVERSTOCK_RETURN, Reason: OVERSTOCK)
API-->>UI: RMA #RMA-2026-00032 Created
Note over MGR, V: Submit, Vendor Response, Ship, Credit (same state machine)
MGR->>UI: Submit RMA
UI->>API: POST /vendor-rma/{id}/submit
API->>DB: Update Status: SUBMITTED
V-->>MGR: Vendor Confirms Acceptance
MGR->>UI: Mark Vendor Approved
UI->>API: POST /vendor-rma/{id}/approve
API->>DB: Update Status: VENDOR_APPROVED
MGR->>UI: Ship Items Back (enter tracking)
UI->>API: POST /vendor-rma/{id}/ship
API->>DB: Decrement Inventory
API->>DB: Log RMA_OUT Movement (Section 4.12)
API->>DB: Update Status: SHIPPED_BACK
V-->>MGR: Vendor Issues Credit (net of restocking fee)
MGR->>UI: Enter Credit Received
UI->>API: POST /vendor-rma/{id}/credit
API->>DB: Record credit_amount, restocking_fee_amount, net_credit_amount
API->>DB: Update Vendor Account Balance (net credit)
API->>DB: Update Status: CREDIT_RECEIVED
MGR->>UI: Close RMA
UI->>API: POST /vendor-rma/{id}/close
API->>DB: Update Status: CLOSED
Overstock Return Business Rules
- Vendor agreement required: The
vendor_agreement_reffield must be populated before an overstock RMA can be submitted. This is a free-text field documenting the pre-agreement (e.g., email reference, contract clause, verbal confirmation date). - No inspection step: Overstock items set
inspection_resulttoNOT_INSPECTED. The items are in sellable condition; they are simply excess. - Restocking fee handling:
restocking_fee_pctis entered by the user (e.g., 20.00 for 20%).restocking_fee_amountis calculated:SUM(line_totals) x (restocking_fee_pct / 100).net_credit_amountis calculated:credit_amount - restocking_fee_amount.- The vendor account balance is credited with the
net_credit_amount, not the gross amount.
- Credit may differ from cost: The
credit_amountfield on the header is the negotiated total credit from the vendor. This may be less than the sum of line item costs if the vendor negotiated a reduced rate. The system displays both the calculated cost total and the actual credit for variance tracking. - Reason code enforcement: When
rma_typeisOVERSTOCK_RETURN, thereasonfield is automatically set toOVERSTOCKand cannot be changed. - Eligibility: Only items in
AVAILABLEstatus can be placed on an overstock return.DAMAGEDitems should go through the defective RMA workflow instead. - Seasonal timing: Overstock returns are typically created in bulk at end-of-season. The system supports adding many line items (50+) to a single overstock RMA.
4.10 Serial & Lot Tracking
Scope: Tracking individual high-value items by serial number and managing batch/lot numbers for recall readiness and FIFO inventory management. Serial tracking captures the full chain of custody from receiving through sale. Lot tracking enables batch-level recall identification.
4.10.1 Serial Number Tracking
Serial tracking is enabled per product via the serial_tracked boolean flag on the product record. When enabled, serial numbers are captured at two critical points: receiving (inbound) and sale (outbound).
Serial Number Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | Reference to product |
serial_number | String | Yes | Unique serial number (unique per tenant) |
status | Enum | Yes | IN_STOCK, SOLD, RETURNED, RMA, WRITE_OFF |
location_id | UUID | No | Current location (null if sold or written off) |
received_at | DateTime | Yes | Timestamp when serial was first received |
received_via | Enum | Yes | PO_RECEIVE, TRANSFER_RECEIVE, RETURN_TO_STOCK, RMA_REPLACEMENT |
source_document_id | UUID | No | FK to the receiving source document |
sold_at | DateTime | No | Timestamp when sold |
sold_to_customer_id | UUID | No | Customer who purchased (if customer attached to sale) |
sale_order_id | UUID | No | Reference to the sale order |
tenant_id | UUID | Yes | Owning tenant |
Serial Number State Machine
stateDiagram-v2
[*] --> IN_STOCK: Received (PO/Transfer/Return)
IN_STOCK --> SOLD: Sold to Customer
SOLD --> RETURNED: Customer Returns Item
RETURNED --> IN_STOCK: Returned to Available Stock
IN_STOCK --> RMA: Sent Back to Vendor
IN_STOCK --> WRITE_OFF: Damaged Beyond Repair
RMA --> [*]: Vendor Received
WRITE_OFF --> [*]: Removed from Inventory
note right of IN_STOCK
Available for sale
Location tracked
end note
note right of SOLD
Customer association
Order reference
end note
Business Rules:
- Cannot sell a serial-tracked product at POS without scanning or entering the serial number.
- Cannot receive a serial-tracked product without assigning a serial number to each unit.
- Serial numbers are unique per tenant. Duplicate serial numbers for the same product are rejected.
- Customer association: serial -> customer link enables after-sale lookup (“Customer X purchased serial Y on date Z at location W”).
- Serial history is immutable. Status changes are appended, never overwritten.
- Serial number transfers between locations update the
location_idfield and create a movement record in the Product Movement History (Section 4.12).
4.10.2 Lot/Batch Tracking
Lot tracking is enabled per product via the lot_tracked boolean flag. Lot numbers are assigned at receiving and tracked through the sales lifecycle.
Lot Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | Reference to product |
lot_number | String | Yes | Lot/batch identifier (from vendor or manually assigned) |
qty_received | Integer | Yes | Total quantity received in this lot |
qty_on_hand | Integer | Yes | Current quantity remaining |
qty_sold | Integer | Yes | Total quantity sold from this lot |
received_date | Date | Yes | Date lot was received |
expiry_date | Date | No | Expiration date (optional; supported for future non-clothing use cases) |
source_po_id | UUID | No | Reference to purchase order that delivered this lot |
location_id | UUID | Yes | Location holding this lot |
tenant_id | UUID | Yes | Owning tenant |
Business Rules:
- FIFO enforcement: when selling lot-tracked items, the system selects from the oldest lot first (by
received_date). - Recall support: given a lot number, the system identifies all units – in stock (with location) and sold (with customer and date) – for recall action.
- Lot numbers can be entered manually or scanned from vendor packaging during receiving.
- Multiple lots of the same product can exist at the same location simultaneously.
- Lot quantities are decremented on sale and incremented on return. The return process associates the returned item back to its original lot where possible.
- Lot transfers between locations create a new lot record at the destination (same lot number, new location) and decrement the source lot’s
qty_on_hand.
4.10.3 Reports: Serial & Lot
| Report | Purpose | Key Data Fields |
|---|---|---|
| Serial Number Lookup | Find current status and full history of a serial | Serial number, product, current status, current location, customer (if sold), purchase date, receiving source |
| Lot Inventory | Stock on hand by lot | Lot number, product, qty received, qty on hand, qty sold, received date, age (days), location |
| Lot Trace (Recall) | Find all units from a specific lot | Lot number, product, units in stock (by location), units sold (customer, sale date, order number), units returned |
| Serial Warranty Lookup | Customer and purchase info for warranty claims | Serial number, product, customer name, purchase date, purchase location, order number |
4.11 Landed Cost & Costing
Scope: Tracking the true cost of inventory by calculating landed cost (purchase price plus all additional costs to get product to the selling floor) and maintaining weighted average cost across multiple purchase orders. Accurate costing is critical for margin reporting and inventory valuation.
4.11.1 Landed Cost Calculation
Landed cost captures the total acquisition cost per unit, including all expenses beyond the vendor’s unit price.
Components:
| # | Component | Description | Example |
|---|---|---|---|
| 1 | Unit Cost | Vendor price per unit (from PO line item) | $25.00 per unit |
| 2 | Freight/Shipping | Carrier charges allocated per unit | $500 total / 200 units = $2.50/unit |
| 3 | Duties/Tariffs | Import duties allocated per unit (based on product category and origin country) | $300 total / 200 units = $1.50/unit |
| 4 | Customs/Brokerage | Customs clearance fees allocated per unit | $100 total / 200 units = $0.50/unit |
| 5 | Handling | Warehouse handling fees allocated per unit | $80 total / 200 units = $0.40/unit |
Formula:
landed_cost_per_unit = unit_cost + (freight / units) + (duties / units) + (customs / units) + (handling / units)
Example: $25.00 + $2.50 + $1.50 + $0.50 + $0.40 = $29.90 landed cost per unit
Cost Allocation Methods
The system supports three methods for distributing PO-level costs across individual line items:
| Method | Allocation Logic | Best For |
|---|---|---|
| BY_UNIT | Equal cost per unit across all lines | Uniform-size items (e.g., t-shirts in poly bags) |
| BY_VALUE | Proportional to unit cost (higher-cost items absorb more) | Mixed-value POs (e.g., accessories + outerwear) |
| BY_WEIGHT | Proportional to item weight | Heavy items driving freight cost (e.g., denim vs. silk) |
Cost Allocation Data Model (per PO)
| Field | Type | Required | Description |
|---|---|---|---|
po_id | UUID | Yes | Reference to purchase order |
freight_total | Decimal(10,2) | No | Total freight cost for PO shipment |
duties_total | Decimal(10,2) | No | Total duties and tariffs |
customs_total | Decimal(10,2) | No | Customs brokerage fees |
handling_total | Decimal(10,2) | No | Warehouse handling fees |
allocation_method | Enum | Yes | BY_UNIT (equal per unit), BY_VALUE (proportional to unit cost), BY_WEIGHT |
tenant_id | UUID | Yes | Owning tenant |
Per-Line Landed Cost Data Model
| Field | Type | Required | Description |
|---|---|---|---|
po_line_id | UUID | Yes | Reference to PO line item |
unit_cost | Decimal(10,2) | Yes | Base vendor price per unit |
freight_per_unit | Decimal(10,2) | No | Allocated freight per unit |
duties_per_unit | Decimal(10,2) | No | Allocated duties per unit |
customs_per_unit | Decimal(10,2) | No | Allocated customs per unit |
handling_per_unit | Decimal(10,2) | No | Allocated handling per unit |
landed_cost_per_unit | Decimal(10,2) | Yes | Sum of all cost components |
4.11.2 Weighted Average Cost
The system maintains a weighted average cost per product per location. This cost is recalculated on every PO receive event.
Formula:
new_avg_cost = ((existing_qty x existing_avg_cost) + (received_qty x landed_cost_per_unit))
/ (existing_qty + received_qty)
Example:
- Existing: 100 units at $28.00 avg cost = $2,800.00
- Received: 50 units at $29.90 landed cost = $1,495.00
- New avg cost: ($2,800 + $1,495) / (100 + 50) = $28.63
Weighted Average Cost Data Model
| Field | Type | Required | Description |
|---|---|---|---|
product_id | UUID | Yes | Reference to product |
location_id | UUID | Yes | Reference to store/warehouse location |
weighted_avg_cost | Decimal(10,4) | Yes | Current weighted average cost (4 decimal places for precision) |
last_updated_at | DateTime | Yes | Timestamp of last recalculation |
Business Rules:
- Recalculated on every PO receive event using landed cost (not raw vendor cost).
- Used for: COGS calculation, margin reporting, inventory valuation, shrinkage valuation.
- Historical cost snapshots are preserved for each receive event to support audit and retroactive analysis.
- Initial weighted average cost for a new product is set to the first PO’s landed cost.
- Transfers between locations do not change the weighted average cost. The receiving location inherits the sending location’s cost for those units.
- Inventory adjustments (Section 4.7) use the current weighted average cost for valuation of adjusted quantities.
- Write-offs and shrinkage are valued at the weighted average cost at the time of the event.
4.11.3 Reports: Costing
| Report | Purpose | Key Data Fields |
|---|---|---|
| Landed Cost Analysis | Breakdown of cost components per PO | PO number, product, unit cost, freight, duties, customs, handling, total landed cost, allocation method |
| Margin Analysis | True margin using landed cost | Product, selling price, weighted avg cost, gross margin $, gross margin %, comparison to target margin |
| Inventory Valuation | Total inventory value at cost | Location, product count, total units, total value (at weighted avg cost), value by status |
| Cost Trend | Cost changes over time per product | Product, vendor, landed cost per PO over time, cost trend direction, % change |
4.12 Product Movement History & Stock Ledger
Scope: Maintaining a complete audit trail of every inventory movement for each product across all locations. The movement history serves as the authoritative record for inventory reconciliation, shrinkage analysis, and regulatory compliance. Every change to inventory quantity must be traced to a source document. This is the single source of truth for “what happened to inventory and why.”
4.12.1 Movement Audit Trail
Every inventory change creates a movement record. No inventory quantity changes without a corresponding movement entry.
| Event Type | Description | Qty Impact | Source Document |
|---|---|---|---|
PO_RECEIVE | Received from vendor via purchase order | +qty | Purchase Order |
TRANSFER_OUT | Shipped to another location (Section 4.8) | -qty | Transfer |
TRANSFER_IN | Received from another location (Section 4.8) | +qty | Transfer |
SALE | Sold to customer | -qty | Sale Order |
RETURN | Customer return to stock | +qty | Return Order |
ADJUSTMENT_UP | Manual positive adjustment (Section 4.7) | +qty | Adjustment |
ADJUSTMENT_DOWN | Manual negative adjustment (Section 4.7) | -qty | Adjustment |
WRITE_OFF | Inventory write-off (damaged, expired, theft) | -qty | Write-Off |
RMA_OUT | Shipped back to vendor via RMA (Section 4.9) | -qty | Vendor RMA |
RMA_IN | Replacement received from vendor via RMA (Section 4.9) | +qty | Vendor RMA |
COUNT_ADJUST | Adjustment from stock count variance | +/- qty | Stock Count |
RESERVE | Reserved for order or transfer | -available | Reservation |
UNRESERVE | Reservation released | +available | Reservation |
4.12.2 Movement Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
product_id | UUID | Yes | Reference to product |
variant_id | UUID | No | Reference to specific variant (if applicable) |
location_id | UUID | Yes | Location where movement occurred |
event_type | Enum | Yes | One of the 13 event types listed above |
qty_change | Integer | Yes | Positive (inbound) or negative (outbound) quantity change |
running_balance | Integer | Yes | Running balance at this location after this movement |
source_document_type | String | Yes | Type of source document (e.g., “PurchaseOrder”, “Transfer”, “SaleOrder”, “Adjustment”, “VendorRMA”, “StockCount”) |
source_document_id | UUID | Yes | FK to the source document |
reference_number | String | No | Human-readable reference (e.g., PO-2026-00042, TRF-2026-00088, ADJ-2026-00005) |
actor_id | UUID | Yes | User or system process that caused the movement |
reason_code | String | No | Reason code for adjustments and write-offs (see Section 4.7 for adjustment reason codes) |
notes | Text | No | Additional context |
tenant_id | UUID | Yes | Owning tenant |
created_at | DateTime | Yes | Timestamp of movement (immutable) |
Business Rules:
- Movement records are immutable. Once created, they cannot be edited or deleted. Corrections are made by creating a new, opposite movement (e.g., an
ADJUSTMENT_UPto correct an erroneousADJUSTMENT_DOWN). - The
running_balanceis computed at insert time:previous_running_balance + qty_change. This provides an instant snapshot of inventory level at any point in time without recalculating from the beginning. - Every module that changes inventory quantity (PO receiving, sales, transfers, adjustments, RMAs, stock counts) must insert a movement record as part of the same database transaction. No inventory change should be committed without its corresponding movement.
- The
actor_idfield distinguishes between human actions (staff user ID) and system actions (system process ID for automated events like auto-suggest transfers or scheduled adjustments).
4.12.3 Stock Ledger
The stock ledger provides a chronological, per-product, per-location view of all movements with running balances.
Features:
- Running balance updated with every movement. The
running_balancefield on each movement record represents the inventory level immediately after that movement was applied. - Ledger view: chronological list of all movements for a product at a location, showing date, event type, quantity change, running balance, source document, and actor.
- Drill-down: clicking any ledger entry navigates to the source document (PO, sale, transfer, adjustment, RMA, etc.) for full context.
- Reconciliation: compare the latest ledger running balance to a physical count to identify discrepancies. Any difference indicates missing or undocumented movements.
- Date-range filtering: view movements for a specific period (e.g., last 30 days, last quarter) to analyze trends.
- Event-type filtering: view only specific movement types (e.g., show only adjustments and write-offs to analyze shrinkage).
4.12.4 Stock Ledger Entity Relationship
erDiagram
PRODUCT {
UUID id PK
String sku
String name
}
LOCATION {
UUID id PK
String name
String type
}
MOVEMENT {
UUID id PK
UUID product_id FK
UUID variant_id FK
UUID location_id FK
String event_type
Integer qty_change
Integer running_balance
String source_document_type
UUID source_document_id
String reference_number
UUID actor_id
String reason_code
DateTime created_at
}
PURCHASE_ORDER {
UUID id PK
String po_number
}
TRANSFER {
UUID id PK
String transfer_number
}
SALE_ORDER {
UUID id PK
String order_number
}
ADJUSTMENT {
UUID id PK
String adjustment_number
}
VENDOR_RMA {
UUID id PK
String rma_number
}
STOCK_COUNT {
UUID id PK
String count_number
}
PRODUCT ||--o{ MOVEMENT : "has movements"
LOCATION ||--o{ MOVEMENT : "at location"
MOVEMENT }o--|| PURCHASE_ORDER : "source: PO_RECEIVE"
MOVEMENT }o--|| TRANSFER : "source: TRANSFER_IN/OUT"
MOVEMENT }o--|| SALE_ORDER : "source: SALE/RETURN"
MOVEMENT }o--|| ADJUSTMENT : "source: ADJUSTMENT"
MOVEMENT }o--|| VENDOR_RMA : "source: RMA_IN/OUT"
MOVEMENT }o--|| STOCK_COUNT : "source: COUNT_ADJUST"
4.12.5 Reports: Movement History
| Report | Purpose | Key Data Fields |
|---|---|---|
| Product Movement Log | Full chronological history for one product at one location | Product, location, event type, qty change, running balance, source document, reference number, actor, timestamp |
| Location Movement Summary | Aggregate all movements at a location for a period | Location, period, event type, total events, total qty in, total qty out, net change |
| Shrinkage Analysis | Identify unexplained inventory loss | Location, period, expected balance (from ledger), actual balance (from count), unexplained variance, shrinkage %, shrinkage value (at weighted avg cost from Section 4.11) |
| Movement by Source | Volume of movements grouped by source document type | Source type (PO, Transfer, Sale, Adjustment, RMA), event count, total qty moved, period |
| Adjustment Audit Trail | All manual adjustments with reason codes | Adjustment reference, product, location, qty change, reason code, actor, timestamp, notes (see Section 4.7 for adjustment workflow) |
4.13 POS & Sales Integration
Scope: Real-time interaction between inventory management and point-of-sale operations. This section defines how inventory quantities are affected at each stage of a sales transaction, including cart operations, payment, voids, returns, and serial number capture.
4.13.1 Reserve on Add to Cart
When a cashier adds an item to a transaction, the system creates a soft reservation against the selling location’s available quantity. The reservation is tied to the terminal and transaction session.
Reservation behavior:
- Available qty at the selling location is decremented immediately in the UI and API layer.
- Other terminals at the same location see the reduced available quantity in real time.
- The reservation is temporary and tied to the active transaction session.
- If the item is removed from the cart, the reservation releases instantly.
- If the entire transaction is voided before payment, all reservations release instantly.
Data written on Add to Cart:
| Field | Value |
|---|---|
reservation_type | SOFT |
reservation_source | POS_CART |
terminal_id | Current terminal |
transaction_session_id | Active session |
product_variant_id | Selected variant |
location_id | Selling location |
qty_reserved | Quantity added |
created_at | Timestamp |
expires_at | NULL (cleared on payment or void) |
Cross-reference: See Section 1.1 (Core Sales Workflow) for the full item entry flow. Reservation logic integrates with the cart state machine defined there.
4.13.2 Commit on Payment
When payment completes successfully, the soft reservation converts to a permanent inventory decrement. A SALE movement is logged in the stock ledger (See Section 4.12).
Payment completion rules:
- On successful payment, the reservation record is deleted and replaced with a finalized
SALEmovement record. - The Weighted Average Cost (WAC) is captured at the moment of sale for COGS calculation.
- If payment fails (card decline, insufficient funds, terminal timeout), the reservation holds for 30 seconds then auto-releases.
- The 30-second hold prevents a race condition where another terminal could claim the last unit while the cashier retries payment.
- If the cashier retries payment within 30 seconds, the existing reservation is reused.
- After 30 seconds with no retry, the reservation releases and the item returns to available stock.
On payment success, the system writes:
| Record | Fields |
|---|---|
| Stock Movement | type: SALE, qty: -N, location_id, product_variant_id, reference_type: ORDER, reference_id: order_id, cost_at_time: WAC, created_by: staff_id |
| Inventory Level Update | available_qty -= N at selling location |
| Reservation Cleanup | Delete soft reservation record |
4.13.3 Reserve for Parked Transactions
Parked (suspended) sales maintain soft reservations throughout their lifecycle. The parked sale state machine (See Section 1.1.1) governs the reservation lifecycle.
Parked sale reservation rules:
| Rule | Value | Configurable |
|---|---|---|
| Reservation type | SOFT | No |
| Visibility to other terminals | Available with warning | No |
| Warning message format | “N units available, M reserved by Terminal X” | No |
| Maximum parked sales per terminal | 5 | Yes |
| Time-to-live (TTL) | 4 hours | Yes |
| On TTL expiry | Auto-release all reservations | No |
| On retrieval | Reservations transfer to active cart session | No |
Parked sale warning display example:
Product: Classic Fit Tee - Navy / M
Available: 2 units
Warning: 1 unit reserved by Terminal 3 (Parked Sale)
When a parked sale expires:
- All reservations release to available stock.
- A
RESERVATION_EXPIREDevent is logged with the parked sale ID. - The expired sale is archived with reason
TTL_EXCEEDED.
Cross-reference: See Section 1.1.1 (Parked Sale State Machine) for TTL configuration and the
parked_salesYAML config in Section 4.18.
4.13.4 Reserve for Hold-for-Pickup
Fully paid orders designated for customer pickup create hard reservations. These items are not visible to other terminals as available stock.
Hold-for-pickup reservation rules:
| Rule | Value | Configurable |
|---|---|---|
| Reservation type | HARD | No |
| Visibility to other terminals | Not visible as available | No |
| Inventory status | RESERVED | No |
| Default hold period | 7 days | Yes |
| Maximum hold extension | 30 days | Yes |
| Reminder before expiry | 2 days | Yes |
| On expiry | Auto-refund process triggers | Yes |
Hard reservation behavior:
- Available qty is decremented. The qty is moved to
reserved_qtyin the inventory level record. - Other terminals see only the
available_qty(excluding reserved). - The POS dashboard shows held orders with countdown timers.
- When the customer picks up, the reservation clears and the sale is marked
COMPLETED. - When the hold expires without pickup, the system initiates the auto-refund workflow and the inventory moves back to
AVAILABLEstatus.
Cross-reference: See Section 1.11 (Hold for Pickup) for the full pickup workflow and BOPIS integration. See
hold_for_pickupYAML config in Section 4.18.
4.13.5 Inventory Decrement on Sale
Each completed sale logs a SALE movement in the stock ledger. This is the authoritative record of inventory leaving the business through a customer transaction.
Movement record for a sale:
| Field | Value |
|---|---|
movement_type | SALE |
qty_change | Negative (e.g., -1) |
location_id | Selling location |
product_variant_id | Sold variant |
reference_type | ORDER |
reference_id | Order ID |
unit_cost_at_time | WAC at moment of sale |
created_by | Staff ID |
created_at | Transaction timestamp |
notes | NULL (auto-generated from sale) |
Multi-item transactions: Each line item in a transaction generates its own movement record. All movements for a single transaction share the same reference_id (order ID).
WAC capture: The system snapshots the current Weighted Average Cost at the moment of sale. This ensures COGS calculations remain accurate even if costs change later due to new receiving events.
Cross-reference: See Section 4.12 (Stock Ledger & Movement Log) for the complete movement type taxonomy.
4.13.6 Inventory Increment on Return
Customer returns automatically restore inventory at the return location. The default behavior places returned items into AVAILABLE status, but staff can override to DAMAGED if the item is not resalable.
Return-to-stock rules:
| Rule | Behavior |
|---|---|
| Default return status | AVAILABLE |
| Staff override options | DAMAGED, QUARANTINE |
| Movement type logged | RETURN |
| Location | Return location (may differ from sale location) |
| Cross-store returns | Allowed; inventory incremented at return location, not original sale location |
| WAC impact | No WAC recalculation on return (original cost basis preserved) |
| Serial-tracked items | Serial status reverts to IN_STOCK at return location |
| Lot-tracked items | Lot assignment restored; FIFO position maintained |
Movement record for a return:
| Field | Value |
|---|---|
movement_type | RETURN |
qty_change | Positive (e.g., +1) |
location_id | Return location |
product_variant_id | Returned variant |
reference_type | RETURN |
reference_id | Return transaction ID |
unit_cost_at_time | Original sale cost |
created_by | Staff ID |
notes | Reason code (e.g., DEFECTIVE, WRONG_SIZE, CHANGED_MIND) |
Cross-reference: See Section 1.4.1 (Void vs. Return Rules) for return eligibility logic and Section 4.7 (Vendor RMA) for items returned to vendor instead of restocked.
4.13.7 Serial Number Capture at POS
For products flagged as serial-tracked, the POS enforces serial number capture during both sale and return transactions.
Serial capture at sale:
- Cashier scans or adds a serial-tracked product to the cart.
- POS prompts: “Scan or enter serial number.”
- Cashier scans barcode or manually enters serial number.
- System validates:
- Serial number exists in the system (was recorded at receiving).
- Serial status is
IN_STOCKat the selling location. - Serial is not already linked to another active sale.
- On validation pass, serial is attached to the line item.
- On payment completion, serial status changes to
SOLDand is linked to the customer record.
Serial capture at return:
- Staff initiates return and scans the serial number.
- System looks up the serial and retrieves the original sale record.
- Serial status reverts to
IN_STOCKat the return location. - If staff marks item as damaged, serial status changes to
DAMAGED.
Serial validation errors:
| Error | Message | Action |
|---|---|---|
| Serial not found | “Serial number not recognized. Verify and retry.” | Block sale line |
| Serial already sold | “Serial already linked to Order #X. Investigate.” | Block sale line |
| Serial at wrong location | “Serial is at [Location]. Transfer required.” | Block sale line |
| Serial in quarantine | “Serial is quarantined. Manager override required.” | Require manager PIN |
Cross-reference: See Section 4.10 (Serial & Lot Tracking) for the full serial lifecycle and Section 1.10 (Serial Number Tracking) for POS-side serial workflows.
4.13.8 Sale Flow Sequence Diagram
sequenceDiagram
autonumber
participant U as Cashier
participant POS as POS Terminal
participant API as Inventory API
participant DB as Database
participant LED as Stock Ledger
Note over U, LED: Phase 1: Item Added to Cart
U->>POS: Scan Item (SKU-1001)
POS->>API: POST /inventory/reserve
API->>DB: Create soft reservation (terminal_id, session_id, product_variant_id, qty)
API->>DB: Decrement available_qty at location
DB-->>API: Reservation confirmed
API-->>POS: Reserved (available: 4 → 3)
POS-->>U: Item added to cart
Note over U, LED: Phase 2: Payment Processing
U->>POS: Tender Payment
POS->>API: POST /orders/finalize
API->>DB: Validate reservation still active
API->>DB: Delete soft reservation
API->>LED: INSERT movement (type: SALE, qty: -1, cost: WAC)
API->>DB: Update available_qty (permanent decrement)
DB-->>API: Sale committed
API-->>POS: Order finalized (Order #ORD-5678)
POS-->>U: Print receipt
Note over U, LED: Phase 3: Payment Failure Path
Note right of POS: If payment fails:
Note right of POS: Reservation holds 30 seconds
Note right of POS: Cashier retries → reuse reservation
Note right of POS: No retry within 30s → auto-release
4.13.9 Return Flow Sequence Diagram
sequenceDiagram
autonumber
participant U as Staff
participant POS as POS Terminal
participant API as Inventory API
participant DB as Database
participant LED as Stock Ledger
Note over U, LED: Phase 1: Return Initiated
U->>POS: Initiate Return
POS-->>U: "Scan receipt or enter order number"
U->>POS: Scan Receipt Barcode
POS->>API: GET /orders/{order_id}
API-->>POS: Order details + line items
Note over U, LED: Phase 2: Validate & Process
U->>POS: Select items to return
POS->>API: POST /returns/validate
API->>API: Check return policy (window, final sale, etc.)
API-->>POS: Return eligible
U->>POS: Confirm return + select condition
alt Item is resalable
POS->>API: POST /returns/process (status: AVAILABLE)
else Item is damaged
POS->>API: POST /returns/process (status: DAMAGED)
end
Note over U, LED: Phase 3: Inventory Updated
API->>DB: Increment available_qty (or damaged_qty) at return location
API->>LED: INSERT movement (type: RETURN, qty: +1, cost: original_cost)
API->>DB: Update order line status to RETURNED
DB-->>API: Return processed
API-->>POS: Return complete (Refund: $29.99)
POS-->>U: Process refund to original payment method
POS-->>U: Print return receipt
4.14 Online Order Fulfillment
Scope: How online orders placed through Shopify interact with physical store inventory, including store assignment, inventory reservation, bidirectional sync, and the pick-pack-ship workflow.
4.14.1 Reserve from Nearest Store
When a customer places an online order through Shopify, the system identifies the optimal fulfillment location based on proximity to the customer’s shipping address and stock availability.
Reservation flow:
- Shopify webhook fires
orders/createevent. - System receives the order and extracts the shipping address.
- Store assignment algorithm (Section 4.14.2) selects the fulfillment location.
- Inventory is hard reserved at the selected store (status:
RESERVED, source:ONLINE_ORDER). - The order appears on the selected store’s fulfillment queue.
- If no store has sufficient stock, the order is flagged for manual assignment.
Online order reservation record:
| Field | Value |
|---|---|
reservation_type | HARD |
reservation_source | ONLINE_ORDER |
shopify_order_id | Shopify order reference |
assigned_location_id | Selected store |
product_variant_id | Ordered variant |
qty_reserved | Order quantity |
status | PENDING_FULFILLMENT |
created_at | Order timestamp |
expires_at | NULL (does not expire; requires manual cancel) |
4.14.2 Store Assignment Algorithm
The store assignment algorithm determines which physical store fulfills an online order. The algorithm prioritizes proximity while ensuring stock availability.
Algorithm steps:
1. FILTER: Stores where available_qty >= ordered_qty for ALL line items
2. IF no single store has all items:
a. IF split_fulfillment_enabled = true:
- Find minimum set of stores to cover all items
- Prefer fewer splits (2 stores over 3)
- Within equal splits, prefer nearest stores
b. IF split_fulfillment_enabled = false:
- Flag order for manual assignment
- Notify HQ manager
3. CALCULATE: Distance from each qualifying store to customer shipping address
- Uses haversine formula on store lat/lng vs. shipping address lat/lng
4. SORT: By distance ascending
5. SELECT: Nearest qualifying store
6. RESERVE: Inventory at selected store
Store assignment decision matrix:
| Scenario | Single Store Available | Multiple Stores Available | No Store Available |
|---|---|---|---|
| Full order at one store | Assign nearest | Assign nearest with full stock | Flag for manual |
| Partial at multiple stores | Flag for manual (split disabled) | Split across nearest stores (split enabled) | Flag for manual |
| HQ warehouse only | Assign HQ | Prefer retail store over HQ | Backorder or cancel |
flowchart TD
A[Online Order Received] --> B{Any store has\nall items?}
B -->|Yes| C[Filter stores with full stock]
C --> D[Calculate distance to each store]
D --> E[Select nearest store]
E --> F[Reserve inventory at store]
F --> G[Order appears on\nstore fulfillment queue]
B -->|No| H{Split fulfillment\nenabled?}
H -->|Yes| I[Find minimum store\nset covering all items]
I --> J[Create split shipments]
J --> K[Reserve at each store]
K --> G
H -->|No| L{HQ has stock?}
L -->|Yes| M[Assign to HQ warehouse]
M --> F
L -->|No| N[Flag for manual\nassignment]
N --> O[Notify HQ manager]
4.14.3 Inventory Sync with Shopify
MOVED TO MODULE 6: Shopify inventory sync triggers, architecture, and reconciliation details have been consolidated into Module 6, Section 6.3.14 (Inventory Sync Triggers) and Section 6.7 (Cross-Platform Inventory Sync Rules).
See: Module 6, Section 6.3.14 for Shopify-specific inventory sync triggers and Section 6.7 for cross-platform inventory sync architecture including safety buffers and oversell prevention.
4.14.4 Pick-Pack-Ship Workflow
Once an online order is assigned to a store, the store staff fulfills it through a structured pick-pack-ship process.
Workflow stages:
| Stage | Action | System Effect |
|---|---|---|
| 1. Order Received | Order appears on store’s fulfillment queue | Status: PENDING_FULFILLMENT. Inventory reserved. |
| 2. Pick | Staff locates and scans each item | Status: PICKING. System validates each scanned item against the order. |
| 3. Pack | Staff packages items for shipping | Status: PACKING. Staff selects box size and records weight. |
| 4. Ship | Staff enters carrier and tracking number | Status: SHIPPED. Inventory decremented (SALE movement logged). Shopify order marked fulfilled with tracking. |
| 5. Deliver | Carrier delivers to customer | Status: DELIVERED. Updated via carrier webhook or manual confirmation. |
Pick validation rules:
- Each item must be scanned individually.
- If scanned item does not match order line, system rejects with “Item not on this order.”
- If item is serial-tracked, serial number must be captured during pick.
- If item is lot-tracked, system auto-selects FIFO lot and records lot number.
- Staff can flag a “short pick” if an item is not found. This triggers a recount at the store and potential reassignment to another store.
sequenceDiagram
autonumber
participant SH as Shopify
participant API as POS API
participant DB as Database
participant ST as Store Staff
participant CR as Carrier
Note over SH, CR: Phase 1: Order Assignment
SH->>API: Webhook: orders/create
API->>API: Run store assignment algorithm
API->>DB: Reserve inventory at selected store
API->>DB: Create fulfillment record (PENDING_FULFILLMENT)
API-->>ST: New order on fulfillment queue
Note over SH, CR: Phase 2: Pick & Pack
ST->>API: Start picking (fulfillment_id)
API->>DB: Status: PICKING
loop Each order line
ST->>API: Scan item barcode
API->>DB: Validate item matches order line
API-->>ST: Item confirmed
end
ST->>API: Picking complete
API->>DB: Status: PACKING
ST->>API: Record package details (weight, dimensions)
API->>DB: Status: READY_TO_SHIP
Note over SH, CR: Phase 3: Ship & Track
ST->>API: Enter carrier + tracking number
API->>DB: Status: SHIPPED
API->>DB: Log SALE movement for each line item
API->>DB: Decrement inventory (release reservation, permanent decrement)
API->>SH: POST fulfillment with tracking number
SH-->>API: Fulfillment confirmed
CR->>API: Webhook: delivered
API->>DB: Status: DELIVERED
4.15 Offline Inventory Operations
Scope: How inventory operations function when a store loses network connectivity to the central server. Under the online-first architecture (ADR-048), inventory operations are API-primary. Offline mode provides read-only access to cached stock data; inventory-modifying operations (other than sale decrements queued via sales_queue) are blocked until connectivity restores.
BRD Amendment (v6.3.0): Rewritten per ADR-048 (Online-First with Offline Fallback). Inventory queue, local cache, conflict resolution engine, and CONFLICT_REVIEW state removed. Inventory is read-only offline; only sales decrements flow through the
sales_queue(see Section 1.16).
4.15.1 Online-First Inventory Access
During normal operation (ONLINE state), all inventory lookups and mutations go through the API via React Query with stale-while-revalidate caching. Stock counts always come from the server when online.
Offline behavior by operation:
| Operation | Online (API-primary) | DEGRADED / OFFLINE | Notes |
|---|---|---|---|
| Stock lookup | Real-time from server | Read-only from product_cache (SQLite) | Stale warning displayed |
| Sale decrement | Server processes immediately | Queued in sales_queue (FIFO) | Server applies on reconnect |
| Return increment | Server processes immediately | Queued in sales_queue (FIFO) | Server applies on reconnect |
| Inventory adjustment | Server processes immediately | BLOCKED | Requires server validation |
| Stock count | Server processes immediately | BLOCKED | Requires server session |
| PO receiving | Server processes immediately | BLOCKED | Requires WAC recalculation |
| Transfer request | Server processes immediately | BLOCKED | Requires multi-store coordination |
Local product_cache structure (SQLite, read-only):
product_cache = {
product_variant_id: {
available_qty: number, // Snapshot at last sync
sku: string,
price: decimal, // May be stale — flagged on sync
last_synced_at: timestamp // When cache was last refreshed from server
}
}
4.15.2 Blocked Operations
The following operations require server connectivity and are blocked during DEGRADED or OFFLINE mode. The POS displays a clear message explaining why the operation is unavailable.
| Blocked Operation | Reason | User Message |
|---|---|---|
| Inventory adjustment | Requires server-side validation and approval workflow | “Adjustments unavailable offline. Wait for connectivity.” |
| Stock count session | Requires server-side count session management | “Counting unavailable offline. Wait for connectivity.” |
| Multi-store inventory lookup | Requires real-time data from all locations | “Multi-store lookup unavailable offline. Check local stock only.” |
| Cross-store transfer request | Requires server to coordinate with other stores | “Transfers unavailable offline. Queue request when online.” |
| Online order fulfillment | Requires Shopify sync and server coordination | “Online fulfillment unavailable offline.” |
| Shopify inventory sync | Requires Shopify API connectivity | Syncs automatically on reconnect. |
| PO submission to vendor | Requires email delivery and server logging | “PO submission unavailable offline. Save as draft.” |
| PO receiving | Requires WAC recalculation and server-side validation | “Receiving unavailable offline. Wait for connectivity.” |
| Gift card activation/reload | Requires server validation of card status | “Gift card operations unavailable offline.” |
| Customer account creation | Requires server-side deduplication | “Customer creation unavailable offline.” |
| RMA creation | Requires server-side RMA number generation | “RMA creation unavailable offline.” |
Cross-reference: See Section 1.16 (Offline Fallback Operations) for the connectivity state machine and the
offline_modeYAML configuration.
4.15.3 Inventory Sync on Reconnect
When connectivity restores, inventory-related entries in the sales_queue (sale decrements and return increments) are flushed to the server in FIFO order. The server applies current state and flags any discrepancies.
Sync process (step by step):
1. POS detects network restored → Status: SYNCING
2. Flush sales_queue entries in strict FIFO order (oldest first)
3. For each queued sale/return:
a. Server applies the transaction using current server inventory
b. IF resulting qty < 0 AND allow_negative_inventory = false:
- Flag as DISCREPANCY (not a blocking conflict)
- Record: { product_variant_id, server_qty, change, resulting_qty, type: NEGATIVE_INVENTORY }
c. IF cached price differs from current server price:
- Apply current server price
- Flag as PRICE_DISCREPANCY with cached vs. server values
d. Log movement with offline_synced: true flag
4. After all entries processed → Status: ONLINE
5. Flagged discrepancies appear on manager dashboard for review
BRD Amendment (v6.3.0): No CONFLICT_REVIEW state. All queued entries are applied; discrepancies are flagged for review but do not block the sync process. Server state is authoritative.
Discrepancy types flagged for manager review:
| Discrepancy Type | Server Action | Manager Dashboard |
|---|---|---|
| Negative inventory (same item sold at two stores) | Apply sale; allow negative balance | Review and approve, or schedule recount |
| Price discrepancy (cached vs. current) | Apply current server price | Review; decide if customer credit warranted |
| Parked sale item no longer available | Auto-release parked sale reservation | Notify customer, offer alternatives |
sequenceDiagram
autonumber
participant POS as POS Terminal
participant SQ as sales_queue (SQLite)
participant API as Server API
participant DB as PostgreSQL
participant MGR as Manager
Note over POS, MGR: Phase 1: Offline Sales Only
POS->>POS: Detect network lost → OFFLINE
POS->>SQ: Queue sale: SKU-1001, qty: -1, 10:15 AM
POS->>SQ: Queue sale: SKU-1001, qty: -2, 10:32 AM
POS->>POS: Adjustment blocked (requires server)
Note right of SQ: Meanwhile, Store B sells 2x SKU-1001 on server
Note over POS, MGR: Phase 2: Reconnect & FIFO Flush
POS->>POS: Detect network restored → SYNCING
POS->>API: Flush sales_queue (2 entries, FIFO)
API->>DB: Apply sale SKU-1001 qty: -1 (10:15 AM)
DB-->>API: OK (server qty was 3, now 2)
API->>DB: Apply sale SKU-1001 qty: -2 (10:32 AM)
DB-->>API: OK (server qty was 2, now 0) — flagged: low stock
API-->>POS: Sync result: 2 OK, 0 discrepancies → ONLINE
Note over POS, MGR: Discrepancies (if any) on dashboard
MGR->>API: Review flagged items on dashboard
MGR->>API: Acknowledge low stock alert
4.16 Alerts, Notifications & Email Templates
Scope: Proactive inventory alerts that notify staff and managers of conditions requiring attention, plus automated email templates for key inventory events.
4.16.1 Alert Types
The system supports five inventory alert types, each with configurable thresholds, severity levels, and delivery channels.
| Alert | Trigger | Severity | Delivery | Configurable Parameters |
|---|---|---|---|---|
| Low Stock | Qty falls below reorder point for product at location | WARNING | Dashboard + daily email digest | Reorder point per product per location; digest send time |
| Overstock | Days of supply exceeds threshold | INFO | Dashboard only | Days of supply threshold (default: 90 days) |
| Shrinkage Threshold | Count variance exceeds % threshold of expected qty | CRITICAL | Dashboard + immediate email | Variance % threshold (default: 5%) |
| Aging Inventory | No sales for product at location in X days | WARNING | Dashboard + weekly email digest | Days threshold (default: 90 days); weekly digest day (default: Monday) |
| PO Overdue | PO not received within vendor lead time + buffer days | WARNING | Dashboard + email to buyer | Buffer days beyond lead time (default: 3 days) |
Alert trigger logic (detailed):
Low Stock:
FOR each (product_variant_id, location_id):
IF available_qty <= reorder_point
AND no active OPEN alert exists for this product/location combo
THEN create LOW_STOCK alert
Overstock:
FOR each (product_variant_id, location_id):
days_of_supply = available_qty / avg_daily_sales_velocity_90d
IF days_of_supply > overstock_threshold_days
AND avg_daily_sales_velocity_90d > 0 -- exclude dead stock (handled separately)
THEN create OVERSTOCK alert
Shrinkage Threshold:
ON count finalization:
FOR each counted product:
variance_pct = ABS(counted_qty - expected_qty) / expected_qty * 100
IF variance_pct > shrinkage_threshold_pct
THEN create SHRINKAGE alert (CRITICAL)
Aging Inventory:
WEEKLY job:
FOR each (product_variant_id, location_id):
last_sale_date = MAX(sale_date) for this product at this location
days_since_sale = TODAY - last_sale_date
IF days_since_sale > aging_threshold_days
AND available_qty > 0
THEN create AGING_INVENTORY alert
PO Overdue:
DAILY job:
FOR each PO in status OPEN or PARTIAL:
expected_date = po.created_at + vendor.lead_time_days + buffer_days
IF TODAY > expected_date
AND no active PO_OVERDUE alert exists for this PO
THEN create PO_OVERDUE alert
4.16.2 Alert Data Model
| Field | Type | Description |
|---|---|---|
alert_id | UUID | Unique alert identifier |
tenant_id | UUID | Tenant scope |
alert_type | ENUM | LOW_STOCK, OVERSTOCK, SHRINKAGE, AGING_INVENTORY, PO_OVERDUE |
severity | ENUM | INFO, WARNING, CRITICAL |
product_variant_id | UUID | Affected product (nullable for PO alerts) |
location_id | UUID | Affected location |
reference_type | VARCHAR | INVENTORY_LEVEL, COUNT_SESSION, PURCHASE_ORDER |
reference_id | UUID | ID of the triggering entity |
message | TEXT | Human-readable alert description |
data_snapshot | JSONB | Key metrics at alert time (e.g., { "available_qty": 2, "reorder_point": 5 }) |
triggered_at | TIMESTAMP | When alert was created |
acknowledged_by | UUID | Staff who acknowledged (nullable) |
acknowledged_at | TIMESTAMP | When acknowledged (nullable) |
resolved_at | TIMESTAMP | When condition cleared (nullable) |
auto_resolved | BOOLEAN | true if resolved by system (e.g., stock replenished) |
4.16.3 Alert Lifecycle
stateDiagram-v2
[*] --> TRIGGERED: Condition detected
TRIGGERED --> ACKNOWLEDGED: Staff views/clicks alert
ACKNOWLEDGED --> RESOLVED: Condition cleared (manual or auto)
TRIGGERED --> RESOLVED: Condition auto-clears (e.g., stock received)
RESOLVED --> [*]
note right of TRIGGERED
Appears on dashboard
May send email/notification
end note
note right of ACKNOWLEDGED
Staff is aware
Working on resolution
end note
note right of RESOLVED
Condition no longer active
Retained for history/reporting
end note
Auto-resolution rules:
| Alert Type | Auto-Resolves When |
|---|---|
| Low Stock | available_qty > reorder_point (stock received or transferred in) |
| Overstock | days_of_supply <= overstock_threshold (stock sold or transferred out) |
| Shrinkage | Never auto-resolves; requires manual acknowledgment |
| Aging Inventory | Sale occurs for the product at the location |
| PO Overdue | PO status changes to RECEIVED or COMPLETED |
4.16.4 Email Templates
Four email templates cover the primary inventory communication needs. Each template uses dynamic field substitution.
Template 1: TMPL_INV_PO_VENDOR
| Property | Value |
|---|---|
| Template ID | TMPL_INV_PO_VENDOR |
| Trigger | PO status changes to OPEN (submitted to vendor) |
| Recipient | Vendor email address (from vendor record) |
| Subject | Purchase Order {po_number} from {tenant_name} |
| Dynamic Fields | vendor_name, ship_to_address (store or HQ), po_number, po_date, expected_delivery_date, line_items_table (SKU, description, qty, unit cost, line total), po_total, special_instructions, buyer_name, buyer_email, buyer_phone |
Template 2: TMPL_INV_TRANSFER_ALERT
| Property | Value |
|---|---|
| Template ID | TMPL_INV_TRANSFER_ALERT |
| Trigger | Transfer status changes to SHIPPED |
| Recipient | Destination store manager email |
| Subject | Incoming Transfer {transfer_number} from {source_store_name} |
| Dynamic Fields | source_store_name, destination_store_name, transfer_number, shipped_date, expected_arrival_date, manifest_table (SKU, description, qty shipped), total_items, tracking_number, carrier_name, shipper_name |
Template 3: TMPL_INV_LOW_STOCK
| Property | Value |
|---|---|
| Template ID | TMPL_INV_LOW_STOCK |
| Trigger | Daily job (configurable time, default: 7:00 AM) |
| Recipient | Store manager and/or HQ inventory manager (configurable) |
| Subject | Low Stock Alert: {count} items below reorder point at {location_name} |
| Dynamic Fields | location_name, report_date, count (number of items), items_table (SKU, product name, current qty, reorder point, reorder qty, primary vendor, vendor lead time), total_reorder_value |
Template 4: TMPL_INV_COUNT_REMINDER
| Property | Value |
|---|---|
| Template ID | TMPL_INV_COUNT_REMINDER |
| Trigger | Scheduled count is N days away (configurable, default: 2 days) |
| Recipient | Store manager |
| Subject | Inventory Count Reminder: {count_type} scheduled for {scheduled_date} |
| Dynamic Fields | count_type (Full, Cycle, Spot Check), scheduled_date, scheduled_time, location_name, scope_description (e.g., “Category: Tops” or “Full Store”), assigned_staff_names, estimated_duration, special_instructions |
4.17 Inventory Dashboard & Reports
Scope: A dedicated inventory dashboard providing at-a-glance KPIs and a comprehensive reporting suite consolidating all reports referenced across Sections 4.3 through 4.16.
4.17.1 Dashboard KPIs
The inventory dashboard displays eight primary KPI cards in a responsive grid layout. Each card shows the current value, trend indicator, and drill-down link.
| KPI Card | Metric | Trend | Drill-Down |
|---|---|---|---|
| Total Inventory Value (WAC) | Sum of (available_qty x WAC) across all locations | 30-day trend arrow (up/down/flat) + % change | Inventory Valuation report |
| Low Stock Items | Count of products where available_qty <= reorder_point | Delta from prior week | Low Stock Alert report |
| Pending PO Count | Count of POs in OPEN or PARTIAL status | Total pending PO value in parentheses | Open PO Report |
| Open Transfers | Count of transfers in REQUESTED, APPROVED, PICKING, or SHIPPED status | Total in-transit items count | Open Transfer Report |
| Upcoming Counts | Count of scheduled counts in next 7 days | Next count date and type | Count schedule calendar |
| Shrinkage % | (Total variance value / Total inventory value) x 100 for last 30 days | vs. prior 30-day period | Shrinkage Analysis report |
| Dead Stock Count | Count of products with zero sales velocity in last 90 days | Delta from prior month | Dead Stock Report |
| Avg Days of Supply | Average days_of_supply across all active products by category | Top 3 categories with lowest supply | Days of Supply report |
Dashboard filters:
- Location: All Stores, specific store, or HQ
- Category: All, or specific category
- Date range: Applies to trend calculations
- Brand: All, or specific brand
4.17.2 Master Report Suite
The following table consolidates all inventory reports from across the module. Each report includes its source section, purpose, key data fields, grouping keys, and export formats.
| # | Report Name | Source Section | Purpose | Key Data Fields | Grouping Keys | Export |
|---|---|---|---|---|---|---|
| 1 | Inventory Snapshot | 4.3 | Current QoH and total value at a point in time | SKU, product name, variant, location, available qty, reserved qty, total qty, WAC, extended value | Location, Category, Brand, Vendor | CSV, PDF |
| 2 | Low Stock Alert | 4.5 | Items below reorder point requiring restock action | SKU, product name, location, available qty, reorder point, reorder qty, primary vendor, lead time | Location, Vendor, Category | CSV, PDF, Dashboard |
| 3 | Stock Movement Log | 4.12 | Full audit trail of all inventory ins and outs | Movement ID, timestamp, type, SKU, product name, qty change, location, reference type, reference ID, staff, notes | Movement Type, Location, Date Range, Staff | CSV |
| 4 | Inventory Valuation (WAC) | 4.11 | Total inventory value using Weighted Average Cost | SKU, product name, location, qty, WAC per unit, extended value, % of total value | Location, Category, Brand | CSV, PDF |
| 5 | Shrinkage Analysis | 4.6 | Expected vs. actual quantities from count sessions | Count session, date, location, SKU, expected qty, counted qty, variance, variance %, variance value | Location, Date Range, Category | CSV, PDF |
| 6 | Vendor Performance Scorecard | 4.4 | Vendor reliability metrics across POs | Vendor name, total POs, on-time %, avg lead time, fill rate %, defect rate %, cost variance %, total spend | Vendor, Date Range | CSV, PDF |
| 7 | Inventory Turnover | 4.5 | Stock efficiency metrics | SKU, product name, category, avg inventory, COGS, turnover rate, days of supply, sell-through % | Category, Brand, Location | CSV, PDF |
| 8 | ABC Classification | 4.5 | Pareto analysis of products by revenue contribution | SKU, product name, revenue (period), cumulative revenue %, classification (A/B/C), qty sold, avg margin | Category, Brand, Location | CSV, PDF |
| 9 | Aging Analysis | 4.5 | Inventory age distribution by time buckets | SKU, product name, location, qty, receive date, age (days), age bucket (0-30, 31-60, 61-90, 90+), value | Location, Category, Age Bucket | CSV, PDF |
| 10 | Dead Stock Report | 4.5 | Items with zero sales velocity over threshold period | SKU, product name, location, available qty, value, last sale date, days since last sale, receive date | Location, Category, Vendor | CSV, PDF |
| 11 | Open PO Report | 4.4 | Purchase orders pending receipt | PO number, vendor, status, created date, expected date, total lines, received lines, total value, outstanding value | Vendor, Status, Location | CSV, PDF |
| 12 | PO Receiving Report | 4.4 | Details of received PO line items | PO number, receive date, SKU, ordered qty, received qty, variance, unit cost, cost variance, receiver staff | PO Number, Vendor, Date Range | CSV, PDF |
| 13 | Vendor Lead Time Report | 4.4 | Actual vs. expected delivery times by vendor | Vendor name, PO number, ordered date, expected date, received date, actual lead time, variance (days) | Vendor, Date Range | CSV, PDF |
| 14 | PO Variance Report | 4.4 | Discrepancies between ordered and received | PO number, SKU, ordered qty, received qty, qty variance, ordered cost, actual cost, cost variance | PO Number, Vendor | CSV, PDF |
| 15 | Cost Analysis Report | 4.11 | WAC trends and cost changes over time | SKU, product name, WAC (current), WAC (30d ago), WAC (90d ago), cost change %, last PO cost, vendor | Category, Vendor, Date Range | CSV, PDF |
| 16 | Reorder Alerts | 4.5 | Products approaching or at reorder point | SKU, product name, location, available qty, reorder point, days of supply, velocity, recommended qty, primary vendor | Location, Vendor, Category | CSV, PDF, Dashboard |
| 17 | Auto-PO Performance | 4.5 | Effectiveness of automatic PO generation | Month, auto-PO count, manual PO count, auto-PO accuracy %, stock-out events, avg days to stock-out | Month, Location | CSV, PDF |
| 18 | Velocity Trends | 4.5 | Sales velocity over time per product | SKU, product name, velocity (7d), velocity (30d), velocity (90d), trend direction, seasonality index | Category, Brand, Location | CSV, PDF |
| 19 | Days of Supply | 4.5 | How long current stock will last at current velocity | SKU, product name, location, available qty, avg daily velocity, days of supply, classification | Location, Category, Risk Level | CSV, PDF |
| 20 | Open RMA Report | 4.7 | Vendor returns in progress | RMA number, vendor, status, created date, total lines, total units, total value, expected credit | Vendor, Status | CSV, PDF |
| 21 | Vendor Return Rate | 4.7 | Defect and return rates by vendor | Vendor name, total received units, returned units, return rate %, top return reasons, credit recovered | Vendor, Date Range | CSV, PDF |
| 22 | RMA Aging | 4.7 | RMA age analysis for follow-up | RMA number, vendor, status, created date, age (days), last action date, total value, expected credit | Vendor, Status, Age Bucket | CSV, PDF |
| 23 | RMA Credit Reconciliation | 4.7 | Expected vs. received vendor credits | RMA number, vendor, expected credit, received credit, variance, credit date, reconciliation status | Vendor, Date Range, Status | CSV, PDF |
| 24 | Open Transfer Report | 4.8 | Transfers in progress between stores | Transfer number, source, destination, status, created date, shipped date, total items, expected arrival | Status, Source, Destination | CSV, PDF |
| 25 | Transfer Variance Report | 4.8 | Discrepancies between shipped and received quantities | Transfer number, SKU, shipped qty, received qty, variance, variance reason, source, destination | Transfer Number, Location | CSV, PDF |
| 26 | Transfer Volume Report | 4.8 | Transfer activity metrics over time | Period, total transfers, total units moved, avg transit days, top source locations, top destination locations | Date Range, Location Pair | CSV, PDF |
| 27 | Rebalancing Suggestions | 4.8 | System-generated transfer recommendations | SKU, product name, source location, source qty, source days of supply, destination location, destination qty, destination days of supply, suggested transfer qty | Category, Priority | CSV, PDF |
| 28 | Serial Number Lookup | 4.10 | Complete history of a serial-tracked unit | Serial number, SKU, product name, current status, current location, receive date, PO number, sale date, order ID, customer, return history | Status, Location | CSV, PDF |
| 29 | Lot Inventory Report | 4.10 | Current stock by lot number | Lot number, SKU, product name, location, qty available, receive date, expiry date (if applicable), age (days), PO number | Location, SKU, Expiry Status | CSV, PDF |
| 30 | Lot Trace (Recall) | 4.10 | Full traceability for recall management | Lot number, SKU, received qty, receive date, PO number, vendor, sold qty, remaining qty, customer list (with order IDs), locations distributed to | Lot Number | CSV, PDF |
| 31 | Landed Cost Analysis | 4.11 | Cost breakdown including freight, duty, and handling | PO number, SKU, base unit cost, freight allocation, duty allocation, handling allocation, total landed cost, allocation method | Vendor, PO Number | CSV, PDF |
| 32 | Margin Analysis | 4.11 | Gross margin by product and category | SKU, product name, category, sell price, WAC (landed), gross margin $, gross margin %, units sold, total margin | Category, Brand, Location | CSV, PDF |
| 33 | Cost Trend Report | 4.11 | Historical cost changes per product | SKU, product name, vendor, cost (current), cost (3m ago), cost (6m ago), cost (12m ago), trend, % change (YoY) | Vendor, Category, Trend Direction | CSV, PDF |
4.17.3 Report Access Control
| Role | View | Export | Schedule | Create Custom |
|---|---|---|---|---|
| Admin | All reports | All formats | Yes | Yes |
| HQ Manager | All reports | All formats | Yes | No |
| Store Manager | Store-scoped reports | CSV, PDF | Yes | No |
| Buyer | PO and vendor reports | CSV, PDF | Yes | No |
| Staff | Inventory Snapshot, Serial Lookup only | CSV only | No | No |
4.18 Inventory Business Rules — YAML Configuration
Cross-Reference: All inventory business rules configuration has been consolidated into Module 5: Setup & Configuration, Section 5.19.4 (Inventory Configuration). See Module 5, Section 5.19 for the complete YAML configuration reference covering all modules.
4.19 Inventory User Stories & Acceptance Criteria
Scope: All user stories organized by epic and Gherkin acceptance criteria for the Inventory Module. Epics 4.A through 4.F are moved from BRD Section 3.23 (renumbered). Epics 4.G through 4.P are new.
Epic 4.A: Vendor RMA
(Moved from Epic 3.I, renumbered)
Story 4.A.1: Create RMA
- As a Store Manager, I want to create a Return Merchandise Authorization with line items (product, qty, reason) so that I can return defective or overstock items to the vendor.
- Constraint: RMA numbers auto-increment per tenant using format
RMA-{YEAR}-{SEQUENCE}.
Story 4.A.2: RMA Workflow
- As a Buyer, I want an RMA to follow Draft > Submitted > Vendor Approved > Shipped Back > Credit/Replacement Received > Closed lifecycle so that vendor returns are tracked through every stage.
- Constraint: Each status transition is logged with timestamp and staff ID.
Story 4.A.3: Credit/Replacement
- As a Buyer, I want to record vendor credits against the RMA and track replacement shipments so that I can reconcile vendor credits with expected amounts.
- Constraint: When vendor sends replacement, a linked PO is created for receiving. Credit variance > 5% triggers alert.
Epic 4.B: Reorder Management
(Moved from Epic 3.J, renumbered)
Story 4.B.1: Dynamic Reorder Points
- As an Inventory Manager, I want the system to calculate reorder points from 90-day sales velocity, lead time, and safety stock so that reorder points stay current without manual maintenance.
- Constraint: Recalculated weekly via background job. Safety stock uses 1.65 sigma (95% service level).
Story 4.B.2: Auto-PO Generation
- As a Buyer, I want the system to auto-create draft POs when stock drops below reorder point so that I can review and submit without manually building each PO.
- Constraint: Staff reviews before submission. POs consolidated by vendor. Minimum PO value enforced.
Story 4.B.3: Seasonal Adjustment
- As an Inventory Manager, I want reorder velocity to adjust based on historical same-period data so that seasonal demand patterns are accounted for in reorder calculations.
- Constraint: Uses trailing 3-year same-month average when available. New products without history use category-level seasonality.
Epic 4.C: Inventory Control
(Moved from Epic 3.K, renumbered)
Story 4.C.1: Inventory Status
- As a Store Manager, I want each inventory unit to have a status (Available, Quarantine, Damaged, In-Transit, Reserved, On-Hold) so that only sellable stock is available for sale or transfer.
- Constraint: Only AVAILABLE status allows sale. Status transitions are logged to the movement history.
Story 4.C.2: Stock Counting
- As a Store Manager, I want five counting methods (full physical count, cycle count, scanner-assisted count, monthly spot check, on-demand count) so that I can choose the right method for each situation.
- Constraint: Workflow is Count > Review Variances > Approve Adjustments. High-variance items require manager approval.
Story 4.C.3: Adjustments
- As a Staff Member, I want to submit manual inventory adjustments with a reason code so that discrepancies can be corrected and tracked.
- Constraint: Reason codes required (DAMAGED, THEFT, COUNT_CORRECTION, VENDOR_RETURN, OTHER). Adjustments above configurable threshold require manager approval.
Story 4.C.4: Unified Receiving
- As a Warehouse Clerk, I want a single receiving workflow that handles all receiving types (PO receive, transfer receive, customer return, RMA replacement) so that the process is consistent regardless of source.
- Constraint: Barcode scanner verification. Variance tracking against expected quantities.
Story 4.C.5: Bulk Operations
- As a Buyer, I want to import products via CSV, export catalog data to CSV/XLSX, and make bulk changes to price/cost/status/category so that large-scale updates are efficient.
- Constraint: Bulk changes above configurable thresholds require approval workflow integration.
Epic 4.D: Inter-Store Transfers
(Moved from Epic 3.L, renumbered)
Story 4.D.1: Transfer Workflow
- As a Store Manager, I want inter-store transfers to follow Request > Approve > Pick > Ship > Receive > Complete lifecycle with variance tracking at each stage so that transfer accuracy is maintained.
- Constraint: Shipped qty vs. received qty variance logged. Destination must scan-confirm received items.
Story 4.D.2: Auto-Rebalancing
- As an HQ Manager, I want the system to analyze velocity vs. stock across locations and suggest transfers to equalize days of supply so that no store is overstocked while another is understocked.
- Constraint: Staff reviews and approves suggested transfers. Minimum imbalance threshold is configurable (default: 14 days difference).
Epic 4.E: Serial & Lot Tracking
(Moved from Epic 3.M, renumbered)
Story 4.E.1: Serial Tracking
- As a Cashier, I want serial-tracked products to require serial number entry at both receive and sale so that each unit is individually tracked for warranty and after-sale support.
- Constraint: Serial number linked to customer on sale. Serial validated as IN_STOCK at selling location before sale proceeds.
Story 4.E.2: Lot Tracking
- As a Warehouse Clerk, I want lot numbers assigned at receiving with FIFO enforcement on sale so that lot-tracked products are sold in order and full traceability is available for recall management.
- Constraint: FIFO enforced automatically. Lot trace report shows all customers who received items from a specific lot.
Epic 4.F: Landed Cost & Costing
(Moved from Epic 3.N, renumbered)
Story 4.F.1: Landed Cost
- As a Buyer, I want PO receiving to include cost allocation for freight, duties, customs, and handling so that the true per-unit cost is known for accurate margin calculations.
- Constraint: Three allocation methods supported (By Value, By Quantity, Manual). Landed cost stored as the true cost basis.
Story 4.F.2: Weighted Average Cost
- As an Inventory Manager, I want the system to maintain weighted average cost per product per location, recalculated on each receive, so that COGS calculations and margin reporting are accurate.
- Constraint: WAC formula:
New WAC = ((Existing Qty x Existing WAC) + (Received Qty x Received Cost)) / (Existing Qty + Received Qty). WAC is used for all COGS and margin calculations.
Epic 4.G: Receiving & Inspection
(New)
Story 4.G.1: Open Receive with Discrepancy Handling
- As a Warehouse Clerk, I want to receive a quantity different from the PO ordered quantity so that I can handle partial shipments, over-shipments, and damaged goods.
- Constraint: Uses the “triple approach” – (1) accept what arrived, (2) quarantine damaged items, (3) auto-create RMA for damages. PO status updates to PARTIAL if not all lines received.
Story 4.G.2: Non-PO Receiving
- As a Store Manager, I want to receive stock without a linked PO (e.g., consignment, found stock, vendor replacement) so that all inventory entering the store is tracked regardless of source.
- Constraint: Reason code required. Creates a standalone receiving record with movement log entry.
Story 4.G.3: Over-Shipment Threshold
- As a Buyer, I want the system to enforce a configurable over-receive threshold so that warehouses cannot accept significantly more than what was ordered without authorization.
- Constraint: Default threshold is 10% above PO line qty. Over-receive above threshold requires manager approval. Over-receive below threshold is accepted and logged.
Epic 4.H: POS Integration
(New)
Story 4.H.1: Reserve on Cart Add
- As a Cashier, I want inventory to be soft-reserved when I add an item to the cart so that another terminal at the same store does not sell the last unit before I complete payment.
- Constraint: Soft reservation decrements available qty immediately. Removing item from cart releases reservation instantly. Other terminals see reduced qty.
Story 4.H.2: Commit on Payment
- As a Cashier, I want the inventory reservation to convert to a permanent decrement when payment completes so that the stock ledger accurately reflects completed sales.
- Constraint: SALE movement logged. WAC captured at time of sale. If payment fails, reservation holds for 30 seconds then auto-releases.
Story 4.H.3: Return to Stock
- As a Store Staff Member, I want customer returns to automatically restore inventory to AVAILABLE status at the return location so that returned items are immediately available for resale.
- Constraint: Default status is AVAILABLE. Staff can override to DAMAGED if item is not resalable. RETURN movement logged. WAC is not recalculated (original cost preserved).
Epic 4.I: Online Fulfillment
(New)
Story 4.I.1: Nearest Store Reservation
- As an Online Operations Manager, I want online orders to automatically reserve inventory at the nearest store with stock so that orders ship quickly from the closest location.
- Constraint: Store assignment uses distance calculation from store to customer shipping address. If no single store has full stock and split fulfillment is disabled, order flags for manual assignment.
Story 4.I.2: Shopify Inventory Sync
- As an Online Operations Manager, I want inventory quantities to always sync bidirectionally between POS and Shopify so that online customers see accurate availability.
- Constraint: Webhook-driven sync (< 5 second target). Reconciliation every 15 minutes. POS is source of truth. Sync operates independently of catalog sync mode.
Story 4.I.3: Pick-Pack-Ship Workflow
- As a Store Staff Member, I want online orders assigned to my store to appear on a fulfillment queue with a guided pick-pack-ship workflow so that fulfillment is accurate and tracked.
- Constraint: Each item scanned during pick. Serial/lot numbers captured. Carrier and tracking entered at ship. Shopify order updated with tracking automatically.
Epic 4.J: Offline Inventory Fallback
(Revised per ADR-048)
BRD Amendment (v6.3.0): Rewritten per ADR-048 (Online-First with Offline Fallback). Inventory is read-only offline; only sale/return decrements are queued. No offline adjustments, counts, or transfers.
Story 4.J.1: Read-Only Inventory Access Offline
- As a Store Staff Member, I want to look up product stock levels from a local cache during network outage so that I can inform customers of approximate availability.
- Constraint: Stock data from
product_cache(SQLite) is read-only. Stale warning displayed withlast_synced_attimestamp. No inventory-modifying operations other than sales/returns (queued viasales_queue).
Story 4.J.2: Inventory Discrepancy Review on Reconnect
- As a Store Manager, I want the system to flag any inventory discrepancies (negative stock, price differences) after the offline sales queue is flushed so that I can review and take corrective action.
- Constraint: Sales queue flushed in FIFO order. Server state is authoritative. Discrepancies flagged on manager dashboard (not blocking). No CONFLICT_REVIEW state.
Epic 4.K: Alerts & Notifications
(New)
Story 4.K.1: Configurable Inventory Alerts
- As an HQ Manager, I want configurable alerts for low stock, overstock, shrinkage, aging inventory, and overdue POs so that I am proactively informed of conditions requiring action.
- Constraint: Each alert type has configurable thresholds, severity, and delivery channels. Alert thresholds can be set per product per location.
Story 4.K.2: Email Templates
- As a Buyer, I want automated email templates for PO submission, transfer notifications, low stock digests, and count reminders so that stakeholders receive timely, formatted communications.
- Constraint: Four templates with dynamic field substitution. Templates support HTML formatting. Digest emails consolidate multiple alerts.
Story 4.K.3: Alert Acknowledgment
- As a Store Manager, I want to acknowledge alerts on the dashboard so that my team knows I am aware of and working on the issue.
- Constraint: Alert lifecycle: TRIGGERED > ACKNOWLEDGED > RESOLVED. Alerts auto-resolve when the triggering condition clears (except shrinkage, which requires manual resolution).
Epic 4.L: Dashboard & Reporting
(New)
Story 4.L.1: Dedicated Inventory Dashboard
- As a Store Manager, I want a dedicated inventory dashboard with KPI cards so that I can see the health of my store’s inventory at a glance.
- Constraint: Eight KPI cards (total value, low stock count, pending POs, open transfers, upcoming counts, shrinkage %, dead stock, avg days of supply). Filterable by location, category, brand.
Story 4.L.2: Analytics Report Suite
- As an HQ Manager, I want a comprehensive suite of 33 inventory reports so that I can analyze every aspect of inventory performance.
- Constraint: Reports exportable to CSV and PDF. Role-based access. Store managers see only their store’s data unless granted multi-store access.
Story 4.L.3: ABC Classification
- As a Buyer, I want monthly Pareto analysis that classifies products as A (top 20% revenue), B (next 30%), or C (bottom 50%) so that I can prioritize inventory management efforts.
- Constraint: New products exempt until 60 days of sales data. Classification drives cycle count frequency (A = 30 days, B = 60 days, C = 90 days).
Epic 4.M: PO Approval Workflow
(New)
Story 4.M.1: Threshold-Based PO Approval
- As an Owner, I want purchase orders above a configurable dollar threshold to require approval before submission so that large purchases are reviewed before committing funds.
- Constraint: Two tiers – manager approval at $500+, admin/owner approval at $5,000+. POs below $500 auto-approve.
Story 4.M.2: Auto-Approve Below Threshold
- As a Buyer, I want POs below the approval threshold to be submitted directly to the vendor without waiting for approval so that routine restocking is not delayed.
- Constraint: Auto-approved POs are still logged in the audit trail with source “AUTO_APPROVED”. Notification sent to manager for visibility.
Story 4.M.3: Approval Expiry
- As a Buyer, I want pending approvals to expire after a configurable period so that stale PO requests do not block the workflow indefinitely.
- Constraint: Default expiry is 7 days. Expired approvals notify the requester. Requester can resubmit.
Epic 4.N: Dead Stock Management
(New)
Story 4.N.1: Dead Stock Detection
- As an Inventory Manager, I want the system to automatically identify products with zero sales velocity over a configurable period so that I can take action on non-performing inventory.
- Constraint: Default threshold is 90 days of zero sales. Detection runs daily. Products with qty > 0 and velocity = 0 are flagged.
Story 4.N.2: Dead Stock Alerting
- As a Store Manager, I want to receive dashboard alerts for dead stock items so that I am aware of products that need markdowns, transfers, or vendor returns.
- Constraint: Alert includes product name, location, qty on hand, value (at WAC), and last sale date. Grouped by category.
Story 4.N.3: Dead Stock Reporting
- As a Buyer, I want a dead stock report showing all zero-velocity items with their value and age so that I can make informed decisions about clearance, vendor returns, or donation.
- Constraint: Report filterable by location, category, vendor, and value range. Exportable to CSV and PDF.
Epic 4.O: Overstock Vendor Returns
(New — renumbered from 4.P to close gap)
Story 4.O.1: Negotiate Vendor Return
- As a Buyer, I want to create overstock vendor return requests with proposed quantities and negotiate terms with the vendor so that excess inventory can be returned before it becomes dead stock.
- Constraint: Overstock returns follow the RMA workflow (Section 4.7). Must be enabled in config (
overstock_returns_enabled: true). Return window configurable per vendor.
Story 4.O.2: Restocking Fee Handling
- As a Buyer, I want to record restocking fees charged by the vendor so that the net credit is accurately tracked.
- Constraint: Restocking fee recorded as a percentage of unit cost. Default is 0%. Maximum configurable (default max: 25%). Net credit = (qty x cost) - restocking fee.
Story 4.O.3: Overstock Return Reporting
- As an HQ Manager, I want reports showing overstock return volume, restocking fees paid, and net credits recovered by vendor so that I can evaluate vendor return programs.
- Constraint: Includes vendor return rate, total credits recovered, total restocking fees, net recovery ratio. Filterable by vendor, date range, and category.
Inventory Acceptance Criteria: Gherkin Scenarios
Feature: Receiving with Discrepancy
Feature: Receiving with Discrepancy
As a Warehouse Clerk
I need to handle partial shipments and damaged goods during receiving
So that inventory records accurately reflect what was received
Background:
Given a Purchase Order "PO-2026-00142" exists in status "OPEN"
And the PO contains the following lines:
| SKU | Product | Ordered Qty | Unit Cost |
| SKU-1001 | Classic Fit Tee Navy M | 20 | $12.50 |
| SKU-1002 | Classic Fit Tee Navy L | 15 | $12.50 |
| SKU-1003 | Slim Chino Khaki 32 | 10 | $24.00 |
And the receiving location is "HQ Warehouse"
Scenario: Partial receive with all items in good condition
When I receive the following quantities:
| SKU | Received Qty |
| SKU-1001 | 20 |
| SKU-1002 | 10 |
And I do not receive SKU-1003
Then the stock level for "SKU-1001" at "HQ Warehouse" should increase by 20
And the stock level for "SKU-1002" at "HQ Warehouse" should increase by 10
And the stock level for "SKU-1003" should remain unchanged
And the PO status should update to "PARTIAL"
And the remaining qty for "SKU-1002" should be 5
And the remaining qty for "SKU-1003" should be 10
And WAC for "SKU-1001" should be recalculated using $12.50
And WAC for "SKU-1002" should be recalculated using $12.50
Scenario: Receive with damaged items quarantined
When I receive 20 units of "SKU-1001"
And I mark 3 units of "SKU-1001" as "DAMAGED" with reason "Stained in transit"
Then the stock level for "SKU-1001" at "HQ Warehouse" should show:
| Status | Qty |
| AVAILABLE | 17 |
| QUARANTINE | 3 |
And a movement log entry should be created with type "RECEIVE" and qty 20
And an RMA should be auto-created for 3 units of "SKU-1001"
And the RMA should reference "PO-2026-00142"
And the RMA reason should be "Stained in transit"
Scenario: Over-receive within threshold
Given the over-receive threshold is 10%
When I receive 22 units of "SKU-1001" (PO ordered 20)
Then the system should accept the over-receive (10% = 2 units, within threshold)
And the stock level for "SKU-1001" should increase by 22
And the PO line received qty should show 22
And an over-receive note should be logged
Scenario: Over-receive exceeds threshold requires approval
Given the over-receive threshold is 10%
When I attempt to receive 25 units of "SKU-1001" (PO ordered 20)
Then the system should block the receive with message "Over-receive of 25% exceeds 10% threshold"
And the system should prompt "Manager approval required for over-receive"
When manager "Mike" approves the over-receive
Then the stock level for "SKU-1001" should increase by 25
And the approval should be logged to the audit trail
Feature: POS Inventory Reservation
Feature: POS Inventory Reservation
As a POS system
I need to manage inventory reservations throughout the sale lifecycle
So that multiple terminals do not oversell available stock
Background:
Given product "Classic Fit Tee Navy M" (SKU-1001) has 5 units available at "Store A"
And Terminal 1 and Terminal 2 are active at "Store A"
Scenario: Reserve on add to cart
When cashier on Terminal 1 adds 1 unit of "SKU-1001" to the cart
Then a soft reservation should be created for Terminal 1
And available qty for "SKU-1001" at "Store A" should show 4
And Terminal 2 should see available qty as 4
Scenario: Release on item removal
Given Terminal 1 has 1 unit of "SKU-1001" in the cart (reserved)
And available qty shows 4
When cashier on Terminal 1 removes "SKU-1001" from the cart
Then the soft reservation should be released
And available qty for "SKU-1001" at "Store A" should show 5
Scenario: Commit on successful payment
Given Terminal 1 has 1 unit of "SKU-1001" in the cart (reserved)
When payment completes successfully on Terminal 1
Then the soft reservation should be deleted
And a SALE movement should be logged with qty -1
And available qty for "SKU-1001" at "Store A" should show 4 (permanent)
And WAC should be captured on the movement record
Scenario: Hold on payment failure then auto-release
Given Terminal 1 has 1 unit of "SKU-1001" in the cart (reserved)
And available qty shows 4
When payment fails on Terminal 1 (card declined)
Then the reservation should hold for 30 seconds
And available qty should remain 4 during the hold
When 30 seconds elapse without payment retry
Then the reservation should auto-release
And available qty should return to 5
Scenario: Hold on payment failure with retry
Given Terminal 1 has 1 unit of "SKU-1001" in the cart (reserved)
When payment fails on Terminal 1 (card declined)
And cashier retries payment within 30 seconds
And the retry succeeds
Then the existing reservation should be used (no new reservation)
And a SALE movement should be logged
And available qty should show 4 (permanent)
Scenario: Void releases all reservations
Given Terminal 1 has 2 units of "SKU-1001" in the cart (reserved)
And available qty shows 3
When cashier voids the entire transaction
Then all reservations for the session should be released
And available qty for "SKU-1001" should return to 5
And no movement records should be created
Scenario: Last unit contention between terminals
Given available qty for "SKU-1001" is 1
When cashier on Terminal 1 adds 1 unit of "SKU-1001" to cart
Then available qty should show 0
When cashier on Terminal 2 attempts to add "SKU-1001" to cart
Then Terminal 2 should see "SKU-1001 is out of stock at this location"
And the item should not be added to Terminal 2's cart
Feature: Inventory Count with Scanner
Feature: Inventory Count with Scanner
As a Store Manager
I need scanner-primary counting with variance detection
So that counts are accurate and discrepancies are reviewed
Background:
Given a cycle count session is created for category "Tops" at "Store A"
And the following expected quantities exist:
| SKU | Product | Expected Qty |
| SKU-1001 | Classic Fit Tee Navy M | 25 |
| SKU-1002 | Classic Fit Tee Navy L | 18 |
| SKU-1003 | V-Neck Tee Black M | 12 |
And blind count mode is enabled (expected qty hidden from counter)
And scanner mode is "scan_primary"
And variance approval threshold is 10 units
Scenario: Scanner-primary count with no variance
When staff scans 25 barcodes for "SKU-1001"
And staff scans 18 barcodes for "SKU-1002"
And staff scans 12 barcodes for "SKU-1003"
And staff submits the count
Then all items should show zero variance
And the count status should change to "COMPLETED"
And no adjustments should be created
And the count should be logged to history
Scenario: Count with minor variance auto-adjusts
When staff scans 23 barcodes for "SKU-1001" (expected 25)
And staff scans 18 barcodes for "SKU-1002"
And staff scans 12 barcodes for "SKU-1003"
And staff submits the count
Then variance for "SKU-1001" should show -2 units
And the variance is below the 10-unit threshold
Then "SKU-1001" inventory should be auto-adjusted to 23
And an adjustment record should be created with reason "COUNT_CORRECTION"
And the movement log should record qty change of -2 for "SKU-1001"
Scenario: Count with major variance requires approval
When staff scans 12 barcodes for "SKU-1001" (expected 25)
And staff submits the count
Then variance for "SKU-1001" should show -13 units
And the variance exceeds the 10-unit threshold
Then the adjustment should be set to "PENDING_APPROVAL" status
And manager should receive an approval notification
And "SKU-1001" inventory should remain at 25 until approved
Scenario: Manager approves high-variance adjustment
Given a pending adjustment exists for "SKU-1001" with counted qty 12 (variance -13)
When manager "Mike" reviews and approves the adjustment
Then "SKU-1001" inventory should be adjusted to 12
And the adjustment reason should be "COUNT_CORRECTION"
And the approval should be logged with approver "Mike"
And a SHRINKAGE alert should be triggered (variance 52% exceeds 5% threshold)
Scenario: Recount required for extreme variance
Given variance approval threshold for recount is 20%
When staff counts 5 units for "SKU-1001" (expected 25, variance 80%)
And staff submits the count
Then the system should flag "SKU-1001" for mandatory recount
And the count status for "SKU-1001" should be "RECOUNT_REQUIRED"
And a different staff member should be assigned the recount
Feature: PO Approval Workflow
Feature: PO Approval Workflow
As an Owner
I need purchase orders above a dollar threshold to require approval
So that large purchases are reviewed before funds are committed
Background:
Given the following PO approval thresholds are configured:
| Threshold | Required Approver |
| < $500 | Auto-approve |
| >= $500 | Manager |
| >= $5000 | Admin/Owner |
And approval requests expire after 7 days
Scenario: Auto-approve PO below threshold
When buyer "Sarah" creates PO "PO-2026-00201" with total value $350.00
And submits the PO
Then the PO should be auto-approved
And the PO status should change to "OPEN"
And the audit log should record source "AUTO_APPROVED"
And an email should be sent to the vendor
And manager "Mike" should receive a visibility notification
Scenario: Manager approval required
When buyer "Sarah" creates PO "PO-2026-00202" with total value $1,200.00
And submits the PO for approval
Then an approval request should be created with status "PENDING"
And the PO status should remain "DRAFT"
And manager "Mike" should receive an approval notification
Scenario: Manager approves PO
Given a pending approval exists for PO "PO-2026-00202" ($1,200.00)
When manager "Mike" approves the PO
Then the PO status should change to "OPEN"
And the vendor should receive the PO email (TMPL_INV_PO_VENDOR)
And the audit log should record approver "Mike"
Scenario: Manager rejects PO
Given a pending approval exists for PO "PO-2026-00202" ($1,200.00)
When manager "Mike" rejects the PO with reason "Wait for vendor sale next month"
Then the PO status should remain "DRAFT"
And buyer "Sarah" should receive a notification with the rejection reason
And the rejection should be logged to the audit trail
Scenario: Large PO escalates to admin
When buyer "Sarah" creates PO "PO-2026-00203" with total value $7,500.00
And submits the PO for approval
Then an approval request should require "ADMIN" level approval
And manager approval should NOT be sufficient
And admin/owner should receive the approval notification
Scenario: Approval request expires
Given a pending approval was created 8 days ago for PO "PO-2026-00204"
When the expiration job runs
Then the approval status should change to "EXPIRED"
And the PO status should remain "DRAFT"
And buyer "Sarah" should be notified of the expiration
And "Sarah" should be able to resubmit the PO for approval
Scenario: Requester cannot approve own PO
Given buyer "Sarah" also has Manager role
When "Sarah" creates and submits PO "PO-2026-00205" ($800.00)
Then "Sarah" should not be able to approve her own PO
And the system should show "Cannot approve your own purchase order"
And a different manager must approve
Feature: Transfer Auto-Suggestion
Feature: Transfer Auto-Suggestion
As an HQ Manager
I need the system to detect inventory imbalances and suggest transfers
So that stock is distributed optimally across stores
Background:
Given the auto-suggest imbalance threshold is 14 days of supply
And minimum transfer quantity is 2 units
And minimum source qty after transfer is 2 units
And the following inventory state exists:
| Product | Store A (qty/velocity) | Store B (qty/velocity) | Store C (qty/velocity) |
| Classic Tee Navy | 30 / 1.0 per day | 2 / 1.5 per day | 15 / 0.5 per day |
And days of supply:
| Product | Store A | Store B | Store C |
| Classic Tee Navy | 30 days | 1.3 days| 30 days |
Scenario: Imbalance detected and suggestion generated
When the daily auto-suggest job runs
Then a transfer suggestion should be generated
And the suggestion should recommend moving stock from "Store A" to "Store B"
And the suggested qty should equalize days of supply across stores
And the suggestion should not reduce "Store A" below minimum qty (2 units)
Scenario: Manager reviews and approves suggestion
Given a transfer suggestion exists: 10 units of "Classic Tee Navy" from "Store A" to "Store B"
When HQ manager "Alex" reviews the suggestion
And approves the transfer
Then a transfer record should be created in "APPROVED" status
And "Store A" staff should be notified to pick and ship 10 units
And the suggestion status should change to "ACCEPTED"
Scenario: Manager modifies suggestion before approval
Given a transfer suggestion exists: 10 units from "Store A" to "Store B"
When HQ manager "Alex" changes the quantity to 8 units
And approves the modified transfer
Then a transfer record should be created for 8 units
And the suggestion should be marked "ACCEPTED_MODIFIED"
Scenario: Manager rejects suggestion
Given a transfer suggestion exists: 10 units from "Store A" to "Store B"
When HQ manager "Alex" rejects the suggestion with reason "Seasonal event at Store A"
Then no transfer should be created
And the suggestion should be marked "REJECTED" with the reason
Scenario: No suggestion when imbalance below threshold
Given all stores have days of supply within 14 days of each other
When the daily auto-suggest job runs
Then no transfer suggestions should be generated
Feature: Offline Inventory Sync
BRD Amendment (v6.3.0): Rewritten per ADR-048 (Online-First with Offline Fallback). Inventory is read-only offline; only sale/return decrements are queued via
sales_queue. No CONFLICT_REVIEW state.
Feature: Offline Inventory Sync
As a POS system
I need to queue sale/return inventory changes offline and flush on reconnect
So that stores can continue selling during network outages
Background:
Given "Store A" has the following inventory in product_cache:
| SKU | Available Qty | Last Synced At |
| SKU-1001 | 10 | 2026-03-01 09:00 |
| SKU-2005 | 5 | 2026-03-01 09:00 |
And the offline strategy is "online_first" per ADR-048
Scenario: Queue sales offline and flush on reconnect
Given "Store A" loses network connectivity
And POS enters OFFLINE mode
When cashier sells 2 units of "SKU-1001" at 10:15 AM
And cashier sells 1 unit of "SKU-2005" at 10:30 AM
Then the sales_queue should contain 2 entries in FIFO order
When network connectivity restores
Then POS should enter SYNCING mode
And all queued sales should flush in FIFO order (oldest first)
And server should apply current prices and decrement inventory
And POS should enter ONLINE mode
And sales_queue should be empty
Scenario: Discrepancy flagged on reconnect (negative inventory)
Given "Store A" loses network connectivity
And while offline, Store A cashier sells 4 units of "SKU-2005"
And while offline, Store B sells 3 units of "SKU-2005" on the server (server qty: 2)
When "Store A" reconnects and flushes the offline sale of 4 units
Then server applies the sale: server qty (2) - offline change (4) = -2
And a discrepancy should be flagged for manager review:
| Field | Value |
| product | SKU-2005 |
| offline_change | -4 |
| server_qty_at_sync | 2 |
| resulting_qty | -2 |
| discrepancy_type | NEGATIVE_INVENTORY |
And POS should enter ONLINE mode (no blocking CONFLICT_REVIEW state)
And manager should see the flagged discrepancy on dashboard
Scenario: Manager reviews flagged discrepancy
Given a discrepancy exists for "SKU-2005" with resulting qty -2
When manager "Mike" reviews the discrepancy on the dashboard
And accepts the negative balance with note "Schedule recount"
Then the discrepancy status should change to "ACKNOWLEDGED"
And a recount should be scheduled for "SKU-2005" at "Store A"
Scenario: Inventory adjustments blocked offline
Given "Store A" is in OFFLINE mode
When staff attempts to create an inventory adjustment
Then the system should display "Adjustments unavailable offline. Wait for connectivity."
When staff attempts to check inventory at other stores
Then the system should display "Multi-store lookup unavailable offline. Check local stock only."
When staff attempts to create a transfer request
Then the system should display "Transfers unavailable offline. Queue request when online."
When staff attempts to receive a PO
Then the system should display "Receiving unavailable offline. Wait for connectivity."
Scenario: Queue capacity warning
Given "Store A" is in OFFLINE mode
And the offline queue has 450 of 500 entries (90%)
Then POS should display warning "Offline queue nearly full. Reconnect soon."
When the queue reaches 500 entries
Then POS should block further inventory-modifying operations
And display "Offline queue full. Cannot process more transactions until reconnected."
Feature: Dead Stock Detection
Feature: Dead Stock Detection
As an Inventory Manager
I need to identify products with zero sales velocity
So that dead stock can be addressed through markdowns, transfers, or vendor returns
Background:
Given the dead stock threshold is 90 days
And the following inventory exists at "Store A":
| SKU | Product | Qty | Last Sale Date |
| SKU-3001 | Printed Scarf | 15 | 2025-10-01 |
| SKU-3002 | Wool Beanie | 8 | 2026-01-15 |
| SKU-3003 | Linen Blazer | 3 | 2025-08-20 |
And today's date is 2026-02-04
Scenario: Product flagged as dead stock after 90 days
When the daily dead stock detection job runs
Then "SKU-3001" should be flagged as dead stock (126 days since last sale)
And "SKU-3003" should be flagged as dead stock (168 days since last sale)
And "SKU-3002" should NOT be flagged (20 days since last sale)
Scenario: Dead stock alert created
When "SKU-3001" is flagged as dead stock
Then an AGING_INVENTORY alert should be created with severity "WARNING"
And the alert data snapshot should include:
| Field | Value |
| available_qty | 15 |
| last_sale_date | 2025-10-01 |
| days_since_sale | 126 |
| value_at_wac | calculated |
Scenario: Dead stock report generated
When manager requests the Dead Stock Report for "Store A"
Then the report should include "SKU-3001" and "SKU-3003"
And the report should NOT include "SKU-3002"
And each row should show: SKU, product name, qty, WAC value, last sale date, days since sale
And the report should be exportable to CSV and PDF
Scenario: Dead stock auto-resolves when sale occurs
Given "SKU-3001" has an active AGING_INVENTORY alert
When a customer purchases 1 unit of "SKU-3001" at "Store A"
Then the AGING_INVENTORY alert for "SKU-3001" should auto-resolve
And the resolved_at timestamp should be set
And auto_resolved should be true
Feature: Online Order Fulfillment
Feature: Online Order Fulfillment
As an Online Operations Manager
I need online orders to be assigned to the nearest store and fulfilled
So that customers receive orders quickly and shipping costs are minimized
Background:
Given the following stores with locations:
| Store | City | Lat | Lng | SKU-1001 Qty |
| Store A | Richmond, VA | 37.54 | -77.44 | 10 |
| Store B | Norfolk, VA | 36.85 | -76.29 | 0 |
| Store C | Virginia Beach| 36.85 | -75.98 | 5 |
| HQ | Glen Allen, VA| 37.66 | -77.51 | 25 |
And store assignment strategy is "nearest"
And split fulfillment is disabled
Scenario: Nearest store with stock is selected
Given a customer in "Chesapeake, VA" (lat 36.77, lng -76.29) places an online order for 2 units of "SKU-1001"
When the store assignment algorithm runs
Then "Store C" should be selected (nearest with stock, ~22 miles)
And 2 units of "SKU-1001" should be hard-reserved at "Store C"
And the order should appear on "Store C" fulfillment queue
Scenario: Nearest store has no stock, next nearest selected
Given "Store C" has 0 units of "SKU-1001" (sold out)
And a customer in "Chesapeake, VA" places an online order for 2 units of "SKU-1001"
When the store assignment algorithm runs
Then "Store A" should be selected (next nearest with stock)
And 2 units should be hard-reserved at "Store A"
Scenario: No store has stock, order flagged for manual assignment
Given all stores have 0 units of "SKU-1001"
And HQ has 25 units of "SKU-1001"
And exclude_hq is false
When the store assignment algorithm runs
Then "HQ" should be selected for fulfillment
And 2 units should be hard-reserved at "HQ"
Scenario: Pick-pack-ship workflow completes
Given order "ORD-SHOP-9001" is assigned to "Store A"
And the order contains 2 units of "SKU-1001"
When staff starts picking
And scans 2 barcodes for "SKU-1001"
Then picking should be complete
When staff packs the order and enters weight
And enters carrier "UPS" with tracking "1Z999AA10123456784"
Then the order status should be "SHIPPED"
And a SALE movement should be logged for 2 units at "Store A"
And Shopify should be updated with fulfillment and tracking number
And available qty for "SKU-1001" at "Store A" should decrease by 2
Scenario: Inventory sync from Shopify online sale
Given "Store A" has 10 units of "SKU-1001" in POS
And Shopify shows 10 units for "Store A"
When a customer purchases 1 unit online (assigned to Store A)
Then Shopify webhook fires orders/create
And POS should decrement "SKU-1001" at "Store A" by 1
And POS should show 9 units available
And the movement type should be "ONLINE_SALE"
Feature: Overstock Vendor Return
Feature: Overstock Vendor Return
As a Buyer
I need to return overstock items to vendors with restocking fee tracking
So that excess inventory can be managed and credits recovered
Background:
Given overstock returns are enabled
And the default restocking fee is 0%
And vendor "StyleCo" has a negotiated restocking fee of 15%
And the following overstock inventory exists:
| SKU | Product | Qty | Days of Supply | Vendor |
| SKU-4001 | Floral Dress S | 50 | 250 days | StyleCo |
| SKU-4002 | Floral Dress M | 35 | 175 days | StyleCo |
Scenario: Create overstock vendor return
When buyer "Sarah" creates an overstock RMA for vendor "StyleCo"
And adds the following return lines:
| SKU | Return Qty | Unit Cost |
| SKU-4001 | 30 | $18.00 |
| SKU-4002 | 20 | $18.00 |
Then an RMA should be created with type "OVERSTOCK"
And the RMA status should be "DRAFT"
And the gross return value should be $900.00
Scenario: Restocking fee applied on vendor approval
Given RMA "RMA-2026-00050" is submitted to vendor "StyleCo"
When vendor approves the return with 15% restocking fee
Then the restocking fee should be $135.00 (15% of $900.00)
And the net credit expected should be $765.00
And the RMA status should change to "VENDOR_APPROVED"
And the restocking fee should be recorded on each line:
| SKU | Gross Credit | Restocking Fee | Net Credit |
| SKU-4001 | $540.00 | $81.00 | $459.00 |
| SKU-4002 | $360.00 | $54.00 | $306.00 |
Scenario: Inventory decremented on shipment back to vendor
Given RMA "RMA-2026-00050" is vendor-approved
When warehouse ships the return items back to vendor
And enters carrier "FedEx" with tracking "794644790132"
Then the RMA status should change to "SHIPPED_BACK"
And a RETURN_TO_VENDOR movement should be logged:
| SKU | Qty Change | Location |
| SKU-4001 | -30 | HQ Warehouse |
| SKU-4002 | -20 | HQ Warehouse |
And inventory at "HQ Warehouse" should decrease accordingly
Scenario: Credit received and reconciled
Given RMA "RMA-2026-00050" was shipped back to vendor
When vendor issues credit of $765.00
And buyer records the credit received
Then the RMA status should change to "CREDIT_RECEIVED"
And credit reconciliation should show:
| Expected Credit | Received Credit | Variance |
| $765.00 | $765.00 | $0.00 |
And the RMA status should change to "CLOSED"
Scenario: Credit variance detected
Given RMA "RMA-2026-00050" was shipped back with expected credit $765.00
When vendor issues credit of $700.00 (less than expected)
And buyer records the credit received
Then credit reconciliation should show variance of -$65.00
And a reconciliation alert should be created
And buyer should investigate the discrepancy before closing the RMA
Feature: Inventory Adjustment Approval
Feature: Inventory Adjustment Approval
As a Store Manager
I need inventory adjustments above a threshold to require approval
So that significant inventory changes are reviewed for accuracy
Background:
Given adjustment approval mode is "threshold"
And the approval threshold is 10 units or $100.00 value
And product "SKU-1001" has 25 units available at "Store A"
And WAC for "SKU-1001" is $12.50
Scenario: Small adjustment auto-applies
When staff "Jane" submits an adjustment for "SKU-1001" at "Store A"
And the adjustment is -3 units (value: $37.50) with reason "DAMAGED"
Then the adjustment should be applied immediately
And "SKU-1001" qty at "Store A" should change to 22
And a movement record should be created:
| Type | Qty | Reason | Staff |
| ADJUSTMENT | -3 | DAMAGED | Jane |
And no approval request should be created
Scenario: Large adjustment requires approval
When staff "Jane" submits an adjustment for "SKU-1001" at "Store A"
And the adjustment is -15 units (value: $187.50) with reason "THEFT"
Then the adjustment should be set to "PENDING_APPROVAL" status
And "SKU-1001" qty should remain at 25 until approved
And manager "Mike" should receive an approval notification
And the notification should include: product, qty change, value, reason, requester
Scenario: Manager approves the adjustment
Given a pending adjustment exists: "SKU-1001" at "Store A", -15 units, reason "THEFT"
When manager "Mike" reviews and approves the adjustment
Then "SKU-1001" qty at "Store A" should change to 10
And a movement record should be created with approver "Mike"
And the adjustment status should be "APPROVED"
And staff "Jane" should be notified of the approval
Scenario: Manager rejects the adjustment
Given a pending adjustment exists: "SKU-1001" at "Store A", -15 units, reason "THEFT"
When manager "Mike" rejects the adjustment with reason "Recount needed first"
Then "SKU-1001" qty should remain at 25
And the adjustment status should be "REJECTED"
And staff "Jane" should be notified with the rejection reason
Scenario: Reason code is required
When staff "Jane" submits an adjustment for "SKU-1001" without selecting a reason code
Then the system should display "Reason code is required for inventory adjustments"
And the adjustment should be blocked
Scenario: Custom reason code with required note
When staff "Jane" submits an adjustment with reason code "OTHER"
And does not provide a note
Then the system should display "A note is required for reason code 'Other'"
And the adjustment should be blocked
When "Jane" provides note "Found box behind shelf during cleaning"
And resubmits
Then the adjustment should proceed (subject to threshold rules)
5. Setup & Configuration Module
5.1 Overview & Scope
Module 5 centralizes all tenant-level system configuration for the POS platform. Every operational behavior in Modules 1 through 4 – how a sale is processed, how a customer is identified, how a product is priced, how inventory is tracked – is governed by configuration defined here. Module 5 is the control plane: it does not process transactions, manage catalog records, or move inventory. It defines the rules, structures, and parameters that those modules consume at runtime.
5.1.1 Executive Summary
A multi-tenant POS system serving five retail stores and one HQ warehouse requires a single, authoritative source for all system-wide configuration. Without centralized setup, configuration drifts across locations, roles are inconsistently enforced, and operational rules become embedded in application logic rather than tenant-controlled settings. Module 5 eliminates this by providing a structured configuration layer that every other module references.
The scope of Module 5 encompasses: system identity and branding, currency and localization, physical location definitions, user identity and role-based access, shift and scheduling configuration, register and terminal management, hardware peripherals, tax rules, receipt templates, payment processing integration, financial accounting codes, operational business rules, inter-store transfer policies, notification and alert routing, RFID hardware integration, system integrations with external platforms, data import/export tooling, audit logging configuration, and tenant onboarding workflows.
Design principle: Module 5 defines how things are configured, not how things operate day-to-day. For example, Module 5 defines that a location exists, its timezone, and its tax rate. Module 1 (Sales) uses that tax rate when calculating a transaction total. Module 5 defines that a user has the MANAGER role with permission to void transactions. Module 1 enforces that permission at the point of sale.
5.1.2 Module Dependencies
Module 5 is the foundational configuration layer consumed by all operational modules. It has no upstream module dependencies – it is configured directly by tenant administrators.
flowchart TD
M5["Module 5\nSetup & Configuration"]
M1["Module 1\nSales & POS"]
M2["Module 2\nCustomers"]
M3["Module 3\nCatalog"]
M4["Module 4\nInventory"]
M5 -->|Users, roles, registers,\ntax rules, payment config,\nreceipt templates, clock-in/out config| M1
M5 -->|Users, roles,\nlocation assignments,\ncustomer data policies| M2
M5 -->|Users, roles,\nbarcode config,\nvendor settings,\nlabel printers| M3
M5 -->|Users, roles,\nlocations,\ntransfer rules,\nRFID config| M4
style M5 fill:#7b2d8e,stroke:#5a1d6e,color:#fff
style M1 fill:#264653,stroke:#1d3557,color:#fff
style M2 fill:#264653,stroke:#1d3557,color:#fff
style M3 fill:#264653,stroke:#1d3557,color:#fff
style M4 fill:#264653,stroke:#1d3557,color:#fff
Downstream consumers (Module 5 provides):
| Consumer Module | Configuration Provided | Purpose |
|---|---|---|
| Module 1 (Sales) | Registers, profiles, tax rates, payment processors, receipt templates, user roles, clock-in/out configuration, cash drawer settings, discount limits | Controls POS terminal behavior, payment routing, receipt output, and staff permissions during sales. |
| Module 2 (Customers) | User roles, data retention policies, communication preferences defaults, location assignments | Governs who can view/edit customer data, default privacy settings, and location-scoped customer association. |
| Module 3 (Catalog) | Barcode format, label printer config, vendor registry, user roles, approval thresholds, Shopify integration settings | Controls barcode generation, label printing, vendor management permissions, and external catalog sync. |
| Module 4 (Inventory) | Locations, transfer rules, RFID reader config, user roles, reorder thresholds, count policies | Defines physical topology, transfer approval rules, and counting schedules. |
5.1.3 Functional Scope
The following table enumerates all functional areas covered by Module 5 and their section references.
| # | Section | Domain | Description |
|---|---|---|---|
| 5.1 | Overview & Scope | Foundation | Module purpose, dependencies, and section index |
| 5.2 | System Settings & Branding | Identity | Tenant identity, operational defaults, and visual branding |
| 5.3 | Multi-Currency Configuration | Localization | Currency definitions, exchange rates, and display formatting |
| 5.4 | Locations | Topology | Physical locations and location type definitions |
| 5.5 | Users & Roles | Access Control | User profiles, role definitions, and feature toggle matrix |
| 5.6 | Time Tracking (Clock-In / Clock-Out) | Time Tracking | Simple clock-in/clock-out time recording for payroll |
| 5.7 | Registers & Terminals | Hardware | Register registry, device pairing, profiles, and peripheral assignments |
| 5.8 | Printer Configuration | Hardware | Printer registry, driver settings, and printer-location assignments |
| 5.9 | Tax Configuration | Financial | Tax rates, tax classes, and location-level tax rules |
| 5.10 | Notification & Alert Rules | Communication | Alert routing, escalation paths, and notification channel preferences |
| 5.11 | Payment Processing | Financial | Payment processor integration, terminal pairing, and gateway configuration |
| 5.12 | Accounting & GL Mapping | Financial | Chart of accounts, GL code assignments, and financial period definitions |
| 5.13 | Operational Business Rules | Rules Engine | Configurable thresholds, approval limits, and policy toggles |
| 5.14 | Receipt Templates | Output | Receipt layout configuration, template variables, and format options |
| 5.15 | Transfer & Logistics Rules | Operations | Transfer approval thresholds, routing rules, and carrier configuration |
| 5.16 | RFID Configuration | Hardware | Reader registration, antenna settings, and scan session parameters |
| 5.17 | System Integrations | External | Shopify, QuickBooks, and third-party API connection management |
| 5.18 | Data Import / Export | Data | Bulk data import templates, export scheduling, and format configuration |
| 5.19 | Audit & Compliance | Governance | Audit log retention, compliance settings, and data purge policies |
| 5.20 | Tenant Onboarding | Lifecycle | Initial setup wizard, seed data provisioning, and go-live checklist |
| 5.21 | User Stories | Acceptance | Gherkin-format acceptance criteria for all Module 5 functionality |
5.2 System Settings & Branding
Scope: Tenant-level identity, operational defaults, and visual branding configuration. These settings establish the foundational parameters that all other modules reference – the tenant’s name, timezone, date formatting, session policies, and customer-facing visual identity. Settings are organized into three categories: Core (identity and localization), Operational (runtime behavior), and Branding (visual presentation).
5.2.1 Core Settings
Core settings define the tenant’s identity and localization defaults. These values are established during onboarding (Section 5.20) and rarely change after initial configuration.
| Setting | Key | Type | Required | Default | Description |
|---|---|---|---|---|---|
| Tenant Name | tenant_name | String(100) | Yes | – | Trading name displayed in UI headers and reports |
| Legal Entity Name | legal_entity_name | String(200) | Yes | – | Registered business name for invoices and legal documents |
| Company Logo | company_logo_url | String(500) | No | System default | URL to uploaded logo image (PNG/SVG, max 2MB, min 200x200px) |
| Default Timezone | default_timezone | String(50) | Yes | America/New_York | IANA timezone identifier; applies to all locations unless overridden at location level |
| Default Currency | default_currency | String(3) | Yes | USD | ISO 4217 currency code; set at onboarding, immutable after first transaction |
| Date Format | date_format | Enum | Yes | MM/DD/YYYY | Display format: MM/DD/YYYY or DD/MM/YYYY; tenant-wide preference |
| Time Format | time_format | Enum | Yes | 12h | Display format: 12h (3:00 PM) or 24h (15:00); tenant-wide preference |
| Fiscal Year Start | fiscal_year_start_month | Integer(1-12) | Yes | 1 (January) | Month number when the fiscal year begins; affects financial reporting periods |
5.2.2 Operational Settings
Operational settings control runtime behavior across the POS system. These are actively tuned by administrators as business needs evolve.
| Setting | Key | Type | Required | Default | Description |
|---|---|---|---|---|---|
| Auto-Logout Timeout | auto_logout_minutes | Integer | Yes | 30 | Minutes of inactivity before automatic POS session logout (range: 5-120) |
| Max Session Duration | max_session_hours | Integer | Yes | 8 | Maximum hours a POS session can remain active before forced re-authentication (range: 1-24) |
| Barcode Format | barcode_format | Enum | Yes | CODE128 | Preferred barcode symbology for system-generated barcodes: CODE128, EAN13, UPCA |
| Default Print Mode | default_print_mode | Enum | Yes | THERMAL | Default receipt output: THERMAL (80mm roll), A4 (full page), EMAIL_ONLY |
| Failed Login Lockout | failed_login_max | Integer | Yes | 5 | Number of consecutive failed PIN/password attempts before temporary lockout |
| Lockout Duration | lockout_duration_minutes | Integer | Yes | 15 | Minutes a user account is locked after exceeding failed login threshold |
Cross-Reference: See Module 5, Section 5.14 for receipt template configuration including default printer assignment per location.
5.2.3 Business Hours & Holiday Calendar
Business hours are configured per location, supporting different schedules per day of week. Holidays override normal business hours for specific dates.
Business Hours Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
location_id | UUID | Yes | – | FK to locations table |
day_of_week | Integer(0-6) | Yes | – | 0 = Sunday, 1 = Monday, …, 6 = Saturday |
open_time | Time | No | 09:00 | Store opening time (null = closed this day) |
close_time | Time | No | 21:00 | Store closing time (null = closed this day) |
is_closed | Boolean | Yes | false | Overrides open/close times; true = location closed this day |
Holiday Calendar Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
holiday_date | Date | Yes | – | Calendar date of the holiday |
name | String(100) | Yes | – | Holiday name (e.g., “Thanksgiving”, “Independence Day”) |
applies_to | Enum | Yes | ALL | ALL (all locations), STORES_ONLY, SPECIFIC (use junction table) |
is_closed | Boolean | Yes | true | Whether locations are closed on this date |
modified_open_time | Time | No | null | Override open time if not fully closed (e.g., holiday shortened hours) |
modified_close_time | Time | No | null | Override close time if not fully closed |
is_recurring | Boolean | Yes | false | If true, repeats annually on the same month/day |
Business Rules:
- Holiday entries override normal business hours for matching dates.
- When
is_closed = true, POS terminals at affected locations display a “Location Closed” banner and block new transactions. - When modified hours are set, the system uses those hours instead of normal business hours for that date.
- Recurring holidays auto-generate entries for the current fiscal year during onboarding and can be manually adjusted per year.
5.2.4 Branding Settings
Branding settings control the visual presentation of the POS system, customer-facing displays, printed receipts, and exported reports.
| Setting | Key | Type | Required | Default | Description |
|---|---|---|---|---|---|
| Primary Color | brand_primary_color | String(7) | Yes | #1A1A2E | Hex color code for primary UI elements (headers, buttons, navigation) |
| Accent Color | brand_accent_color | String(7) | Yes | #E94560 | Hex color code for accent elements (highlights, active states, badges) |
| Login Background | login_bg_image_url | String(500) | No | System default | URL to custom login page background image (JPG/PNG, max 5MB, 1920x1080 recommended) |
| Login Tagline | login_tagline | String(200) | No | null | Custom text displayed below the company logo on the login screen |
| Receipt Logo | receipt_logo_url | String(500) | No | company_logo_url | Logo printed/displayed on receipts; falls back to company logo if not set |
| Report Header Logo | report_header_logo_url | String(500) | No | company_logo_url | Logo displayed in the header of printed and exported reports |
| Report Header Address | report_header_address | Text | No | null | Company address block printed on report headers |
| Customer Display Welcome | customer_display_welcome | String(200) | No | "Welcome!" | Welcome message shown on customer-facing displays |
| Customer Display Promo Images | customer_display_promo_urls | JSONB | No | [] | Array of image URLs for rotating promotional display on customer-facing screens |
Cross-Reference: See Module 5, Section 5.14 for receipt-specific branding (receipt logo placement, footer text, color printing support).
5.2.5 System Settings Data Model
All settings are stored in a key-value table with JSONB values, enabling flexible schema evolution without database migrations.
system_settings Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
setting_key | String(100) | Yes | – | Unique setting identifier within tenant (e.g., tenant_name, auto_logout_minutes) |
setting_value | JSONB | Yes | – | Setting value; JSONB supports strings, numbers, booleans, arrays, and objects |
category | Enum | Yes | – | CORE, OPERATIONAL, BRANDING |
updated_by | UUID | Yes | – | FK to users table; last user who modified this setting |
updated_at | DateTime | Yes | Auto | Timestamp of last modification |
Unique constraint: (tenant_id, setting_key)
location_settings Table (Per-Location Overrides)
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
location_id | UUID | Yes | – | FK to locations table |
setting_key | String(100) | Yes | – | Setting identifier being overridden at location level |
setting_value | JSONB | Yes | – | Location-specific override value |
updated_by | UUID | Yes | – | FK to users table |
updated_at | DateTime | Yes | Auto | Timestamp of last modification |
Unique constraint: (tenant_id, location_id, setting_key)
Resolution order: Location-level setting overrides tenant-level setting. If no location override exists, the tenant-level value is used.
Overridable settings: Not all settings support location-level override. The following settings are overridable per location: default_timezone, auto_logout_minutes, max_session_hours, default_print_mode, barcode_format.
5.3 Multi-Currency Configuration
Scope: Defining the base currency and optional additional currencies for vendor purchase order support. All POS sales transactions and financial reports operate exclusively in the tenant’s base currency. Multi-currency support exists solely to enable purchase orders denominated in a vendor’s native currency, with manual exchange rate management and date-stamped rate history.
5.3.1 Currency Model
Each tenant has exactly one base currency, established at onboarding and immutable after the first transaction is recorded. Additional currencies are activated to support vendor procurement workflows where the vendor invoices in a foreign currency.
Currency Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
code | String(3) | Yes | – | ISO 4217 currency code (e.g., USD, EUR, GBP, CAD) |
name | String(50) | Yes | – | Display name (e.g., “US Dollar”, “Euro”, “British Pound”) |
symbol | String(5) | Yes | – | Currency symbol (e.g., $, €, £) |
decimal_places | Integer | Yes | 2 | Number of decimal places for amounts (0-4) |
symbol_position | Enum | Yes | BEFORE | Symbol placement: BEFORE ($100.00) or AFTER (100.00€) |
thousands_separator | String(1) | Yes | , | Thousands grouping character: , or . or (space) |
decimal_separator | String(1) | Yes | . | Decimal point character: . or , |
is_base | Boolean | Yes | false | Whether this is the tenant’s base currency (exactly one per tenant) |
is_active | Boolean | Yes | true | Whether this currency is available for selection on new POs |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, code)
Business Rules:
- Exactly one currency per tenant must have
is_base = true. This constraint is enforced at the application level. - The base currency cannot be deactivated (
is_activecannot be set tofalsefor the base currency). - The base currency code cannot be changed after the first transaction is recorded in the system.
- Deactivating a non-base currency prevents it from being selected on new POs but does not affect existing POs already denominated in that currency.
5.3.2 Exchange Rates
Exchange rates are manually entered by an administrator. The system does not integrate with external rate feeds or auto-update rates. Each rate entry is date-stamped; the system uses the most recent rate on or before the PO date when converting vendor currency amounts to the base currency.
Exchange Rate Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
from_currency_id | UUID | Yes | – | FK to currencies table; the source currency |
to_currency_id | UUID | Yes | – | FK to currencies table; the target currency (typically the base currency) |
rate | Decimal(12,6) | Yes | – | Exchange rate: 1 unit of from_currency = rate units of to_currency |
effective_date | Date | Yes | – | Date from which this rate is effective |
created_by | UUID | Yes | – | FK to users table; administrator who entered the rate |
created_at | DateTime | Yes | Auto | Record creation timestamp |
Unique constraint: (tenant_id, from_currency_id, to_currency_id, effective_date)
Rate Resolution Logic:
- When a PO is created or received in a foreign currency, the system looks up the most recent
exchange_rateentry whereeffective_date <= PO date. - If no rate exists for the currency pair, the system blocks the PO with an error: “No exchange rate found for [CURRENCY] as of [DATE]. Enter a rate in Setup > Currencies.”
- Exchange rate changes do NOT retroactively affect existing POs. The rate is captured and stored on the PO at creation time.
5.3.3 Currency Use Cases
| Use Case | Currency Behavior | Module Reference |
|---|---|---|
| Vendor Purchase Orders | PO can be denominated in vendor’s currency; line totals display in both vendor currency and base currency | Module 4, Section 4.3 |
| PO Receiving / Landed Cost | Received goods are costed in base currency using the exchange rate captured at PO creation | Module 4, Section 4.4 |
| Sales Transactions | Always in base currency (USD). No foreign currency tender support. | Module 1, Section 1.3 |
| Financial Reports | Always in base currency | Module 5, Section 5.12 |
| Customer Accounts / Credit | Always in base currency | Module 2 |
5.3.4 Currency Display Format Examples
| Currency | Format | Example |
|---|---|---|
| USD (default) | $1,234.56 | Symbol before, comma thousands, period decimal |
| EUR | 1.234,56 € | Symbol after, period thousands, comma decimal |
| GBP | £1,234.56 | Symbol before, comma thousands, period decimal |
| JPY | ¥1,234 | Symbol before, comma thousands, zero decimal places |
5.4 Locations
Scope: Defining the physical topology of the tenant’s retail operation – store locations and warehouse facilities. Locations are the organizational unit for inventory, staffing, registers, and reporting. Every inventory balance, every register, every user assignment, and every transaction is scoped to a location.
5.4.1 Location Types
Two location types are supported. The type determines which operational capabilities are available at the location.
| Type | Code | Description | Capabilities |
|---|---|---|---|
| Store | STORE | Retail store location; customer-facing with POS registers and staff | Sales, returns, exchanges, customer service, inventory counts, receiving, transfers (send and receive) |
| Warehouse | WAREHOUSE | Distribution or HQ facility; receives vendor shipments, distributes to stores | Receiving, transfers (send only to stores), inventory counts, purchase order management. No customer-facing POS. |
Business Rules:
- A warehouse location cannot have registers assigned (no POS capability).
- A warehouse location does not receive inbound transfers from stores. Transfer direction is one-way: Warehouse -> Stores, and bidirectional between Stores (Store <-> Store).
- A tenant must have at least one
STORElocation to process sales. - The
HQwarehouse receives vendor shipments and distributes stock to retail stores.
Cross-Reference: The HQ-as-warehouse pattern is documented in the SalesSight inventory analysis methodology. HQ is the distribution hub, not a retail location. Online orders displayed as “HQ sales” are fulfilled from physical stores.
5.4.2 Location Data Model
locations Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
code | String(10) | Yes | – | Unique short code within tenant (e.g., GM, HM, LM, NM, HQ) |
name | String(100) | Yes | – | Display name (e.g., “Garden Mall”, “Heritage Mall”, “Headquarters”) |
type | Enum | Yes | – | STORE or WAREHOUSE |
address_line_1 | String(200) | Yes | – | Street address |
address_line_2 | String(200) | No | null | Suite, unit, floor |
city | String(100) | Yes | – | City |
state | String(50) | Yes | – | State or province |
zip | String(20) | Yes | – | Postal / ZIP code |
country | String(2) | Yes | US | ISO 3166-1 alpha-2 country code |
phone | String(20) | No | null | Location phone number |
email | String(200) | No | null | Location email address |
timezone | String(50) | Yes | Tenant default | IANA timezone identifier; overrides tenant default timezone |
tax_jurisdiction_id | UUID | Yes | – | FK to tax_jurisdictions table; defines the compound tax jurisdiction for this location. See Section 5.9 for tax jurisdiction and rate configuration. |
is_active | Boolean | Yes | true | Whether this location is operational |
is_franchise | Boolean | Yes | false | Indicates whether this location operates as a franchise (true) or is company-owned (false). Franchise locations may have different operational rules, reporting requirements, and fee structures. |
sort_order | Integer | Yes | 0 | Display ordering in dropdowns and reports |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, code)
Cross-Reference: See Module 5, Section 5.9 for tax jurisdiction and compound rate configuration. Each location references a
tax_jurisdictionsrecord viatax_jurisdiction_id, which defines the compound tax rates (State + County + City) applied at that location. See Section 5.9.1 for thetax_jurisdictionsandtax_ratestables.
5.5 Users & Roles
Scope: Defining user profiles, authentication credentials, role-based access control, location assignments, and the feature toggle matrix that governs what each role can and cannot do within the system. Users are the human operators of the POS platform; roles determine their permissions. Every action in the system is attributable to a specific user, and every capability is gated by that user’s assigned role and feature toggles.
5.5.1 User Profile
Each user represents a staff member, manager, or administrator who interacts with the POS system. Users authenticate via email/password for management access and via PIN for POS register mode.
users Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
email | String(200) | Yes | – | Login email; unique per tenant |
password_hash | String(255) | Yes | – | Bcrypt-hashed password for Nexus POS management login |
display_name | String(100) | Yes | – | Full name displayed in UI, receipts, and reports |
pin | String(6) | Yes | – | 4-6 digit numeric PIN for POS terminal login; stored as bcrypt hash |
role_id | UUID | Yes | – | FK to roles table |
employee_id | String(20) | No | null | Tenant-assigned employee identifier (e.g., badge number, payroll ID) |
commission_rate_percent | Decimal(5,2) | No | null | Default commission rate for this user (e.g., 5.00 for 5%). Null = no commission. |
default_register_id | UUID | No | null | FK to registers table; preferred register for auto-assignment at login |
hire_date | Date | No | null | Employee hire date for reporting and tenure tracking |
phone | String(20) | No | null | Contact phone number |
avatar_url | String(500) | No | null | URL to user avatar image for POS display |
is_active | Boolean | Yes | true | Whether user can log in; deactivated users cannot authenticate |
last_login_at | DateTime | No | null | Timestamp of most recent successful login |
failed_login_count | Integer | Yes | 0 | Consecutive failed login attempts; resets on successful login |
locked_until | DateTime | No | null | If set, user is locked out until this timestamp |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, email), (tenant_id, pin)
Business Rules:
- PIN must be unique within a tenant. Two users at the same tenant cannot share a PIN.
- Deactivating a user (
is_active = false) immediately invalidates all active sessions. The user cannot log in until reactivated. - Deleting a user is not supported; users are deactivated. All historical transactions, audit entries, and reports retain the user reference.
commission_rate_percentis the user’s default rate. Module 1, Section 1.14 details how commission is calculated per transaction and how the rate can be overridden per sale.
Cross-Reference: See Module 1, Section 1.14 for commission calculation logic including split commissions and override rates.
5.5.2 User-Location Assignment
Users can be assigned to one or more locations. The primary location determines which location the POS terminal defaults to at login. Multi-location users (e.g., managers overseeing two stores) can switch locations within the UI.
user_locations Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
user_id | UUID | Yes | – | FK to users table |
location_id | UUID | Yes | – | FK to locations table |
is_primary | Boolean | Yes | false | Whether this is the user’s primary/default location (exactly one per user) |
created_at | DateTime | Yes | Auto | Record creation timestamp |
Unique constraint: (user_id, location_id)
Business Rules:
- Every active user must have at least one location assignment.
- Exactly one location assignment per user must have
is_primary = true. - Location assignments are informational and used for default location selection, reporting filters, and organizational grouping. They do not restrict transaction processing — any user can process transactions at any location within their tenant.
- Removing a user’s last location assignment automatically deactivates the user.
5.5.3 Predefined Roles
The system ships with five predefined roles. These cover the standard organizational structure of a multi-store retail operation. Roles are tenant-scoped: each tenant gets their own copy of the predefined roles at onboarding, which they can then customize via the feature toggle matrix.
roles Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
code | String(20) | Yes | – | Unique role code within tenant |
name | String(50) | Yes | – | Display name |
description | String(500) | No | null | Role description |
is_system | Boolean | Yes | true | System roles cannot be deleted (but can be customized via feature toggles) |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, code)
Predefined Role Definitions
| Role | Code | Description | Typical Users |
|---|---|---|---|
| Staff | STAFF | POS operator; processes sales, returns, exchanges, and basic inventory tasks | Sales associates, cashiers |
| Manager | MANAGER | Store manager; approves adjustments, refunds, and price overrides; accesses location-level reports | Store managers, assistant managers |
| Admin | ADMIN | System administrator; configures all Module 5 settings, manages users and locations | IT staff, operations director |
| Buyer | BUYER | Procurement specialist; creates and manages purchase orders, vendor relationships | Purchasing agents, buyers |
| Owner | OWNER | Full access; all permissions including financial reports, audit logs, and read-only access to all configuration | Business owner, CEO |
5.5.4 Feature Toggle Matrix
The feature toggle matrix provides granular control over which capabilities each role has access to. Toggles are configured at the tenant level per role – changing a toggle affects all users with that role across the tenant.
role_feature_toggles Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
role_id | UUID | Yes | – | FK to roles table |
feature_code | String(50) | Yes | – | Feature identifier (e.g., process_sale, void_transaction) |
is_enabled | Boolean | Yes | – | Whether this feature is enabled for this role |
updated_by | UUID | No | null | FK to users table; last user who modified this toggle |
updated_at | DateTime | Yes | Auto | Timestamp of last modification |
Unique constraint: (tenant_id, role_id, feature_code)
Default Feature Toggle Configuration
The following matrix defines the default state of each feature toggle per role. Administrators can override any toggle. A checkmark indicates enabled by default; an X indicates disabled by default. All toggles are mutable.
| Feature Code | Description | Staff | Manager | Admin | Buyer | Owner |
|---|---|---|---|---|---|---|
process_sale | Create and complete a sale transaction | Y | Y | Y | N | Y |
process_return | Process merchandise returns with refund | Y | Y | Y | N | Y |
process_exchange | Process merchandise exchanges | Y | Y | Y | N | Y |
apply_discount | Apply line-item or cart-level discounts | Y | Y | Y | N | Y |
void_transaction | Void a completed same-day transaction | N | Y | Y | N | Y |
process_layaway | Create and manage layaway transactions | Y | Y | Y | N | Y |
inventory_count | Participate in physical inventory counts | Y | Y | Y | N | N |
inventory_adjust | Create manual inventory adjustments | N | Y | Y | N | N |
create_transfer | Initiate inter-store inventory transfers | N | Y | Y | N | Y |
approve_transfer | Approve outbound transfer requests | N | Y | Y | N | Y |
create_po | Create vendor purchase orders | N | N | Y | Y | Y |
approve_po | Approve purchase orders for submission | N | Y | Y | N | Y |
receive_shipment | Process inbound receiving (PO or transfer) | Y | Y | Y | Y | N |
price_change | Modify product pricing in the catalog | N | Y | Y | N | Y |
view_reports | Access reporting dashboards and exports | N | Y | Y | Y | Y |
manage_users | Create, edit, deactivate user accounts | N | N | Y | N | Y |
manage_settings | Modify Module 5 configuration settings | N | N | Y | N | Y |
view_cost_data | View product cost, margin, and vendor pricing | N | Y | Y | Y | Y |
manage_customers | Create and edit customer profiles | Y | Y | Y | N | Y |
view_audit_log | Access system audit trail | N | N | Y | N | Y |
manage_gift_cards | Issue and adjust gift card balances | N | Y | Y | N | Y |
override_price | Override selling price at POS beyond discount limits | N | Y | Y | N | Y |
cash_drawer_operations | Open cash drawer, perform cash drops, reconcile drawer | Y | Y | Y | N | Y |
end_of_day | Execute end-of-day close procedures | N | Y | Y | N | Y |
Business Rules:
- Feature toggles are evaluated at runtime. Changing a toggle takes effect immediately for all active sessions of users with that role.
- The
OWNERrole cannot havemanage_settingsormanage_userstoggled off – these are locked totruefor the OWNER role to prevent lockout. - Custom roles can be created by duplicating an existing role’s toggle configuration and modifying it. Custom roles have
is_system = false.
5.5.5 User Authentication Flow
sequenceDiagram
autonumber
participant U as User
participant POS as POS Terminal
participant API as Backend
participant DB as DB
Note over U, DB: POS Terminal Login (PIN-based)
U->>POS: Enter 4-6 Digit PIN
POS->>API: POST /auth/pin-login {pin, register_id}
API->>DB: Lookup user by PIN hash + tenant_id
alt User Found & Active
API->>DB: Check failed_login_count < max threshold
alt Not Locked
API->>DB: Reset failed_login_count = 0
API->>DB: Update last_login_at
API->>DB: Load role + feature toggles
API-->>POS: Auth Token + User Profile + Permissions
POS->>POS: Render POS UI with role-appropriate menus
else Account Locked
API-->>POS: "Account locked. Try again in X minutes."
end
else User Not Found or Inactive
API->>DB: Increment failed_login_count (by register/IP)
API-->>POS: "Invalid PIN"
end
Cross-Reference: See Module 5, Section 5.6 for clock-in/clock-out time tracking.
5.6 Time Tracking (Clock-In / Clock-Out)
Scope: Recording staff clock-in and clock-out times for basic time tracking and payroll reporting. The system provides a simple punch-clock model — staff clock in via the POS terminal using their PIN, and clock out at the end of their work period. This section does not implement shift scheduling, shift types, or workforce management.
5.6.1 Clock-In / Clock-Out
clock_records Table:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | FK to tenants table; owning tenant |
user_id | UUID | Yes | – | FK to users table; employee who clocked in/out |
location_id | UUID | Yes | – | FK to locations table; location where clock event occurred |
clock_in | DateTime | Yes | – | Timestamp when user clocked in |
clock_out | DateTime | No | null | Timestamp when user clocked out (null = currently clocked in) |
notes | Text | No | null | Optional notes (e.g., reason for late clock-out, manager override note) |
created_at | DateTime | Yes | Auto | Record creation timestamp |
Business Rules:
- A user cannot clock in if they are already clocked in at any location (must clock out first).
- Clock-out is required before end-of-day close procedures can complete at the location.
- If a user forgets to clock out, a manager can manually enter the clock-out time with an audit note in the
notesfield. - Clock-in records are retained indefinitely for payroll and audit purposes.
- Maximum clock-in duration: 16 hours. If no clock-out is recorded within 16 hours, the system sends an alert to the location manager.
Cross-Reference: See Module 1, Section 1.8 for end-of-day cash drawer procedures that typically coincide with clock-out.
5.7 Registers & Terminals
Scope: Defining the register registry, device pairing, register profiles that control available functionality, and peripheral device assignments. A register is the logical unit representing a point-of-sale station at a location. Each register is paired with one or more physical devices and linked to peripherals (printers, scanners, payment terminals, cash drawers). Register profiles determine which POS functions are available on each terminal type.
5.7.1 Register Registry
Each location maintains a numbered set of registers. Registers are logical entities that persist across hardware replacements – when a device is swapped, the register retains its identity, transaction history, and peripheral assignments.
registers Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
location_id | UUID | Yes | – | FK to locations table |
register_number | String(20) | Yes | – | Unique register identifier within location (e.g., REG-001, REG-002) |
name | String(100) | No | null | Friendly name (e.g., “Main Counter”, “Back Register”, “Floor Mobile 1”) |
profile_id | UUID | Yes | – | FK to register_profiles table; determines available functions |
status | Enum | Yes | ACTIVE | ACTIVE, MAINTENANCE, RETIRED |
ip_address | String(45) | No | null | Network IP address of the physical device paired to this register. Supports IPv4 and IPv6. |
notes | Text | No | null | Administrative notes (e.g., “New iPad deployed 2026-01-15”) |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, location_id, register_number)
Business Rules:
- A register in
MAINTENANCEstatus cannot accept new transactions. Active sessions are preserved but no new sales can be initiated. - A register in
RETIREDstatus is permanently decommissioned. It cannot be reactivated. Its transaction history is preserved. - Registers cannot be deleted; only retired. This ensures audit trail integrity.
- Warehouse-type locations cannot have registers assigned.
- A register’s network IP address (
ip_address) can be modified a maximum of 2 times within any rolling 365-day period. IP changes are automatically tracked in theregister_ip_changesaudit table. Before updating the IP address, the system queries:SELECT COUNT(*) FROM register_ip_changes WHERE register_id = :id AND changed_at >= NOW() - INTERVAL '365 days'. IfCOUNT >= 2, the update is rejected with error:[ERR-5071] IP address change limit reached. A register's IP address can only be changed 2 times per year. Contact the system owner for an override.
register_ip_changes Table:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | FK to tenants table; owning tenant |
register_id | UUID | Yes | – | FK to registers table |
old_ip | String(45) | No | null | Previous IP address (null for first assignment) |
new_ip | String(45) | Yes | – | New IP address being assigned |
changed_by | UUID | Yes | – | FK to users table; user who made the change |
changed_at | DateTime | Yes | Auto | Timestamp of the IP change |
5.7.2 Register State Machine
stateDiagram-v2
[*] --> ACTIVE: Register Created
ACTIVE --> MAINTENANCE: take_offline
MAINTENANCE --> ACTIVE: bring_online
ACTIVE --> RETIRED: decommission
MAINTENANCE --> RETIRED: decommission
RETIRED --> [*]
note right of ACTIVE
Accepting transactions
Device paired and online
Peripherals connected
end note
note right of MAINTENANCE
Temporarily offline
No new transactions
Active sessions preserved
Hardware swap / repair
end note
note right of RETIRED
Permanently decommissioned
Cannot reactivate
History preserved
end note
State Transition Rules:
| Transition | From | To | Trigger | Authorization | Side Effects |
|---|---|---|---|---|---|
take_offline | ACTIVE | MAINTENANCE | Manual (admin/manager) | ADMIN or MANAGER role | Active sessions warned; no new sales |
bring_online | MAINTENANCE | ACTIVE | Manual (admin/manager) | ADMIN or MANAGER role | Register available for transactions |
decommission | ACTIVE or MAINTENANCE | RETIRED | Manual (owner only) | OWNER role only | Register permanently disabled; device pairing cleared; requires type-to-confirm (see below) |
Register Retirement Safety: Register retirement (decommission) is restricted to the OWNER role only. When the owner initiates retirement, the system displays a confirmation dialog with the following warning:
‘This action permanently retires this register. Retired registers cannot be reactivated. All transaction history will be preserved but no new transactions can be processed. This action cannot be undone or reverted.’
The owner must type the word RETIRE (case-sensitive, exact match) in a confirmation text field before the system proceeds. If the typed text does not match exactly, the action is blocked with error: [ERR-5072] Confirmation text does not match. Type RETIRE to confirm.
5.7.3 Device Pairing
Each register is associated with one or more physical devices. Devices are the hardware (iPad, PC terminal, mobile phone) on which the POS application runs. A register can have multiple paired devices (e.g., a backup iPad) but only one may be the active/primary device at any time.
devices Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
register_id | UUID | Yes | – | FK to registers table |
hardware_id | String(100) | Yes | – | Unique device identifier (serial number, UDID, or system-generated fingerprint) |
device_type | Enum | Yes | – | IPAD, PC_TERMINAL, MOBILE, ANDROID_TABLET |
device_name | String(100) | No | null | Friendly name (e.g., “iPad Pro 12.9 - Main Counter”) |
os_version | String(50) | No | null | Operating system version (e.g., “iPadOS 17.4”, “Windows 11”) |
app_version | String(20) | No | null | POS application version installed (e.g., “2.3.1”) |
is_primary | Boolean | Yes | false | Whether this is the active device for the register (exactly one per register) |
last_seen_at | DateTime | No | null | Last successful heartbeat or API call from this device |
last_sync_at | DateTime | No | null | Last successful data synchronization timestamp |
paired_at | DateTime | Yes | Auto | When this device was first paired to the register |
paired_by | UUID | Yes | – | FK to users table; admin who paired the device |
Unique constraint: (tenant_id, hardware_id)
Business Rules:
- A
hardware_idcan only be paired to one register at a time across the entire tenant. Pairing to a new register automatically unpairs from the previous register. - Exactly one device per register must be
is_primary = true. When a new device is set as primary, the previous primary is automatically set tois_primary = false. - A device that has not sent a heartbeat in 5 minutes is flagged as “Offline” in the admin dashboard. After 24 hours without contact, the device status is escalated to “Disconnected” with an alert to the admin.
app_versionis reported by the device at each heartbeat. The admin dashboard highlights devices running outdated versions.
5.7.4 Register Profiles
Register profiles define which POS functions are available on a terminal. Two profiles are provided by default; tenants cannot create custom profiles (this prevents an explosion of untested UI configurations).
register_profiles Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
code | String(20) | Yes | – | Unique profile code within tenant |
name | String(50) | Yes | – | Display name |
description | String(500) | No | null | Profile description |
allowed_functions | JSONB | Yes | – | Array of function codes available on this profile |
is_system | Boolean | Yes | true | System profiles cannot be deleted |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, code)
Default Profile Definitions
| Profile | Code | Description | Available Functions |
|---|---|---|---|
| Full POS | FULL_POS | Standard counter terminal with complete POS capability | sale, return, exchange, layaway, hold, void, inventory_lookup, price_check, cash_drawer, customer_management, gift_card, reports, end_of_day, park_sale, special_order |
| Mobile Checkout | MOBILE | Handheld device for floor-based sales assistance | sale, price_check, inventory_lookup, customer_lookup, park_sale |
Function availability comparison:
| Function | Full POS | Mobile |
|---|---|---|
sale | Y | Y |
return | Y | N |
exchange | Y | N |
layaway | Y | N |
hold | Y | N |
void | Y | N |
inventory_lookup | Y | Y |
price_check | Y | Y |
cash_drawer | Y | N |
customer_management | Y | N |
customer_lookup | Y | Y |
gift_card | Y | N |
reports | Y | N |
end_of_day | Y | N |
park_sale | Y | Y |
special_order | Y | N |
Business Rules:
- The register profile controls which menu items and action buttons are rendered on the POS UI. Functions not in the profile’s
allowed_functionsarray are hidden from the interface entirely. - User role permissions (Section 5.5.4) are enforced IN ADDITION TO profile restrictions. A function must be allowed by BOTH the register profile AND the user’s role feature toggles. For example, a Staff user on a Full POS terminal cannot void a transaction because their role toggle
void_transaction = false, even though the profile allows thevoidfunction.
5.7.5 Peripheral Assignments
Each register has linked peripheral devices that provide physical I/O capabilities. Peripherals are assigned to registers via a junction table that references the device registry from the appropriate configuration section.
register_peripherals Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key, system-generated |
tenant_id | UUID | Yes | – | Owning tenant |
register_id | UUID | Yes | – | FK to registers table |
peripheral_type | Enum | Yes | – | Type of peripheral (see enumeration below) |
peripheral_ref_id | UUID | No | null | FK to the peripheral’s registry table (e.g., printers.id, payment_terminals.id) |
connection_type | Enum | Yes | – | USB, BLUETOOTH, NETWORK, BUILT_IN |
is_active | Boolean | Yes | true | Whether this peripheral is currently connected and operational |
last_status_check | DateTime | No | null | Last successful peripheral status check |
created_at | DateTime | Yes | Auto | Record creation timestamp |
Unique constraint: (register_id, peripheral_type) – one peripheral per type per register
Peripheral Type Enumeration
| Peripheral Type | Code | Required (Full POS) | Required (Mobile) | Source Configuration |
|---|---|---|---|---|
| Receipt Printer | RECEIPT_PRINTER | Yes | No | Section 5.8 (Printer Configuration) |
| Label Printer | LABEL_PRINTER | No | No | Section 5.8 (Printer Configuration) |
| Barcode Scanner | BARCODE_SCANNER | Yes | Yes | Direct pairing (USB/Bluetooth) |
| Payment Terminal | PAYMENT_TERMINAL | Yes | Yes | Section 5.11 (Payment Processing) |
| Cash Drawer | CASH_DRAWER | Yes | No | Direct pairing (connected via receipt printer kick cable) |
| Customer Display | CUSTOMER_DISPLAY | No | No | Direct pairing (secondary screen, pole display) |
| RFID Reader | RFID_READER | No | No | Section 5.16 (RFID Configuration) |
Business Rules:
- A register with the
FULL_POSprofile must have at minimum: receipt printer, barcode scanner, payment terminal, and cash drawer assigned and active before it can process transactions. - A register with the
MOBILEprofile must have at minimum: barcode scanner and payment terminal assigned. - Missing required peripherals are flagged on the admin dashboard. The POS terminal displays a warning on login: “Register [REG-001] is missing required peripheral: [Cash Drawer]. Some functions may be unavailable.”
- Cash drawers are typically connected to the receipt printer via a kick cable (RJ12 connector). The cash drawer opens when the receipt printer sends a drawer kick signal. For this reason, the cash drawer’s operational status is dependent on the receipt printer’s status.
5.7.6 Register-Peripheral Entity Relationship
erDiagram
REGISTERS ||--o{ DEVICES : "paired with"
REGISTERS ||--|| REGISTER_PROFILES : "uses profile"
REGISTERS ||--o{ REGISTER_PERIPHERALS : "has peripherals"
LOCATIONS ||--o{ REGISTERS : "contains"
REGISTERS {
UUID id PK
UUID tenant_id FK
UUID location_id FK
String register_number
UUID profile_id FK
Enum status
}
DEVICES {
UUID id PK
UUID register_id FK
String hardware_id
Enum device_type
Boolean is_primary
DateTime last_seen_at
}
REGISTER_PROFILES {
UUID id PK
String code
String name
JSONB allowed_functions
}
REGISTER_PERIPHERALS {
UUID id PK
UUID register_id FK
Enum peripheral_type
UUID peripheral_ref_id
Enum connection_type
Boolean is_active
}
LOCATIONS {
UUID id PK
String code
String name
Enum type
}
Cross-Reference: See Module 1 for POS transaction flow and how register context (profile, peripherals) affects the sales workflow. See Module 5, Section 5.8 for the printer registry that
peripheral_ref_idreferences for receipt and label printers. See Module 5, Section 5.11 for the payment terminal configuration thatperipheral_ref_idreferences for payment devices.
5.8 Printers & Peripherals
Scope: Central registry of all printers across all tenant locations, linking printers to registers, and managing network printer discovery. This section covers receipt printers, label printers, and the register-to-printer assignment model.
Cross-Reference: See Module 5, Section 5.7 for register profile definitions and peripheral assignments. See Module 5, Section 5.14 for receipt layout and content configuration.
5.8.1 Printer Registry
Every physical printer in the organization is registered in a central table. Printers are scoped to a specific location and classified by type and connection method.
Printer Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
location_id | UUID | Yes | FK to locations table – physical location where the printer resides |
name | String(100) | Yes | Human-readable name (e.g., “Main Counter Printer”, “Back Office Label Printer”) |
type | Enum | Yes | RECEIPT, LABEL |
connection_type | Enum | Yes | USB, NETWORK_IP, BLUETOOTH |
connection_address | String(255) | Yes | Connection target: IP:port for NETWORK_IP (e.g., “192.168.1.50:9100”), device path for USB (e.g., “/dev/usb/lp0”), MAC address for BLUETOOTH |
model | String(100) | No | Manufacturer model identifier (e.g., “Epson TM-T88VI”, “Zebra ZD421”, “Star TSP143IV”) |
paper_width | Enum | Yes | Receipt: 58MM, 80MM. Label: 25x50MM, 50x25MM, 50x75MM, CUSTOM |
is_shared | Boolean | Yes | Whether multiple registers can use this printer simultaneously (default: false) |
is_active | Boolean | Yes | Soft-delete flag (default: true) |
last_health_check | DateTime | No | Timestamp of the most recent successful health check ping |
last_health_status | Enum | No | ONLINE, OFFLINE, ERROR, UNKNOWN |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
5.8.2 Receipt Printers
Receipt printers produce transaction receipts, X/Z-reports, and end-of-day summaries. Every register with a Full POS profile (see Section 5.7) requires exactly one primary receipt printer.
Receipt Printer Specifications:
| Attribute | Options | Notes |
|---|---|---|
| Paper Width | 58mm (compact), 80mm (standard) | Configured per printer; determines receipt layout column width |
| Connection | USB (direct), Network IP (shared) | USB printers are 1:1 with a register; Network IP printers can be shared |
| Print Speed | Varies by model | Minimum recommended: 200mm/sec for high-volume registers |
| Auto-Cutter | Required for all receipt printers | Full or partial cut supported |
| Cash Drawer Kick | Supported via printer relay | Printer sends electrical pulse to open drawer on receipt print |
Business Rules:
- Each register in a Full POS profile must be linked to exactly one primary receipt printer. Registers with a Mobile POS or Inventory-Only profile do not require a receipt printer.
- A single receipt printer may serve multiple registers only if
is_shared = trueandconnection_type = NETWORK_IP. USB printers cannot be shared. - When a receipt printer is marked
is_active = false, any register linked to it as primary receipt printer will display a configuration warning on the POS terminal dashboard.
5.8.3 Label Printers
Label printers produce barcode labels, price tags, and shelf tags. Label printers are typically shared resources used from the back office or receiving area, though they may also be linked to individual registers.
Supported Label Sizes:
| Size Code | Dimensions | Common Use |
|---|---|---|
25x50MM | 1“ x 2“ | Small barcode labels, jewelry tags |
50x25MM | 2“ x 1“ | Standard shelf tags, price labels |
50x75MM | 2“ x 3“ | Hang tags with barcode + price + description |
CUSTOM | User-defined | Tenant-configured custom dimensions |
Business Rules:
- Label printers are always shared (
is_shared = trueby default) and can be linked to multiple registers. - Label template selection is driven by the label size configured on the printer and the template definitions in Module 3, Section 3.10.
- A location may have zero or more label printers. Label printing is optional – stores without a label printer can still operate but cannot print labels locally.
Cross-Reference: See Module 3, Section 3.10 for label template definitions, barcode symbology, and print queue management.
5.8.4 Register-Printer Linking
The register_printers junction table defines which printers are available to each register and in what role.
Register-Printer Assignment Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
register_id | UUID | Yes | FK to registers table |
printer_id | UUID | Yes | FK to printers table |
printer_role | Enum | Yes | PRIMARY_RECEIPT, LABEL, SECONDARY_RECEIPT |
is_default | Boolean | Yes | Whether this is the default printer for the given role (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
Printer Role Definitions:
| Role | Required | Max Per Register | Description |
|---|---|---|---|
PRIMARY_RECEIPT | Yes (Full POS) | 1 | Main receipt printer for transactions, X/Z-reports |
LABEL | No | 1 | Label printer for barcode/price tag printing |
SECONDARY_RECEIPT | No | 1 | Backup receipt printer; used if primary is offline |
Uniqueness Constraint: A register may have at most one printer per role. The composite key (register_id, printer_role) is unique.
5.8.5 Network Printer Discovery
Administrators can scan the local network subnet for printers to streamline the registration process.
Discovery Flow:
sequenceDiagram
autonumber
participant A as Admin
participant UI as Nexus POS
participant API as Backend
participant NET as Local Network
A->>UI: Click "Discover Printers"
UI->>API: POST /printers/discover
API->>NET: Scan subnet for devices on port 9100 (RAW), 631 (IPP)
NET-->>API: Respond with discovered IPs and device info
API-->>UI: Return discovered printer list
Note right of UI: Display: IP, hostname, model (if available), port
A->>UI: Select discovered printer
A->>UI: Assign name, type (RECEIPT/LABEL), paper width
UI->>API: POST /printers
API-->>UI: Printer registered successfully
Discovery Rules:
- Scan is limited to the local subnet of the location’s network.
- Discovery returns IP address, hostname (if resolvable), and model string (if the printer supports SNMP or IPP device identification).
- Discovered printers are presented as candidates – the administrator must explicitly add them to the registry with a name and type assignment.
- Discovery does not modify any existing printer records.
5.8.6 Printer Health Monitoring
The system performs periodic health checks on all active network printers.
| Setting | Value | Description |
|---|---|---|
| Health check interval | Every 5 minutes | Background ping to NETWORK_IP printers only |
| Offline threshold | 3 consecutive failures | Printer status changes to OFFLINE after 3 failed pings |
| Alert trigger | On status change to OFFLINE | Dashboard notification sent to location manager |
| USB printers | Not health-checked | USB status determined at print time |
Business Rules:
- Health checks apply only to printers with
connection_type = NETWORK_IP. - When a primary receipt printer goes offline, the register automatically attempts to use the secondary receipt printer (if configured).
- Printer health status is visible on the Nexus POS dashboard per location.
5.9 Tax Configuration
Scope: Location-level compound tax configuration using a 3-level jurisdiction model (State / County / City) with support for product-level and customer-level exemptions. Each location references a tax jurisdiction; all active rates within that jurisdiction are summed at time of sale to produce the effective compound rate.
Cross-Reference: See Module 1, Section 1.17 for the tax calculation engine and line-item tax computation. See Module 2 for customer tax exemption fields. See Module 3, Section 3.1 for product-level tax exemption.
5.9.1 Tax Jurisdiction and Rate Setup
Tax is modeled as a 3-level compound system: State, County, and City. Each location references a tax jurisdiction, and each jurisdiction defines up to three rate levels that are summed at time of sale to produce the effective compound tax rate.
tax_jurisdictions Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key |
tenant_id | UUID | Yes | – | FK to tenants table |
code | String(20) | Yes | – | Unique jurisdiction code (e.g., “VA-NFK”, “VA-VB”, “CA-LA”) |
name | String(100) | Yes | – | Human-readable name (e.g., “Norfolk, Virginia”) |
state_name | String(50) | Yes | – | State or province name |
description | String(500) | No | null | Additional notes |
is_active | Boolean | Yes | true | Whether available for assignment |
created_at | DateTime | Yes | Auto | Record creation timestamp |
updated_at | DateTime | Yes | Auto | Last modification timestamp |
Unique constraint: (tenant_id, code)
tax_rates Table
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | Auto | Primary key |
tenant_id | UUID | Yes | – | FK to tenants table |
jurisdiction_id | UUID | Yes | – | FK to tax_jurisdictions table |
level | Enum | Yes | – | Tax level: STATE, COUNTY, CITY |
rate_name | String(100) | Yes | – | Display name (e.g., “Virginia State Tax”, “Norfolk City Tax”) |
rate_percent | Decimal(5,3) | Yes | – | Tax rate as percentage (e.g., 4.300 for 4.3%) |
effective_date | Date | Yes | – | Date this rate becomes active |
end_date | Date | No | null | Date this rate expires (null = no expiry) |
created_by | UUID | Yes | – | FK to users table |
is_active | Boolean | Yes | true | Whether currently in effect |
notes | String(500) | No | null | Reason for rate or change |
created_at | DateTime | Yes | Auto | Record creation timestamp |
Unique constraint: (jurisdiction_id, level, effective_date)
Business Rules:
- A jurisdiction may have up to 3 active tax rate levels (
STATE,COUNTY,CITY). Not all levels are required. - At time of sale, all active rates for the location’s jurisdiction are summed to produce the effective compound tax rate.
- When a new rate’s
effective_datearrives, the system deactivates any existing rate at the same level without anend_date. - Future rates can be scheduled by setting
effective_datein the future. A background job activates the rate at midnight on the effective date. - Rate changes never modify historical records. All past rates are preserved for audit and historical transaction recalculation if needed.
- The system does not support tax-inclusive pricing. All product prices are tax-exclusive; tax is computed and displayed separately.
- Example: Norfolk, VA = State 4.3% + Regional 0.7% + City 1.0% = 6.0% compound rate.
5.9.2 Tax Calculation Priority
Tax determination follows a strict priority order. The first matching rule wins:
flowchart TD
A[Line Item Added to Cart] --> B{Product tax_exempt = true?}
B -->|Yes| C[Tax Amount = $0.00]
B -->|No| D{Customer attached?}
D -->|No| G[Apply Location Jurisdiction Compound Rate]
D -->|Yes| E{Customer tax_exempt = true AND certificate valid?}
E -->|Yes| F[Tax Amount = $0.00]
E -->|No| G
G --> H[Sum all active rates for jurisdiction]
H --> I["Tax = line_subtotal × sum_of_rates / 100"]
Priority Order (highest first):
| Priority | Condition | Result |
|---|---|---|
| 1 | Product tax_exempt = true | No tax on this line item |
| 2 | Customer tax_exempt = true AND exemption_certificate_expiry >= today | No tax on any line item for this customer |
| 3 | Location jurisdiction compound rate | Sum all active rates for location’s jurisdiction (State + County + City). Apply sum_of_rates to taxable line subtotal. Formula: Tax = line_subtotal × sum_of_rates / 100 |
5.9.3 Tax Exemption
Product-Level Exemption:
- The
tax_exemptboolean flag on the product record (Module 3, Section 3.1) exempts individual products from tax regardless of customer or location. - Common use: Food items, certain clothing categories in jurisdictions with clothing exemptions.
Customer-Level Exemption:
- Customer records (Module 2) include three exemption fields:
| Field | Type | Description |
|---|---|---|
tax_exempt | Boolean | Whether this customer is tax-exempt (default: false) |
exemption_certificate_number | String(50) | State or federal tax exemption certificate number |
exemption_certificate_expiry | Date | Expiration date of the certificate – system checks validity at time of sale |
- When a tax-exempt customer is attached to a transaction, the system validates that the certificate has not expired. If expired, the customer is treated as taxable and the cashier sees a warning: “Tax exemption certificate expired – tax will be applied.”
- Exemption applies to all line items in the transaction (unless the product itself is tax-exempt, in which case it remains exempt regardless).
5.9.4 Tax Display and Reporting
Receipt Display:
- Tax is calculated per line item and aggregated at the transaction level.
- Receipt shows: Subtotal (pre-tax) + Tax Amount = Total.
- Compound tax rate is printed on the receipt (e.g., “Tax (6.000%): $4.50”). Optionally, the breakdown by level can be shown (e.g., State 4.3%, County 0.7%, City 1.0%).
- Tax-exempt transactions display “Tax Exempt” with the certificate number.
Tax Reporting Period:
| Setting | Options | Default | Description |
|---|---|---|---|
tax_reporting_period | MONTHLY, QUARTERLY | QUARTERLY | Determines aggregation period for tax liability reports |
- Tax liability reports aggregate taxable sales, exempt sales, and tax collected by reporting period.
- Reports are available per location and consolidated across all locations.
Cross-Reference: See Module 1, Section 1.17 for detailed tax calculation engine, rounding rules, and tax line-item storage.
5.10 Units of Measure
Scope: Predefined and tenant-customizable units of measure (UoMs) used for selling, purchasing, and inventory tracking. The UoM system supports conversion factors between related units, enabling scenarios where products are purchased in bulk units (cases, dozens) and sold in individual units (each, pair).
Cross-Reference: See Module 3, Section 3.1 for product UoM assignment fields (
selling_uom,purchasing_uom,uom_conversion_factor). See Module 4, Section 4.2 for purchase order UoM handling.
5.10.1 System-Predefined UoMs
The following UoMs are provided out-of-the-box and cannot be deleted or modified. They are available to all tenants.
| Code | Name | Category | Base Unit | Conversion to Base | Example Use |
|---|---|---|---|---|---|
EACH | Each | QUANTITY | EACH | 1 (base) | Individual garments, accessories |
PAIR | Pair | QUANTITY | EACH | 2 | Shoes, gloves, earrings, socks |
PACK | Pack | QUANTITY | EACH | Varies (set per product) | Multi-pack underwear, sock bundles |
BOX | Box | QUANTITY | EACH | Varies (set per product) | Boxed gift sets, assortments |
DOZEN | Dozen | QUANTITY | EACH | 12 | Bulk socks, accessories wholesale |
CASE | Case | QUANTITY | EACH | Varies (set per product) | Vendor case packs |
YARD | Yard | LENGTH | YARD | 1 (base) | Fabric, ribbon, trim |
METER | Meter | LENGTH | YARD | 1.0936 | Fabric (metric suppliers) |
FOOT | Foot | LENGTH | YARD | 0.3333 | Chain, cord, elastic |
KG | Kilogram | WEIGHT | KG | 1 (base) | Bulk items by weight |
LB | Pound | WEIGHT | KG | 0.4536 | Bulk items (imperial) |
OZ | Ounce | WEIGHT | KG | 0.02835 | Small items, jewelry |
5.10.2 Custom UoMs
Tenants can create additional UoMs to match their specific business needs. Custom UoMs must belong to an existing category and define a conversion factor to the category’s base unit.
UoM Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table (NULL for system-predefined UoMs) |
code | String(20) | Yes | Unique code within tenant scope (e.g., “ROLL”, “SPOOL”, “BUNDLE”, “SET”) |
name | String(50) | Yes | Display name (e.g., “Roll”, “Spool”, “Bundle”, “Set of 3”) |
category | Enum | Yes | QUANTITY, LENGTH, WEIGHT |
conversion_factor | Decimal(12,6) | Yes | Number of base units in one of this UoM (e.g., ROLL = 25 YARD, so factor = 25) |
base_uom_id | UUID | Yes | FK to uom table – the base unit this converts to (EACH, YARD, or KG) |
is_system | Boolean | Yes | true for predefined UoMs, false for tenant-created (default: false) |
is_active | Boolean | Yes | Soft-delete flag (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Custom UoM Examples:
| Code | Name | Category | Base UoM | Conversion Factor | Meaning |
|---|---|---|---|---|---|
ROLL | Roll | LENGTH | YARD | 25 | 1 Roll = 25 Yards |
SPOOL | Spool | LENGTH | YARD | 100 | 1 Spool = 100 Yards |
BUNDLE | Bundle | QUANTITY | EACH | 5 | 1 Bundle = 5 Each |
SET3 | Set of 3 | QUANTITY | EACH | 3 | 1 Set = 3 Each |
HALFYD | Half Yard | LENGTH | YARD | 0.5 | 1 Half Yard = 0.5 Yards |
5.10.3 UoM Conversion Table
For complex multi-step conversions (e.g., converting between two non-base units), the system maintains an explicit conversion table.
UoM Conversion Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
from_uom_id | UUID | Yes | FK to uom table – source unit |
to_uom_id | UUID | Yes | FK to uom table – target unit |
factor | Decimal(12,6) | Yes | Multiply source quantity by this factor to get target quantity |
tenant_id | UUID | Yes | FK to tenants table |
created_at | DateTime | Yes | Record creation timestamp |
Conversion Examples:
| From | To | Factor | Explanation |
|---|---|---|---|
| DOZEN | EACH | 12 | 1 Dozen = 12 Each |
| CASE | EACH | 24 | 1 Case = 24 Each (varies per product) |
| PAIR | EACH | 2 | 1 Pair = 2 Each |
| METER | YARD | 1.0936 | 1 Meter = 1.0936 Yards |
| LB | KG | 0.4536 | 1 Pound = 0.4536 Kilograms |
| ROLL | YARD | 25 | 1 Roll = 25 Yards |
Uniqueness Constraint: The composite key (from_uom_id, to_uom_id, tenant_id) is unique. The system auto-generates the inverse conversion (e.g., if DOZEN->EACH = 12, then EACH->DOZEN = 0.083333) so both directions are always available.
5.10.4 Product UoM Assignment
Each product specifies how it is sold and how it is purchased. The conversion factor bridges these two units for inventory tracking.
Product UoM Fields (on Product Record):
| Field | Type | Required | Description |
|---|---|---|---|
selling_uom_id | UUID | Yes | FK to uom table – unit used at POS (e.g., EACH, PAIR, YARD) |
purchasing_uom_id | UUID | Yes | FK to uom table – unit used on purchase orders (e.g., CASE, DOZEN, ROLL) |
uom_conversion_factor | Decimal(12,6) | Yes | Number of selling units per purchasing unit (e.g., 24 EACH per CASE) |
Conversion in Practice:
Purchase Order: 5 CASES of "Classic V-Neck Tee"
uom_conversion_factor = 24 (1 CASE = 24 EACH)
→ Receiving adds 5 × 24 = 120 EACH to inventory
POS Sale: Customer buys 3 EACH of "Classic V-Neck Tee"
→ Inventory decremented by 3 EACH
→ Remaining: 117 EACH (or 4.875 CASES)
Business Rules:
- The
selling_uom_iddetermines how inventory quantities are displayed at the POS and in stock reports. - The
purchasing_uom_iddetermines the unit on purchase orders and receiving documents. - When receiving a PO, the system multiplies received quantity by
uom_conversion_factorto compute the inventory increment in selling units. - If
selling_uom_idequalspurchasing_uom_id, thenuom_conversion_factormust be 1. - UoM changes on a product with existing inventory require a manager approval and trigger an inventory adjustment record.
Cross-Reference: See Module 3, Section 3.1 for full product data model. See Module 4, Section 4.2 for purchase order line-item UoM handling. See Module 4, Section 4.3 for receiving UoM conversion.
5.11 Payment Methods & Processors
Scope: Configuration of accepted payment methods per location, payment processor integrations, terminal management, and cash rounding rules. This section defines the payment method registry and processor setup – the transactional payment flow is documented in Module 1.
Cross-Reference: See Module 1, Section 1.18 for payment integration flow and split-payment logic. See Module 5, Section 5.7 for register payment terminal assignment.
5.11.1 Payment Methods
The system supports a fixed set of payment method types. Each method has inherent capabilities (processor requirement, split eligibility, offline support) that cannot be overridden.
Payment Method Registry
| Code | Name | Requires Processor | Can Split | Offline Capable | Description |
|---|---|---|---|---|---|
CASH | Cash | No | Yes | Yes | Physical currency; change calculated automatically |
CREDIT_CARD | Credit/Debit Card | Yes (external) | Yes (multi-card) | No | Chip, swipe, tap, or manual entry via payment terminal |
GIFT_CARD | Gift Card | Internal | Yes | No | Store-issued gift cards with balance tracking |
STORE_CREDIT | Store Credit | Internal | Yes | No | Credit balance on customer account (from returns, adjustments) |
LAYAWAY_PAYMENT | Layaway Payment | Via card/cash | Yes | No | Partial payment applied to layaway balance |
FINANCING | Third-Party Financing | Yes (external) | No | No | Affirm, Klarna, or similar buy-now-pay-later provider |
Payment Method Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
code | String(20) | Yes | Method code from the table above |
name | String(50) | Yes | Display name (customizable by tenant, e.g., “Visa/MC/Amex” instead of “Credit/Debit Card”) |
requires_processor | Boolean | Yes | Whether an external or internal processor is required |
can_split | Boolean | Yes | Whether this method can be combined with other methods in a single transaction |
offline_capable | Boolean | Yes | Whether this method can be used when the terminal is offline |
is_active | Boolean | Yes | Global enable/disable for the tenant (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
5.11.2 Per-Location Payment Configuration
Each payment method can be independently enabled or disabled at each location. This allows tenants to offer different payment options at different store locations (e.g., financing only at the flagship store, no gift cards at popup locations).
Location Payment Method Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
location_id | UUID | Yes | FK to locations table |
payment_method_id | UUID | Yes | FK to payment_methods table |
is_enabled | Boolean | Yes | Whether this payment method is accepted at this location (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Uniqueness Constraint: The composite key (location_id, payment_method_id) is unique.
Business Rules:
- A payment method must be active at the tenant level (
payment_methods.is_active = true) AND enabled at the location level (location_payment_methods.is_enabled = true) to appear as an option on the POS terminal at that location. - CASH is always enabled and cannot be disabled at any location.
- When a payment method is disabled at the tenant level, it is automatically hidden at all locations regardless of the location-level setting.
5.11.3 Payment Processor Configuration
MOVED TO MODULE 6: Payment processor data model, terminal mapping, processor type details, and business rules have been consolidated into Module 6, Section 6.8.3 (Processor Configuration).
See: Module 6, Section 6.8 for the complete payment processor integration specification including SAQ-A architecture, terminal communication, failure handling, and batch settlement.
5.11.4 Cash Rounding Rules
When the total transaction amount results in a fractional cent, rounding rules determine how the final amount is adjusted for cash payments. Card payments are always exact (no rounding applied).
Rounding Rule Options
| Rule | Code | Description | Example |
|---|---|---|---|
| Nearest Cent | NEAREST_CENT | Round to nearest $0.01 (standard – no visible rounding) | $12.347 → $12.35 |
| Nearest Nickel | NEAREST_NICKEL | Round to nearest $0.05 (cash only) | $12.32 → $12.30; $12.33 → $12.35 |
| Nearest Dime | NEAREST_DIME | Round to nearest $0.10 (cash only) | $12.34 → $12.30; $12.36 → $12.40 |
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
tenant_id | UUID | Yes | FK to tenants table |
cash_rounding_rule | Enum | Yes | NEAREST_CENT, NEAREST_NICKEL, NEAREST_DIME (default: NEAREST_CENT) |
Business Rules:
- Rounding applies ONLY to the total amount when the payment method is CASH or includes a CASH component in a split payment.
- Card payments, gift cards, and store credits are always exact – no rounding.
- The rounding adjustment (positive or negative) is recorded as a separate line on the receipt (e.g., “Cash Rounding: -$0.02”) and tracked in the
cash_rounding_amountfield on the transaction record. - Rounding adjustments are excluded from tax calculations – tax is computed on the pre-rounding subtotal.
5.12 Custom Fields
Scope: Tenant-defined custom fields that extend the standard data model for products, customers, orders, and vendors. Custom fields provide schema flexibility without database migrations, enabling each tenant to capture business-specific attributes unique to their operation.
Cross-Reference: See Module 3, Section 3.1.4 for the original product custom attribute specification. This section generalizes that pattern to all supported entity types.
5.12.1 Supported Entity Types
| Entity Type | Module | Max Fields Per Tenant | Use Cases |
|---|---|---|---|
PRODUCT | Module 3 | 50 | Care instructions, fabric composition, country of origin, certification level, custom sizing notes |
CUSTOMER | Module 2 | 50 | Preferred size, allergies/sensitivities, referral source, VIP notes, personal shopper assignment |
ORDER | Module 1 | 20 | Delivery instructions, gift wrap preference, event name, sales associate notes |
VENDOR | Module 3 | 20 | Internal account number, EDI trading partner code, preferred contact method, payment terms notes |
5.12.2 Custom Field Definition
Each custom field is defined once at the tenant level and then applied to individual entity records.
Custom Field Definition Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | – | Primary key |
tenant_id | UUID | Yes | – | FK to tenants table – owning tenant |
entity_type | Enum | Yes | – | PRODUCT, CUSTOMER, ORDER, VENDOR |
field_name | String(50) | Yes | – | Internal key in snake_case (e.g., “care_instructions”, “referral_source”). Must be unique within entity_type + tenant. |
label | String(100) | Yes | – | Human-readable display name (e.g., “Care Instructions”, “Referral Source”) |
field_type | Enum | Yes | – | TEXT, NUMBER, DATE, DROPDOWN, BOOLEAN |
is_required | Boolean | Yes | false | Whether this field must be filled when saving the entity |
default_value | String(500) | No | NULL | Default value applied when a new entity is created (must match field_type validation) |
sort_order | Integer | Yes | 0 | Display position in the entity edit form (lower = higher) |
is_active | Boolean | Yes | true | Soft-delete flag; inactive fields are hidden from forms but data is preserved |
show_on_pos | Boolean | Yes | false | Whether this field is visible on the POS terminal (useful for quick customer notes or product care info) |
validation_min | Decimal(12,4) | No | NULL | Minimum value for NUMBER fields |
validation_max | Decimal(12,4) | No | NULL | Maximum value for NUMBER fields |
validation_max_length | Integer | No | 500 | Maximum character length for TEXT fields |
created_at | DateTime | Yes | – | Record creation timestamp |
updated_at | DateTime | Yes | – | Last modification timestamp |
5.12.3 Field Type Specifications
| Field Type | Stored Column | Validation Rules | Example Value |
|---|---|---|---|
TEXT | value_text (VARCHAR 500) | Max length enforced; blank allowed unless is_required | “Dry clean only” |
NUMBER | value_number (DECIMAL 12,4) | Must be numeric; validation_min / validation_max enforced if set | 42.5000 |
DATE | value_date (DATE) | Must be a valid ISO 8601 date | “2026-03-15” |
DROPDOWN | value_text (VARCHAR 100) | Value must match one of the defined options in custom_field_options | “Cotton” |
BOOLEAN | value_boolean (BOOLEAN) | Must be true or false | true |
5.12.4 Dropdown Options
When field_type = DROPDOWN, the allowed values are stored in a separate options table.
Custom Field Options Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
field_id | UUID | Yes | FK to custom_field_definitions table |
option_value | String(100) | Yes | The selectable value (e.g., “Cotton”, “Polyester”, “Silk”) |
sort_order | Integer | Yes | Display position in the dropdown (lower = higher) |
is_active | Boolean | Yes | Soft-delete flag (default: true); inactive options are hidden from new selections but preserved on existing records |
created_at | DateTime | Yes | Record creation timestamp |
Example Dropdown Configuration:
| Field Label | Entity Type | Options |
|---|---|---|
| Material | PRODUCT | Cotton, Polyester, Silk, Wool, Linen, Blend, Leather, Synthetic |
| Referral Source | CUSTOMER | Walk-in, Website, Social Media, Friend/Family, Google, Event, Other |
| Gift Wrap Style | ORDER | None, Standard, Premium, Holiday |
| Payment Terms | VENDOR | Net 30, Net 60, Net 90, COD, Prepaid |
5.12.5 Custom Field Values
Custom field values are stored in a generic key-value table using typed columns. Only the column matching the field’s type is populated; the others remain NULL.
Custom Field Values Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
entity_type | Enum | Yes | PRODUCT, CUSTOMER, ORDER, VENDOR – matches the definition’s entity_type |
entity_id | UUID | Yes | FK to the entity record (product, customer, order, or vendor) |
field_id | UUID | Yes | FK to custom_field_definitions table |
value_text | String(500) | No | Populated when field_type = TEXT or DROPDOWN |
value_number | Decimal(12,4) | No | Populated when field_type = NUMBER |
value_date | Date | No | Populated when field_type = DATE |
value_boolean | Boolean | No | Populated when field_type = BOOLEAN |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Uniqueness Constraint: The composite key (entity_type, entity_id, field_id) is unique – each entity record has at most one value per custom field.
Indexing Strategy:
- GIN index on
(entity_type, entity_id)for fast retrieval of all custom field values for a given entity. - B-tree index on
(field_id, value_text)for custom fields markedsearchable = true(PRODUCT entity type only).
5.12.6 Business Rules
- Field Limit: Maximum 50 custom field definitions per entity type per tenant for PRODUCT and CUSTOMER. Maximum 20 per entity type per tenant for ORDER and VENDOR. Attempting to exceed the limit returns error: “Maximum custom fields reached for [entity_type]. Archive unused fields to create new ones.”
- Archival: Setting
is_active = falseon a field definition hides it from all forms and POS screens but preserves existing values. Reactivating the field restores visibility and all previously stored values. - Deletion: Custom field definitions cannot be hard-deleted. Only soft-delete via
is_active = falseis supported. This ensures data integrity and audit compliance. - POS Visibility: Fields with
show_on_pos = trueappear in a “Custom Info” panel on the POS terminal. Maximum 5 fields per entity type can haveshow_on_pos = trueto prevent POS screen clutter. - Required Field Enforcement: When
is_required = true, the entity cannot be saved without a value for this field. For PRODUCT entities, this applies when transitioning from DRAFT to ACTIVE status (drafts may have incomplete custom fields). - Dropdown Integrity: If an active option is deactivated, existing records that reference that option retain their value (displayed with a “deprecated” indicator). New records cannot select the deactivated option.
5.13 Approval Workflows
Scope: Configurable approval rules that gate sensitive business actions behind manager or administrator review. Each approvable action has its own rule defining whether approval is required, the threshold that triggers it, who can approve, and how notifications are delivered.
Cross-Reference: See Module 1 for refund and void transaction workflows. See Module 3 for price markdown workflows. See Module 4, Section 4.3 for purchase order approval. See Module 4, Section 4.7 for inventory adjustment approval.
5.13.1 Approvable Actions
The system supports the following approvable actions. Each action is identified by a unique code and linked to a specific module.
| Action Code | Module | Description | Threshold Type | Default Threshold |
|---|---|---|---|---|
PO_CREATE | Module 4 | Purchase order creation and submission | Dollar amount | $5,000 |
PO_ABOVE_THRESHOLD | Module 4 | PO exceeding the tenant’s auto-approve limit | Dollar amount | $10,000 |
INVENTORY_ADJUSTMENT | Module 4 | Manual inventory quantity or value adjustment | Unit count or dollar value | 50 units or $500 |
PRICE_MARKDOWN | Module 3 | Price reduction on one or more products | Percentage or dollar amount | 30% or $50 |
REFUND_ABOVE_THRESHOLD | Module 1 | Refund exceeding the per-transaction refund limit | Dollar amount | $200 |
VOID_TRANSACTION | Module 1 | Voiding a completed, finalized transaction | Always (no threshold) | N/A – always requires approval |
INTER_STORE_TRANSFER | Module 4 | Transfer of inventory between locations | Unit count | 100 units |
VENDOR_RMA | Module 4 | Return merchandise authorization to vendor | Dollar amount | $1,000 |
DISCOUNT_OVERRIDE | Module 1 | Discount exceeding the maximum allowed percentage | Percentage | 25% |
5.13.2 Approval Rule Configuration
Each tenant configures one rule per approvable action. Rules can be enabled or disabled independently.
Approval Rule Data Model
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | UUID | Yes | – | Primary key |
tenant_id | UUID | Yes | – | FK to tenants table – owning tenant |
action_code | String(50) | Yes | – | Action code from the approvable actions table (unique per tenant) |
is_enabled | Boolean | Yes | true | Whether this approval rule is active |
threshold_value | Decimal(12,2) | No | NULL | Numeric threshold that triggers the approval requirement (NULL when threshold_type = ALWAYS) |
threshold_type | Enum | Yes | – | AMOUNT (dollar), UNITS (count), PERCENT (percentage), ALWAYS (no threshold – always requires approval) |
approver_role | Enum | Yes | MANAGER | Minimum role required to approve: MANAGER, ADMIN, OWNER |
notification_method | Enum | Yes | BOTH | How the approver is notified: IN_APP, EMAIL, BOTH |
escalation_timeout_hours | Integer | No | 24 | Hours before a pending request escalates to the next higher role (NULL = no escalation) |
auto_reject_on_timeout | Boolean | Yes | false | If true, requests that exceed escalation timeout without action are auto-rejected |
created_at | DateTime | Yes | – | Record creation timestamp |
updated_at | DateTime | Yes | – | Last modification timestamp |
5.13.3 Approval Request Lifecycle
When an action triggers an approval requirement, the system creates an approval request and routes it through the following state machine.
stateDiagram-v2
[*] --> CHECK_THRESHOLD : Action Initiated
CHECK_THRESHOLD --> AUTO_APPROVED : Below Threshold
CHECK_THRESHOLD --> PENDING_APPROVAL : At or Above Threshold
PENDING_APPROVAL --> APPROVED : Approver Accepts
PENDING_APPROVAL --> REJECTED : Approver Rejects
PENDING_APPROVAL --> ESCALATED : Escalation Timeout Reached
ESCALATED --> APPROVED : Higher-Role Approver Accepts
ESCALATED --> REJECTED : Higher-Role Approver Rejects
ESCALATED --> AUTO_REJECTED : Auto-Reject on Timeout (if enabled)
AUTO_APPROVED --> [*]
APPROVED --> [*]
REJECTED --> [*]
AUTO_REJECTED --> [*]
State Definitions:
| State | Description |
|---|---|
CHECK_THRESHOLD | System evaluates the action value against the rule’s threshold |
AUTO_APPROVED | Action value is below the threshold – no human approval needed; action proceeds immediately |
PENDING_APPROVAL | Waiting for a user with the required role to review and accept or reject |
APPROVED | An authorized approver accepted the request – action proceeds |
REJECTED | An authorized approver rejected the request – action is blocked and the requester is notified with the rejection reason |
ESCALATED | The escalation_timeout_hours elapsed without action; the request is re-routed to users with the next higher role |
AUTO_REJECTED | The escalation timeout elapsed AND auto_reject_on_timeout = true – request is automatically rejected |
5.13.4 Escalation Chain
When a request escalates, the system promotes the required approver role one level up.
| Original Role | Escalates To | Final Escalation |
|---|---|---|
MANAGER | ADMIN | OWNER |
ADMIN | OWNER | No further escalation – remains pending until OWNER acts or auto-reject triggers |
OWNER | N/A | Cannot escalate; remains pending or auto-rejects |
5.13.5 Notification Behavior
| Method | Behavior |
|---|---|
IN_APP | Dashboard notification badge and entry in the “Pending Approvals” queue visible to all users with the required role at the relevant location(s) |
EMAIL | Email sent to all users with the approver role at the relevant location(s). Email includes: action description, requested by, threshold value, and a direct link to approve/reject in Nexus POS. |
BOTH | Dashboard notification AND email are sent simultaneously |
Notification Rules:
- Notifications are scoped to the location where the action originated. If the action is tenant-wide (e.g., a PO for the entire organization), notifications go to all users with the approver role across all locations.
- When a request escalates, a new notification is sent to users with the escalated role. The original notification is updated to show “Escalated.”
- Upon approval or rejection, the requester receives a notification with the decision and any rejection reason.
5.13.6 Approval Request Data Model
Approval Request Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
action_code | String(50) | Yes | Action code that triggered this request |
status | Enum | Yes | PENDING, APPROVED, REJECTED, ESCALATED, AUTO_APPROVED, AUTO_REJECTED |
requested_by | UUID | Yes | FK to users table – user who initiated the action |
requested_at | DateTime | Yes | Timestamp when the request was created |
approved_by | UUID | No | FK to users table – user who approved or rejected (NULL while pending) |
approved_at | DateTime | No | Timestamp of approval/rejection |
rejection_reason | String(500) | No | Free-text reason provided by the approver when rejecting |
reference_type | Enum | Yes | Entity type the request relates to: PO, ADJUSTMENT, TRANSACTION, TRANSFER, RMA, PRODUCT, DISCOUNT |
reference_id | UUID | Yes | FK to the specific entity record (purchase order, transaction, adjustment, etc.) |
threshold_value_at_time | Decimal(12,2) | No | The actual value that triggered the approval (e.g., PO total, refund amount) – captured at request time for audit |
location_id | UUID | No | FK to locations table – location where the action originated (NULL for tenant-wide actions) |
escalated_at | DateTime | No | Timestamp when the request was escalated (NULL if not escalated) |
created_at | DateTime | Yes | Record creation timestamp |
Business Rules:
- Approval requests are immutable once resolved (APPROVED, REJECTED, or AUTO_REJECTED). The status cannot be changed after resolution.
- A user cannot approve their own request – the
approved_byuser must be different from therequested_byuser. - When
VOID_TRANSACTIONis the action, approval is always required regardless of transaction amount (threshold_type = ALWAYS). - Approval requests older than 90 days in PENDING or ESCALATED status are automatically rejected with reason: “Request expired – no action taken within 90 days.”
- The
threshold_value_at_timefield captures the actual value at request creation, ensuring accurate audit even if the underlying rule’s threshold is later changed.
5.14 Receipt Configuration
Scope: Full customization of receipt layout, content, and formatting for both printed thermal receipts and email receipts. Receipt configuration is set at the tenant level with optional location-level overrides.
Cross-Reference: See Module 5, Section 5.8 for receipt printer hardware configuration. See Module 1 for transaction receipt generation and print trigger logic.
5.14.1 Receipt Field Registry
Each field on the receipt can be independently toggled (shown or hidden) and reordered. The following fields are available.
| Field Code | Default Show | Category | Description |
|---|---|---|---|
store_name | Yes | Header | Store or location name |
store_address | Yes | Header | Store street address, city, state, zip (from location configuration, Section 5.3) |
store_phone | Yes | Header | Store phone number (from location configuration) |
cashier_name | Yes | Transaction | Name of the staff member who processed the sale |
register_number | No | Transaction | Register identifier (e.g., “Register 3”) |
transaction_number | Yes | Transaction | Unique transaction ID (e.g., “TXN-2026-001234”) |
transaction_date | Yes | Transaction | Date and time of the transaction |
barcode | Yes | Transaction | Scannable CODE-128 barcode encoding the transaction number (for easy lookup on returns) |
item_list | Yes | Line Items | Itemized list showing: item name, SKU, quantity, unit price, line discount (if any), line total |
subtotal | Yes | Totals | Pre-tax total of all line items |
discount_total | Yes | Totals | Total discount amount applied (shown only if > $0.00) |
tax_amount | Yes | Totals | Tax line showing rate and amount (e.g., “Tax (6.000%): $4.50”) |
total | Yes | Totals | Grand total (subtotal - discounts + tax) |
payment_details | Yes | Payment | Payment method(s) used and amount per method (e.g., “Visa ****1234: $45.00, Cash: $10.00”) |
change_due | Yes | Payment | Change amount returned to customer (shown only for cash payments with overpayment) |
loyalty_points | No | Loyalty | Points earned on this transaction and current balance (shown only if loyalty module is enabled) |
customer_name | No | Customer | Customer name (shown only if a customer is attached to the transaction) |
savings_total | No | Totals | “You saved $X.XX” message showing total promotional and discount savings |
5.14.2 Layout Settings
Receipt Layout Data Model
| Setting | Type | Options | Default | Description |
|---|---|---|---|---|
paper_width | Enum | 58MM, 80MM | 80MM | Paper width – determines character-per-line limit (58mm = ~32 chars, 80mm = ~48 chars) |
font_size | Enum | SMALL, MEDIUM, LARGE | MEDIUM | Print font size – affects line density and readability |
field_order | JSON Array | Array of field_code strings | Default order from field registry | Ordered list defining top-to-bottom print sequence |
line_separator | Enum | DASH, EQUALS, BLANK, STAR | DASH | Character used to separate receipt sections |
alignment | Enum | LEFT, CENTER | CENTER | Header and footer text alignment |
print_density | Enum | LIGHT, NORMAL, DARK | NORMAL | Thermal print darkness (affects readability and paper consumption) |
Line Separator Examples:
| Option | Rendered As |
|---|---|
DASH | -------------------------------- |
EQUALS | ================================ |
BLANK | (empty line) |
STAR | ******************************** |
5.14.3 Header Configuration
The receipt header appears at the top of every printed receipt and supports up to 3 customizable text lines plus an optional logo.
| Field | Type | Max Length | Default | Description |
|---|---|---|---|---|
header_line_1 | String | 100 chars | Tenant name | Primary header text (typically the company name) |
header_line_2 | String | 100 chars | “Thank you for shopping with us!” | Secondary header text (tagline, greeting, or blank) |
header_line_3 | String | 100 chars | (empty) | Tertiary header text (promotional message, seasonal greeting, or blank) |
header_logo | Image URL | – | NULL | Uploaded logo image (max 300px wide; auto-scaled to paper width; monochrome recommended for thermal printers) |
Logo Specifications:
- Format: PNG or BMP (monochrome 1-bit BMP preferred for thermal printers).
- Maximum width: 300 pixels. Height auto-scales proportionally.
- The logo prints above
header_line_1. - If no logo is uploaded, the header begins with
header_line_1.
5.14.4 Footer Configuration
The receipt footer appears at the bottom of every printed receipt and supports up to 3 customizable text lines.
| Field | Type | Max Length | Default | Description |
|---|---|---|---|---|
footer_line_1 | String | 200 chars | “Returns accepted within 30 days with receipt.” | Primary footer text (typically return policy) |
footer_line_2 | String | 200 chars | (empty) | Secondary footer text (website URL, social media handles) |
footer_line_3 | String | 200 chars | “Thank you!” | Tertiary footer text (closing message) |
Business Rules:
- Footer lines can be blank (empty string). Blank lines are omitted from the printed receipt – no empty space is printed.
- Footer text should fit within the character-per-line limit of the configured paper width. Text exceeding the limit is word-wrapped automatically.
5.14.5 Receipt Configuration Data Model
Receipt Config Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
location_id | UUID | No | FK to locations table – NULL for tenant-wide default; non-NULL for location-specific override |
paper_width | Enum | Yes | 58MM, 80MM (default: 80MM) |
font_size | Enum | Yes | SMALL, MEDIUM, LARGE (default: MEDIUM) |
line_separator | Enum | Yes | DASH, EQUALS, BLANK, STAR (default: DASH) |
alignment | Enum | Yes | LEFT, CENTER (default: CENTER) |
print_density | Enum | Yes | LIGHT, NORMAL, DARK (default: NORMAL) |
header_lines | JSON | Yes | {"line_1": "...", "line_2": "...", "line_3": "..."} |
footer_lines | JSON | Yes | {"line_1": "...", "line_2": "...", "line_3": "..."} |
header_logo_url | String(500) | No | URL to uploaded header logo image |
field_order | JSON Array | Yes | Ordered array of field_code strings defining print sequence |
show_fields | JSON Object | Yes | Map of field_code: boolean controlling visibility (e.g., {"store_name": true, "register_number": false, ...}) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Configuration Inheritance:
- The system first looks for a location-specific receipt configuration (
location_id = target location). - If no location-specific configuration exists, it falls back to the tenant-wide default (
location_id = NULL). - Every tenant is initialized with a tenant-wide default receipt configuration using the default values from the field registry.
5.14.6 Email Receipt Template
Email receipts are HTML-formatted and sent when a customer provides an email address at checkout or explicitly requests an email receipt.
Email Receipt Template Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
subject_line | String(200) | Yes | Email subject (default: “Your receipt from {{store_name}}”) |
html_template | Text | Yes | HTML template body with merge fields |
is_active | Boolean | Yes | Whether email receipts are enabled (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Supported Merge Fields:
| Merge Field | Resolves To |
|---|---|
{{store_name}} | Location name |
{{store_address}} | Location full address |
{{store_phone}} | Location phone number |
{{transaction_id}} | Transaction number |
{{transaction_date}} | Formatted date and time |
{{items}} | HTML table of line items (name, qty, price, discount, total) |
{{subtotal}} | Pre-tax subtotal |
{{discount_total}} | Total discounts applied |
{{tax_amount}} | Tax amount with rate |
{{total}} | Grand total |
{{payment_method}} | Payment method(s) used |
{{customer_name}} | Customer name (if attached) |
{{loyalty_points_earned}} | Points earned on this transaction |
{{loyalty_balance}} | Current loyalty point balance |
{{barcode_image}} | Inline barcode image of transaction number |
Email Receipt Business Rules:
- Email receipts use the tenant’s branding colors (from Section 5.2) for header background, button colors, and accent elements.
- The company logo from the receipt header configuration is placed at the top of the email template.
- Every email receipt includes an unsubscribe link at the bottom: “Unsubscribe from receipt emails.” Clicking this sets the customer’s
email_receipt_opt_out = true. - Email receipts are queued asynchronously – the POS terminal does not wait for email delivery confirmation before completing the transaction.
- If the email fails to send (invalid address, mailbox full), the failure is logged but does not affect the transaction. The staff member sees no error; the customer simply does not receive the email.
- Email receipts are retained in the system for 7 years for audit and compliance purposes.
5.14.7 Receipt Preview
Nexus POS provides a live preview of the receipt configuration, rendering a sample receipt with placeholder data so the administrator can verify layout, field order, and branding before saving.
Preview Behavior:
- Preview updates in real-time as the administrator toggles fields, reorders sections, or modifies header/footer text.
- Preview renders at the configured paper width (58mm or 80mm) using a monospace font to simulate thermal printer output.
- A “Send Test Email” button sends a sample email receipt to the administrator’s email address using the current email template configuration.
Cross-Reference: See Module 5, Section 5.8 for receipt printer hardware configuration and register-printer linking. See Module 1 for the transaction completion flow that triggers receipt printing.
5.15 Email Templates & Communications
Scope: Centralized email provider configuration and template registry for all automated communications sent by the POS system. This section covers SMTP/API provider setup, the complete email template catalog consolidated from all modules, merge field definitions, and per-template enablement controls.
Cross-Reference: See Module 1, Section 1.13 for sales-triggered email events. See Module 2, Section 2.9 for customer communication preferences. See Module 4, Section 4.16 for inventory alert email templates.
5.15.1 Email Provider Configuration
MOVED TO MODULE 6: Email provider configuration, data model, and business rules have been consolidated into Module 6, Section 6.9.1 (Provider Configuration).
See: Module 6, Section 6.9 for the complete email provider integration specification including SMTP/SendGrid/Mailgun configuration and delivery monitoring.
5.15.2 Template Registry
Every automated email sent by the POS system is defined as a template in a central registry. Templates are pre-seeded during tenant onboarding and can be individually enabled or disabled by the tenant administrator.
Consolidated Email Template Catalog
| Template Code | Source Module | Trigger Event | Default Recipients | Description |
|---|---|---|---|---|
TMPL-REFUND-CONFIRMATION | Sales (M1) | Refund processed | Customer email | Confirms refund amount, method, and expected processing time |
TMPL-SPECIAL-ORDER-READY | Sales (M1) | Special order arrived | Customer email | Notifies customer that their special order is ready for pickup |
TMPL-SHIPMENT-TRACKING | Sales (M1) | Ship-to-customer dispatched | Customer email | Provides carrier name and tracking number |
TMPL-DELIVERY-CONFIRMATION | Sales (M1) | Delivery confirmed | Customer email | Confirms package delivery with order summary |
TMPL-OFFLINE-SOLD | Sales (M1) | Reserved item sold offline | Customer email | Informs customer when an offline-sold transfer/ship/reserve item is unavailable |
TMPL-WELCOME | Customers (M2) | New customer created | Customer email | Welcome message with loyalty program introduction |
TMPL-TIER-UPGRADE | Customers (M2) | Loyalty tier change | Customer email | Congratulates customer on tier upgrade with new benefits summary |
TMPL-PO-VENDOR | Inventory (M4) | PO submitted to vendor | Vendor email | Formatted purchase order with line items, quantities, and expected delivery |
TMPL-TRANSFER-ALERT | Inventory (M4) | Transfer shipped | Destination manager | Notifies destination store that a transfer is in transit |
TMPL-LOW-STOCK | Inventory (M4) | Daily low stock digest | Store manager | Consolidated list of products below reorder point at each location |
TMPL-COUNT-REMINDER | Inventory (M4) | Upcoming count scheduled | Assigned counters | Reminder email with count date, location, and scope (full/cycle) |
TMPL-RECEIPT-EMAIL | Setup (M5) | Customer requests email receipt | Customer email | Full transaction receipt in HTML format with scannable barcode |
TMPL-PASSWORD-RESET | Setup (M5) | User password reset request | User email | Secure password reset link with 24-hour expiry |
5.15.3 Template Data Model
Email Template Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
code | String(50) | Yes | Unique template code (e.g., TMPL-REFUND-CONFIRMATION). Immutable after creation. |
name | String(100) | Yes | Human-readable template name (e.g., “Refund Confirmation”) |
subject_template | String(255) | Yes | Email subject with merge fields (e.g., “Your refund of {refund_amount} has been processed”) |
body_template | Text | Yes | HTML email body with merge fields. Supports inline CSS for styling. Maximum 50KB. |
trigger_event | String(100) | Yes | System event that triggers this email (e.g., REFUND_PROCESSED, SPECIAL_ORDER_ARRIVED) |
default_recipient_type | Enum | Yes | CUSTOMER, VENDOR, STORE_MANAGER, ASSIGNED_USER, CUSTOM |
custom_recipient_email | String(255) | No | Static email address when default_recipient_type = CUSTOM |
is_enabled | Boolean | Yes | Whether this template is active and will be sent when triggered (default: true) |
is_system | Boolean | Yes | true for pre-seeded templates, false for tenant-created (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
5.15.4 Merge Fields
Each template uses merge fields enclosed in curly braces. The system resolves merge fields at send time by injecting context-specific values from the triggering event.
Common Merge Fields (available in all templates):
| Merge Field | Type | Description |
|---|---|---|
{tenant_name} | String | Tenant trading name |
{store_name} | String | Location name where the event occurred |
{store_address} | String | Full store address |
{store_phone} | String | Store phone number |
{current_date} | Date | Date the email is generated |
{current_time} | Time | Time the email is generated |
Transaction Merge Fields (sales-triggered templates):
| Merge Field | Type | Description |
|---|---|---|
{customer_name} | String | Customer full name |
{transaction_id} | String | Transaction reference number |
{order_total} | Currency | Total transaction amount |
{refund_amount} | Currency | Refund amount (refund templates only) |
{payment_method} | String | Payment method used |
{tracking_number} | String | Carrier tracking number (shipment templates only) |
{carrier_name} | String | Shipping carrier name (shipment templates only) |
{pickup_deadline} | Date | Pickup deadline date (special order and hold templates) |
Inventory Merge Fields (inventory-triggered templates):
| Merge Field | Type | Description |
|---|---|---|
{po_number} | String | Purchase order number |
{vendor_name} | String | Vendor company name |
{transfer_number} | String | Transfer reference number |
{source_location} | String | Transfer origin location name |
{destination_location} | String | Transfer destination location name |
{count_date} | Date | Scheduled count date |
{count_type} | String | Count type (Full Physical, Cycle, On-Demand, etc.) |
{low_stock_items} | HTML Table | Rendered table of low-stock products (digest templates only) |
Business Rules:
- Unresolved merge fields are replaced with an empty string and logged as a warning. They do not prevent the email from sending.
- Tenant administrators can customize the
subject_templateandbody_templateof system templates but cannot modify thecode,trigger_event, ordefault_recipient_type. - Disabling a template (
is_enabled = false) prevents the email from being sent when the trigger event fires. The event itself still processes normally. - Email receipts (
TMPL-RECEIPT-EMAIL) render the same field data as printed receipts, formatted in responsive HTML with a scannable CODE-128 barcode image.
5.16 RFID Configuration
Scope: Configuration of RFID hardware, EPC encoding parameters, tag printing, and scan session settings for the dedicated inventory counting subsystem. RFID is a counting-only system — it counts inventory through bulk tag reads. It does NOT participate in sales transactions, receiving, or transfers. Barcode Scanners remain the input device for those workflows (see Section 4.4 Receiving, Section 1.A.1 Item Entry).
Terminology Distinction:
- Scanner = barcode input device used at the POS register for sales, returns, receiving, and item lookup (Modules 1, 3, 4). Operates one-item-at-a-time via USB HID keyboard wedge.
- RFID = dedicated counting subsystem using radio-frequency readers for bulk inventory counting and auditing (Module 4, Section 4.6). Operates via the Raptag mobile application, reading 40+ tags per second.
- These are separate abstractions that coexist. Decision #11 (“Scanner Terminology”) applies to barcode input. RFID has its own configuration and workflow documented here.
Cross-References:
- (Raptag Mobile Application chapter — planned future rewrite) — mobile RFID counting interface
- Section 4.6.8 (RFID-Assisted Counting) — counting workflow integration
- Module 6, Section 6.11 (Integration Hub) — external system integrations (Shopify, Amazon, Google)
5.16.1 Reader Registration
RFID readers are registered as enterprise devices, paired to a location, and managed via claim codes generated in Nexus POS.
Supported Reader Models:
| Model | Form Factor | Read Range | Use Case | Connectivity |
|---|---|---|---|---|
| Zebra MC3390R | Handheld gun | 20 ft | Full store inventory counts | WiFi, Bluetooth |
| Zebra RFD40 | Phone sled attachment | 12 ft | Zone/section counts | Bluetooth |
| Zebra FX9600 | Fixed (dock door) | 30 ft | Receiving dock verification | Ethernet, WiFi |
Reader Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to shared.tenants |
name | VARCHAR(100) | Yes | Human-readable name (e.g., “Store GM Handheld #1”) |
model | VARCHAR(50) | Yes | Reader model (MC3390R, RFD40, FX9600) |
serial_number | VARCHAR(50) | Yes | Manufacturer serial number |
location_id | INT | Yes | FK to locations — reader’s assigned location |
connection_type | VARCHAR(20) | Yes | wifi, bluetooth, ethernet |
claim_code | VARCHAR(6) | No | One-time registration code (e.g., “X7K9M2”) |
status | VARCHAR(20) | Yes | active, offline, maintenance, retired |
registered_at | TIMESTAMP | Yes | When device was first registered |
last_seen_at | TIMESTAMP | No | Last heartbeat from device |
Claim Code Registration Workflow:
- Admin generates a 6-character alphanumeric claim code in Nexus POS (
Settings > RFID > Devices) - Claim code is valid for 24 hours from generation
- Operator enters claim code on the Raptag mobile app during setup
- System validates claim code, registers device to the tenant and location
- Claim code is consumed (one-time use)
Business Rules:
- One reader can be registered to one location at a time
- A reader can be reassigned to a different location by an Admin
- Readers with
status = 'offline'for >15 minutes trigger an alert in Nexus POS - Retired readers cannot be re-activated — a new claim code must be generated
5.16.2 EPC Encoding Configuration
All RFID tags use the SGTIN-96 standard (96-bit EPC, encoded as 24 hexadecimal characters). Each tenant configures their EPC encoding parameters once during onboarding.
SGTIN-96 Structure:
Header (8 bits) | Filter (3) | Partition (3) | Company Prefix (20-40) | Item Ref (4-24) | Serial (38)
Tenant EPC Configuration:
| Field | Type | Default | Description |
|---|---|---|---|
epc_company_prefix | VARCHAR(24) | — | GS1-assigned company prefix (set during onboarding) |
epc_indicator | CHAR(1) | 0 | SGTIN indicator digit |
epc_filter | CHAR(1) | 3 | Filter value (3 = individual trade item, per GS1 spec) |
epc_partition | INT | 5 | Partition value (5 = 20-bit company prefix + 24-bit item reference) |
min_rssi_threshold | SMALLINT | -70 | Minimum RSSI in dBm to accept a tag read; weaker reads are filtered as phantom reads |
Serial Number Management:
- Serial numbers use a PostgreSQL SEQUENCE per tenant (not a column counter)
- Sequence:
CREATE SEQUENCE rfid_epc_serial_{tenant_short_id} START 1 INCREMENT 1 NO CYCLE - Application calls
nextval()during tag encoding — guarantees uniqueness under concurrent printing - 38-bit serial field supports up to 274 billion unique tags per company prefix
EPC Format Validation:
- All EPCs must match:
^[0-9A-F]{24}$(exactly 24 uppercase hexadecimal characters) - Enforced via database CHECK constraint on
rfid_tags.epc
Scope Constraint: RFID is counting-only. The rfid_tags table tracks tag status as active, void, or lost. There are no sold_at, transferred_at, or sold_order_id fields — sales and transfers are tracked by the core inventory system via barcode, not RFID.
5.16.3 Tag Printing Parameters
RFID tags are printed and encoded using dedicated RFID-enabled label printers. The POS system manages the full tag printing lifecycle: template design, job queue, encoding, and verification.
Supported Printer Models:
| Model | Manufacturer | DPI | Connection | RFID Position |
|---|---|---|---|---|
| ZD621R | Zebra | 300 | Network, USB | Center |
| ZD500R | Zebra | 203 | Network, USB, Bluetooth | Center |
| CL4NX | SATO | 305 | Network, USB | Left |
| MX240P | TSC | 203 | Network, USB | Right |
Template Types:
| Type | Use Case | Typical Size |
|---|---|---|
hang_tag | Clothing hang tags with price and size | 2“ x 3“ |
price_tag | Shelf price labels with EPC | 1.5“ x 1“ |
label | Adhesive labels for boxes/bins | 4“ x 2“ |
Templates use ZPL (Zebra Programming Language) format. SATO and TSC printers accept ZPL via built-in translation.
Print Job Configuration:
| Setting | Default | Description |
|---|---|---|
default_priority | 5 | Job priority (1=highest, 10=lowest) |
max_retry_attempts | 3 | Retries for failed tag encoding |
job_timeout_minutes | 30 | Max time before job is marked failed |
| Default printer per location | — | Set in Nexus POS > Settings > RFID > Printers |
Business Rules:
- Large print jobs (>1,000 tags) should be split into sub-jobs of 500-1,000 tags for progress tracking
- Failed tags within a job can be retried individually without resubmitting the entire job
- If the assigned printer goes offline mid-job, the job pauses until the printer recovers (no automatic failover in v1.0)
5.16.4 Scan Session Configuration
RFID scan sessions are the core counting operation. A session represents a single counting activity — from starting the reader to submitting results.
Session Types (Counting Only):
| Type Code | Name | Scope | Typical Items |
|---|---|---|---|
full_inventory | Full Store Count | All products at a location | 2,000 – 100,000+ |
cycle_count | Cycle Count | Rolling partial count by category | 200 – 2,000 |
spot_check | Spot Check | Discrepancy verification | 10 – 50 |
find_item | Find Item | Locate a specific SKU using reader | 1 |
Note:
receivingis NOT an RFID session type. Receiving uses barcode scanners (Section 4.4).
Session Parameters:
| Setting | Key | Default | Description |
|---|---|---|---|
| Session Timeout | session_timeout_minutes | 480 (8 hours) | Max session duration before auto-expire |
| Auto-Save Interval | auto_save_interval_seconds | 30 | Frequency of SQLite checkpoint writes on mobile device |
| Chunk Upload Size | chunk_upload_size | 5,000 | Events per upload chunk when syncing to server |
| RSSI Threshold | min_rssi_threshold | -70 dBm | Tag reads below this are filtered (phantom read prevention) |
Variance Thresholds:
| Variance % | Color | Action |
|---|---|---|
| 0% | Green | Auto-approve — no discrepancy |
| 1–2% | Yellow | Review recommended |
| 3–5% | Orange | Manager review required |
| >5% | Red | Mandatory recount with different operator |
Multi-Operator Support:
- A single session can have multiple operators (up to 10), each assigned to a section of the store
- Each operator scans independently using their own device
- Server merges results and deduplicates by EPC (keeps highest RSSI read)
- See Section 4.6.8 for the full multi-operator workflow
5.16.5 RFID Business Rules (YAML)
The following RFID-specific business rules are part of the consolidated configuration system (Section 5.19). They are documented here for reference and cross-referenced from the YAML block.
rfid_config:
# EPC Encoding
epc:
company_prefix: "" # Tenant-specific, set during onboarding
partition: 5 # 20-bit company prefix + 24-bit item reference
filter: 3 # Individual trade item (GS1 standard)
indicator: "0" # SGTIN indicator digit
format: "SGTIN-96" # 96-bit EPC standard
serial_strategy: "sequence" # PostgreSQL SEQUENCE (not column counter)
format_regex: "^[0-9A-F]{24}$"
# Reader Hardware
readers:
supported_models:
- "MC3390R" # Handheld gun, 20 ft range
- "RFD40" # Phone sled, 12 ft range
- "FX9600" # Fixed reader, 30 ft range
claim_code_length: 6
claim_code_expiry_hours: 24
heartbeat_interval_seconds: 300
offline_threshold_minutes: 15
# Scanning Sessions
scanning:
session_types:
- "full_inventory"
- "cycle_count"
- "spot_check"
- "find_item"
# NOTE: "receiving" is NOT an RFID session type
min_rssi_threshold: -70 # dBm, tags weaker than this are filtered
auto_save_interval_seconds: 30
session_timeout_minutes: 480 # 8 hours max
chunk_upload_size: 5000 # Events per upload chunk
max_operators_per_session: 10
# Tag Printing
printing:
default_priority: 5 # 1=highest, 10=lowest
max_retry_attempts: 3
job_timeout_minutes: 30
max_tags_per_sub_job: 1000 # Large jobs split into sub-jobs
# Variance Thresholds
variance:
auto_approve_threshold_percent: 0
review_threshold_percent: 2
manager_review_threshold_percent: 5
recount_required_threshold_percent: 20
5.16.6 Integration Hub Reference
Note: The Integration Hub (integration registry, credentials storage, Shopify/Amazon/Google configurations, sync logging, and health dashboard) has been consolidated into Module 6, Section 6.11. RFID is a first-party subsystem and does NOT use the Integration Hub — it connects directly to the central API via REST endpoints (API Design chapter and API Reference appendix — planned future rewrite).
5.17 Loyalty & Rewards Settings
Scope: Configurable parameters for the loyalty program, tier thresholds, reward redemption rates, and gift card settings. This section defines the settings – the configurable values that govern loyalty behavior. The loyalty rules (tier upgrade/downgrade logic, point accrual timing, redemption application in the payment flow) are defined in Module 2 (Customers).
Cross-Reference: See Module 2, Section 2.6 for loyalty tier upgrade/downgrade rules, point accrual logic, and redemption flow. See Module 1, Section 1.15 for loyalty redemption in the payment calculation sequence.
5.17.1 Point Configuration
| Setting | Key | Type | Default | Description |
|---|---|---|---|---|
| Base Earn Rate | points_per_dollar | Integer | 1 | Points earned per dollar spent (before tier multiplier). Applied to the post-tax total. |
| Points Expiry | points_expiry_months | Integer | 12 | Months after last earning activity before points expire. 0 = never expire. |
| Exclude Tax | exclude_tax_from_points | Boolean | false | If true, points are calculated on the pre-tax subtotal. |
| Exclude Discounted Amount | exclude_discounts_from_points | Boolean | false | If true, points are calculated on the original price, not the discounted price. |
5.17.2 Tier Thresholds
Tier definitions are configurable. The system supports up to 4 tiers. Each tier defines a spend threshold, point multiplier, and automatic discount percentage.
| Tier | Code | Annual Spend Threshold | Point Multiplier | Auto Discount % | Description |
|---|---|---|---|---|---|
| Bronze | BRONZE | $0 (default tier) | 1.0x | 0% | Entry tier – all new customers start here |
| Silver | SILVER | $1,000 | 1.5x | 5% | Mid-tier – 50% more points per dollar, 5% discount on all purchases |
| Gold | GOLD | $5,000 | 2.0x | 10% | Premium tier – double points, 10% automatic discount |
| Platinum | PLATINUM | $10,000 | 3.0x | 15% | Top tier – triple points, 15% automatic discount |
Business Rules:
- Tier thresholds are evaluated against the customer’s rolling 12-month spend total. The evaluation period resets annually on the customer’s enrollment anniversary date.
- The automatic discount is applied before any manual or promotional discounts in the calculation order (see Module 1 discount application order).
- Point multiplier applies to the base
points_per_dollarrate. A Gold customer earning 1 point per dollar receives 2 points per dollar.
Cross-Reference: See Module 2, Section 2.6 for tier upgrade trigger logic, downgrade grace period, and tier evaluation cadence.
5.17.3 Reward Redemption
| Setting | Key | Type | Default | Description |
|---|---|---|---|---|
| Redemption Rate | redemption_rate | Integer | 100 | Points required for $1.00 discount |
| Minimum Redemption | minimum_redemption | Integer | 100 | Minimum points that can be redeemed in a single transaction |
| Maximum Redemption % | max_redemption_percent | Integer | 50 | Maximum percentage of the transaction total payable by points (0-100) |
| Allow Partial Redemption | allow_partial_redemption | Boolean | true | Whether customers can redeem a subset of their available points |
5.17.4 Gift Card Settings
| Setting | Key | Type | Default | Description |
|---|---|---|---|---|
| Predefined Denominations | denominations | Array[Decimal] | [10, 25, 50, 100] | Quick-select amounts shown at POS during gift card purchase |
| Allow Custom Amount | allow_custom_amount | Boolean | true | Whether cashiers can enter an arbitrary gift card amount |
| Minimum Load | minimum_load | Decimal(10,2) | 10.00 | Minimum dollar amount for initial activation or reload |
| Maximum Load | maximum_load | Decimal(10,2) | 500.00 | Maximum dollar amount for initial activation or reload |
| Expiry Months | expiry_months | Integer | 0 | Months from activation before the gift card expires. 0 = no expiry (most restrictive jurisdiction default). |
| Allow Reload | allow_reload | Boolean | true | Whether depleted or partially-used gift cards can be reloaded |
Jurisdiction Rules:
- Default expiry is
0(no expiry), conforming to the most restrictive US jurisdiction (California). - Tenants operating in states that permit expiry can override
expiry_monthsto a compliant value (e.g., Virginia: minimum 60 months). - Jurisdiction-specific cash-out rules (e.g., California requires cash redemption below $10.00) are enforced at the POS transaction level per Module 1 gift card rules.
5.17.5 Loyalty Settings Data Model
Loyalty Settings Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant (unique constraint) |
points_per_dollar | Integer | Yes | Base earn rate (default: 1) |
points_expiry_months | Integer | Yes | Months to expiry, 0 = never (default: 12) |
exclude_tax_from_points | Boolean | Yes | Default: false |
exclude_discounts_from_points | Boolean | Yes | Default: false |
redemption_rate | Integer | Yes | Points per $1.00 discount (default: 100) |
minimum_redemption | Integer | Yes | Minimum redeemable points (default: 100) |
max_redemption_percent | Integer | Yes | Max % of transaction payable by points (default: 50) |
allow_partial_redemption | Boolean | Yes | Default: true |
tier_config | JSON | Yes | JSON array of tier objects: [{code, name, threshold, multiplier, discount_percent}] |
gift_card_denominations | JSON | Yes | JSON array of decimal amounts (default: [10, 25, 50, 100]) |
gift_card_allow_custom | Boolean | Yes | Default: true |
gift_card_min_load | Decimal(10,2) | Yes | Default: 10.00 |
gift_card_max_load | Decimal(10,2) | Yes | Default: 500.00 |
gift_card_expiry_months | Integer | Yes | Default: 0 |
gift_card_allow_reload | Boolean | Yes | Default: true |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
5.18 Audit Configuration
Scope: Configuration of the audit logging system – which event categories are tracked, how long logs are retained, archival policies, and export capabilities. The audit log provides a tamper-evident record of every significant action in the system for compliance, investigation, and operational accountability.
Cross-Reference: See Module 5, Section 5.13 for approval workflow audit trails. See Module 4, Section 4.9 for inventory movement history (the inventory-specific audit trail).
5.18.1 Audit Categories
Each audit category can be independently toggled on or off. Disabling a category stops new log entries from being created for events in that category. Existing log entries are never deleted by disabling a category.
| Category Code | Description | Default | Example Events |
|---|---|---|---|
LOGIN | User login and logout events | On | Login success, login failure, logout, session timeout |
SALE | Transaction completed | On | Sale finalized, split payment processed |
RETURN | Return processed | On | Return with receipt, return without receipt, exchange |
VOID | Transaction voided | On | Same-day void, void with manager approval |
ADJUSTMENT | Inventory adjustment | On | Manual qty adjustment, count correction applied |
PRICE_CHANGE | Product price modified | On | Retail price change, cost update, markdown applied |
DISCOUNT | Discount applied | On | Line discount, global discount, coupon applied, loyalty redemption |
PO | Purchase order actions | On | PO created, PO approved, PO submitted, PO received, PO closed |
TRANSFER | Inter-store transfer actions | On | Transfer requested, approved, shipped, received, completed |
USER_MGMT | User account actions | On | User created, role changed, user deactivated, password reset |
SETTINGS | System settings changed | On | Tax rate changed, business rule modified, integration updated |
INVENTORY_COUNT | Count session actions | On | Count started, count submitted, variance approved, count finalized |
5.18.2 Retention Configuration
| Setting | Key | Type | Default | Description |
|---|---|---|---|---|
| Retention Period | retention_days | Integer | 365 | Days to keep detailed audit log entries in the primary database |
| Archive Enabled | archive_enabled | Boolean | true | Whether entries older than retention_days are moved to archive storage |
| Archive Format | archive_format | Enum | COMPRESSED_JSON | Format for archived records: COMPRESSED_JSON (gzip), CSV |
| Purge Archived | purge_archived_after_days | Integer | 2190 | Days to retain archived records before permanent deletion (2190 = 6 years). Set to 0 to retain archives indefinitely. |
Retention Lifecycle:
flowchart LR
A["Active Log\n(Primary DB)"] -->|After retention_days| B["Archive Storage\n(Compressed JSON)"]
B -->|After purge_archived_after_days| C["Permanently Deleted"]
style A fill:#2d6a4f,stroke:#1b4332,color:#fff
style B fill:#264653,stroke:#1d3557,color:#fff
style C fill:#6c757d,stroke:#495057,color:#fff
Business Rules:
- Minimum
retention_daysis 90. The system rejects values below 90 to ensure basic operational audit capability. - Minimum
purge_archived_after_daysis 365 (or 0 for indefinite). Values between 1 and 364 are rejected. - The archival background job runs daily at 02:00 AM (tenant timezone). It processes entries older than
retention_daysin batches of 10,000 records. - Archived records are stored with the same data fidelity as active records – no fields are dropped during archival.
5.18.3 Export Configuration
| Setting | Key | Type | Default | Description |
|---|---|---|---|---|
| Supported Formats | export_formats | Array[Enum] | ["CSV", "JSON", "PDF"] | Formats available for audit log export |
| Max Export Rows | max_export_rows | Integer | 10000 | Maximum rows per single export request. Larger exports must be split by date range. |
| Max Date Range | max_export_date_range_days | Integer | 365 | Maximum date range span allowed in a single export request |
| Include Archived | include_archived_in_export | Boolean | true | Whether exports can pull from archived records (slower but comprehensive) |
5.18.4 Audit Log Data Model
Audit Config Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant (unique constraint) |
categories_enabled | JSON | Yes | Map of category_code: boolean (e.g., {"LOGIN": true, "SALE": true, "VOID": true, ...}) |
retention_days | Integer | Yes | Default: 365 |
archive_enabled | Boolean | Yes | Default: true |
archive_format | Enum | Yes | COMPRESSED_JSON, CSV (default: COMPRESSED_JSON) |
purge_archived_after_days | Integer | Yes | Default: 2190 |
max_export_rows | Integer | Yes | Default: 10000 |
max_export_date_range_days | Integer | Yes | Default: 365 |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Audit Log Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
category_code | Enum | Yes | One of the 12 category codes defined above |
action | String(100) | Yes | Specific action (e.g., SALE_COMPLETED, USER_CREATED, PO_APPROVED) |
actor_user_id | UUID | Yes | FK to users table – user who performed the action |
actor_role | String(50) | Yes | Role of the user at time of action (captured for audit, not FK) |
location_id | UUID | No | FK to locations table – location where the action occurred (NULL for tenant-wide actions) |
register_id | UUID | No | FK to registers table – register involved (NULL for non-POS actions) |
entity_type | String(50) | Yes | Type of entity affected (e.g., TRANSACTION, PRODUCT, USER, PO) |
entity_id | UUID | Yes | FK to the affected entity record |
details | JSON | No | Structured JSON capturing before/after values, amounts, reason codes, and other context |
ip_address | String(45) | No | IP address of the client that initiated the action |
occurred_at | DateTime | Yes | Timestamp when the action occurred (event time, not log write time) |
created_at | DateTime | Yes | Record creation timestamp (log write time) |
Business Rules:
- Audit log entries are immutable. No UPDATE or DELETE operations are permitted on the
audit_logtable. Only INSERT and SELECT are allowed. - The
detailsJSON field captures change context. For settings changes, it records{"old_value": ..., "new_value": ...}. For transactions, it records key financial fields (total,tax,discount,payment_method). - Audit logs are queryable by category, date range, user, location, and entity type. Nexus POS provides a searchable, filterable audit log viewer.
5.19 Business Rules Configuration – Consolidated YAML
Scope: Single, authoritative source for all configurable business rules across the entire POS system. This section consolidates business rules from Module 1 (Sales), Module 2 (Customers), Module 3 (Catalog), and Module 4 (Inventory) into one centralized YAML configuration. All values shown are defaults and can be overridden at tenant or store level.
Cross-Reference: Individual module sections define the behavior that these rules govern. This section defines the configurable values.
5.19.1 Sales Configuration
# ============================================
# SALES MODULE BUSINESS RULES
# ============================================
# All values shown are defaults and can be
# overridden at tenant or store level.
# ============================================
sales_config:
# ------------------------------------------
# RETURN POLICY
# ------------------------------------------
return_policy:
# Full refund period (with receipt)
full_refund_days: 30
# Store credit only period (with receipt)
store_credit_days: 90
# Restocking fee for opened items (percentage)
restocking_fee_percent: 15
# Categories exempt from restocking fee
restocking_fee_exempt_categories:
- "clothing"
- "accessories"
# Categories marked as final sale (no returns)
final_sale_categories:
- "clearance"
- "as-is"
# Channel-specific policies
online_policy:
return_days: 30
exchange_days: 30
exclude_shipping_fees: true
exclude_processing_fees: true
receipt_required: true
in_store_policy:
return_hours: 24
exchange_hours: 24
receipt_required: true
receipt_scan_validation: true
# ------------------------------------------
# PARKED SALES
# ------------------------------------------
parked_sales:
# Maximum parked sales per terminal
max_per_terminal: 5
# Time-to-live before auto-expiry (hours)
ttl_hours: 4
# Inventory reservation type
reservation_type: "soft" # soft = visible with warning, hard = blocked
# ------------------------------------------
# SPECIAL ORDERS
# ------------------------------------------
special_orders:
# Minimum deposit percentage
minimum_deposit_percent: 50
# Maximum days to hold after arrival
pickup_deadline_days: 30
# Auto-cancel after missed pickup (days)
abandonment_days: 45
# ------------------------------------------
# HOLD FOR PICKUP
# ------------------------------------------
hold_for_pickup:
# Default hold duration
default_days: 7
# Maximum hold extension allowed
max_days: 30
# Days before expiry to send reminder
reminder_days_before: 2
# ------------------------------------------
# TRANSFERS & RESERVATIONS
# ------------------------------------------
transfers:
# Require full payment before processing
require_full_payment: true
# Estimated transit days (for display)
estimated_transit_days: 3
reservations:
# Require full payment before reserving
require_full_payment: true
# Default reservation duration
default_hold_days: 7
# Auto-refund after expiry
auto_refund_on_expiry: true
# ------------------------------------------
# SHIP TO CUSTOMER
# ------------------------------------------
ship_to_customer:
enabled: true
require_full_payment: true
include_shipping_in_total: true
# Carrier integration
carriers:
- provider: "configured_per_tenant"
api_key: "configured_per_tenant"
test_mode: true
# Shipping options
shipping_options:
standard:
label: "Standard (3-5 business days)"
enabled: true
express:
label: "Express (1-2 business days)"
enabled: true
overnight:
label: "Overnight"
enabled: false
# ------------------------------------------
# CASH DRAWER
# ------------------------------------------
cash_drawer:
# Variance tolerance before manager approval required
variance_tolerance: 5.00
# Require blind count (staff can't see expected)
blind_count_enabled: true
# Maximum float amount
max_opening_float: 500.00
# ------------------------------------------
# DISCOUNTS & PRICING
# ------------------------------------------
discounts:
# Maximum line item discount without manager approval
max_line_discount_percent: 20
# Maximum global discount without manager approval
max_global_discount_percent: 15
# Reason codes required for discounts
require_reason_code: true
# Discount application order
application_order:
- "price_tier"
- "line_discount"
- "auto_promo"
- "global_discount"
- "coupon"
- "tax"
- "loyalty_redemption"
# ------------------------------------------
# COMMISSIONS
# ------------------------------------------
commissions:
# Default commission rate (percentage of sale)
default_rate_percent: 2.0
# Higher rate for specific categories
category_rates:
electronics: 3.0
services: 5.0
# Void reverses commission (full)
reverse_on_void: true
void_reversal_method: "full"
# Return reduces commission (proportional)
reduce_on_return: true
return_reversal_method: "proportional"
# ------------------------------------------
# OFFLINE MODE (ADR-048: Online-First with Offline Fallback)
# ------------------------------------------
offline_mode:
# Architecture: API-primary, offline is fallback only
strategy: "online_first"
# Local SQLite tables
local_storage:
product_cache: "read_only" # Stale prices for offline lookups
sales_queue: "write_queue" # FIFO queue for offline sales
# Queue processing
queue:
order: "fifo" # Strict first-in-first-out
max_size: null # Unlimited (no artificial cap)
flush_trigger: "reconnection" # Real-time flush when API reachable
# Discrepancy handling (server is authoritative)
discrepancy_handling:
price_changed: "flag_for_review"
item_out_of_stock: "flag_for_review"
strategy: "server_authoritative"
# Operations allowed offline (sales only)
allowed_offline:
- "sale_new"
- "return_with_receipt"
- "price_check"
- "parked_sale_create"
- "parked_sale_retrieve"
# Operations blocked offline
blocked_offline:
- "customer_create"
- "on_account_payment"
- "gift_card_activation"
- "gift_card_reload"
- "gift_card_balance_check"
- "gift_card_redemption"
- "multi_store_inventory"
- "transfer_request"
- "reservation_create"
- "inventory_adjustment"
- "receiving"
# ------------------------------------------
# PAYMENT INTEGRATION
# ------------------------------------------
payment_integration:
# PCI compliance level
pci_scope: "SAQ-A"
# Integration type
integration_type: "semi_integrated"
# Terminal timeout (seconds)
payment_timeout_seconds: 60
connection_timeout_seconds: 10
# Batch close time (24-hour format)
batch_close_time: "23:00"
# Same-day void allowed
same_day_void: true
# ------------------------------------------
# THIRD-PARTY FINANCING
# ------------------------------------------
third_party_financing:
affirm:
enabled: true
minimum_order_amount: 50.00
maximum_order_amount: 5000.00
merchant_id: "configured_per_tenant"
webhook_url: "/api/webhooks/affirm"
Sales Configuration Field Reference
| Key | Type | Default | Description |
|---|---|---|---|
return_policy.full_refund_days | Integer | 30 | Calendar days from purchase for full original-method refund |
return_policy.store_credit_days | Integer | 90 | Calendar days from purchase for store credit refund |
return_policy.restocking_fee_percent | Integer | 15 | Percentage deducted as restocking fee on non-exempt items |
parked_sales.max_per_terminal | Integer | 5 | Maximum concurrent parked sales per register |
parked_sales.ttl_hours | Integer | 4 | Hours before an unrecalled parked sale auto-expires |
special_orders.minimum_deposit_percent | Integer | 50 | Minimum deposit required at special order creation |
special_orders.abandonment_days | Integer | 45 | Days after arrival before abandoned special order is forfeited |
hold_for_pickup.default_days | Integer | 7 | Default hold duration in days |
cash_drawer.variance_tolerance | Decimal | 5.00 | Dollar variance before manager review is required at close |
cash_drawer.blind_count_enabled | Boolean | true | Hide expected drawer total from staff during count |
discounts.max_line_discount_percent | Integer | 20 | Maximum per-line discount before manager approval |
discounts.max_global_discount_percent | Integer | 15 | Maximum transaction-wide discount before manager approval |
commissions.default_rate_percent | Decimal | 2.0 | Default commission percentage of sale amount |
offline_mode.max_queue_size | Integer | 100 | Maximum offline-queued transactions before blocking new sales |
payment_integration.payment_timeout_seconds | Integer | 60 | Seconds to wait for payment terminal response |
payment_integration.batch_close_time | String | “23:00” | Daily batch settlement time (24-hour format) |
5.19.2 Customer Configuration
# ============================================
# CUSTOMER MODULE BUSINESS RULES
# ============================================
customer_config:
# ------------------------------------------
# LOYALTY PROGRAM
# ------------------------------------------
loyalty:
# Points per dollar spent
points_per_dollar: 1
# Points required for $1 redemption
redemption_rate: 100
# Minimum points to redeem
minimum_redemption: 100
# Points expiry (months, 0 = never)
points_expiry_months: 12
# Maximum % of transaction payable by points
max_redemption_percent: 50
# ------------------------------------------
# CUSTOMER TIERS
# ------------------------------------------
customer_tiers:
bronze:
# No threshold - default tier
point_multiplier: 1.0
automatic_discount_percent: 0
silver:
annual_spend_threshold: 1000
point_multiplier: 1.5
automatic_discount_percent: 5
gold:
annual_spend_threshold: 5000
point_multiplier: 2.0
automatic_discount_percent: 10
platinum:
annual_spend_threshold: 10000
point_multiplier: 3.0
automatic_discount_percent: 15
# ------------------------------------------
# LAYAWAY
# ------------------------------------------
layaway:
# Minimum deposit percentage
minimum_deposit_percent: 20
# Maximum layaway duration (days)
max_duration_days: 90
# Minimum payment frequency (days)
payment_frequency_days: 30
# Forfeiture fee percentage (of deposit)
forfeiture_fee_percent: 10
# ------------------------------------------
# GIFT CARDS
# ------------------------------------------
gift_cards:
# Minimum load amount
minimum_load: 10.00
# Maximum load amount
maximum_load: 500.00
# Default expiry period (months from activation)
default_expiry_months: 0 # No expiry (most restrictive)
# Allow reload of depleted cards
allow_reload: true
# Jurisdiction-specific overrides
jurisdiction_rules:
virginia:
expiry_allowed: true
expiry_months: 60
inactivity_fee_allowed: true
inactivity_fee_months: 12
cash_out_threshold: 0
california:
expiry_allowed: false
inactivity_fee_allowed: false
cash_out_threshold: 10.00
cash_out_required: true
# ------------------------------------------
# PRIVACY & DATA RETENTION
# ------------------------------------------
privacy:
# Days to fulfill data export request
export_request_days: 30
# Days to fulfill deletion request
deletion_request_days: 30
# Auto-anonymize inactive customers (months, 0 = never)
auto_anonymize_months: 0
# Consent change audit retention (years)
consent_audit_retention_years: 7
# ------------------------------------------
# EXPORT LIMITS
# ------------------------------------------
exports:
# Maximum rows per CSV export
max_rows: 1000
# Maximum date range for reports (days)
max_date_range_days: 365
Customer Configuration Field Reference
| Key | Type | Default | Description |
|---|---|---|---|
loyalty.points_per_dollar | Integer | 1 | Base points earned per dollar (before tier multiplier) |
loyalty.redemption_rate | Integer | 100 | Points required to redeem $1.00 discount |
loyalty.minimum_redemption | Integer | 100 | Minimum points a customer can redeem per transaction |
loyalty.points_expiry_months | Integer | 12 | Months since last earn before points expire (0 = never) |
loyalty.max_redemption_percent | Integer | 50 | Maximum percentage of transaction total payable by points |
customer_tiers.silver.annual_spend_threshold | Integer | 1000 | Annual spend in dollars to qualify for Silver tier |
customer_tiers.gold.annual_spend_threshold | Integer | 5000 | Annual spend in dollars to qualify for Gold tier |
customer_tiers.platinum.annual_spend_threshold | Integer | 10000 | Annual spend in dollars to qualify for Platinum tier |
layaway.minimum_deposit_percent | Integer | 20 | Minimum deposit as percentage of layaway total |
layaway.max_duration_days | Integer | 90 | Maximum calendar days for layaway completion |
layaway.forfeiture_fee_percent | Integer | 10 | Fee deducted from deposit on layaway cancellation |
gift_cards.minimum_load | Decimal | 10.00 | Minimum dollar amount per activation or reload |
gift_cards.maximum_load | Decimal | 500.00 | Maximum dollar amount per activation or reload |
gift_cards.default_expiry_months | Integer | 0 | Months to expiry (0 = never, California baseline) |
privacy.export_request_days | Integer | 30 | Days to fulfill customer data export request |
privacy.auto_anonymize_months | Integer | 0 | Months of inactivity before auto-anonymization (0 = disabled) |
5.19.3 Catalog Configuration
# ============================================
# CATALOG MODULE BUSINESS RULES
# ============================================
catalog_config:
# ------------------------------------------
# PRODUCT LIFECYCLE
# ------------------------------------------
product_lifecycle:
# Default status for newly created products
default_status: "DRAFT"
# Valid status transitions
# DRAFT -> ACTIVE -> DISCONTINUED
# ACTIVE -> DRAFT (revert to draft for editing)
allowed_transitions:
DRAFT: ["ACTIVE"]
ACTIVE: ["DRAFT", "DISCONTINUED"]
DISCONTINUED: ["ACTIVE"] # Re-activate if needed
# Require at least one image before activation
require_image_on_activate: false
# Require barcode before activation
require_barcode_on_activate: true
# Require price before activation
require_price_on_activate: true
# ------------------------------------------
# PRICING
# ------------------------------------------
pricing:
# Price hierarchy levels (highest priority first)
price_hierarchy:
- "price_book_override"
- "channel_price"
- "sale_price"
- "default_price"
- "msrp"
# Require manager approval for markdown
markdown_approval_required: true
# Maximum markdown percentage without owner approval
max_markdown_percent: 50
# Minimum margin threshold (warn if below)
minimum_margin_warning_percent: 10
# Allow selling below cost
allow_below_cost_sale: false
# ------------------------------------------
# BARCODE
# ------------------------------------------
barcode:
# Default barcode format
default_format: "CODE128" # Options: "CODE128", "EAN13", "UPC_A"
# Auto-generate barcode if none provided
auto_generate: true
# Auto-generated barcode prefix (tenant-specific)
auto_prefix: "POS"
# Barcode uniqueness scope
uniqueness_scope: "tenant" # Options: "tenant", "global"
# ------------------------------------------
# CATEGORIES
# ------------------------------------------
categories:
# Maximum category nesting depth
max_depth: 4
# Require every product to have a category
require_category_on_product: true
# Allow product in multiple categories
allow_multi_category: false
# Default category sort order
default_sort: "name_asc"
# ------------------------------------------
# SHOPIFY SYNC
# ------------------------------------------
shopify_sync:
# Scheduled reconciliation interval (minutes)
reconciliation_interval_minutes: 15
# Field ownership model (determines which system can edit each field)
field_ownership:
pos_owned:
- "title"
- "vendor"
- "product_type"
- "variants.price"
- "variants.compare_at_price"
- "variants.sku"
- "variants.barcode"
- "variants.cost"
- "variants.weight"
shopify_owned:
- "body_html"
- "seo_title"
- "seo_description"
- "images"
- "tags"
bidirectional:
- "status"
- "variants.inventory_quantity"
# Conflict resolution for bidirectional fields
conflict_resolution: "pos_wins" # Options: "pos_wins", "shopify_wins", "latest_wins"
# Delete behavior when product discontinued in POS
delete_on_discontinue: false # Set to draft in Shopify, not delete
# Webhook retry on failure
webhook_retry_count: 3
webhook_retry_backoff_seconds: [5, 15, 45]
# ------------------------------------------
# SCANNER CONFIGURATION
# ------------------------------------------
scanner:
# Maximum tags per bulk lookup request
max_tags_per_request: 50
# Tag read timeout (milliseconds)
read_timeout_ms: 5000
# Auto-lookup on scan
auto_lookup_on_scan: true
# Scan confirmation beep
confirmation_beep_enabled: true
Catalog Configuration Field Reference
| Key | Type | Default | Description |
|---|---|---|---|
product_lifecycle.default_status | Enum | DRAFT | Status assigned to newly created products |
product_lifecycle.require_barcode_on_activate | Boolean | true | Barcode must exist before product can transition to ACTIVE |
pricing.markdown_approval_required | Boolean | true | Whether manager must approve markdown price reductions |
pricing.max_markdown_percent | Integer | 50 | Maximum markdown before owner-level approval |
pricing.allow_below_cost_sale | Boolean | false | Whether POS allows sale price below product cost |
barcode.default_format | Enum | CODE128 | Default barcode symbology for auto-generated barcodes |
barcode.auto_generate | Boolean | true | Auto-create barcode if product has no barcode at creation |
categories.max_depth | Integer | 4 | Maximum category tree nesting levels |
categories.require_category_on_product | Boolean | true | Products must be assigned to at least one category |
shopify_sync.reconciliation_interval_minutes | Integer | 15 | Minutes between scheduled full reconciliation |
shopify_sync.conflict_resolution | Enum | pos_wins | Tie-breaking strategy for bidirectional field conflicts |
scanner.max_tags_per_request | Integer | 50 | Maximum scanner tags per bulk API request |
scanner.read_timeout_ms | Integer | 5000 | Milliseconds before scanner read times out |
5.19.4 Inventory Configuration
# ============================================
# INVENTORY MODULE BUSINESS RULES
# ============================================
# All values shown are defaults and can be
# overridden at tenant or store level.
# ============================================
inventory_config:
# ------------------------------------------
# GENERAL SETTINGS
# ------------------------------------------
general:
# Allow inventory to go negative (e.g., sell without stock)
allow_negative_inventory: false
# Costing method for COGS and valuation
costing_method: "weighted_average" # Options: "weighted_average", "fifo"
# Default currency for all inventory valuations
default_currency: "USD"
# Enforce reorder point monitoring
enforce_reorder_points: true
# Minimum display quantity on POS (show "Low Stock" below this)
min_display_qty: 3
# Show exact quantity or generic "In Stock" / "Low Stock"
show_exact_qty_on_pos: true
# ------------------------------------------
# INVENTORY STATUS MODEL
# ------------------------------------------
status_model:
# Valid inventory statuses
statuses:
- "AVAILABLE"
- "RESERVED"
- "QUARANTINE"
- "DAMAGED"
- "IN_TRANSIT"
- "ON_HOLD"
# Only these statuses allow sale
sellable_statuses:
- "AVAILABLE"
# Only these statuses allow transfer out
transferable_statuses:
- "AVAILABLE"
# ------------------------------------------
# PURCHASE ORDERS
# ------------------------------------------
purchase_orders:
# Auto-close PO threshold (% received to auto-complete)
auto_close_threshold_percent: 100
# Allow editing cost at time of receiving
allow_cost_editing_on_receive: true
# Default tax rate applied to PO lines
default_tax_rate: 0.0
# PO approval thresholds (value-based)
approval:
auto_approve_below: 500.00
manager_approval_threshold: 500.00
admin_approval_threshold: 5000.00
approval_expiry_days: 7
# Auto-PO generation
auto_po:
enabled: true
mode: "draft" # Options: "draft", "auto_submit"
minimum_po_value: 50.00
consolidate_by_vendor: true
# PO number format
number_format: "PO-{YEAR}-{SEQUENCE:5}"
# ------------------------------------------
# RECEIVING
# ------------------------------------------
receiving:
# Receiving mode: "open" or "strict"
mode: "open"
# Over-receive threshold (% above PO qty allowed)
over_receive_threshold_percent: 10
# Scanner mode for receiving
scanner_mode: "scan_primary" # Options: "scan_required", "scan_optional", "scan_primary"
# Auto-print labels on receive
auto_print_labels: true
# Label template for received items
label_template: "standard_barcode"
# Discrepancy handling (the "triple approach")
discrepancy:
allow_partial_receive: true
auto_quarantine_damaged: true
auto_create_rma_on_damage: true
cost_variance_alert_percent: 5
# Non-PO receiving
non_po_receiving:
enabled: true
require_reason: true
valid_reasons:
- "CUSTOMER_RETURN"
- "VENDOR_REPLACEMENT"
- "CONSIGNMENT"
- "FOUND_STOCK"
- "OTHER"
# ------------------------------------------
# REORDER & VELOCITY
# ------------------------------------------
reorder:
# Sales velocity calculation window (days)
velocity_window_days: 90
# Safety stock calculation (standard deviations)
safety_stock_sigma: 1.65 # ~95% service level
# Dead stock threshold (days with zero sales)
dead_stock_days: 90
# Seasonal adjustment
seasonal_adjustment_enabled: true
seasonal_history_years: 3
# Reorder point recalculation frequency
recalculation_frequency: "weekly" # Options: "daily", "weekly"
recalculation_day: "sunday"
# Minimum reorder quantity
min_reorder_qty: 1
# Round reorder qty to vendor's case pack size
round_to_case_pack: true
# ------------------------------------------
# COUNTING (STOCKTAKE)
# ------------------------------------------
counting:
# Freeze mode during full physical count
default_freeze_mode: "soft" # Options: "hard", "soft", "none"
# Blind count (hide expected qty from counter)
blind_count_enabled: true
# Scanner mode for counting
scanner_mode: "scan_primary"
# Variance threshold requiring manager approval (units)
variance_approval_threshold_units: 10
# Variance threshold requiring manager approval (%)
variance_approval_threshold_percent: 5
# Auto-adjust variances below threshold
auto_adjust_below_threshold: true
# Require second count for high-variance items
require_recount_above_percent: 20
# Supported count types
count_types:
- "full_physical"
- "cycle_count"
- "spot_check"
- "scanner_assisted"
- "on_demand"
# Cycle count frequency (days between counts per product)
cycle_count_interval_days:
A_items: 30
B_items: 60
C_items: 90
# ------------------------------------------
# ADJUSTMENTS
# ------------------------------------------
adjustments:
# Approval mode for manual adjustments
approval_mode: "threshold" # Options: "all", "threshold", "none"
# Threshold above which approval is required (units)
approval_threshold_units: 10
# Threshold above which approval is required (value)
approval_threshold_value: 100.00
# Default reason codes (built-in)
default_reason_codes:
- code: "DAMAGED"
label: "Damaged / Defective"
requires_note: false
- code: "THEFT"
label: "Theft / Shrinkage"
requires_note: false
- code: "COUNT_CORRECTION"
label: "Count Correction"
requires_note: false
- code: "VENDOR_RETURN"
label: "Returned to Vendor"
requires_note: false
- code: "FOUND_STOCK"
label: "Found Stock (Positive Adjustment)"
requires_note: true
- code: "SAMPLE"
label: "Removed for Sample / Display"
requires_note: false
- code: "DONATION"
label: "Donated"
requires_note: true
- code: "OTHER"
label: "Other"
requires_note: true
# Allow tenants to create custom reason codes
allow_custom_reason_codes: true
# Require note for all adjustments
require_note_for_all: false
# ------------------------------------------
# TRANSFERS
# ------------------------------------------
transfers:
# Require acceptance scan at destination
require_acceptance_at_destination: true
# Allow partial transfer receive
allow_partial_receive: true
# Transfer auto-suggestion
auto_suggest:
enabled: true
imbalance_threshold_days: 14
min_transfer_qty: 2
min_source_qty_after_transfer: 2
frequency: "daily"
# Transfer number format
number_format: "TRF-{YEAR}-{SEQUENCE:5}"
# Transit time estimate (default days)
default_transit_days: 3
# Auto-cancel unfulfilled transfer requests after N days
auto_cancel_unfulfilled_days: 14
# ------------------------------------------
# VENDOR RMA
# ------------------------------------------
rma:
# Allow overstock returns to vendor
overstock_returns_enabled: true
# Default restocking fee (% of cost)
default_restocking_fee_percent: 0
# Maximum restocking fee allowed
max_restocking_fee_percent: 25
# RMA expiry (days to ship back after vendor approval)
ship_back_deadline_days: 30
# Auto-create RMA for damaged items on receive
auto_create_on_damaged_receive: true
# RMA number format
number_format: "RMA-{YEAR}-{SEQUENCE:5}"
# Credit reconciliation reminder (days after shipment)
credit_followup_days: 30
# ------------------------------------------
# SERIAL & LOT TRACKING
# ------------------------------------------
serial_tracking:
# Require serial scan at POS sale
require_serial_at_sale: true
# Require serial scan at receiving
require_serial_at_receive: true
# Serial number format validation (regex, optional)
format_validation: null
lot_tracking:
# FIFO enforcement on sale
fifo_enforcement: true
# Lot number format
number_format: "LOT-{YEAR}{MONTH}-{SEQUENCE:4}"
# Track expiry dates
expiry_tracking_enabled: false # Clothing typically doesn't expire
# ------------------------------------------
# ALERTS & NOTIFICATIONS
# ------------------------------------------
alerts:
low_stock:
enabled: true
delivery: ["dashboard", "email_digest"]
email_digest_time: "07:00"
severity: "warning"
overstock:
enabled: true
delivery: ["dashboard"]
days_of_supply_threshold: 90
severity: "info"
shrinkage:
enabled: true
delivery: ["dashboard", "email_immediate"]
variance_threshold_percent: 5
severity: "critical"
aging_inventory:
enabled: true
delivery: ["dashboard", "email_digest"]
days_threshold: 90
email_digest_day: "monday"
severity: "warning"
po_overdue:
enabled: true
delivery: ["dashboard", "email"]
buffer_days: 3
severity: "warning"
# ------------------------------------------
# OFFLINE INVENTORY (ADR-048: Online-First)
# ------------------------------------------
offline:
# Strategy: online-first with offline fallback
strategy: "online_first"
# Inventory is read-only offline; only sale/return decrements
# flow through the sales_queue (see Module 1, Section 1.16)
inventory_offline_mode: "read_only"
# Discrepancy handling (server is authoritative)
discrepancy_handling: "flag_for_review"
# Operations allowed offline (inventory perspective)
allowed_offline:
- "sale_decrement" # Queued in sales_queue
- "return_increment" # Queued in sales_queue
- "stock_lookup" # Read-only from product_cache
# Operations blocked offline
blocked_offline:
- "adjustment"
- "count_entry"
- "multi_store_lookup"
- "transfer_request"
- "online_fulfillment"
- "shopify_sync"
- "po_submission"
- "po_receiving"
- "rma_creation"
# ------------------------------------------
# ONLINE FULFILLMENT
# ------------------------------------------
online_fulfillment:
# Store assignment strategy
strategy: "nearest" # Options: "nearest", "round_robin", "priority"
# Allow split fulfillment across multiple stores
split_fulfillment_enabled: false
max_splits: 3
# Exclude HQ warehouse from online fulfillment
exclude_hq: false
# Minimum stock to retain at store after online order reservation
min_retain_qty: 1
# Shopify sync settings
shopify_sync:
reconciliation_interval_minutes: 15
webhook_retry_count: 3
webhook_retry_backoff: [5, 15, 45]
source_of_truth: "pos"
# ------------------------------------------
# POS INTEGRATION
# ------------------------------------------
pos_integration:
# Soft reservation behavior on add to cart
cart_reservation:
enabled: true
type: "soft"
payment_failure_hold_seconds: 30
# Parked sale reservation
parked_sale_reservation:
type: "soft"
show_warning_to_other_terminals: true
# Hold-for-pickup reservation
hold_for_pickup_reservation:
type: "hard"
# Return to stock default status
return_default_status: "AVAILABLE"
# Allow staff to override return status
return_status_override_allowed: true
# Override options
return_status_options:
- "AVAILABLE"
- "DAMAGED"
- "QUARANTINE"
Inventory Configuration Field Reference
| Key | Type | Default | Description |
|---|---|---|---|
general.allow_negative_inventory | Boolean | false | Whether sales can proceed when stock is zero |
general.costing_method | Enum | weighted_average | Valuation method for COGS calculation |
general.min_display_qty | Integer | 3 | POS shows “Low Stock” warning below this threshold |
purchase_orders.approval.auto_approve_below | Decimal | 500.00 | PO total below which no approval is needed |
purchase_orders.approval.admin_approval_threshold | Decimal | 5000.00 | PO total requiring admin/owner approval |
purchase_orders.auto_po.enabled | Boolean | true | Auto-generate draft POs when stock hits reorder point |
receiving.mode | Enum | open | Staff sees expected qty; can receive different amount |
receiving.over_receive_threshold_percent | Integer | 10 | Maximum over-receive without manager approval |
reorder.velocity_window_days | Integer | 90 | Days of sales history for velocity calculation |
reorder.safety_stock_sigma | Decimal | 1.65 | Standard deviations for safety stock (~95% service level) |
reorder.dead_stock_days | Integer | 90 | Days of zero sales before flagging as dead stock |
counting.default_freeze_mode | Enum | soft | Inventory freeze behavior during physical counts |
counting.blind_count_enabled | Boolean | true | Hide expected quantities from counters |
counting.variance_approval_threshold_percent | Integer | 5 | Variance percentage requiring manager approval |
adjustments.approval_mode | Enum | threshold | When manual adjustments require manager approval |
transfers.auto_suggest.imbalance_threshold_days | Integer | 14 | Days-of-supply imbalance to trigger auto-suggestion |
transfers.default_transit_days | Integer | 3 | Default estimated transit time between locations |
rma.ship_back_deadline_days | Integer | 30 | Days to ship RMA items after vendor approval |
offline.max_queue_size | Integer | 500 | Maximum queued offline inventory changes |
offline.max_offline_hours | Integer | 24 | Hours before system forces reconnect attempt |
online_fulfillment.strategy | Enum | nearest | Store selection algorithm for online orders |
online_fulfillment.min_retain_qty | Integer | 1 | Minimum stock to keep at store after online reservation |
pos_integration.cart_reservation.type | Enum | soft | Reservation type when item is added to POS cart |
pos_integration.return_default_status | Enum | AVAILABLE | Default inventory status for returned items |
5.20 Tenant Onboarding Wizard
Scope: Step-by-step guided setup workflow for provisioning a new tenant from initial registration through operational go-live readiness. The onboarding wizard ensures that every required configuration area is addressed before the tenant begins processing transactions.
Cross-Reference: All configuration sections referenced below (5.2 through 5.19) contain the detailed data models and business rules for each step.
5.20.1 Onboarding Flow
The onboarding wizard presents 13 sequential steps. Steps 1-5 are mandatory for go-live. Steps 6-13 are recommended but can be deferred.
flowchart TD
S1["Step 1: Company Info\n(5.2, 5.3)"] --> S2["Step 2: Locations\n(5.4)"]
S2 --> S3["Step 3: Registers\n(5.7)"]
S3 --> S4["Step 4: Printers\n(5.8)"]
S4 --> S5["Step 5: Users & Roles\n(5.5)"]
S5 --> S6["Step 6: Clock-In/Out\n(5.6)"]
S6 --> S7["Step 7: Tax\n(5.9)"]
S7 --> S8["Step 8: Units of Measure\n(5.10)"]
S8 --> S9["Step 9: Payment Methods\n(5.11)"]
S9 --> S10["Step 10: Email\n(5.15)"]
S10 --> S11["Step 11: Integrations\n(5.16)"]
S11 --> S12["Step 12: Business Rules\n(5.19)"]
S12 --> S13["Step 13: Go-Live Checklist\n(Validation)"]
S13 -->|All checks pass| GL["GO LIVE"]
S13 -->|Checks failed| FIX["Review Failures\n(Return to failing step)"]
FIX --> S13
style S1 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S2 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S3 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S4 fill:#264653,stroke:#1d3557,color:#fff
style S5 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S6 fill:#264653,stroke:#1d3557,color:#fff
style S7 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S8 fill:#264653,stroke:#1d3557,color:#fff
style S9 fill:#2d6a4f,stroke:#1b4332,color:#fff
style S10 fill:#264653,stroke:#1d3557,color:#fff
style S11 fill:#264653,stroke:#1d3557,color:#fff
style S12 fill:#264653,stroke:#1d3557,color:#fff
style S13 fill:#7b2d8e,stroke:#5a1d6e,color:#fff
style GL fill:#2d6a4f,stroke:#1b4332,color:#fff
style FIX fill:#c0392b,stroke:#922b21,color:#fff
5.20.2 Step Details
| Step | Name | Section Ref | Required | Description |
|---|---|---|---|---|
| 1 | Company Info | 5.2, 5.3 | Yes | Set tenant name, legal entity name, logo, timezone, base currency, date/time format, fiscal year start |
| 2 | Locations | 5.4 | Yes | Create all physical locations (stores and warehouses). Set address, phone, timezone override, and location type. |
| 3 | Registers | 5.7 | Yes | Add registers to each store location. Select profile (Full POS, Mobile POS, Inventory-Only). Pair physical devices. |
| 4 | Printers | 5.8 | No | Register receipt and label printers. Link printers to registers. Run network discovery if applicable. |
| 5 | Users & Roles | 5.5 | Yes | Create user accounts. Assign roles (Owner, Admin, Manager, Cashier, Inventory Clerk). Assign users to locations. |
| 6 | Clock-In/Out | 5.6 | No | Configure clock-in/clock-out settings. Enable time tracking for staff if needed. Skip for operations that do not require time tracking. |
| 7 | Tax | 5.9 | Yes | Assign tax jurisdiction to each store location. Review compound tax rates (State/County/City), tax calculation priority, and exemption handling. |
| 8 | Units of Measure | 5.10 | No | Review predefined UoMs. Create custom UoMs if needed (e.g., BUNDLE, SET, ROLL). Skip if standard UoMs are sufficient. |
| 9 | Payment Methods | 5.11 | Yes | Enable payment methods per location (Cash, Credit Card, Gift Card, Store Credit, etc.). Configure payment processor credentials. |
| 10 | 5.15 | No | Configure SMTP or API email provider. Send test email. Enable/disable individual email templates. | |
| 11 | Integrations | 5.16 | No | Connect Shopify store (enter shop URL, API key, access token). Verify connection. Configure sync mode. |
| 12 | Business Rules | 5.19 | No | Review all business rule defaults. Customize return policy, discount limits, offline mode settings, and inventory rules as needed. |
| 13 | Go-Live Checklist | – | Yes | Automated validation of all mandatory requirements. Displays pass/fail for each check. |
5.20.3 Go-Live Validation Rules
The go-live checklist runs automated validation against all mandatory configuration requirements. The tenant cannot begin processing transactions until all mandatory checks pass.
Mandatory Checks (must all pass):
| # | Validation Rule | Failure Message |
|---|---|---|
| 1 | At least 1 location created | “No locations configured. Create at least one store location.” |
| 2 | At least 1 location of type STORE | “No store locations found. At least one location must be a retail store.” |
| 3 | At least 1 register per store location | “Store ‘{location_name}’ has no registers. Add at least one register.” |
| 4 | At least 1 user with OWNER role | “No Owner user found. At least one user must have the Owner role.” |
| 5 | Tax jurisdiction assigned to each store location | “Store ‘{location_name}’ has no tax jurisdiction assigned. Assign a tax jurisdiction with at least one active rate.” |
| 6 | At least 1 payment method enabled per store location | “Store ‘{location_name}’ has no payment methods enabled. Enable at least one.” |
| 7 | Email sender configured | “No email provider configured. Configure SMTP or API email for receipts.” |
| 8 | Base currency set | “Base currency is not configured. Set the tenant’s base currency.” |
| 9 | Default timezone set | “Default timezone is not configured. Set the tenant’s timezone.” |
Recommended Checks (advisory, do not block go-live):
| # | Validation Rule | Advisory Message |
|---|---|---|
| 1 | Shopify integration connected | “Shopify integration is not connected. Online orders will not sync.” |
| 2 | At least 1 label printer per location | “No label printers configured. Barcode printing will be unavailable.” |
| 3 | Receipt printer linked to each Full POS register | “Register ‘{register_name}’ has no receipt printer linked.” |
| 4 | Custom fields configured (if applicable) | “No custom fields defined. Custom fields can be added later.” |
| 5 | Business rules reviewed | “Business rules are using system defaults. Review and customize as needed.” |
| 6 | Clock-in/out reviewed | “Clock-in/out settings not reviewed. Time tracking will use system defaults.” |
| 7 | Receipt header/footer customized | “Receipt template is using system defaults. Customize header and footer.” |
5.20.4 Onboarding State Tracking
Onboarding Progress Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant (unique constraint) |
current_step | Integer | Yes | Current wizard step (1-13) |
steps_completed | JSON | Yes | Map of step_number: {completed: boolean, completed_at: DateTime, skipped: boolean} |
go_live_ready | Boolean | Yes | Whether all mandatory checks pass (default: false) |
go_live_at | DateTime | No | Timestamp when the tenant was activated for live operations |
started_at | DateTime | Yes | Timestamp when onboarding began |
completed_at | DateTime | No | Timestamp when the wizard was fully completed (all 13 steps addressed) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Steps can be completed in any order, though the wizard presents them sequentially as a recommended progression.
- Steps can be revisited and modified at any time before go-live.
- Steps marked as “No” in the Required column can be explicitly skipped. Skipping sets
skipped: truefor that step. - After go-live, the onboarding wizard is no longer displayed. All configuration is managed through the standard Nexus POS settings pages.
- The onboarding wizard state is preserved so that if the tenant administrator abandons the wizard mid-way and returns later, progress is restored.
5.21 User Stories & Acceptance Criteria
Scope: All user stories and Gherkin acceptance criteria for Module 5 (Setup & Configuration). Stories are organized into 17 epics covering all functional areas. Each epic includes user stories in standard format and one or more Gherkin feature files with acceptance scenarios.
Epic 5.A: System Settings & Branding
US-5.A.1: Configure Company Identity
- As a tenant administrator, I want to configure the company name, legal entity name, logo, and timezone so that the system reflects our brand identity across all interfaces and reports.
- Constraint: Logo must be PNG or SVG, max 2MB, min 200x200px. Timezone uses IANA identifiers.
US-5.A.2: Set Business Hours per Location
- As a tenant administrator, I want to set operating hours per location so that shift schedules and report periods align with actual store hours.
- Constraint: Hours are defined per day of week. Locations can have different hours.
US-5.A.3: Customize Branding
- As a tenant administrator, I want to customize the login page branding, primary color scheme, and Nexus POS header so that the system looks consistent with our brand.
- Constraint: Primary and secondary color must be valid hex codes. Preview available before saving.
Feature: System Settings Configuration
As a tenant administrator
I need to configure company identity and branding
So that the system reflects our business across all touchpoints
Background:
Given I am logged in as a user with "OWNER" role
And I am on the System Settings page
Scenario: Configure company identity on initial setup
When I enter "Nexus Clothing" as the tenant name
And I enter "Nexus Clothing LLC" as the legal entity name
And I upload a valid PNG logo "nexus-logo.png" (500x500px, 150KB)
And I select timezone "America/New_York"
And I select currency "USD"
And I click "Save Settings"
Then the settings should be saved successfully
And the header should display "Nexus Clothing"
And the logo should appear in the top-left corner
Scenario: Reject invalid logo upload
When I attempt to upload a logo file "oversized.png" (5MB)
Then the system should reject the upload with message "Logo must be under 2MB"
And the existing logo should remain unchanged
Scenario: Currency becomes immutable after first transaction
Given at least one transaction has been processed
When I attempt to change the base currency from "USD" to "CAD"
Then the system should block the change with message "Currency cannot be changed after transactions have been processed"
Epic 5.B: Location Management
US-5.B.1: Create Store and Warehouse Locations
- As a tenant administrator, I want to create store and warehouse locations with addresses and contact information so that the system knows the physical topology of the business.
- Constraint: Each location must have a unique name within the tenant. Location type is
STOREorWAREHOUSE.
Feature: Location Management
As a tenant administrator
I need to define physical locations
So that the system maps to our real-world store topology
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: Create a new store location
When I navigate to Locations management
And I click "Add Location"
And I enter name "Georgetown Store"
And I select type "STORE"
And I enter address "3100 M Street NW, Washington, DC 20007"
And I enter phone "(202) 555-0100"
And I click "Save Location"
Then the location "Georgetown Store" should be created
Scenario: Prevent duplicate location names
Given a location named "Georgetown Store" already exists
When I attempt to create another location named "Georgetown Store"
Then the system should reject with message "A location with this name already exists"
Epic 5.C: User & Role Management
US-5.C.1: Create User Accounts
- As a tenant administrator, I want to create user accounts with profiles including name, email, phone, and PIN so that staff can access the system with appropriate credentials.
- Constraint: Email must be unique within the tenant. PIN must be exactly 4 digits. Username auto-generated from email.
US-5.C.2: Configure Feature Toggles per Role
- As a tenant administrator, I want to configure which features each role can access so that staff only see functionality relevant to their job.
- Constraint: Feature toggles are grouped by module. Changes take effect on next login.
US-5.C.3: Assign Users to Multiple Locations
- As a tenant administrator, I want to assign a user to one or more locations so that I can set their default location, control reporting filters, and organize staff by store.
- Constraint: A user must be assigned to at least one location. Location assignments are informational — they set defaults and reporting scope but do not restrict which locations a user can process transactions at.
Feature: User and Role Management
As a tenant administrator
I need to manage staff accounts, roles, and location assignments
So that employees have appropriate defaults and reporting scope
Background:
Given I am logged in as a user with "OWNER" role
And locations "Georgetown Store" and "HQ Warehouse" exist
Scenario: Create a new cashier user
When I navigate to User Management
And I click "Add User"
And I enter first name "Sarah" and last name "Johnson"
And I enter email "sarah.johnson@nexus.com"
And I enter PIN "4521"
And I assign role "CASHIER"
And I assign location "Georgetown Store"
And I click "Create User"
Then user "Sarah Johnson" should be created with role "CASHIER"
And she should be assigned to "Georgetown Store"
And a welcome email should be queued
Scenario: Prevent duplicate email
Given user "sarah.johnson@nexus.com" already exists
When I attempt to create a user with email "sarah.johnson@nexus.com"
Then the system should reject with message "A user with this email already exists"
Scenario: Assign user to multiple locations (informational)
Given user "Mark Chen" exists with role "MANAGER"
When I edit user "Mark Chen"
And I add location assignment "HQ Warehouse"
And I click "Save"
Then "Mark Chen" should be assigned to both "Georgetown Store" and "HQ Warehouse"
And his primary location should be used as the default at login
And he should be able to process transactions at any tenant location regardless of assignment
Epic 5.D: Register & Device Management
US-5.D.1: Add Registers to a Location
- As a tenant administrator, I want to add registers to a store location so that the store can process transactions.
- Constraint: Register name must be unique within the location. Register number auto-increments.
US-5.D.2: Pair Devices with Registers
- As a tenant administrator, I want to pair a physical device (iPad, PC, terminal) with a register so that the device operates as that register.
- Constraint: Device pairing uses a one-time PIN code. One device per register at a time.
US-5.D.3: Assign Register Profiles
- As a tenant administrator, I want to assign a profile (Full POS, Mobile POS, Inventory-Only) to each register so that the interface matches the register’s purpose.
- Constraint: Full POS requires a receipt printer link. Mobile POS and Inventory-Only do not require printers.
Feature: Register and Device Management
As a tenant administrator
I need to configure registers and pair physical devices
So that store staff can process transactions
Background:
Given I am logged in as a user with "ADMIN" role
And location "Georgetown Store" exists
Scenario: Add a Full POS register
When I navigate to Registers for "Georgetown Store"
And I click "Add Register"
And I enter name "Main Counter"
And I select profile "FULL_POS"
And I click "Save Register"
Then register "Main Counter" should be created at "Georgetown Store"
And a configuration warning should show "No receipt printer linked"
And the register status should be "INACTIVE" until a device is paired
Scenario: Pair a device using PIN code
Given register "Main Counter" exists at "Georgetown Store"
When I click "Generate Pairing PIN" on the register
Then a 6-digit pairing PIN should be displayed with 5-minute expiry
When the POS device enters the pairing PIN "847293"
Then the device should be paired to "Main Counter"
And the register status should change to "ACTIVE"
Scenario: Prevent dual device pairing
Given register "Main Counter" is already paired to "iPad-001"
When I attempt to pair "iPad-002" to "Main Counter"
Then the system should prompt "This register is already paired to iPad-001. Unpair first?"
Epic 5.E: Printer Configuration
US-5.E.1: Register Printers
- As a tenant administrator, I want to register receipt and label printers at each location so that transactions can print receipts and staff can print barcode labels.
- Constraint: Printers are classified as RECEIPT or LABEL. Connection types: USB, NETWORK_IP, BLUETOOTH.
US-5.E.2: Link Printers to Registers
- As a tenant administrator, I want to link printers to specific registers so that each register knows which printer to use.
- Constraint: Each Full POS register requires exactly one PRIMARY_RECEIPT printer. LABEL and SECONDARY_RECEIPT are optional.
Feature: Printer Configuration
As a tenant administrator
I need to register and link printers to registers
So that receipts and labels print to the correct devices
Background:
Given I am logged in as a user with "ADMIN" role
And location "Georgetown Store" exists with register "Main Counter"
Scenario: Register a network receipt printer
When I navigate to Printers for "Georgetown Store"
And I click "Add Printer"
And I enter name "Main Counter Printer"
And I select type "RECEIPT"
And I select connection "NETWORK_IP"
And I enter address "192.168.1.50:9100"
And I select paper width "80MM"
And I click "Save Printer"
Then printer "Main Counter Printer" should be registered
And a health check should be initiated
And status should display "ONLINE" or "OFFLINE"
Scenario: Link receipt printer to register
Given printer "Main Counter Printer" exists and is ONLINE
When I navigate to register "Main Counter" printer assignments
And I assign "Main Counter Printer" as "PRIMARY_RECEIPT"
And I click "Save"
Then the configuration warning "No receipt printer linked" should be cleared
And "Main Counter" should print to "Main Counter Printer"
Scenario: USB printer cannot be shared
Given a USB printer "USB Receipt Printer" is linked to "Main Counter"
When I attempt to link "USB Receipt Printer" to register "Side Counter"
Then the system should reject with message "USB printers cannot be shared between registers"
Epic 5.F: Tax Configuration
US-5.F.1: Assign Tax Jurisdiction to Location
- As a tenant administrator, I want to assign a tax jurisdiction to each store location so that compound sales tax (State + County + City) is correctly calculated on transactions.
- Constraint: Each location references exactly one jurisdiction. A jurisdiction defines up to 3 rate levels. Rate changes per level are scheduled with an effective date.
US-5.F.2: Automatic Compound Tax Calculation
- As a cashier, I want tax to automatically calculate on each line item by summing all active rates for the store location’s jurisdiction so that I do not need to manually compute tax.
- Constraint: Tax follows priority: product exemption > customer exemption > jurisdiction compound rate.
Feature: Tax Jurisdiction Configuration
As a tenant administrator
I need to configure tax jurisdictions and assign them to store locations
So that compound sales tax is correctly applied to all transactions
Background:
Given I am logged in as a user with "ADMIN" role
And location "Georgetown Store" exists in Virginia
Scenario: Create a tax jurisdiction and assign to location
When I navigate to Tax Jurisdictions
And I create jurisdiction "VA-NFK" named "Norfolk, Virginia"
And I add a STATE rate "Virginia State Tax" at 4.300%
And I add a COUNTY rate "Hampton Roads Regional Tax" at 0.700%
And I add a CITY rate "Norfolk City Tax" at 1.000%
And I assign jurisdiction "VA-NFK" to "Georgetown Store"
Then the effective compound tax rate for "Georgetown Store" should be 6.000%
And all future transactions at this location should apply 6.000% compound tax
Scenario: Schedule a future rate change at one level
Given "Georgetown Store" has jurisdiction "VA-NFK" with compound rate 6.000%
When I add a new STATE rate of "4.500%" with effective date "2027-01-01"
And I click "Save Tax Rate"
Then the system should show the current compound rate as 6.000%
And the scheduled STATE rate as 4.500% effective January 1, 2027
And the compound rate should automatically change to 6.200% on the effective date
Scenario: Tax exemption applied for exempt customer
Given "Georgetown Store" has a compound tax rate of 6.000%
And customer "City of Alexandria" is marked as tax-exempt with valid certificate
When a sale is processed for "City of Alexandria"
Then all line items should show tax as $0.00
And the receipt should display "Tax Exempt" with the certificate number
Epic 5.G: UoM Management
US-5.G.1: Manage Units of Measure
- As a tenant administrator, I want to view and manage the available units of measure so that products use the correct selling and purchasing units.
- Constraint: System-predefined UoMs cannot be modified or deleted. Custom UoMs can be deactivated.
US-5.G.2: Create Custom UoMs
- As a tenant administrator, I want to create custom units of measure with conversion factors so that I can handle specialty product measurements.
- Constraint: Custom UoMs must specify a category (QUANTITY, LENGTH, WEIGHT) and conversion factor to the base unit.
Feature: Unit of Measure Management
As a tenant administrator
I need to manage units of measure
So that products are sold and purchased in the correct units
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: View predefined UoMs
When I navigate to Units of Measure management
Then I should see system UoMs including "Each", "Pair", "Dozen", "Case", "Yard"
And system UoMs should be marked as non-editable
Scenario: Create a custom UoM
When I click "Add Custom UoM"
And I enter code "BUNDLE" and name "Bundle of 5"
And I select category "QUANTITY"
And I enter conversion factor 5 to base unit "Each"
And I click "Save"
Then UoM "BUNDLE" should be created
And the conversion table should show "1 BUNDLE = 5 EACH"
And the inverse "1 EACH = 0.2 BUNDLE" should be auto-generated
Scenario: Prevent deleting a UoM in use
Given UoM "BUNDLE" is assigned to product "Sock Pack"
When I attempt to delete UoM "BUNDLE"
Then the system should reject with message "Cannot delete UoM in use. Deactivate instead."
Epic 5.H: Payment Methods Setup
US-5.H.1: Enable Payment Methods per Location
- As a tenant administrator, I want to enable or disable payment methods per location so that each store only accepts configured payment types.
- Constraint: At least one payment method must be enabled per store location. Cash cannot be disabled.
US-5.H.2: Configure Payment Processor
- As a tenant administrator, I want to configure the payment processor credentials so that credit card transactions are processed.
- Constraint: Credentials are encrypted at rest. Test transaction available for validation.
Feature: Payment Methods Setup
As a tenant administrator
I need to configure payment methods per location
So that each store can accept the correct payment types
Background:
Given I am logged in as a user with "ADMIN" role
And location "Georgetown Store" exists
Scenario: Enable standard payment methods
When I navigate to Payment Methods for "Georgetown Store"
And I enable "Cash", "Credit Card", "Gift Card", and "Store Credit"
And I disable "Affirm"
And I click "Save"
Then "Georgetown Store" should accept Cash, Credit Card, Gift Card, and Store Credit
And Affirm should not be available as a payment option at this location
Scenario: Prevent disabling all payment methods
When I attempt to disable all payment methods for "Georgetown Store"
Then the system should reject with message "At least one payment method must be enabled"
Scenario: Configure payment processor credentials
When I navigate to Payment Processor settings
And I enter merchant ID "MID_12345"
And I enter API key and secret
And I click "Test Connection"
Then the system should perform a $0.00 authorization test
And the result should display "Connection Successful" or an error message
Epic 5.I: Custom Fields
US-5.I.1: Define Custom Fields per Entity
- As a tenant administrator, I want to define custom fields for products, customers, and transactions so that I can capture business-specific data not covered by standard fields.
- Constraint: Max 20 custom fields per entity type. Supported types: TEXT, NUMBER, DATE, BOOLEAN, SELECT.
US-5.I.2: Custom Fields on Forms
- As a staff member, I want custom fields to appear on the relevant entity forms so that I can enter the custom data during normal workflows.
- Constraint: Custom fields appear in a dedicated “Custom Fields” section. Sort order controls display sequence.
Feature: Custom Fields Configuration
As a tenant administrator
I need to define custom fields for business entities
So that we can capture data specific to our business
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: Create a custom text field for products
When I navigate to Custom Fields management
And I select entity type "PRODUCT"
And I click "Add Custom Field"
And I enter label "Fabric Composition" and field type "TEXT"
And I set max length to 200
And I mark it as required
And I click "Save"
Then custom field "Fabric Composition" should be added to products
And it should appear on the product edit form
Scenario: Create a select field with options
When I add a custom field for "CUSTOMER"
And I enter label "Preferred Contact Method" and field type "SELECT"
And I add options: "Phone", "Email", "SMS", "None"
And I click "Save"
Then the field should appear as a dropdown on customer forms
And only the defined options should be selectable
Scenario: Enforce custom field limit
Given 20 custom fields already exist for entity type "PRODUCT"
When I attempt to add a 21st custom field
Then the system should reject with message "Maximum of 20 custom fields per entity type"
Epic 5.J: Approval Workflows
US-5.J.1: Configure Approval Rules
- As a tenant administrator, I want to configure which actions require approval and the threshold values so that high-value or sensitive operations are reviewed before proceeding.
- Constraint: Approval rules support threshold-based, always-required, and never-required modes per action type.
US-5.J.2: Approve or Reject Requests
- As a manager, I want to view pending approval requests and approve or reject them with an optional reason so that the workflow continues without delay.
- Constraint: A user cannot approve their own request. Pending requests expire after 90 days.
Feature: Approval Workflows
As a tenant administrator
I need to configure approval rules for sensitive actions
So that high-value operations receive proper authorization
Background:
Given I am logged in as a user with "OWNER" role
And approval rules are configured with defaults
Scenario: Configure PO approval threshold
When I navigate to Approval Rules
And I set "PURCHASE_ORDER" manager threshold to $1000.00
And I set "PURCHASE_ORDER" admin threshold to $10000.00
And I click "Save"
Then POs below $1000 should auto-approve
And POs between $1000 and $9999.99 should require manager approval
And POs at $10000+ should require admin approval
Scenario: Approve a pending PO request
Given a PO "PO-2026-00200" for $2500.00 is pending manager approval
And I am logged in as a "MANAGER"
When I navigate to Pending Approvals
And I select "PO-2026-00200"
And I click "Approve"
Then the PO status should change to "APPROVED"
And the requester should receive a notification
Scenario: Self-approval is prevented
Given user "John" submitted PO "PO-2026-00201"
When "John" attempts to approve his own PO
Then the system should reject with message "You cannot approve your own request"
Epic 5.K: Receipt Builder
US-5.K.1: Configure Receipt Layout
- As a tenant administrator, I want to configure the receipt paper width, font size, field order, and separator style so that receipts match our brand and printer capabilities.
- Constraint: Preview must be available before saving. Changes apply to all registers at the affected location.
US-5.K.2: Customize Header and Footer
- As a tenant administrator, I want to customize the receipt header (company name, tagline, logo) and footer (return policy, website, thank-you message) so that receipts include our business information.
- Constraint: Header supports 3 text lines plus logo. Footer supports 3 text lines. Blank lines are omitted from print.
Feature: Receipt Builder
As a tenant administrator
I need to configure receipt layout and content
So that printed receipts match our brand and include required information
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: Configure receipt header
When I navigate to Receipt Configuration
And I set header line 1 to "Nexus Clothing"
And I set header line 2 to "Fashion for Everyone"
And I upload a monochrome logo
And I set footer line 1 to "Returns accepted within 30 days with receipt."
And I set footer line 2 to "www.nexusclothing.com"
And I click "Save"
Then the receipt preview should show the updated header and footer
And all future receipts should use this configuration
Scenario: Toggle receipt fields
When I navigate to Receipt Field Configuration
And I disable "register_number" and "savings_total"
And I enable "loyalty_points" and "customer_name"
And I click "Save"
Then receipts should show loyalty points and customer name
And receipts should not show register number or savings total
Scenario: Location-level receipt override
Given a tenant-wide receipt configuration exists
When I create a location-specific override for "Georgetown Store"
And I set header line 2 to "Georgetown Location - Since 2015"
And I click "Save"
Then "Georgetown Store" should use the override header
And all other locations should use the tenant-wide default
Epic 5.L: Email & Communications
US-5.L.1: Configure Email Provider
- As a tenant administrator, I want to configure the SMTP or API email provider so that the system can send transactional and notification emails.
- Constraint: Configuration must be verified via test email before activation.
US-5.L.2: Enable or Disable Templates
- As a tenant administrator, I want to enable or disable individual email templates so that I control which automated emails are sent.
- Constraint: Disabling a template prevents sending but does not affect the triggering event.
Feature: Email Configuration
As a tenant administrator
I need to configure email delivery and manage templates
So that customers and staff receive appropriate automated communications
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: Configure SMTP email provider
When I navigate to Email Configuration
And I select provider type "SMTP"
And I enter host "smtp.gmail.com" and port 587
And I enter username "pos@nexusclothing.com"
And I enter password and sender name "Nexus Clothing"
And I click "Send Test Email"
Then a test email should be sent to "pos@nexusclothing.com"
And the configuration should be marked as "Verified"
Scenario: Disable a template
Given all email templates are enabled by default
When I navigate to Email Templates
And I disable template "TMPL-TIER-UPGRADE"
And I click "Save"
Then when a customer's tier changes, no email should be sent
And the tier upgrade should still be applied normally
Scenario: Unverified configuration shows warning
Given no email provider has been configured
When I navigate to the Nexus POS dashboard
Then a warning banner should display "Email provider not configured -- email receipts and notifications will not be sent"
Epic 5.M: Integration Hub
US-5.M.1: Connect Shopify Integration
- As a tenant administrator, I want to connect my Shopify store by entering API credentials so that products and inventory sync between the POS and online store.
- Constraint: Connection verified via test API call. Webhook endpoints auto-registered on successful connection.
US-5.M.2: View Integration Health
- As a tenant administrator, I want to view a dashboard showing the health status of all integrations so that I can identify and troubleshoot sync issues.
- Constraint: Dashboard shows status, last sync time, error count, and latency per integration.
Feature: Integration Hub
As a tenant administrator
I need to connect and monitor external system integrations
So that data flows correctly between the POS and external platforms
Background:
Given I am logged in as a user with "ADMIN" role
Scenario: Connect Shopify store
When I navigate to Integration Hub
And I click "Connect Shopify"
And I enter shop URL "nexus-clothes.myshopify.com"
And I enter API key, API secret, and access token
And I select sync mode "pos_master"
And I click "Verify Connection"
Then the system should make a test API call to Shopify
And the Shopify integration status should change to "CONNECTED"
And webhook endpoints should be auto-registered
Scenario: Integration health alert on errors
Given the Shopify integration has accumulated 6 errors in the past 24 hours
Then the Integration Hub dashboard should show Shopify health as "Red"
And a dashboard alert should be sent to all ADMIN and OWNER users
And the error log should be accessible from the integration detail page
Scenario: Disable integration temporarily
Given Shopify integration is currently "CONNECTED" and "ENABLED"
When I toggle the integration to "DISABLED"
Then all sync operations should halt immediately
And queued webhooks should be preserved
And the status should show "CONNECTED" but "DISABLED"
Epic 5.N: Loyalty Settings
US-5.N.1: Configure Loyalty Rates and Tiers
- As a tenant administrator, I want to configure the points-per-dollar rate, tier thresholds, and tier multipliers so that the loyalty program matches our business strategy.
- Constraint: Tier thresholds must be in ascending order. Point multiplier must be >= 1.0.
US-5.N.2: Configure Gift Card Settings
- As a tenant administrator, I want to configure gift card denominations, load limits, and expiry rules so that gift cards comply with our jurisdiction requirements.
- Constraint: Minimum load must be >= $1.00. Maximum load must be >= minimum load. Expiry must comply with state law.
Feature: Loyalty and Rewards Settings
As a tenant administrator
I need to configure loyalty program parameters
So that the rewards program operates according to our business rules
Background:
Given I am logged in as a user with "OWNER" role
Scenario: Configure loyalty point rates
When I navigate to Loyalty Settings
And I set points per dollar to 2
And I set redemption rate to 200 points per dollar
And I set minimum redemption to 200 points
And I set max redemption percent to 25
And I click "Save"
Then customers should earn 2 base points per dollar
And 200 points should be required for $1.00 discount
And customers can pay up to 25% of their total with points
Scenario: Configure tier thresholds
When I set Silver threshold to $500 with 1.25x multiplier
And I set Gold threshold to $2500 with 1.75x multiplier
And I set Platinum threshold to $7500 with 2.5x multiplier
And I click "Save"
Then the tier thresholds should be updated
And existing customers should be re-evaluated against new thresholds
Scenario: Validate tier threshold ordering
When I attempt to set Silver threshold to $5000 and Gold threshold to $2000
Then the system should reject with message "Tier thresholds must be in ascending order"
Epic 5.O: Audit Configuration
US-5.O.1: Toggle Audit Categories
- As a tenant administrator, I want to enable or disable specific audit log categories so that I can control the volume and focus of audit logging.
- Constraint: At least LOGIN and VOID categories must remain enabled. Other categories can be toggled freely.
US-5.O.2: Set Audit Retention Period
- As a tenant administrator, I want to set the audit log retention period and archival policy so that logs are retained long enough for compliance but do not consume unlimited storage.
- Constraint: Minimum retention is 90 days. Minimum archive retention is 365 days.
Feature: Audit Configuration
As a tenant administrator
I need to configure audit logging categories and retention
So that audit data is captured appropriately and stored compliantly
Background:
Given I am logged in as a user with "OWNER" role
Scenario: Disable a non-mandatory audit category
When I navigate to Audit Configuration
And I disable category "DISCOUNT"
And I click "Save"
Then discount events should no longer generate audit log entries
And existing discount audit entries should remain unchanged
Scenario: Prevent disabling mandatory categories
When I attempt to disable category "LOGIN"
Then the system should reject with message "LOGIN audit category cannot be disabled"
When I attempt to disable category "VOID"
Then the system should reject with message "VOID audit category cannot be disabled"
Scenario: Configure retention and archival
When I set retention period to 180 days
And I enable archival with format "COMPRESSED_JSON"
And I set archive purge to 2555 days (7 years)
And I click "Save"
Then logs older than 180 days should be archived during the next nightly job
And archived logs older than 7 years should be permanently deleted
Epic 5.P: Business Rules Configuration
US-5.P.1: Review and Modify Defaults
- As a tenant administrator, I want to review all business rule defaults and modify values that do not fit our operations so that the system behavior matches our policies.
- Constraint: Changes are validated against business rule constraints (e.g., max_line_discount_percent must be 0-100). Changes take effect immediately.
US-5.P.2: Override Rules at Store Level
- As a tenant administrator, I want to override specific business rules at the store level so that different locations can have different policies where needed.
- Constraint: Store-level overrides take precedence over tenant-level defaults. Only overridden values differ; all others inherit from tenant level.
Feature: Business Rules Configuration
As a tenant administrator
I need to review and customize business rules
So that the system enforces our specific operational policies
Background:
Given I am logged in as a user with "OWNER" role
Scenario: Modify return policy
When I navigate to Business Rules > Sales Configuration
And I change "full_refund_days" from 30 to 14
And I change "store_credit_days" from 90 to 60
And I click "Save"
Then the return policy should update immediately
And new transactions should use 14-day refund and 60-day store credit policies
Scenario: Store-level override for discount limits
When I navigate to Business Rules for "Georgetown Store"
And I override "max_line_discount_percent" to 25 (tenant default is 20)
And I click "Save"
Then "Georgetown Store" should allow up to 25% line discounts
And all other stores should continue using the 20% tenant default
Scenario: Validate business rule constraints
When I attempt to set "max_line_discount_percent" to 150
Then the system should reject with message "Value must be between 0 and 100"
When I attempt to set "cash_drawer.variance_tolerance" to -5.00
Then the system should reject with message "Value must be greater than or equal to 0"
Epic 5.Q: Tenant Onboarding
US-5.Q.1: Provision New Tenant via Wizard
- As a platform administrator, I want to provision a new tenant using the step-by-step onboarding wizard so that all required configuration is completed before the tenant goes live.
- Constraint: Wizard tracks progress across sessions. Steps can be revisited. Mandatory steps cannot be skipped.
US-5.Q.2: Validate Go-Live Readiness
- As a platform administrator, I want the system to validate all go-live requirements and display a pass/fail checklist so that I can confirm the tenant is ready for production operations.
- Constraint: All 9 mandatory checks must pass. Advisory checks are informational only. Go-live timestamp is recorded permanently.
Feature: Tenant Onboarding Wizard
As a platform administrator
I need to provision and validate new tenants
So that they are fully configured before processing transactions
Background:
Given I am provisioning a new tenant "Nexus Clothing"
And I am on the onboarding wizard
Scenario: Complete mandatory onboarding steps
When I complete Step 1 (Company Info) with name "Nexus Clothing", timezone "America/New_York", currency "USD"
And I complete Step 2 (Locations) by creating "Georgetown Store" (type: STORE)
And I complete Step 4 (Registers) by adding "Main Counter" (profile: FULL_POS) to "Georgetown Store"
And I complete Step 6 (Users) by creating user "Will" with role "OWNER" at "Georgetown Store"
And I complete Step 8 (Tax) by setting rate 6.000% for "Georgetown Store"
And I complete Step 10 (Payment Methods) by enabling "Cash" and "Credit Card" at "Georgetown Store"
And I complete Step 11 (Email) by configuring SMTP provider
Then the wizard should show steps 1, 2, 4, 6, 8, 10, and 11 as completed
Scenario: Go-live validation passes with all mandatory requirements
Given all mandatory onboarding steps are completed
When I navigate to Step 13 (Go-Live Checklist)
Then all 9 mandatory checks should show "PASS"
And the "Go Live" button should be enabled
When I click "Go Live"
Then the tenant status should change to "ACTIVE"
And the go-live timestamp should be recorded
And the onboarding wizard should no longer be displayed
Scenario: Go-live validation fails with missing requirements
Given Step 7 (Tax) has not been completed for "Georgetown Store"
When I navigate to Step 13 (Go-Live Checklist)
Then mandatory check "Tax jurisdiction assigned to each store location" should show "FAIL"
And the failure message should read "Store 'Georgetown Store' has no tax jurisdiction assigned. Assign a tax jurisdiction with at least one active rate."
And the "Go Live" button should be disabled
And a link should navigate to Step 8 (Tax Configuration)
End of Module 5: Setup & Configuration (Sections 5.15 – 5.21)
6. Integrations & External Systems Module
6.1 Overview & Scope
6.1.1 Module Purpose
Module 6 consolidates every external-system integration into a single, dedicated module. Rather than scattering API credentials, retry logic, webhook handlers, and protocol-specific code across the Sales, Catalog, Inventory, and Setup modules, the Integration Module provides a unified abstraction layer that all other modules call when they need to communicate with the outside world.
This design delivers three key benefits:
- Single Responsibility – Each business module owns its domain logic; Module 6 owns the wire protocol, authentication, error handling, and data mapping for every external provider.
- Swap-ability – Adding a new payment processor or replacing an email provider requires changes only inside Module 6. Upstream modules remain untouched.
- Operational Visibility – Centralised logging, circuit breakers, rate-limit tracking, and webhook ingestion give the operations team one place to monitor every integration.
6.1.2 Scope Statement
In scope – handled inside Module 6:
| Concern | Examples |
|---|---|
| Provider authentication & credential storage | OAuth 2.0 flows, API-key vaults, token refresh |
| Request construction & serialisation | REST, GraphQL, SOAP envelope building |
| Response parsing & normalisation | Mapping provider-specific DTOs to internal canonical models |
| Retry, back-off & circuit-breaker logic | Exponential retry, dead-letter queues, half-open probes |
| Rate-limit management | Leaky-bucket tracking, queue throttling, cost estimation |
| Webhook ingestion & verification | HMAC signature checks, idempotent handler dispatch |
| Idempotency framework | Dedup window, idempotency-key generation, record storage |
| Provider health monitoring | Heartbeat checks, latency histograms, uptime SLAs |
Out of scope – remains in the originating module:
| Concern | Owning Module |
|---|---|
| Deciding when to sync a product listing | Module 3 (Catalog) |
| Deciding which items need restocking from a channel | Module 4 (Inventory) |
| Applying business rules to a payment result (e.g., partial-capture policy) | Module 1 (Sales) |
| Configuring which integrations are enabled per tenant | Module 5 (Setup) |
| Rendering integration status in the Nexus POS UI | Module 5 (Setup) / Front-end layer |
6.1.3 Cross-References to Source Modules
Cross-Reference: See Module 1, Section 1.6 for payment-processing business rules that Module 6 executes against the configured payment provider.
Cross-Reference: See Module 3, Section 3.9 for the product-sync lifecycle that triggers Module 6 outbound calls to Shopify, Amazon, and Google Merchant.
Cross-Reference: See Module 4, Section 4.5 for inventory-level sync events that Module 6 pushes to connected channels.
Cross-Reference: See Module 5, Section 5.4 for tenant-level integration configuration (credentials, enabled providers, sync schedules).
6.1.4 Integration Types
| Code | Provider | Direction | Primary Data |
|---|---|---|---|
SHOPIFY | Shopify (REST + GraphQL) | Bi-directional | Products, orders, inventory levels, fulfillments |
AMAZON | Amazon SP-API | Bi-directional | Listings, orders, FBA inventory, reports |
GOOGLE_MERCHANT | Google Merchant Center | Outbound + webhooks | Product feeds, local inventory, promotions |
PAYMENT_PROCESSOR | Stripe / Square / Adyen | Bi-directional | Charges, refunds, disputes, payouts |
EMAIL_PROVIDER | SendGrid / Postmark / SES | Outbound + webhooks | Transactional email, delivery events |
SHIPPING_CARRIER | EasyPost / ShipStation | Bi-directional | Rate quotes, labels, tracking events |
6.1.5 Module Interconnection Diagram
flowchart TD
M1[Module 1: Sales] -->|Payment flow| M6
M3[Module 3: Catalog] -->|Product sync| M6
M4[Module 4: Inventory] -->|Stock sync| M6
M5[Module 5: Setup] -->|Configuration| M6
M6[Module 6: Integrations]
M6 -->|Product listings| SHOP[Shopify]
M6 -->|Product listings| AMZ[Amazon SP-API]
M6 -->|Local inventory| GOOG[Google Merchant]
M6 -->|Card processing| PAY[Payment Processors]
M6 -->|Transactional email| EMAIL[Email Providers]
M6 -->|Rate/Label/Track| CARRIER[Shipping Carriers]
6.2 Integration Architecture
6.2.1 Provider Abstraction Layer
Every external provider – regardless of protocol – implements a common interface so that calling code never depends on provider-specific details.
Abstract Interface: IntegrationProvider
export interface IntegrationProvider {
connect(config: IntegrationConfig): Promise<ConnectionResult>;
disconnect(connectionId: string): Promise<DisconnectResult>;
sync(request: SyncRequest): Promise<SyncResult>;
getStatus(connectionId: string): Promise<ProviderStatus>;
validateCredentials(credentials: ProviderCredentials): Promise<ValidationResult>;
}
| Method | Purpose | Idempotent | Timeout |
|---|---|---|---|
Connect | Establish a new authenticated session or exchange OAuth tokens | No | 30 s |
Disconnect | Revoke tokens and mark the connection inactive | Yes | 15 s |
Sync | Execute a data-synchronisation operation (push or pull) | Yes (via idempotency key) | 120 s |
GetStatus | Return current health, last-sync timestamp, error counts | Yes | 10 s |
ValidateCredentials | Test credentials without persisting a connection | Yes | 15 s |
Provider Registry Pattern
The system maintains a runtime registry of all available provider implementations. At startup, each provider self-registers via dependency injection. Tenant configuration (Module 5) determines which providers are enabled for a given tenant at runtime.
# Example provider registry configuration
integration:
providers:
shopify:
enabled: true
implementation: ShopifyProvider
max_connections_per_tenant: 3
amazon:
enabled: true
implementation: AmazonSpApiProvider
max_connections_per_tenant: 2
google_merchant:
enabled: true
implementation: GoogleMerchantProvider
max_connections_per_tenant: 1
payment_processor:
enabled: true
implementation: StripeProvider # swappable
max_connections_per_tenant: 2
email_provider:
enabled: true
implementation: SendGridProvider # swappable
max_connections_per_tenant: 1
shipping_carrier:
enabled: true
implementation: EasyPostProvider # swappable
max_connections_per_tenant: 5
Data Model: integration_providers
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
provider_type | VARCHAR(50) | Yes | Enum: SHOPIFY, AMAZON, GOOGLE_MERCHANT, PAYMENT_PROCESSOR, EMAIL_PROVIDER, SHIPPING_CARRIER |
provider_name | VARCHAR(100) | Yes | Human-readable name (e.g., “Nexus Shopify Store”) |
status | VARCHAR(20) | Yes | ACTIVE, INACTIVE, ERROR, PENDING_AUTH |
credentials_vault_ref | VARCHAR(255) | Yes | Reference to encrypted credential store (never plaintext) |
config_json | JSONB | No | Provider-specific configuration overrides |
last_sync_at | TIMESTAMPTZ | No | Timestamp of most recent successful sync |
last_error_at | TIMESTAMPTZ | No | Timestamp of most recent error |
error_count | INTEGER | Yes | Rolling error count (reset on success) – default 0 |
circuit_state | VARCHAR(15) | Yes | CLOSED, OPEN, HALF_OPEN – default CLOSED |
created_at | TIMESTAMPTZ | Yes | Row creation timestamp |
updated_at | TIMESTAMPTZ | Yes | Last modification timestamp |
Cross-Reference: See Module 5, Section 5.4.3 for the admin UI screens that manage rows in this table.
6.2.2 Authentication Patterns
Each provider family uses a different authentication mechanism. The table below summarises the patterns; subsequent subsections detail the flows.
| Provider | Auth Method | Token Lifetime | Refresh Mechanism | Credential Storage |
|---|---|---|---|---|
| Shopify | OAuth 2.0 (offline access tokens) | Permanent (until revoked) | N/A – token does not expire | Encrypted vault, single token per store |
| Amazon SP-API | OAuth 2.0 via Login with Amazon (LWA) | 1 hour | refresh_token grant to LWA endpoint | Vault stores refresh_token; access token cached in memory |
| Google Merchant | OAuth 2.0 (Service Account) | 1 hour | Self-signed JWT assertion exchanged for access token | Vault stores service-account JSON key file |
| Payment Processor | API Key + Secret | Permanent (until rotated) | Manual key rotation via provider dashboard | Vault stores key pair; rotation tracked in audit log |
| Email Provider | API Key or SMTP credentials | Permanent (until rotated) | Manual key rotation | Vault stores API key or SMTP user/pass |
| Shipping Carrier | API Key | Permanent (until rotated) | Manual key rotation | Vault stores API key |
Token refresh is automatic. For providers with expiring tokens (Amazon, Google), the Integration Module schedules a background refresh 5 minutes before expiry. If the refresh fails, the circuit breaker transitions the provider to ERROR state and the retry mechanism takes over.
Cross-Reference: See Module 5, Section 5.4.5 for the credential-rotation workflow that triggers re-validation of stored credentials.
6.2.3 Retry & Backoff Strategy
All outbound calls from Module 6 pass through a shared retry pipeline. The strategy uses exponential backoff with jitter to avoid thundering-herd effects when a provider recovers from an outage.
Retry Parameters
| Parameter | Value | Notes |
|---|---|---|
| Max retries | 3 | After initial attempt |
| Base delay | 5 seconds | First retry wait |
| Multiplier | 3x | 5 s -> 15 s -> 45 s |
| Jitter | +/- 20 % | Randomised to spread load |
| Retryable status codes | 429, 500, 502, 503, 504 | Non-retryable: 400, 401, 403, 404, 409 |
| Dead-letter after | 3 failed retries | Message persisted for manual review |
Retry Sequence Diagram
sequenceDiagram
participant Caller as Business Module
participant RM as Retry Manager
participant Provider as External Provider
participant DLQ as Dead Letter Queue
Caller->>RM: Send request
RM->>Provider: Attempt 1
Provider-->>RM: 503 Service Unavailable
Note over RM: Wait 5s (+/- jitter)
RM->>Provider: Attempt 2
Provider-->>RM: 503 Service Unavailable
Note over RM: Wait 15s (+/- jitter)
RM->>Provider: Attempt 3
Provider-->>RM: 503 Service Unavailable
Note over RM: Wait 45s (+/- jitter)
RM->>Provider: Attempt 4 (final)
Provider-->>RM: 503 Service Unavailable
RM->>DLQ: Persist failed message
RM-->>Caller: SyncResult { Success: false, Reason: "DLQ" }
Dead Letter Queue Processing
Failed messages land in the integration_dead_letters table. An operations dashboard (Module 5, Nexus POS) surfaces these records. Operators can:
- Retry – re-enqueue the message for another attempt cycle.
- Skip – mark as resolved without retrying (e.g., stale data that has since been corrected).
- Escalate – flag for engineering investigation.
6.2.4 Circuit Breaker Pattern
The circuit breaker prevents a failing provider from consuming retry budget and delaying upstream callers. Each integration_providers row maintains its own circuit state.
State Machine
| Current State | Condition | Next State | Action |
|---|---|---|---|
CLOSED | Fewer than 5 failures in 60 s | CLOSED | Pass requests through normally |
CLOSED | 5 or more failures in 60 s | OPEN | Reject all requests immediately; log alert |
OPEN | Less than 30 s since transition | OPEN | Return cached error; do not attempt call |
OPEN | 30 s cooldown elapsed | HALF_OPEN | Allow a single probe request |
HALF_OPEN | Probe succeeds | CLOSED | Reset failure counter; resume normal traffic |
HALF_OPEN | Probe fails | OPEN | Restart 30 s cooldown; increment alert severity |
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : 5 failures in 60s
OPEN --> HALF_OPEN : 30s cooldown elapsed
HALF_OPEN --> CLOSED : Probe succeeds
HALF_OPEN --> OPEN : Probe fails
CLOSED --> CLOSED : Request succeeds / failures < threshold
Circuit Breaker Configuration
circuit_breaker:
failure_threshold: 5
failure_window_seconds: 60
cooldown_seconds: 30
half_open_max_probes: 1
alert_on_open: true
alert_channel: "ops-integrations"
Cross-Reference: See Module 5, Section 5.18 for the alerting configuration that fires when a circuit opens.
6.2.5 Idempotency Framework
All mutating operations (product creates, inventory updates, payment captures) are wrapped in an idempotency layer. This guarantees exactly-once semantics even when retries or duplicate webhooks occur.
Dedup Window
| Parameter | Value |
|---|---|
| Window duration | 24 hours |
| Key algorithm | SHA-256 |
| Key input | provider + entity_type + entity_id + operation + timestamp_bucket |
| Timestamp bucket | Truncated to nearest hour |
| Cleanup schedule | Daily at 03:00 UTC – purge records older than 48 hours |
Idempotency Key Generation
idempotency_key = SHA-256(
provider = "SHOPIFY"
entity_type = "PRODUCT"
entity_id = "prod_abc123"
operation = "UPDATE"
timestamp_bucket = "2026-02-17T14:00:00Z" // truncated to hour
)
If a record with the same key already exists in the idempotency_records table, the system returns the cached response without re-executing the operation.
Data Model: idempotency_records
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
idempotency_key | VARCHAR(64) | Yes | SHA-256 hex digest (unique index) |
provider_type | VARCHAR(50) | Yes | Provider enum value |
entity_type | VARCHAR(50) | Yes | E.g., PRODUCT, ORDER, INVENTORY_LEVEL |
entity_id | VARCHAR(100) | Yes | Domain entity identifier |
operation | VARCHAR(30) | Yes | CREATE, UPDATE, DELETE, SYNC |
request_hash | VARCHAR(64) | Yes | SHA-256 of the serialised request body |
response_status | INTEGER | No | HTTP status code of cached response |
response_body | JSONB | No | Cached response payload |
executed_at | TIMESTAMPTZ | Yes | When the operation was executed |
expires_at | TIMESTAMPTZ | Yes | executed_at + 24 hours |
created_at | TIMESTAMPTZ | Yes | Row creation timestamp |
6.2.6 Rate Limit Management
Each provider enforces its own rate limits. Module 6 tracks consumption in real time and throttles outbound requests to stay within published quotas.
Rate Limits by Provider
| Provider | Limit Type | Rate | Strategy |
|---|---|---|---|
| Shopify REST | Leaky bucket | 40-request bucket, leaks at 2 req/s | Queue with inter-request delay; monitor X-Shopify-Shop-Api-Call-Limit header |
| Shopify GraphQL | Calculated query cost | 1,000-point bucket; restores at 50 pts/s (regular) or 500 pts/s (Plus) | Pre-calculate query cost; defer if bucket < required cost |
| Amazon SP-API | Token bucket | Per-endpoint (e.g., getOrders 0.0167 req/s burst 20) | Read x-amzn-RateLimit-Limit response header; adjust dynamically |
| Google Merchant | Daily quota | Products.insert: 2 calls/product/day; batch up to 10,000 entries | Batch mutations into single custombatch call; schedule during off-peak |
| Payment Processor (Stripe) | Sliding window | 100 read/s, 100 write/s (live mode) | Token bucket in-memory; back-off on 429 |
| Email Provider (SendGrid) | Sliding window | Varies by plan (100-10,000 emails/s) | In-memory counter; degrade gracefully |
| Shipping Carrier (EasyPost) | Per-second | 50 req/s | In-memory token bucket |
Rate Limit Tracking
The module maintains an in-memory sliding-window counter per (tenant_id, provider_type) pair. When the counter approaches the limit (80 % threshold), requests are queued rather than rejected. If the queue depth exceeds 500 items, new requests receive a 429 back-pressure signal to the calling module.
rate_limiter:
warning_threshold_pct: 80
max_queue_depth: 500
queue_drain_interval_ms: 100
metrics_export_interval_s: 10
6.2.7 Webhook Processing Pipeline
Inbound webhooks from external providers flow through a multi-stage pipeline that verifies authenticity, deduplicates, and dispatches to the appropriate handler.
Signature Verification by Provider
| Provider | Verification Method | Header / Field | Algorithm |
|---|---|---|---|
| Shopify | HMAC signature | X-Shopify-Hmac-Sha256 | HMAC-SHA256 of raw body with app secret |
| Amazon SP-API | SQS message validation | SignatureVersion, Signature | RSA-SHA1 of canonical message string using Amazon certificate |
| Google Merchant | Push endpoint SSL + JWT | Authorization: Bearer <token> | Verify JWT against Google public keys; validate audience claim |
| Stripe | HMAC signature | Stripe-Signature | HMAC-SHA256 with whsec_ signing secret; timestamp tolerance 300 s |
| SendGrid | Basic auth or OAuth | Authorization header | Compare against stored webhook signing key |
| EasyPost | HMAC signature | X-Hmac-Signature | HMAC-SHA256 of raw body with webhook secret |
Webhook Processing Flow
sequenceDiagram
participant EXT as External Provider
participant GW as API Gateway
participant SIG as Signature Verifier
participant DEDUP as Idempotency Check
participant HANDLER as Webhook Handler
participant DLQ as Dead Letter Queue
participant BUS as Domain Event Bus
EXT->>GW: POST /webhooks/{provider}
GW->>SIG: Verify signature
alt Signature invalid
SIG-->>GW: 401 Unauthorized
GW-->>EXT: 401
else Signature valid
SIG->>DEDUP: Check idempotency key
alt Duplicate webhook
DEDUP-->>GW: 200 OK (cached)
GW-->>EXT: 200
else New webhook
DEDUP->>HANDLER: Dispatch to typed handler
alt Handler succeeds
HANDLER->>BUS: Publish domain event
HANDLER-->>DEDUP: Cache result
DEDUP-->>GW: 200 OK
GW-->>EXT: 200
else Handler fails
HANDLER->>DLQ: Persist failed webhook
HANDLER-->>GW: 200 OK (accepted for retry)
GW-->>EXT: 200
end
end
end
Idempotent Handler Pattern
Every webhook handler follows this template:
- Extract idempotency key from the webhook payload (e.g., Shopify’s
X-Shopify-Webhook-Id, Amazon’snotificationId, Stripe’sevent.id). - Check
idempotency_records– if a record exists and has not expired, return the cached response immediately. - Execute business logic – map the webhook payload to a domain event and publish it to the internal event bus.
- Persist idempotency record – store the key and response so future duplicates are short-circuited.
- Return 200 – always return
200 OKto the provider promptly. Processing failures are handled asynchronously via the dead-letter queue. Returning non-200codes to providers like Shopify triggers exponential re-delivery, which compounds the problem.
Dead Letter Queue for Webhooks
Failed webhooks are stored in integration_dead_letters with full payload and error context. The operations dashboard allows:
| Action | Description |
|---|---|
| Replay | Re-inject the webhook into the processing pipeline |
| Discard | Mark as resolved; no further action |
| Investigate | Attach notes; assign to engineering team |
Cross-Reference: See Module 5, Section 5.18.4 for the admin UI that manages dead-letter webhook records.
6.3 Shopify Integration (Enhanced)
Scope: End-to-end synchronization of product catalog, inventory, orders, and customer data between the Nexus POS system and Shopify e-commerce. This section consolidates all Shopify integration requirements previously distributed across Module 3 (Section 3.7 – Catalog Sync), Module 4 (Section 4.14.3 – Inventory Sync), and Module 5 (Section 5.16.3 – Integration Configuration), and adds new material covering GraphQL API preferences, bulk operations, idempotency, rate limits, webhook topics, POS UI extensions, hardware compatibility, omnichannel requirements, and third-party POS compliance rules.
Cross-Reference: See Section 6.1 for the Integration Hub architecture. See Section 6.2 for integration credentials and security model. See Module 3, Section 3.6 for multi-channel management. See Module 4, Section 4.14 for online order fulfillment workflows.
Consolidation Note: This section replaces and supersedes the following legacy sections:
- Old Section 3.7 (Shopify Integration) – catalog sync modes, field ownership, conflict resolution
- Old Section 4.14.3 (Inventory Sync with Shopify) – inventory sync triggers and architecture
- Old Section 5.16.3 (Shopify Integration) – configuration fields, webhook endpoints
6.3.1 Sync Modes
Two tenant-configurable modes control how data flows between the POS and Shopify:
| Mode | Direction | Default | Use Case |
|---|---|---|---|
| POS-Master | POS -> Shopify (one-way) | Yes | All product data managed in POS. Changes in Shopify for POS-owned fields are overwritten on next sync. Recommended for retailers who manage their catalog exclusively through the POS. |
| Bidirectional with POS Priority | POS <-> Shopify (two-way) | No | Changes flow both directions. POS-owned fields: POS wins on conflict. Shopify-owned fields: Shopify wins. Configurable fields: POS wins with audit trail. Supports retailers whose external teams (SEO agencies, product photographers, copywriters) work directly in Shopify Admin. |
Rationale: Industry standard (Lightspeed, Retail Pro, SKU IQ) uses POS-master as default. Bidirectional supports retailers whose external teams work directly in Shopify Admin. ADR #24 documents this decision.
Important: Inventory sync is ALWAYS bidirectional regardless of catalog sync mode. Even in POS-Master mode, Shopify online sales decrement POS inventory and POS sales decrement Shopify inventory.
Sync Decision Flowchart
flowchart TD
A[Change Detected] --> B{Which System?}
B -->|POS Change| C{Field Ownership?}
B -->|Shopify Change| D{Field Ownership?}
C -->|POS-Owned| E[Push to Shopify via GraphQL Mutation]
C -->|Shopify-Owned| F[Ignore -- Not POS Managed]
C -->|Configurable| G{Sync Mode?}
D -->|POS-Owned| H{Sync Mode?}
D -->|Shopify-Owned| I[Pull to POS as Read-Only]
D -->|Configurable| J{Sync Mode?}
G -->|POS-Master| E
G -->|Bidirectional| E
H -->|POS-Master| K[Overwrite on Next Sync]
H -->|Bidirectional| L[Reject Shopify Change + Log Conflict]
J -->|POS-Master| K
J -->|Bidirectional| M{Conflict?}
M -->|Same Field Changed Both Sides| N[POS Wins + Log Audit Entry]
M -->|Different Fields Changed| O[Merge Both Changes]
E --> P[Log Sync Event to Integration Sync Log]
I --> P
K --> P
L --> P
N --> P
O --> P
6.3.2 Field-Level Ownership
Three ownership categories determine which system has authority over each product field. This model eliminates the majority of sync conflicts by design.
| Category | Fields | Direction | Rationale |
|---|---|---|---|
| POS-Owned | SKU, barcode, base_price, cost, compare_at_price, variants (options/values), vendor, weight, dimensions, tax_code, product_type, lifecycle_status, inventory_qty | POS -> Shopify | Core retail operations data managed exclusively in POS. These fields drive pricing, costing, and inventory accuracy. |
| Shopify-Owned | SEO title (meta_title), SEO description (meta_description), URL handle (slug), metafields, additional images (after primary), image alt text, online collections, sales channel publishing | Shopify -> POS (read-only) | Online-only fields with no POS equivalent. Managed by e-commerce team or SEO agencies directly in Shopify Admin. |
| Configurable | long_description, tags, primary_image | Default: POS -> Shopify. Bidirectional when enabled. | May require external editing when retailer uses agencies for product content. Tenant can toggle per field. |
Business Rules:
- POS-Owned fields changed in Shopify (in POS-Master mode) are silently overwritten on the next sync cycle.
- Shopify-Owned fields are stored in POS as read-only reference data for display purposes (e.g., showing SEO title in admin dashboard).
- Configurable field direction is set per tenant in the Integration Configuration (Section 6.2).
- Field ownership is enforced at the API layer – the sync engine checks ownership before applying any inbound change.
6.3.3 Conflict Resolution
Per-field ownership eliminates the majority of sync conflicts. The remaining edge cases – primarily configurable fields modified on both sides in bidirectional mode – are handled by the conflict resolution engine.
Conflict Audit Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
product_id | UUID | Yes | FK to products table – product where conflict occurred |
shopify_product_id | String(50) | Yes | Shopify product GID (e.g., gid://shopify/Product/123456) |
field_name | String(100) | Yes | The specific field in conflict (e.g., long_description, tags, primary_image) |
pos_value | Text | Yes | Value from POS at time of conflict |
shopify_value | Text | Yes | Value from Shopify at time of conflict |
resolved_value | Text | Yes | Final value written to both systems |
resolution_method | Enum | Yes | AUTO_POS_WINS, AUTO_SHOPIFY_WINS, AUTO_MERGE, MANUAL |
resolved_by | UUID | No | FK to users table – user who manually resolved (null for auto-resolution) |
resolved_at | DateTime | Yes | Timestamp of resolution |
created_at | DateTime | Yes | Conflict detection timestamp |
Conflict Resolution Sequence
sequenceDiagram
autonumber
participant SH as Shopify Webhook
participant GW as Webhook Gateway
participant API as POS Sync Engine
participant DB as POS Database
participant Q as Conflict Review Queue
SH->>GW: Product Updated Webhook (HMAC-SHA256 signed)
GW->>GW: Verify HMAC Signature
GW->>API: Forward Verified Payload
API->>API: Parse Changed Fields from Webhook Body
API->>DB: Fetch Current POS Product State
loop For Each Changed Field
API->>API: Determine Field Ownership Category
alt POS-Owned Field Changed in Shopify
API->>DB: Log Conflict Audit (resolution = AUTO_POS_WINS)
API->>SH: Push POS Value Back to Shopify (GraphQL Mutation)
else Shopify-Owned Field
API->>DB: Update POS Record (read-only reference copy)
else Configurable Field (Bidirectional Mode)
API->>DB: Check POS last_modified Timestamp
alt POS Also Changed Same Field (within sync window)
API->>DB: POS Wins -- Log Conflict Audit
API->>SH: Push POS Value to Shopify
else Only Shopify Changed
API->>DB: Accept Shopify Value into POS
end
end
end
opt Manual Review Flagged
API->>Q: Add to Admin Review Queue
API->>DB: Set conflict status = PENDING_REVIEW
end
Business Rules:
- Configurable fields in bidirectional mode: POS value wins automatically. The overridden Shopify value is preserved in the conflict audit table.
- Non-conflicting changes (different fields modified on each side) merge automatically with no conflict entry created.
- Optional manual review queue: admin dashboard shows pending conflicts flagged for human review. Staff with ADMIN or OWNER role can override auto-resolution.
- Conflict audit records are retained for 12 months for compliance and debugging, then archived.
- Conflicts exceeding 10 per product per day trigger an alert to the admin dashboard (possible integration loop).
6.3.4 Sync Constraints & Technical Notes
| Constraint | Limit | Handling Strategy |
|---|---|---|
| Max variants per product | 100 (Shopify hard limit) | Products exceeding 100 variants are flagged and excluded from Shopify sync with admin notification. Tracked in Sync Coverage report. |
| Max option dimensions | 3 (Shopify hard limit) | POS supports 3 dimensions, aligned with Shopify. Products with >3 dimensions cannot be synced. |
| API rate limit (REST) | 40 req bucket (regular) / 400 req bucket (Plus) | Request queue with leaky bucket tracking; batch operations throttled to stay within budget. |
| API rate limit (GraphQL) | 50 points/sec (regular) / 500 points/sec (Plus) | Query cost pre-calculation before execution; throttle queue when approaching limit. |
| Webhook reliability | HMAC-SHA256 signature verification required | Idempotent handlers with deduplication key. Dead letter queue for failed processing. |
| Product title max length | 255 characters (Shopify limit) | Truncate with ellipsis and log warning. |
| Tag max count | 250 tags per product (Shopify limit) | Excess tags dropped with admin notification. |
Image Sync:
- POS sends the primary image on first product publish only.
- All subsequent image management is performed in Shopify.
- Additional images added in Shopify are pulled to POS as read-only references for display in the POS UI.
- Image URLs from Shopify CDN are stored – images are NOT downloaded to POS storage.
Inventory Sync:
- Inventory sync is ALWAYS bidirectional regardless of catalog sync mode.
- Sales on Shopify decrement POS inventory. POS sales decrement Shopify inventory.
- Uses Shopify Inventory API (
inventorySetQuantitiesGraphQL mutation) with location-level granularity. - Each POS physical location maps 1:1 to a Shopify location.
Sync Timing:
- Real-time for individual changes (webhook-driven + API push).
- Batch processing for bulk operations (imports, stock takes) queued and processed during off-peak hours.
- Periodic reconciliation job runs every 15 minutes to detect and correct drift.
- Daily full reconciliation at 02:00 local time as a safety net.
Variant Limit Handling:
- Products exceeding 100 variants or 3 option dimensions are automatically excluded from Shopify sync.
- Admin receives notification: “Product [SKU] excluded from Shopify sync: exceeds variant limits.”
- Excluded products are tracked in the Sync Coverage report with exclusion reason.
6.3.5 Reports: Shopify Integration
| Report | Purpose | Key Data Fields |
|---|---|---|
| Sync Status Dashboard | Monitor overall sync health in real-time | Products synced, products pending, products failed, last sync time, error count (24h), average sync latency (ms) |
| Conflict Log | Review and resolve sync conflicts | Product SKU, field name, POS value, Shopify value, resolution method, timestamp, resolved by |
| Sync Coverage | Identify products NOT synced to Shopify | Product SKU, product name, exclusion reason (exceeds limits / manually excluded / new / sync error), action needed |
| Sync Activity Log | Detailed sync event history for troubleshooting | Product SKU, direction (POS->Shopify / Shopify->POS), fields synced, timestamp, duration (ms), result (success/failed/partial) |
Report Access: All reports available in Nexus POS under Integrations > Shopify. Exportable to CSV. Sync Status Dashboard refreshes every 60 seconds.
6.3.6 GraphQL API Preference & Query Cost Model
Shopify recommends GraphQL over REST for all new development. The Nexus POS integration MUST use the Shopify GraphQL Admin API as the primary interface.
Rationale:
- GraphQL returns only requested fields, reducing payload size and bandwidth.
- Single request can fetch related resources (product + variants + images) without multiple round trips.
- GraphQL supports bulk operations not available in REST.
- Shopify is actively deprecating REST endpoints in favor of GraphQL.
Query Cost Calculation:
Shopify calculates query cost using the formula:
cost = requested_fields * requested_objects
Each query returns an extensions.cost object with requestedQueryCost, actualQueryCost, and throttleStatus (including currentlyAvailable points).
Query Cost Budget:
| Plan | Points per Second | Max Single Query Cost | Restore Rate |
|---|---|---|---|
| Regular (Development/Basic/Shopify/Advanced) | 50 | 1,000 | 50 points/sec |
| Shopify Plus | 500 | 10,000 | 500 points/sec |
Common Operation Costs:
| Operation | Estimated Cost (points) | Notes |
|---|---|---|
| Fetch single product with variants | 12-15 | Depends on variant count |
| Fetch 50 products (list) | 50-80 | Paginated with cursor |
| Update single product | 10 | productUpdate mutation |
| Update inventory at location | 10 | inventorySetQuantities mutation |
| Fetch 250 inventory levels | 30-50 | inventoryItems query with locations |
| Create product with 5 variants | 15-20 | productCreate mutation |
| Bulk product query (JSONL) | 10 (submit) | Actual processing is async |
Implementation Rules:
- All Shopify API calls MUST use GraphQL Admin API (version
2025-04or later). - REST API is permitted only for endpoints not yet available in GraphQL (e.g., certain carrier service endpoints).
- Every query MUST request the
extensions.costfield to monitor budget consumption. - Sync engine MUST track
currentlyAvailablepoints and pause requests when below 20% of maximum budget. - Query complexity MUST be pre-estimated before execution; queries exceeding 80% of max single query cost must be split.
6.3.7 Idempotency Directive
Starting with API version 2026-04, Shopify requires the @idempotent directive on all mutations to prevent duplicate operations during retries and network failures.
Implementation:
All Shopify mutations issued by the POS sync engine MUST include an idempotencyKey parameter:
idempotency:
key_generation:
algorithm: SHA-256
input_template: "{tenant_id}:{mutation_name}:{entity_id}:{timestamp_bucket}"
timestamp_bucket: 5_minutes # Floor timestamp to 5-minute windows
retry_behavior:
same_key_returns: cached_response
cache_ttl: 60_minutes # Shopify caches idempotent responses for 60 min
example:
tenant_id: "t_abc123"
mutation: "productUpdate"
entity_id: "gid://shopify/Product/789"
timestamp_bucket: "2026-02-17T14:30" # Floored to 5-min boundary
key: "SHA256(t_abc123:productUpdate:gid://shopify/Product/789:2026-02-17T14:30)"
Business Rules:
- Every mutation wrapper function MUST generate and attach an idempotency key before submission.
- If a mutation fails with a network timeout, the sync engine retries with the SAME idempotency key. Shopify returns the cached result if the original mutation succeeded.
- Idempotency keys are logged in the Integration Sync Log for audit and debugging.
- The 5-minute timestamp bucket prevents accidental deduplication of legitimate rapid updates (e.g., price change followed by description change within seconds).
6.3.8 Bulk Operations API
For large data syncs (initial catalog onboarding, full inventory reconciliation, daily product exports), the POS system uses Shopify’s Bulk Operations API instead of individual GraphQL queries.
Bulk Operation Lifecycle:
sequenceDiagram
autonumber
participant POS as POS Sync Engine
participant GQL as Shopify GraphQL API
participant S3 as Shopify Storage (JSONL)
POS->>GQL: Submit bulkOperationRunQuery / bulkOperationRunMutation
GQL-->>POS: Return operation ID + status: CREATED
loop Poll for Completion (every 10 seconds)
POS->>GQL: query { currentBulkOperation { status url } }
GQL-->>POS: status: RUNNING / COMPLETED / FAILED
end
alt Operation Completed
GQL-->>POS: status: COMPLETED, url: "https://storage.shopify.com/result.jsonl"
POS->>S3: Download JSONL Result File
S3-->>POS: Stream JSONL Data
POS->>POS: Parse JSONL, Update Local Database
else Operation Failed
GQL-->>POS: status: FAILED, error details
POS->>POS: Log Error, Queue for Retry
end
Concurrency Limits:
| Operation Type | Max Concurrent | Scope |
|---|---|---|
| QUERY (bulk export) | 5 | Per app per shop |
| MUTATION (bulk import) | 5 | Per app per shop |
| Combined | 10 | 5 queries + 5 mutations simultaneously |
Use Cases:
| Use Case | Operation Type | Frequency | Estimated Duration |
|---|---|---|---|
| Initial catalog sync (new tenant onboarding) | QUERY (export all products from Shopify) | Once | 2-15 min depending on catalog size |
| Full inventory reconciliation | QUERY + MUTATION | Daily at 02:00 | 5-30 min |
| Daily product export to Shopify | MUTATION (staged uploads) | Daily at 03:00 | 5-20 min |
| Price book sync | MUTATION | On demand | 1-10 min |
| Variant bulk update | MUTATION | On demand | 1-10 min |
Implementation Rules:
- Bulk operations MUST be used for any sync involving more than 50 products or 100 inventory levels.
- Individual GraphQL queries are used for real-time, single-entity syncs (e.g., sale completed, product updated).
- JSONL results MUST be streamed (not loaded fully into memory) to handle large catalogs.
- Failed bulk operations are retried up to 3 times with 5-minute intervals before alerting admin.
- Bulk operation results are stored locally for 7 days for debugging.
6.3.9 POS UI Extensions
Shopify POS provides extension points for embedded apps. The Nexus POS integration leverages these APIs for enhanced in-store experiences.
Supported POS Extension APIs:
| API | Minimum Version | Purpose | Nexus Usage |
|---|---|---|---|
| Camera API | POS v10.0+ | Access device camera for barcode scanning | Scan product barcodes within embedded Nexus app view on Shopify POS hardware |
| Translation API | POS v10.19+ | Multi-language support for POS UI | Support bilingual staff (English/Spanish) at retail locations |
| Session Token API | POS v9.0+ | Authenticate embedded app sessions | Secure communication between Nexus embedded app and POS backend without separate login |
| Cart API | POS v10.0+ | Read and modify the active POS cart | Inject custom line items, apply Nexus-managed discounts |
| Customer API | POS v10.0+ | Access customer data in POS context | Display unified customer profile with cross-channel purchase history |
Session Token Authentication:
session_token:
flow: "Shopify POS -> Embedded App"
mechanism: JWT
issued_by: Shopify
validated_by: POS Backend (verify with Shopify public key)
claims:
- iss: "https://{shop}.myshopify.com/admin"
- dest: "https://{shop}.myshopify.com"
- sub: "{staff_member_id}"
- exp: "{expiry_timestamp}"
token_refresh: automatic (before expiry)
no_separate_login: true # Staff authenticates via Shopify POS PIN
Business Rules:
- The Nexus POS app MUST function as a Shopify POS UI extension when deployed alongside Shopify POS hardware.
- Session tokens replace API key authentication for all POS-embedded interactions.
- Camera API usage requires explicit permission grant during app installation.
- Translation API strings are managed in the Nexus localization system and pushed to the extension.
6.3.10 Rate Limits 2026
Shopify enforces rate limits across all API surfaces. The POS sync engine MUST respect these limits to avoid request rejection (HTTP 429) and potential app throttling.
Rate Limit Summary:
| API Type | Regular Store | Shopify Plus | Burst Capacity | Leak Rate / Restore |
|---|---|---|---|---|
| REST Admin API | 40-request bucket | 400-request bucket | Full bucket available immediately | 2 req/sec (regular), 20 req/sec (Plus) |
| GraphQL Admin API | 50 points/sec | 500 points/sec | N/A (cost-based) | Cost-based, restored continuously |
| Bulk Operations | 5 concurrent queries + 5 concurrent mutations | Same | N/A | Poll-based completion |
| Storefront API | 100 req/sec per app per store | Same | N/A | Fixed rate |
| Webhook Delivery | No outbound limit from Shopify | Same | N/A | Must respond within 5 seconds |
Throttle Handling Strategy:
rate_limit_strategy:
monitoring:
track_remaining_points: true # From X-Shopify-Shop-Api-Call-Limit header (REST) or extensions.cost (GraphQL)
alert_threshold: 20_percent # Alert when remaining capacity drops below 20%
backoff:
initial_delay_ms: 500
max_delay_ms: 30000
multiplier: 2.0
jitter: true # Add random jitter to prevent thundering herd
queue:
max_queue_size: 1000
priority_levels:
- CRITICAL: inventory_updates, order_syncs # Process first
- NORMAL: product_updates, customer_syncs
- LOW: bulk_exports, reconciliation
overflow_action: reject_with_retry_after
Business Rules:
- All API calls MUST check remaining rate limit budget before execution.
- When rate limited (HTTP 429), the sync engine MUST use the
Retry-Afterheader value for backoff timing. - Critical operations (inventory updates after sales) receive priority queue placement over non-critical operations (product description syncs).
- Rate limit consumption is logged per minute for capacity planning and plan upgrade recommendations.
6.3.11 Webhook Topics Catalog
The POS system registers for the following Shopify webhook topics. All webhooks are verified using HMAC-SHA256 before processing.
Registered Webhook Topics:
| Topic | Direction | Trigger | POS Action |
|---|---|---|---|
orders/create | Shopify -> POS | Customer places online order | Create fulfillment task at assigned store. Reserve inventory. Notify store staff. |
orders/updated | Shopify -> POS | Order modified (address change, note added) | Update local order record. Refresh fulfillment queue. |
orders/cancelled | Shopify -> POS | Online order cancelled | Release inventory reservation. Remove from fulfillment queue. Log cancellation. |
orders/fulfilled | Shopify -> POS | Order marked fulfilled (may be by third-party) | Update local order status. Confirm inventory decrement. |
orders/partially_fulfilled | Shopify -> POS | Partial shipment completed | Update line-item fulfillment status. Adjust remaining reservation. |
products/create | Shopify -> POS | Product created in Shopify Admin | Import as read-only reference (bidirectional mode). Ignore in POS-Master mode. |
products/update | Shopify -> POS | Product fields modified in Shopify | Apply field-ownership conflict resolution (Section 6.3.3). |
products/delete | Shopify -> POS | Product deleted from Shopify | Flag local product as SHOPIFY_DELETED. Do NOT delete from POS. Admin notification. |
inventory_levels/update | Shopify -> POS | Inventory adjusted in Shopify Admin or by another app | Sync adjusted quantity to POS at corresponding location. Log as EXTERNAL_ADJUSTMENT. |
inventory_levels/connect | Shopify -> POS | Product stocked at a new Shopify location | Create location-level inventory record in POS if location is mapped. |
inventory_levels/disconnect | Shopify -> POS | Product removed from a Shopify location | Set POS inventory to zero at that location. Flag for admin review. |
customers/create | Shopify -> POS | New customer registers online | Create or link customer profile in POS. Merge if email/phone matches existing. |
customers/update | Shopify -> POS | Customer profile updated | Sync updated fields to POS customer record (email, phone, address). |
refunds/create | Shopify -> POS | Online order refunded | Process refund in POS. Increment inventory if items restocked. Update order status. |
app/uninstalled | Shopify -> POS | Merchant uninstalls Nexus app | Disable all sync operations. Preserve local data. Alert admin. Set integration status to DISCONNECTED. |
shop/update | Shopify -> POS | Shop settings changed (currency, timezone, name) | Update cached shop metadata. Validate currency alignment. |
bulk_operations/finish | Shopify -> POS | A bulk operation completes | Download and process JSONL result file. Update sync status. |
Webhook Security & Reliability:
| Parameter | Value |
|---|---|
| Verification method | HMAC-SHA256 using app secret as key |
| Header | X-Shopify-Hmac-Sha256 (Base64-encoded) |
| Mandatory response time | < 5 seconds (return HTTP 200/201/202) |
| Shopify retry policy | 19 retries over 48 hours with exponential backoff |
| Failure threshold | After 19 failed deliveries, webhook is automatically removed by Shopify |
| Deduplication | X-Shopify-Webhook-Id header used as idempotency key for handler deduplication |
| Processing model | Acknowledge immediately (HTTP 200), process asynchronously via background job queue |
Business Rules:
- Webhook handlers MUST return HTTP 200 within 5 seconds. All processing happens asynchronously after acknowledgment.
- Every incoming webhook is deduplicated using
X-Shopify-Webhook-Idbefore queuing for processing. - Failed webhook processing (after acknowledgment) is retried 3 times internally before moving to dead letter queue.
- Webhook registrations are verified daily; any missing registrations are automatically re-created.
- All webhook payloads are logged (with PII redaction) for 30 days for debugging.
6.3.12 Third-Party POS Integration Rules
The Nexus POS is a non-native, custom POS system connecting to Shopify as a third-party application. This imposes specific compliance requirements and architectural constraints that differ from Shopify’s own native POS product.
Integration Architecture:
integration_type: third_party_pos
authentication: OAuth 2.0 (mandatory -- no API key bypass)
app_listing: Shopify App Store (or custom/unlisted app for private deployment)
data_authority: POS is source of truth for products and inventory
checkout_model: Must NOT bypass standard Shopify checkout for online orders
Compliance Requirements:
| Requirement | Description | Implementation |
|---|---|---|
| OAuth Authentication | All API access MUST use OAuth 2.0 access tokens obtained through Shopify’s authorization flow. Direct API key authentication is not permitted for production apps. | Implement OAuth install flow with PKCE. Store encrypted access tokens in Integration Credentials table (Section 6.2). |
| App Security | App MUST follow Shopify’s mandatory security requirements including HTTPS, secure credential storage, and regular security audits. | TLS 1.2+ for all API calls. AES-256 encryption for stored tokens. Annual security review. |
| Checkout Integrity | Third-party POS MUST NOT bypass Shopify’s standard checkout process for online orders. In-store transactions process through the POS payment system. | Online orders flow through Shopify checkout. POS handles in-store payments via its own payment integration (Module 1, Section 1.18). |
| Data Overwrite Awareness | Data pushed from POS potentially overwrites Shopify data. The field ownership model (Section 6.3.2) controls which fields POS is authorized to modify. | Sync engine enforces field ownership before every write operation. Shopify-owned fields are never overwritten by POS. |
| Real-Time Integration | Must support real-time API integration to avoid data delays that cause overselling or price mismatches. | Webhook-driven architecture with < 5 second latency target. Scheduled reconciliation as safety net. |
| Partner Program Compliance | Must comply with Shopify Partner Program requirements including app review, privacy policy, and terms of service. | Maintain active Shopify Partner account. Submit app for review before merchant deployment. |
| Data Privacy | Must handle customer data according to Shopify’s data protection requirements. Implement data deletion webhooks. | Process customers/data_request, customers/redact, and shop/redact mandatory compliance webhooks. |
| API Versioning | Must use a supported API version (within 12 months of release). Deprecated versions result in app rejection. | Track Shopify API version calendar. Update to latest stable version within 6 months of release. Test against release candidate versions. |
Compliance Checklist:
| # | Item | Status | Verification Method |
|---|---|---|---|
| 1 | OAuth 2.0 implementation with PKCE | Required | Shopify app review |
| 2 | HTTPS on all endpoints (TLS 1.2+) | Required | SSL certificate check |
| 3 | HMAC webhook verification on all webhook handlers | Required | Code review |
| 4 | Mandatory compliance webhooks implemented (customers/data_request, customers/redact, shop/redact) | Required | Shopify app review |
| 5 | Access token encryption at rest (AES-256) | Required | Security audit |
| 6 | No Shopify checkout bypass for online orders | Required | Functional test |
| 7 | Field ownership enforcement (no unauthorized overwrites) | Required | Integration test suite |
| 8 | API version within supported window | Required | Automated version check |
| 9 | Privacy policy URL configured in app settings | Required | Shopify Partner Dashboard |
| 10 | App terms of service URL configured | Required | Shopify Partner Dashboard |
| 11 | Rate limit compliance (no brute-force retry loops) | Required | Log analysis |
| 12 | Idempotency keys on all mutations | Required (2026-04+) | Code review |
6.3.13 Shopify Sync Rules & Best Practices
This section documents the operational rules and best practices that govern the day-to-day Shopify integration. These rules reflect both Shopify platform requirements and Nexus POS architectural decisions.
Single Source of Truth
- POS is the inventory master (consistent with ADR #24: POS-Master default sync mode).
- Product data (titles, descriptions, variants, pricing) syncs FROM POS TO Shopify in the default mode.
- Inventory quantities ALWAYS sync bidirectionally regardless of catalog sync mode. This is non-negotiable – Shopify online sales must decrement POS inventory in real time.
Location Configuration
Every POS physical location maps 1:1 to a Shopify location. Online sales decrement inventory at the fulfillment location, NOT a global pool.
Location Mapping Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
pos_location_id | UUID | Yes | FK to locations table (POS location) |
shopify_location_id | String(50) | Yes | Shopify location GID (e.g., gid://shopify/Location/12345) |
shopify_location_name | String(100) | Yes | Shopify location display name (cached) |
is_fulfillment_location | Boolean | Yes | Whether this location fulfills online orders (default: true) |
is_active | Boolean | Yes | Whether sync is active for this location (default: true) |
last_synced_at | DateTime | No | Last successful inventory sync for this location |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Every active POS location MUST have a corresponding Shopify location before inventory sync is enabled.
- Location mapping is configured during tenant onboarding (Step 12 of onboarding wizard, Module 5, Section 5.20).
- If a POS location has no Shopify mapping, inventory changes at that location are NOT synced to Shopify. A warning is displayed in the Integration Health Dashboard.
- “Track Inventory” MUST be enabled for all synced products in Shopify. The sync engine verifies this during reconciliation and enables tracking automatically if missing.
Real-Time Sync Requirements
| Sync Event | Latency Target | Method |
|---|---|---|
| In-store sale completed -> Shopify inventory update | < 5 seconds | Direct GraphQL mutation (fire-and-forget with retry) |
| Shopify online order -> POS fulfillment queue | < 10 seconds | Webhook receipt + async processing |
| Product update in POS -> Shopify product update | < 30 seconds | Queued GraphQL mutation (batched for rate limit efficiency) |
| Customer profile change -> cross-system sync | < 60 seconds | Queued mutation (lower priority) |
| Full inventory reconciliation | Every 15 minutes | Scheduled comparison job |
| Full catalog reconciliation | Daily at 02:00 | Bulk operations API |
Omnichannel Requirements
Customer Profile Unification:
- In-store purchases are linked to the same Shopify customer profile using email or phone match.
- Customer merge logic (Module 2, Section 2.2) handles deduplication when an online customer first visits a physical store.
- Staff can view a customer’s complete purchase history (online + in-store) from the POS terminal.
Cross-Channel Returns:
- Items bought online can be returned in-store. The POS retrieves the Shopify order and processes the return locally.
- Items bought in-store can be returned via Shopify online return flow (if enabled by the retailer).
- Return policy engine (Module 1, Section 1.3) validates return eligibility regardless of purchase channel.
BOPIS (Buy Online, Pick Up In-Store):
sequenceDiagram
autonumber
participant C as Customer
participant SF as Shopify Storefront
participant WH as Shopify Webhook
participant POS as POS Backend
participant ST as Store Staff
participant N as Notification Service
C->>SF: Place Order (select "Local Pickup" at checkout)
SF->>SF: Create Order (fulfillment_status: unfulfilled, delivery_method: local_pickup)
SF->>WH: Emit orders/create webhook
WH->>POS: Deliver orders/create payload (HMAC verified)
POS->>POS: Identify assigned pickup location
POS->>POS: Reserve inventory at pickup location
POS->>ST: Display order on Fulfillment Queue (tagged: PICKUP)
POS->>N: Send "Order Ready for Prep" email to customer
ST->>POS: Pick items, mark as READY_FOR_PICKUP
POS->>N: Send "Your Order is Ready for Pickup" email to customer
POS->>SF: Update Shopify order (fulfillment: ready_for_pickup)
C->>ST: Arrive at store, present order confirmation
ST->>POS: Scan order barcode or search by order number
POS->>POS: Verify customer identity
ST->>POS: Mark as PICKED_UP (complete fulfillment)
POS->>SF: Update Shopify order (fulfillment_status: fulfilled)
POS->>POS: Decrement inventory (SALE movement type)
POS->>N: Send "Pickup Confirmed" email to customer
BOPIS Business Rules:
- BOPIS orders appear in the store’s fulfillment queue with a
PICKUPtag for visual distinction. - Pickup window is configurable per location (default: 48 hours). After expiry, staff is notified and order may be cancelled.
- Inventory is reserved (not decremented) until customer picks up. If pickup expires and order is cancelled, reservation is released.
- Customer receives three notifications: order confirmed, ready for pickup, pickup completed.
Staff & Security
| Requirement | Implementation |
|---|---|
| Staff PINs | 4-6 digit PINs for POS terminal access and sale attribution (Module 5, Section 5.5) |
| OAuth authentication | All Shopify API access uses OAuth tokens – no API key shortcuts in production |
| Cycle counts | Weekly recommended, monthly minimum. Keeps inventory accurate even with real-time auto-sync. Discrepancies logged and investigated. |
| Audit trail | Every sync operation, conflict resolution, and manual override is logged with user ID and timestamp |
| Token rotation | Access tokens are monitored for expiry. Re-authorization flow triggered 30 days before token expiration. |
6.3.14 Inventory Sync Triggers
Inventory quantities sync bidirectionally between the POS system and Shopify, regardless of catalog sync mode. This ensures online customers see accurate availability at all times.
Cross-Reference: See Module 4, Section 4.12 for the POS inventory reservation model (reserve on cart add, commit on sale complete). See Module 4, Section 4.14 for online order fulfillment and pick-pack-ship workflows.
POS -> Shopify Sync Triggers:
| POS Event | Shopify Update | Mutation Used |
|---|---|---|
| Sale completed | Decrement inventory at sale location | inventorySetQuantities |
| Return processed | Increment inventory at return location | inventorySetQuantities |
| Purchase order received | Increment inventory at receiving location | inventorySetQuantities |
| Inventory adjustment posted | Adjust inventory at location (increment or decrement) | inventorySetQuantities |
| Transfer completed | Decrement source location, increment destination location | inventorySetQuantities (two calls) |
| Stock count finalized | Set inventory to physical count result at location | inventorySetQuantities |
| Reservation expired | Release reserved quantity (no Shopify update – reservation is POS-only) | None |
Shopify -> POS Sync Triggers:
| Shopify Event | POS Update | Webhook Topic |
|---|---|---|
| Online order placed | Decrement available qty at assigned fulfillment store | orders/create |
| Online order cancelled | Release reservation; increment available qty | orders/cancelled |
| Online return processed | Increment available qty at return location | refunds/create |
| Manual Shopify adjustment | Sync to POS with EXTERNAL_ADJUSTMENT movement type | inventory_levels/update |
| Product stocked at new location | Create location-level inventory record | inventory_levels/connect |
| Product removed from location | Zero out inventory at that location | inventory_levels/disconnect |
Sync Architecture Parameters:
| Parameter | Value |
|---|---|
| Sync method | Webhook-driven (near real-time) + scheduled reconciliation |
| Webhook latency target | < 5 seconds from event to POS database update |
| Reconciliation frequency | Every 15 minutes (incremental) + daily at 02:00 (full) |
| Conflict resolution | POS is source of truth; POS value wins on discrepancy |
| Retry on failure | 3 retries with exponential backoff (5s, 15s, 45s) |
| Dead letter queue | Failed syncs after 3 retries queued for manual review |
| Reconciliation tolerance | Differences of +/- 0 units trigger correction (zero tolerance) |
Business Rule: Inventory sync does NOT depend on catalog sync mode. Even if a tenant uses POS-Master catalog sync (where POS owns product data), inventory quantities always flow bidirectionally. The POS system is the authoritative source for inventory levels – if a discrepancy is detected during reconciliation, the POS value overwrites the Shopify value.
6.3.15 Shopify Hardware Compatibility
When the Nexus POS is deployed alongside or integrated with Shopify POS hardware, the following devices are compatible. This section covers hardware that can be shared between Shopify POS and the Nexus POS application.
Card Readers:
| Device | Connection | Shopify POS | Nexus POS | Notes |
|---|---|---|---|---|
| Shopify Tap & Chip Reader | Bluetooth | Yes | No (uses own payment integration) | Shopify-exclusive; cannot process payments for third-party POS |
| Shopify POS Terminal (Chipper 2X BT) | Bluetooth | Yes | No | Shopify-exclusive hardware |
| WisePOS E (Stripe Terminal) | Internet / USB | No | Yes | Recommended for Nexus POS payment processing |
| BBPOS Chipper 2X BT | Bluetooth | Yes | Via Stripe SDK | Dual-compatible with configuration |
Note: Shopify-branded card readers process payments exclusively through Shopify Payments. The Nexus POS uses its own payment integration (Module 1, Section 1.18) with Stripe Terminal or equivalent semi-integrated devices. Card readers are NOT shared between the two POS systems.
Receipt Printers:
| Device | Connection | Protocol | Shopify POS | Nexus POS | Notes |
|---|---|---|---|---|---|
| Star Micronics TSP143IV | USB / LAN / Bluetooth | StarPRNT | Yes | Yes | Recommended. Shared between both POS systems. |
| Star Micronics mPOP | Bluetooth | StarPRNT | Yes | Yes | Combined printer + cash drawer. Compact form factor. |
| Star Micronics SM-L200 | Bluetooth | StarPRNT | Yes | Yes | Mobile receipt printer for floor sales. |
| Epson TM-m30III | USB / LAN / Bluetooth | ESC/POS | Yes | Yes | Alternative to Star Micronics. Industry standard protocol. |
| Epson TM-T88VII | USB / LAN | ESC/POS | No (not Shopify certified) | Yes | High-speed thermal printer. Nexus POS only. |
Barcode Scanners:
| Device | Connection | Shopify POS | Nexus POS | Notes |
|---|---|---|---|---|
| Socket Mobile S700 | Bluetooth | Yes | Yes | 1D barcode scanner. Compact, retail-grade. |
| Socket Mobile S740 | Bluetooth | Yes | Yes | 2D barcode scanner (supports QR codes). |
| Shopify Retail Scanner | Bluetooth | Yes | No | Shopify-exclusive accessory. |
| Any HID-compliant USB scanner | USB | Yes | Yes | Generic USB scanners work with both systems via keyboard wedge. |
| Zebra DS2208 | USB | No | Yes | High-performance 2D imager. Nexus POS recommended. |
Cash Drawers:
| Device | Connection | Shopify POS | Nexus POS | Notes |
|---|---|---|---|---|
| Star Micronics Cash Drawer (via mPOP) | Integrated | Yes | Yes | Opens via mPOP printer signal. |
| APG Vasario Cash Drawer | RJ-12 (via printer) | Yes | Yes | Standard cash drawer. Triggered by receipt printer kick signal. |
| Any RJ-12 compatible drawer | RJ-12 (via printer) | Yes | Yes | Opened by ESC/POS or StarPRNT printer kick command. |
Device Compatibility Matrix (Summary):
| Peripheral Type | Shared Between Shopify POS & Nexus POS | Nexus POS Only | Shopify POS Only |
|---|---|---|---|
| Card Readers | None | WisePOS E, Stripe Terminal devices | Shopify Tap & Chip, Chipper 2X BT |
| Receipt Printers | Star TSP143IV, Star mPOP, Star SM-L200, Epson TM-m30III | Epson TM-T88VII | None |
| Barcode Scanners | Socket Mobile S700/S740, USB HID scanners | Zebra DS2208 | Shopify Retail Scanner |
| Cash Drawers | All RJ-12 compatible (via shared printer) | None | None |
Business Rules:
- Receipt printers and cash drawers can be shared between Shopify POS and Nexus POS because they connect via standard protocols (ESC/POS, StarPRNT).
- Card readers are NOT shared – each POS system uses its own payment processing hardware.
- Barcode scanners using USB HID (keyboard wedge) mode work with any POS system that accepts keyboard input.
- Hardware compatibility is validated during tenant onboarding. Incompatible devices are flagged with recommended alternatives.
- The Nexus POS Register Management screen (Module 5, Section 5.7) tracks which peripherals are paired to each register.
6.4 Amazon SP-API Integration
Scope: Integrating the POS system with Amazon’s Selling Partner API (SP-API) to enable multi-channel retail operations across Amazon marketplaces. This section covers authentication, catalog synchronization, listing management, order fulfillment (FBA and FBM), inventory tracking, push notifications, rate limiting, compliance requirements, and reporting.
Cross-Reference: See Module 3, Section 3.7 for Shopify integration patterns (field ownership, conflict resolution, sync modes). See Module 4, Section 4.14 for the pick-pack-ship fulfillment workflow reused by Amazon FBM orders. See Module 5, Section 5.16 for the Integration Hub registry and health dashboard where Amazon is registered as an integration provider.
6.4.1 Authentication & Authorization
Amazon SP-API uses OAuth 2.0 via Login with Amazon (LWA) for all API access. The POS system acts as a registered SP-API application and stores per-tenant seller credentials.
OAuth 2.0 Token Flow:
- Access tokens are valid for 1 hour and must be refreshed using the
refresh_tokengrant type before expiry. - The POS backend handles all token lifecycle management transparently – store staff and admin users never interact with OAuth directly.
- Regional endpoints determine which Amazon marketplace cluster the API calls target.
Regional Endpoints:
| Region | Endpoint | Marketplaces |
|---|---|---|
| North America (NA) | sellingpartnerapi-na.amazon.com | US (ATVPDKIKX0DER), CA (A2EUQ1WTGCTBG2), MX (A1AM78C64UM0Y8) |
| Europe (EU) | sellingpartnerapi-eu.amazon.com | UK (A1F83G8C2ARO7P), DE (A1PA6795UKMFR9), FR, IT, ES |
| Far East (FE) | sellingpartnerapi-fe.amazon.com | JP (A1VC38T7YXB528), AU (A39IBJ37TRP1C6) |
Amazon Credential Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key, system-generated |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
integration_id | UUID | Yes | FK to integrations table (Module 5, Section 5.16) |
selling_partner_id | String(50) | Yes | Amazon Seller Central account identifier |
marketplace_id | String(20) | Yes | Amazon marketplace identifier (e.g., ATVPDKIKX0DER for US) |
client_id | String(100) | Yes | LWA application client ID |
client_secret_encrypted | String(500) | Yes | AES-256 encrypted LWA client secret. Never returned in API responses. |
refresh_token_encrypted | String(500) | Yes | AES-256 encrypted OAuth refresh token. Long-lived credential. |
access_token_encrypted | String(1000) | No | AES-256 encrypted current access token. Null when expired. |
token_expiry | DateTime | No | Expiration timestamp of the current access token |
region | Enum | Yes | NA, EU, FE – determines API endpoint |
is_active | Boolean | Yes | Whether this Amazon connection is actively processing (default: false) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
OAuth Token Refresh Sequence:
sequenceDiagram
autonumber
participant POS as POS Backend
participant CACHE as Token Cache
participant LWA as Login with Amazon
participant SPAPI as SP-API Endpoint
Note over POS, SPAPI: Token Refresh Flow
POS->>CACHE: Request access_token for tenant
alt Token Valid (expiry > now + 5 min)
CACHE-->>POS: Return cached access_token
else Token Expired or Near-Expiry
POS->>LWA: POST /auth/o2/token
Note right of LWA: grant_type=refresh_token<br/>refresh_token=***<br/>client_id=***<br/>client_secret=***
LWA-->>POS: access_token (1 hour TTL)
POS->>CACHE: Store encrypted access_token + expiry
end
POS->>SPAPI: API Request + Authorization: Bearer {access_token}
alt Success
SPAPI-->>POS: 200 OK + Response Data
else Token Rejected (401)
POS->>LWA: Force refresh token
LWA-->>POS: New access_token
POS->>SPAPI: Retry API Request
SPAPI-->>POS: 200 OK + Response Data
end
Business Rules:
- The POS system refreshes the access token proactively when the current token has less than 5 minutes remaining, avoiding mid-request expiration.
- If the refresh token itself becomes invalid (seller revokes access or Amazon rotates credentials), the integration status transitions to
DISCONNECTEDand an alert is raised to all ADMIN/OWNER users. - Each tenant may connect to at most one Amazon Seller Central account per marketplace. Multiple marketplaces within the same region are supported (e.g., US + CA + MX under NA).
- All credentials are encrypted at rest using AES-256. The
client_secret_encrypted,refresh_token_encrypted, andaccess_token_encryptedfields are write-only – API responses redact these to"***".
6.4.2 Catalog Items API
The Catalog Items API resolves Amazon Standard Identification Numbers (ASINs) from POS product identifiers, enabling product matching and listing creation.
Endpoint: GET /catalog/2022-04-01/items
Primary Use Cases:
- Look up existing ASINs by UPC/EAN barcode before creating a new listing.
- Retrieve Amazon product detail pages for enrichment (titles, bullet points, images).
- Validate that a POS product maps to the correct Amazon catalog entry.
Rate Limit: 5 requests per second, burst of 5.
Field Mapping: POS to Amazon Catalog
| POS Field | Amazon Field | Notes |
|---|---|---|
sku | seller_sku | Unique per seller account. POS SKU used as seller_sku unless overridden. |
name | item_name | Max 500 characters for Amazon. Truncated with ellipsis if POS name exceeds limit. |
long_description | product_description | HTML allowed. Max 2,000 characters. |
brand | brand | Required for most Amazon categories. Must match Amazon Brand Registry if enrolled. |
barcode | external_id (UPC/EAN) | Used for ASIN lookup. UPC-A (12 digits) or EAN-13 (13 digits). |
primary_image_url | main_image_url | Minimum 1000x1000px. Pure white background required. No watermarks or text overlays. |
base_price | price.amount | Per-marketplace pricing. Currency determined by marketplace. |
weight | item_weight | Required for FBA. Must include unit (lb, kg, oz, g). |
product_type | product_type | Amazon Browse Node taxonomy. Mapped via Amazon Product Type Definition API. |
color | color_name | Amazon standard color values. Custom colors must map to nearest Amazon standard. |
size | size_name | Amazon standard size values. Apparel uses specific size systems (US, EU, UK). |
material | material_type | Required for certain categories (apparel, jewelry). |
ASIN Resolution Workflow:
flowchart TD
A[POS Product Selected for Amazon Listing] --> B{Has UPC/EAN Barcode?}
B -->|Yes| C[Search Catalog Items API by barcode]
B -->|No| D[Search by product name + brand]
C --> E{ASIN Found?}
D --> E
E -->|Yes, Single Match| F[Auto-link ASIN to POS product]
E -->|Yes, Multiple Matches| G[Present matches to admin for selection]
E -->|No Match| H[Create new listing - ASIN assigned by Amazon]
F --> I[Store ASIN in amazon_product_mapping table]
G --> I
H --> I
I --> J[Product ready for listing creation]
Amazon Product Mapping Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
product_id | UUID | Yes | FK to POS products table |
variant_id | UUID | No | FK to POS product_variants table (null for simple products) |
marketplace_id | String(20) | Yes | Amazon marketplace (e.g., ATVPDKIKX0DER) |
asin | String(10) | No | Amazon Standard Identification Number. Null until resolved. |
seller_sku | String(40) | Yes | Seller SKU on Amazon. Defaults to POS SKU. |
fnsku | String(10) | No | Fulfillment Network SKU. Assigned by Amazon for FBA items. |
fulfillment_type | Enum | Yes | FBA, FBM, BOTH – determines fulfillment method |
listing_status | Enum | Yes | DRAFT, ACTIVE, INACTIVE, SUPPRESSED, DELETED |
last_synced_at | DateTime | No | Timestamp of most recent sync with Amazon |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
6.4.3 Listings Items API
The Listings Items API manages the creation, update, and deletion of product listings on Amazon marketplaces. POS serves as the system of record for listing data, pushing changes to Amazon.
Endpoints:
| Operation | Method | Endpoint | Use Case |
|---|---|---|---|
| Create/Update | PUT | /listings/2021-08-01/items/{sellerId}/{sellerSku} | Full listing creation or complete overwrite |
| Partial Update | PATCH | /listings/2021-08-01/items/{sellerId}/{sellerSku} | Update specific attributes only |
| Delete | DELETE | /listings/2021-08-01/items/{sellerId}/{sellerSku} | Remove listing from marketplace |
| Bulk Submit | POST | /feeds/2021-06-30/feeds | JSON_LISTINGS_FEED for bulk operations (up to 10,000 items per feed) |
Listing Lifecycle State Machine:
stateDiagram-v2
[*] --> DRAFT: Admin selects product for Amazon
DRAFT --> PENDING_REVIEW: Submit listing to Amazon
PENDING_REVIEW --> ACTIVE: Amazon approves listing
PENDING_REVIEW --> SUPPRESSED: Amazon rejects listing
ACTIVE --> INACTIVE: Admin deactivates or out of stock
ACTIVE --> SUPPRESSED: Amazon policy violation
INACTIVE --> ACTIVE: Admin reactivates with stock
SUPPRESSED --> PENDING_REVIEW: Fix issues and resubmit
ACTIVE --> DELETED: Admin permanently removes
INACTIVE --> DELETED: Admin permanently removes
DELETED --> [*]
Listing Attribute Mapping: POS to Amazon
| POS Field | Amazon Listing Attribute | Constraints | Notes |
|---|---|---|---|
name | item_name | Max 500 chars | Title formula: Brand + Product Type + Key Feature + Size/Color |
long_description | product_description | Max 2,000 chars, HTML allowed | Sanitized before push – no JavaScript or external links |
bullet_points[] | bullet_point (x5) | Max 1,000 chars each, max 5 bullets | POS stores as array. Truncated if exceeds limit. |
search_terms | generic_keyword | Max 250 bytes total | Backend keywords. No brand names, ASINs, or profanity. |
base_price | purchasable_offer.our_price | Per marketplace, currency auto-set | Converted to marketplace currency if multi-currency enabled |
compare_at_price | purchasable_offer.list_price | Must be > our_price | Used for “was/now” pricing on Amazon |
quantity | fulfillment_availability.quantity | Integer >= 0 | FBM quantity only. FBA managed by Amazon. |
condition | condition_type | new_new, used_*, refurbished | Default: new_new for retail POS |
handling_time | fulfillment_availability.handling_time | Integer (business days) | FBM only. Default: 2 days. |
Bulk Feed Processing:
For initial catalog push or large-scale updates, the system uses the Feeds API with JSON_LISTINGS_FEED type:
- POS collects up to 10,000 product listings into a single feed document.
- Feed is submitted via
POST /feeds/2021-06-30/feedswithfeedType=JSON_LISTINGS_FEED. - POS polls
GET /feeds/2021-06-30/feeds/{feedId}untilprocessingStatus=DONE. - Download the processing report to identify per-item success/failure.
- Failed items are logged to the Integration Sync Log (Module 5, Section 5.16.4) with Amazon error codes.
Business Rules:
- All required Amazon fields are validated in the POS before submission. Missing fields block the listing with a clear error message (e.g., “Brand is required for Amazon category ‘Clothing’”).
- Search terms must not contain brand names, ASINs, competitor names, or offensive language. POS applies a deny-list filter before submission.
- Bullet points are strongly recommended (Amazon penalizes listings without them in search ranking). POS displays a completeness score for Amazon-bound products.
- Price changes are applied via PATCH to avoid overwriting other listing attributes.
6.4.4 Orders API
The Orders API enables the POS system to import Amazon marketplace orders for tracking and fulfillment (FBM). FBA orders are tracked for visibility but fulfilled by Amazon.
Core Endpoints:
| Operation | Method | Endpoint | Purpose |
|---|---|---|---|
| Poll Orders | GET | /orders/v0/orders | Retrieve new/updated orders every 2 minutes |
| Get Order Items | GET | /orders/v0/orders/{orderId}/orderItems | Retrieve line item details for a specific order |
| Confirm Shipment | POST | /orders/v0/orders/{orderId}/shipment | Provide tracking for FBM fulfillment |
Order Status Mapping:
| Amazon Status | POS Status | Action |
|---|---|---|
Pending | PENDING_FULFILLMENT | Wait for Amazon payment confirmation. Do not begin fulfillment. |
Unshipped | ASSIGNED | Route to nearest fulfillment-capable store for FBM. |
PartiallyShipped | PARTIALLY_SHIPPED | Some line items shipped, remainder pending. |
Shipped | SHIPPED | Tracking provided to Amazon. Inventory decremented. |
Canceled | CANCELLED | Release reserved inventory. Log cancellation reason. |
Unfulfillable | UNFULFILLABLE | FBA cannot fulfill (e.g., out of stock at FC). Alert admin. |
InvoiceUnconfirmed | PENDING_INVOICE | EU VAT invoice required before shipment. |
PendingAvailability | BACKORDER | Pre-order. Fulfill when stock arrives. |
FBA vs FBM Fulfillment Routing:
| Fulfillment Type | Who Ships | Inventory Source | POS Action |
|---|---|---|---|
| FBA (Fulfilled by Amazon) | Amazon warehouse | Amazon Fulfillment Center (FC) | POS monitors order status only. No pick-pack-ship required. Inventory tracked as “FBA” channel. |
| FBM (Fulfilled by Merchant) | Our stores | POS physical inventory | Full pick-pack-ship workflow (Section 4.14.4). Store assignment algorithm routes to nearest location. |
| SFP (Seller Fulfilled Prime) | Our stores (Prime SLA) | POS physical inventory | Same as FBM but with Prime delivery SLA (1-2 day shipping). Requires carrier integration. |
Order Import and Fulfillment Sequence:
sequenceDiagram
autonumber
participant AMZ as Amazon SP-API
participant POLL as Order Poller (2 min)
participant API as POS Backend
participant DB as Database
participant STAFF as Store Staff
participant CARRIER as Carrier
Note over AMZ, CARRIER: Phase 1: Order Import
POLL->>AMZ: GET /orders/v0/orders?CreatedAfter={lastPoll}
AMZ-->>POLL: Order list (new + updated)
loop Each New Order
POLL->>AMZ: GET /orders/v0/orders/{orderId}/orderItems
AMZ-->>POLL: Line items with ASIN, seller_sku, qty, price
POLL->>API: Map seller_sku to POS product_id
API->>DB: Create amazon_order record
API->>DB: Create order_line_items
alt FBA Order
API->>DB: Status: TRACKING_ONLY (no fulfillment action)
else FBM Order
API->>DB: Status: PENDING_FULFILLMENT
API->>API: Run store assignment algorithm (Section 4.14.2)
API->>DB: Reserve inventory at selected store
API-->>STAFF: New Amazon FBM order on fulfillment queue
end
end
Note over AMZ, CARRIER: Phase 2: FBM Fulfillment
STAFF->>API: Begin pick-pack-ship (Section 4.14.4)
API->>DB: Status: PICKING -> PACKING -> SHIPPED
STAFF->>API: Enter carrier + tracking number
API->>AMZ: POST /orders/v0/orders/{orderId}/shipment
Note right of AMZ: carrier_code, tracking_number,<br/>ship_date, line_items
AMZ-->>API: Shipment confirmation
API->>DB: Log SALE movement, decrement inventory
Note over AMZ, CARRIER: Phase 3: Delivery Tracking
CARRIER->>API: Delivery webhook
API->>DB: Status: DELIVERED
Amazon Order Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
amazon_order_id | String(20) | Yes | Amazon order identifier (e.g., 113-1234567-1234567) |
marketplace_id | String(20) | Yes | Marketplace where order was placed |
order_status | Enum | Yes | Current Amazon order status |
pos_status | Enum | Yes | Internal POS fulfillment status |
fulfillment_channel | Enum | Yes | AFN (Amazon Fulfillment Network / FBA) or MFN (Merchant Fulfillment Network / FBM) |
assigned_location_id | UUID | No | FK to locations table. FBM orders only. |
purchase_date | DateTime | Yes | When the customer placed the order |
buyer_name | String(100) | No | Buyer display name (PII – encrypted at rest) |
shipping_address | JSONB | No | Encrypted shipping address object. Used for store assignment. |
order_total | Decimal(12,2) | Yes | Total order amount |
currency_code | String(3) | Yes | Order currency (e.g., USD) |
items_shipped | Integer | Yes | Count of shipped line items |
items_unshipped | Integer | Yes | Count of unshipped line items |
carrier_code | String(20) | No | Shipping carrier (e.g., UPS, USPS, FedEx) |
tracking_number | String(50) | No | Shipment tracking number |
shipped_at | DateTime | No | When the order was shipped |
delivered_at | DateTime | No | When the order was delivered |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Order polling runs every 2 minutes using the
CreatedAfterparameter set to the last successful poll timestamp. Pendingorders are imported for visibility but NOT queued for fulfillment until Amazon confirms payment (status transitions toUnshipped).- FBM orders reuse the existing pick-pack-ship workflow (Section 4.14.4) with Amazon-specific carrier code and tracking submission.
- Amazon buyer PII (name, address, email) is encrypted at rest and purged after 30 days per Amazon’s Acceptable Use Policy.
- If a seller_sku in an incoming order does not map to any POS product, the order is flagged as
UNMAPPEDand an alert is raised for admin resolution.
6.4.5 FBA Inventory API
The FBA Inventory API provides visibility into stock held at Amazon Fulfillment Centers. The POS system reads FBA inventory levels for unified stock reporting but does not directly control FBA quantities (Amazon manages FC inventory).
Core Endpoint: GET /fba/inventory/v1/summaries
Identifier Mapping:
| Identifier | Source | Purpose |
|---|---|---|
SKU (seller_sku) | POS system | Our internal product identifier used across all channels |
ASIN | Amazon catalog | Amazon’s product catalog identifier. One ASIN can map to multiple seller SKUs. |
FNSKU | Amazon FBA | Fulfillment Network SKU. Amazon’s internal label for FBA units. Unique per seller + product. |
UPC / EAN | Manufacturer | Universal barcode. Used for ASIN resolution during listing creation. |
MSKU | Amazon (legacy) | Merchant SKU. Equivalent to seller_sku. Used in older API versions. |
FBA Inventory States:
| State | Description | POS Tracking |
|---|---|---|
Fulfillable | Available for customer orders at Amazon FC | Shown as “FBA Available” in POS inventory view |
Inbound Working | Shipment plan created, not yet shipped to FC | Shown as “FBA Inbound” – subtotal of all inbound stages |
Inbound Shipped | In transit to FC, not yet received | Shown as “FBA Inbound” |
Inbound Receiving | Arrived at FC, being processed and stowed | Shown as “FBA Receiving” |
Reserved | Allocated to pending customer orders or FC transfers | Shown as “FBA Reserved” (not available for new orders) |
Unfulfillable | Defective, customer-damaged, carrier-damaged, or expired at FC | Triggers alert to admin – requires removal order or disposal decision |
Researching | Discrepancy under investigation by Amazon | Triggers alert to admin – monitor and follow up if unresolved > 30 days |
FBA Inventory Sync Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
product_mapping_id | UUID | Yes | FK to amazon_product_mapping table |
fnsku | String(10) | Yes | Amazon Fulfillment Network SKU |
fulfillable_qty | Integer | Yes | Units available for sale at Amazon FC |
inbound_working_qty | Integer | Yes | Units in shipment plans not yet shipped |
inbound_shipped_qty | Integer | Yes | Units in transit to FC |
inbound_receiving_qty | Integer | Yes | Units being processed at FC |
reserved_qty | Integer | Yes | Units allocated to orders or FC transfers |
unfulfillable_qty | Integer | Yes | Defective or damaged units at FC |
researching_qty | Integer | Yes | Units under investigation |
last_updated_at | DateTime | Yes | Timestamp of last data refresh from Amazon |
created_at | DateTime | Yes | Record creation timestamp |
Inbound Shipment Creation Workflow:
When the POS system needs to send inventory to Amazon FBA, it uses the Inbound Shipment API to create a shipment plan:
flowchart TD
A[Admin selects products for FBA replenishment] --> B[POS calculates quantities per SKU]
B --> C[POST createInboundShipmentPlan]
C --> D{Amazon splits into shipment groups?}
D -->|Single FC| E[One shipment plan created]
D -->|Multiple FCs| F[Multiple shipment plans created]
E --> G[Print FNSKU labels for each unit]
F --> G
G --> H[Pack and ship to assigned FC addresses]
H --> I[Enter carrier + tracking per shipment]
I --> J[POS moves inventory to FBA Inbound status]
J --> K[Poll inventory summaries for receiving confirmation]
K --> L[Amazon confirms receipt -- FBA Fulfillable updated]
Business Rules:
- FBA inventory is read-only in the POS inventory view. Staff cannot adjust FBA quantities manually – all FBA adjustments originate from Amazon.
- The POS displays a unified inventory view:
Total Available = POS On-Hand + FBA Fulfillable. Channel-specific breakdowns are shown on the product detail screen. - FBA inventory is synced every 15 minutes via
getInventorySummaries. More frequent polling is unnecessary as Amazon updates FBA quantities asynchronously. - Unfulfillable inventory exceeding a configurable threshold (default: 5 units per SKU) triggers a removal recommendation alert to the admin.
6.4.6 Notifications (SQS / EventBridge)
Amazon SP-API supports push notifications for key events, eliminating the need for constant polling. The POS system subscribes to relevant notification types and processes them via an Amazon SQS queue.
Key Notification Types:
| Notification Type | Trigger | POS Action |
|---|---|---|
ORDER_CHANGE | Order status changes (new, shipped, cancelled, returned) | Update POS order status. Trigger fulfillment queue for new FBM orders. |
ITEM_INVENTORY_EVENT_CHANGE | FBA inventory level changes (receipt, sale, adjustment) | Update FBA stock display in POS. Recalculate unified available quantity. |
LISTINGS_ITEM_STATUS_CHANGE | Listing approved, suppressed, or policy-flagged by Amazon | Update product listing_status. Alert admin for suppressed listings. |
REPORT_PROCESSING_FINISHED | A requested report (settlement, inventory) is ready for download | Download and process report. Update POS financial or inventory records. |
FBA_OUTBOUND_SHIPMENT_STATUS | FBA order shipped or delivery status updated | Update FBA order tracking info. Mark order as SHIPPED or DELIVERED. |
FEED_PROCESSING_FINISHED | A submitted feed (listings, pricing, inventory) has been processed | Download processing report. Log successes and failures per item. |
BRANDED_ITEM_CONTENT_CHANGE | A+ Content or brand content changes on a shared ASIN | Log change for awareness. No auto-action (POS does not manage A+ Content). |
SQS Queue Configuration Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
integration_id | UUID | Yes | FK to integrations table |
sqs_queue_url | String(500) | Yes | Amazon SQS queue URL for receiving notifications |
sqs_queue_arn | String(200) | Yes | SQS queue ARN for subscription registration |
aws_access_key_id_encrypted | String(200) | Yes | Encrypted AWS IAM access key for SQS polling |
aws_secret_key_encrypted | String(500) | Yes | Encrypted AWS IAM secret key |
aws_region | String(20) | Yes | AWS region where SQS queue is provisioned (e.g., us-east-1) |
subscribed_notifications | String[] | Yes | Array of notification types subscribed (e.g., ["ORDER_CHANGE", "ITEM_INVENTORY_EVENT_CHANGE"]) |
polling_interval_seconds | Integer | Yes | How often to poll SQS (default: 30 seconds) |
is_active | Boolean | Yes | Whether notification processing is enabled |
last_polled_at | DateTime | No | Timestamp of last successful SQS poll |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Notification Processing Flow:
sequenceDiagram
autonumber
participant AMZ as Amazon SP-API
participant SQS as Amazon SQS Queue
participant WORKER as POS Notification Worker
participant API as POS Backend
participant DB as Database
Note over AMZ, DB: Push Notification Flow
AMZ->>SQS: Publish notification (ORDER_CHANGE, etc.)
loop Every 30 seconds
WORKER->>SQS: ReceiveMessage (max 10 messages)
SQS-->>WORKER: Notification batch
loop Each Notification
WORKER->>WORKER: Parse notification type + payload
alt ORDER_CHANGE
WORKER->>API: Update order status
API->>DB: Write order update + log
else ITEM_INVENTORY_EVENT_CHANGE
WORKER->>API: Update FBA inventory
API->>DB: Write inventory snapshot
else LISTINGS_ITEM_STATUS_CHANGE
WORKER->>API: Update listing status
API->>DB: Write status change + alert if suppressed
else REPORT_PROCESSING_FINISHED
WORKER->>AMZ: Download completed report
WORKER->>API: Process report data
API->>DB: Write report results
end
WORKER->>SQS: DeleteMessage (acknowledge)
end
end
Business Rules:
- SQS messages are processed at-least-once. All notification handlers are idempotent – duplicate messages produce the same result without side effects.
- Failed notification processing retries up to 3 times with exponential backoff (30s, 60s, 120s). After 3 failures, the message moves to a Dead Letter Queue (DLQ) and an admin alert is raised.
- The DLQ is monitored daily. Messages in the DLQ older than 7 days are automatically logged and purged.
- When
ORDER_CHANGEnotifications are active, order polling frequency (Section 6.4.4) can be reduced from 2 minutes to 15 minutes as a fallback mechanism.
6.4.7 Rate Limits & Throttling
Amazon SP-API enforces per-endpoint rate limits using a token bucket model. The POS system must respect these limits to avoid throttling (HTTP 429) and potential API access suspension.
Per-Endpoint Rate Limits:
| Endpoint | Rate Limit | Burst | Recovery Rate | Notes |
|---|---|---|---|---|
Catalog Items (GET /catalog/...) | 5 req/sec | 5 | 1 req/sec | Shared across all catalog operations |
Listings Items (PUT/PATCH/DELETE) | 5 req/sec | 5 | 1 req/sec | Per selling partner ID |
Orders (GET /orders) | 1 req/sec | 1 | 0.5 req/sec | Shared across getOrders + getOrder |
Order Items (GET .../orderItems) | 2 req/sec | 2 | 1 req/sec | Per order ID lookup |
Feeds (POST /feeds) | 1 req/sec | 1 | 0.5 req/sec | Feed submission only |
Feed Results (GET /feeds/{id}) | 2 req/sec | 2 | 1 req/sec | Polling feed status |
Reports (POST /reports) | 1 req/sec | 1 | 0.5 req/sec | Report request submission |
| Report Download | 15 req/sec | 15 | 1 req/sec | Downloading completed reports |
FBA Inventory (GET /summaries) | 2 req/sec | 2 | 1 req/sec | Per marketplace |
Notifications (POST /subscriptions) | 1 req/sec | 1 | 0.5 req/sec | Subscription management |
Throttle Response Headers:
| Header | Purpose |
|---|---|
x-amzn-RateLimit-Limit | Current rate limit for the endpoint (requests per second) |
x-amzn-RequestId | Unique request identifier for troubleshooting with Amazon support |
Retry-After | Seconds to wait before retrying (present on 429 responses) |
Rate Limit Handling Strategy:
amazon_rate_limiting:
strategy: token_bucket_with_dynamic_adjustment
# Pre-request: check available tokens before sending
pre_request_check: true
# Track remaining capacity from response headers
header_tracking:
rate_limit_header: "x-amzn-RateLimit-Limit"
request_id_header: "x-amzn-RequestId"
# Throttle response handling (HTTP 429)
throttle_handling:
respect_retry_after: true # Wait for Retry-After seconds
fallback_wait_seconds: 30 # If no Retry-After header
max_retries: 5 # Per individual request
backoff_strategy: exponential # 1s, 2s, 4s, 8s, 16s
jitter: true # Add random 0-500ms to prevent thundering herd
# Request prioritization during high load
priority_queue:
high: # Processed first
- order_import # Customer-facing
- shipment_confirmation # Time-sensitive
- inventory_sync # Prevents overselling
medium:
- listing_updates # Can tolerate delay
- catalog_lookups # Admin-initiated
low:
- report_requests # Background tasks
- feed_status_polling # Non-urgent
# Circuit breaker: disable endpoint temporarily on repeated failures
circuit_breaker:
failure_threshold: 10 # Consecutive 429s to trigger
open_duration_seconds: 300 # Wait 5 min before retrying
half_open_requests: 2 # Test requests before fully reopening
# Utilization target (% of rate limit to use)
utilization_plans:
peak_hours: 60% # Conservative during business hours
off_peak: 90% # Aggressive during overnight sync
bulk_operations: 80% # Feed submissions and bulk updates
Business Rules:
- The POS never exceeds 90% of any endpoint’s rate limit, even during bulk operations, to leave headroom for manual admin actions.
- All API calls are routed through a centralized HTTP client that enforces rate limits before dispatch. Direct API calls bypassing the rate limiter are prohibited.
- When a 429 response is received, the system logs the event, waits the specified
Retry-Afterduration (or 30 seconds if absent), and retries with exponential backoff. - Rate limit utilization metrics are displayed on the Integration Health Dashboard (Module 5, Section 5.16.5) with per-endpoint graphs.
6.4.8 Amazon Compliance & Seller Requirements
This section defines the compliance checks, packaging standards, and business rules the POS system must enforce to maintain good standing on Amazon’s marketplace.
6.4.8.1 Seller Code of Conduct
Amazon enforces strict seller performance standards. The POS system validates data quality before pushing to Amazon to prevent listing suppressions, account warnings, and potential suspension.
Pre-Submission Validation Checklist:
| Validation | Rule | Action on Failure |
|---|---|---|
| Product title | Non-empty, <= 500 chars, no ALL CAPS, no promotional phrases (“FREE”, “SALE”) | Block listing. Show specific error. |
| Brand name | Must match Amazon Brand Registry if enrolled | Block listing. Suggest registry lookup. |
| UPC/EAN | Valid check digit, not on Amazon’s blocked barcode list | Block listing. Prompt for GS1 verification. |
| Product images | Min 1000x1000px, pure white background (RGB 255,255,255), JPEG/PNG/TIFF | Block listing. Show image requirements. |
| Price | > $0.00, within Amazon category price range, no extreme markups vs. other channels | Warning. Admin override allowed with reason code. |
| Description | Non-empty, no HTML injection, no external links, no competitor references | Sanitize and warn. Strip disallowed content. |
| Bullet points | Non-empty for at least 3 of 5 bullets, no HTML, no promotional claims | Warning. Listing proceeds but flagged as “incomplete”. |
| Condition | Valid condition type for the category | Block listing. Show valid options. |
| Weight/Dimensions | Required for FBA. Must be positive values with valid units. | Block FBA listing. Allow FBM without. |
| Restricted category | Check if product type requires Amazon approval (e.g., jewelry, pesticides) | Block listing. Provide approval application link. |
Pricing Compliance:
- Amazon monitors pricing across channels. If a product is listed significantly cheaper on the retailer’s own website (Shopify), Amazon may suppress the Buy Box or flag the listing.
- The POS displays a cross-channel price comparison alert when Amazon price differs from Shopify price by more than a configurable threshold (default: 10%).
- The system does NOT enforce price parity automatically – it alerts the admin who makes the business decision.
6.4.8.2 Packaging & Labeling Guidelines
FBA Label Requirements:
| Specification | Requirement |
|---|---|
| Label format | 4“ x 6“ thermal label (Zebra-compatible) |
| Barcode type | FNSKU barcode (Code 128 symbology) |
| Label placement | One label per sellable unit, covering any existing UPC |
| Readability | Minimum 1“ barcode height, no damage, smudging, or obstruction |
| Suffocation warning | Required on poly bags with opening > 5“ |
FBA Prep Requirements by Product Category:
| Category | Prep Required | Label Required | Special Notes |
|---|---|---|---|
| Apparel | Poly bag with suffocation warning | FNSKU barcode on bag exterior | Transparent bag preferred. No hangers for FBA. |
| Shoes | Individual shoe box | FNSKU barcode on box exterior | Each pair in separate box. No loose shoes in poly bags. |
| Accessories (belts, scarves) | Poly bag or bubble wrap | FNSKU barcode on outer packaging | Small items must be in bag to prevent loss. |
| Jewelry | Bubble wrap + rigid box | FNSKU barcode on box exterior | High-value handling. Minimum 2“ cushion padding. |
| Fragile items | Bubble wrap + rigid box | FNSKU barcode on box exterior | “FRAGILE” label optional but recommended. |
| Oversized items | None (ship as-is) | FNSKU barcode on product | Box dimensions must not exceed FBA limits. |
Shipping Box Requirements (FBA Inbound):
| Specification | Limit |
|---|---|
| Maximum box weight | 50 lbs (23 kg) |
| Maximum box dimensions | 25“ x 25“ x 25“ (standard), oversize varies |
| Box material | New corrugated cardboard, minimum 200# burst strength |
| Contents per box | Single SKU (preferred) or mixed SKU with manifest |
| Pallet shipments | Standard 40“ x 48“ pallets, max 72“ stack height |
6.4.8.3 FBA vs FBM Support
The POS system supports both Amazon fulfillment methods with per-product configuration. A single product can use FBA in one marketplace and FBM in another, or both simultaneously.
Fulfillment Method Configuration:
| Setting | Options | Default | Description |
|---|---|---|---|
fulfillment_type | FBA, FBM, BOTH | FBM | How this product is fulfilled on Amazon |
fba_location_id | UUID | null | Virtual POS location representing Amazon FBA stock (Section 6.4.8.4) |
fbm_location_ids | UUID[] | all stores | Which POS locations can fulfill FBM orders for this product |
auto_replenish_fba | Boolean | false | Whether POS auto-generates FBA inbound shipment plans when FBA stock drops below threshold |
fba_reorder_point | Integer | 0 | FBA stock level that triggers replenishment alert or auto-plan |
fba_reorder_qty | Integer | 0 | Quantity to send per FBA replenishment shipment |
FBA Shipment Creation Workflow:
sequenceDiagram
autonumber
participant ADMIN as Admin
participant POS as POS Backend
participant AMZ as Amazon SP-API
participant STORE as Store Staff
Note over ADMIN, STORE: FBA Inbound Shipment Creation
ADMIN->>POS: Select products + quantities for FBA replenishment
POS->>POS: Validate stock available at source location
POS->>AMZ: POST createInboundShipmentPlan
Note right of AMZ: SKUs, quantities,<br/>ship-from address,<br/>label preference
AMZ-->>POS: Shipment plan(s) with FC destination(s)
Note left of POS: Amazon may split into<br/>multiple shipments to<br/>different FCs
loop Each Shipment Plan
POS->>POS: Generate FNSKU labels (4x6 thermal)
POS-->>ADMIN: Display FC destination + packing instructions
ADMIN->>STORE: Assign packing task to store staff
STORE->>STORE: Apply FNSKU labels, pack boxes
STORE->>POS: Confirm shipment packed + carrier + tracking
POS->>AMZ: PUT updateInboundShipment (carrier, tracking, box contents)
POS->>POS: Move inventory: POS On-Hand -> FBA Inbound
end
Note over ADMIN, STORE: Amazon receives and processes
AMZ-->>POS: Notification: ITEM_INVENTORY_EVENT_CHANGE
POS->>POS: Move inventory: FBA Inbound -> FBA Fulfillable
6.4.8.4 Safety Buffer Rules
To prevent overselling when sync delays exist between the POS and Amazon, configurable safety buffers reduce the quantity advertised on Amazon.
Buffer Calculation:
Amazon Available = POS Allocatable Quantity - Safety Buffer
Where:
- POS Allocatable Quantity = On-hand at designated Amazon fulfillment location(s), minus reserved, minus safety stock.
- Safety Buffer = Greater of (percentage buffer) or (minimum unit buffer).
Buffer Configuration:
| Setting | Type | Default | Description |
|---|---|---|---|
buffer_percentage | Decimal | 10% | Percentage of allocatable quantity to withhold |
buffer_minimum_units | Integer | 2 | Minimum units to always withhold, regardless of percentage |
buffer_scope | Enum | PER_PRODUCT | PER_PRODUCT (individual) or GLOBAL (all products same rule) |
amazon_location_id | UUID | null | Dedicated virtual location for Amazon inventory (recommended) |
use_specific_location | Boolean | false | If true, only inventory at amazon_location_id is available for Amazon. If false, network-wide available. |
Examples:
| Scenario | POS Available | Buffer % | Min Units | Buffer Applied | Amazon Qty |
|---|---|---|---|---|---|
| Normal stock | 50 | 10% | 2 | 5 (10% of 50) | 45 |
| Low stock | 10 | 10% | 2 | 2 (min units > 10% of 10) | 8 |
| Very low stock | 3 | 10% | 2 | 2 (min units) | 1 |
| Minimal stock | 2 | 10% | 2 | 2 (min units) | 0 |
| Out of stock | 0 | 10% | 2 | 0 | 0 |
Business Rules:
- When
Amazon Availabledrops to 0, the listing quantity is set to 0 on Amazon. The listing remains ACTIVE but shows “Currently unavailable.” - Safety buffers are recalculated on every inventory change event (sale, receiving, adjustment, transfer).
- Admins can override the buffer for individual products (e.g., set buffer to 0 for high-velocity items where overselling risk is acceptable).
- The dedicated Amazon virtual location approach is recommended for retailers with significant Amazon volume. It provides a clear physical segregation of Amazon-allocated inventory.
6.4.8.5 Order Routing Rules
Amazon FBM orders are integrated into the existing fulfillment infrastructure alongside Shopify orders.
Routing Logic:
flowchart TD
A[Amazon Order Received] --> B{Fulfillment Channel?}
B -->|AFN / FBA| C[Track status only]
C --> D[Display in POS as FBA order]
D --> E[Monitor via ITEM_INVENTORY_EVENT_CHANGE]
B -->|MFN / FBM| F{SFP / Prime?}
F -->|Prime SLA| G[Flag as HIGH PRIORITY]
F -->|Standard FBM| H[Standard priority]
G --> I[Run store assignment algorithm]
H --> I
I --> J{Stock available at nearest store?}
J -->|Yes| K[Assign to nearest store]
J -->|No| L{Stock available at ANY store?}
L -->|Yes| M[Assign to store with stock - warn about shipping cost]
L -->|No| N{Split fulfillment enabled?}
N -->|Yes| O[Split across multiple stores]
N -->|No| P[Alert admin: cannot fulfill]
K --> Q[Add to store fulfillment queue]
M --> Q
O --> Q
Q --> R[Pick-Pack-Ship workflow<br/>Section 4.14.4]
R --> S[POST shipment confirmation to Amazon]
Priority Rules for Mixed Channel Fulfillment:
| Priority | Source | SLA | Notes |
|---|---|---|---|
| 1 (Highest) | Amazon Prime (SFP) | 1-2 business days | Must ship same day if ordered before cutoff |
| 2 | Amazon FBM Standard | 3-5 business days | Ship within handling_time (default: 2 days) |
| 3 | Shopify orders | Per shipping method selected | Existing Shopify fulfillment SLA applies |
| 4 | In-store pickup | Customer-defined | Lower urgency, customer comes to store |
Business Rules:
- Amazon FBM orders appear in the same fulfillment queue as Shopify orders, with priority badges indicating the channel and SLA.
- The store assignment algorithm (Section 4.14.2) is reused for Amazon FBM orders with the same proximity + stock availability logic.
- If an FBM order cannot be fulfilled within the stated handling time, the system alerts the admin to either fulfill from an alternate location or request a cancellation from the buyer.
- Late shipments are tracked as a seller performance metric. Amazon penalizes sellers with >4% late shipment rate.
6.4.9 Reports: Amazon Integration
All Amazon integration reports are accessible from the Nexus POS reporting module with date range filtering, export to CSV/PDF, and drill-down capability.
| Report | Purpose | Key Data Fields |
|---|---|---|
| Amazon Sales Summary | Daily/weekly/monthly sales performance across Amazon channel | Date range, total revenue, order count, units sold, avg order value, FBA vs FBM breakdown, refund rate, marketplace breakdown |
| Amazon Inventory Status | Current stock levels across FBA and FBM channels | SKU, product name, FBA fulfillable qty, FBA inbound qty, FBA reserved qty, FBA unfulfillable qty, FBM available qty, total Amazon available |
| Amazon Listing Health | Status of all Amazon product listings | SKU, ASIN, listing status (active/inactive/suppressed), suppression reason, Buy Box %, listing completeness score, last sync timestamp |
| Amazon Order Fulfillment | Fulfillment performance metrics | Order count, avg fulfillment time (hours), on-time shipment %, late shipment %, carrier breakdown, FBA vs FBM split, return rate |
| Amazon Fee Analysis | Total Amazon costs and profitability per SKU | SKU, ASIN, referral fee %, referral fee $, FBA fee (pick + pack + weight), storage fee (monthly + long-term), total Amazon fees, POS cost, net margin after fees |
| Amazon Sync Health | Integration connectivity and sync reliability | Sync success rate (24h), failed syncs (with error codes), avg sync latency, API quota utilization %, notification delivery rate, DLQ message count |
| Amazon Compliance Scorecard | Seller account health metrics from Amazon | Order defect rate (target <1%), late shipment rate (target <4%), pre-fulfillment cancel rate (target <2.5%), valid tracking rate (target >95%), policy violations |
Cross-Channel Comparison View:
A dedicated cross-channel report enables the admin to compare performance across POS in-store, Shopify online, and Amazon channels:
| Metric | In-Store (POS) | Shopify Online | Amazon FBM | Amazon FBA |
|---|---|---|---|---|
| Revenue | Sum of in-store sales | Sum of Shopify orders | Sum of FBM order revenue | Sum of FBA order revenue |
| Units Sold | POS transaction items | Shopify line items | FBM shipped units | FBA shipped units |
| Avg Order Value | POS avg | Shopify avg | FBM avg | FBA avg |
| Margin % | After POS costs | After Shopify fees | After Amazon referral + shipping | After Amazon referral + FBA fees |
| Return Rate | In-store returns | Shopify returns | FBM returns | FBA returns |
Business Rules:
- Amazon fee data is sourced from Amazon Settlement Reports, requested bi-weekly and reconciled against POS order records.
- The Amazon Compliance Scorecard polls Amazon’s Seller Performance API daily and surfaces warnings when any metric approaches the threshold (yellow at 75% of limit, red at 90%).
- All reports respect the tenant’s timezone for date boundaries and the tenant’s currency for financial values.
- Report data is cached for 1 hour. An admin can force-refresh any report to pull the latest data from Amazon.
6.4.10 Amazon Integration Configuration (YAML Reference)
The following YAML reference consolidates all Amazon integration business rules for the rules engine:
amazon_integration:
version: "1.0"
# Connection settings
connection:
oauth_token_refresh_buffer_minutes: 5
max_marketplaces_per_tenant: 3
credential_encryption: AES-256
pii_retention_days: 30
# Sync intervals
sync_intervals:
order_polling_minutes: 2
order_polling_with_notifications_minutes: 15
fba_inventory_sync_minutes: 15
listing_sync_on_change: true
daily_reconciliation_hour: 3 # 3 AM tenant-local
# Safety buffer defaults
safety_buffer:
default_percentage: 10
default_minimum_units: 2
scope: PER_PRODUCT
recalculate_on: [SALE, RECEIVING, ADJUSTMENT, TRANSFER, FBA_UPDATE]
# Order routing
order_routing:
fbm_use_store_assignment_algorithm: true
prime_same_day_cutoff_hour: 14 # 2 PM local
default_handling_time_days: 2
late_shipment_alert_threshold_hours: 36
# Notification processing
notifications:
sqs_polling_interval_seconds: 30
max_messages_per_poll: 10
retry_max_attempts: 3
retry_backoff: [30, 60, 120] # seconds
dlq_retention_days: 7
# Compliance thresholds (Amazon seller performance)
compliance:
order_defect_rate_warning: 0.75 # Yellow at 0.75%
order_defect_rate_critical: 0.90 # Red at 0.90% (limit: 1%)
late_shipment_rate_warning: 3.0 # Yellow at 3%
late_shipment_rate_critical: 3.6 # Red at 3.6% (limit: 4%)
cancel_rate_warning: 1.875 # Yellow at 1.875%
cancel_rate_critical: 2.25 # Red at 2.25% (limit: 2.5%)
valid_tracking_warning: 96.25 # Yellow below 96.25%
valid_tracking_critical: 95.0 # Red below 95% (limit: 95%)
# Listing validation
listing_validation:
title_max_chars: 500
description_max_chars: 2000
bullet_points_max: 5
bullet_point_max_chars: 1000
search_terms_max_bytes: 250
image_min_dimension_px: 1000
price_cross_channel_alert_threshold_percent: 10
# FBA settings
fba:
unfulfillable_alert_threshold_units: 5
researching_followup_days: 30
label_format: "4x6_thermal"
label_barcode_symbology: "CODE128"
max_box_weight_lbs: 50
max_box_dimension_inches: 25
End of Section 6.4: Amazon SP-API Integration
6.5 Google Merchant API Integration
Scope: Outbound product-feed management, local inventory advertising, Google Business Profile linkage, push notification handling, and Content API-to-Merchant API migration for Google Shopping and Local Inventory Ads.
Cross-Reference: See Module 3, Section 3.9 for the product-sync lifecycle that triggers outbound calls to Google Merchant Center.
Cross-Reference: See Module 4, Section 4.5 for inventory-level sync events that Module 6 pushes to connected channels including Google local inventory.
Cross-Reference: See Module 5, Section 5.4 for tenant-level integration configuration (credentials, enabled providers, sync schedules).
CRITICAL: The Content API for Shopping reaches end-of-life on August 18, 2026. All new development MUST target the Merchant API (v1beta / v1). See Section 6.5.10 for the migration plan.
6.5.1 Authentication & Authorization
Google Merchant Center uses OAuth 2.0 with service accounts for server-to-server authentication. Unlike Shopify’s long-lived access tokens, Google service accounts issue short-lived JWTs that must be self-signed and exchanged for access tokens every 60 minutes.
Service Account Auth Flow
sequenceDiagram
autonumber
participant POS as POS Backend
participant VAULT as Credential Vault
participant JWT as JWT Builder
participant GAUTH as Google OAuth2 Endpoint
participant GAPI as Google Merchant API
Note over POS, GAPI: Phase 1: Credential Retrieval
POS->>VAULT: Fetch service account key (AES-256 decrypted)
VAULT-->>POS: Return private_key, client_email, project_id
Note over POS, GAPI: Phase 2: JWT Assertion
POS->>JWT: Build self-signed JWT
Note right of JWT: iss = client_email<br/>scope = content, merchantapi<br/>aud = https://oauth2.googleapis.com/token<br/>iat = now, exp = now + 3600
JWT->>JWT: Sign with RSA-SHA256 (private_key)
JWT-->>POS: Return signed JWT assertion
Note over POS, GAPI: Phase 3: Token Exchange
POS->>GAUTH: POST /token (grant_type=jwt-bearer, assertion=JWT)
GAUTH-->>POS: Return access_token (expires_in: 3600)
POS->>POS: Cache access_token (refresh at T-5min)
Note over POS, GAPI: Phase 4: API Call
POS->>GAPI: GET /accounts/{merchantId}/products<br/>Authorization: Bearer {access_token}
GAPI-->>POS: 200 OK (product data)
Note over POS, GAPI: Phase 5: Auto-Refresh (background)
POS->>POS: Timer fires at T-5min before expiry
POS->>GAUTH: POST /token (new JWT assertion)
GAUTH-->>POS: Return fresh access_token
Merchant Center Account Setup
Before API access is possible, the tenant must:
- Create a Merchant Center account at https://merchants.google.com
- Create a Google Cloud project and enable the Merchant API
- Create a service account in the Cloud Console with the
Merchant Center Adminrole - Download the JSON key file and upload it to Nexus POS (Module 5, Settings > Integrations)
- Link the service account to the Merchant Center account via the MC settings panel
- Verify and claim the website URL associated with the product feed
Data Model: google_merchant_credentials
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
merchant_account_id | VARCHAR(20) | Yes | Google Merchant Center account ID (numeric) |
service_account_email | VARCHAR(255) | Yes | Service account email (e.g., pos-sync@project.iam.gserviceaccount.com) |
private_key_encrypted | TEXT | Yes | RSA private key, AES-256-GCM encrypted at rest |
project_id | VARCHAR(100) | Yes | Google Cloud project ID |
token_endpoint | VARCHAR(255) | Yes | Default: https://oauth2.googleapis.com/token |
access_token | TEXT | No | Current cached access token (encrypted) |
token_expiry | TIMESTAMPTZ | No | Expiry timestamp of current access token |
scopes | TEXT[] | Yes | Granted scopes: content, merchantapi |
is_active | BOOLEAN | Yes | Whether this credential set is enabled – default true |
last_auth_at | TIMESTAMPTZ | No | Timestamp of last successful authentication |
last_auth_error | TEXT | No | Last authentication error message (if any) |
created_at | TIMESTAMPTZ | Yes | Row creation timestamp |
updated_at | TIMESTAMPTZ | Yes | Last modification timestamp |
Security Note: The
private_key_encryptedfield uses AES-256-GCM with a tenant-specific key derived from the master encryption key. The plaintext private key is never written to logs, error messages, or API responses. See Module 5, Section 5.4.5 for the credential vault architecture.
6.5.2 Product Data Management
The Google Merchant API separates product writes and reads into distinct resources:
ProductInput– the write resource. Used to create or update product data. Endpoint:accounts/{account}/productInputs:insertProduct– the read-only processed resource. Represents the product after Google’s validation, enrichment, and policy review. Endpoint:accounts/{account}/products/{product}
This distinction matters because the data you submit (ProductInput) may differ from the data Google stores (Product) after processing. The POS must read the Product resource to check approval status, supplemental attributes, and policy violations.
API Endpoint Reference
| Operation | Merchant API Endpoint | HTTP Method | Notes |
|---|---|---|---|
| Create/update product | accounts/{account}/productInputs:insert | POST | Upsert by offerId + feedLabel + contentLanguage |
| Get processed product | accounts/{account}/products/{product} | GET | Returns Google-enriched version with status |
| List products | accounts/{account}/products | GET | Paginated, max 250 per page |
| Delete product input | accounts/{account}/productInputs/{productInput}:delete | DELETE | Removes from feed; may take up to 24h to delist |
| Get product status | accounts/{account}/productStatuses/{product} | GET | Approval status, disapproval reasons, warnings |
Content API End-of-Life
| Milestone | Date | Impact |
|---|---|---|
| Merchant API v1beta GA | Available now | New features only in Merchant API |
| Content API deprecation announcement | 2025 | No new features; maintenance only |
| v1beta migration deadline | February 28, 2026 | Must begin migration |
| Content API full EOL | August 18, 2026 | All Content API endpoints cease functioning |
CRITICAL: Any integration built against the Content API (
/content/v2.1/) will stop working on August 18, 2026. The POS MUST use the Merchant API from day one. See Section 6.5.10 for legacy migration details.
Product Field Mapping: POS to Google Merchant
| POS Field | Google Field | API Path | Notes |
|---|---|---|---|
sku | offerId | productInput.offerId | Max 50 chars, unique per feed. Used as dedup key. |
name | title | productInput.title | Max 150 chars. No promotional text (“Sale!”, “Free Shipping” prohibited). |
long_description | description | productInput.description | Max 5,000 chars. Plain text preferred; HTML tags stripped by Google. |
primary_image_url | imageLink | productInput.imageLink | HTTPS required. Min 100x100px (apparel: 250x250px). |
base_price + currency | price | productInput.price | Object: { amountMicros: 2999000000, currencyCode: "USD" }. Price in micros (1 USD = 1,000,000 micros). |
| Computed from inventory | availability | productInput.availability | Enum: in_stock, out_of_stock, preorder, backorder. Derived from real-time inventory qty. |
brand | brand | productInput.brand | Required for most categories. Must be manufacturer brand, not store name. |
barcode (UPC/EAN) | gtin | productInput.gtin | Valid GTIN-8/12/13/14. No dashes. Check digit validated (mod-10 algorithm). |
product_condition | condition | productInput.condition | Enum: new, refurbished, used. Default: new. |
website_url | link | productInput.link | Must be live, HTTPS, product data on page must match feed exactly. |
manufacturer_part_number | mpn | productInput.mpn | Required if no GTIN available. Manufacturer’s part number. |
product_type_taxonomy | googleProductCategory | productInput.googleProductCategory | Full Google taxonomy path (e.g., “Apparel & Accessories > Clothing > Shirts”). |
weight + weight_unit | shippingWeight | productInput.shippingWeight | Object: { value: 0.5, unit: "lb" }. |
additional_images[] | additionalImageLinks | productInput.additionalImageLinks | Up to 10 additional images. Same quality requirements as primary. |
variant_color | color | productInput.color | Required for apparel. Google-normalised colour names. |
variant_size | size | productInput.size | Required for apparel. Use standard sizing (S, M, L, XL or numeric). |
Price Format Conversion
The POS stores prices as DECIMAL(10,2) but Google requires prices in micros (millionths of the currency unit):
POS price: $29.99 (DECIMAL)
Google micros: 29990000 (INT64)
Conversion: price_micros = ROUND(pos_price * 1_000_000)
6.5.3 Local Inventory
Local Inventory Ads (LIA) allow the POS to surface real-time store-level availability directly in Google Shopping results. When a shopper searches for a product near a physical store, Google can display “In stock at [Store Name]” with options for in-store pickup or same-day delivery.
Local Inventory API
| Operation | Endpoint | Method | Notes |
|---|---|---|---|
| Insert local inventory | accounts/{account}/products/{product}/localInventories:insert | POST | Upsert by storeCode |
| List local inventories | accounts/{account}/products/{product}/localInventories | GET | Returns all store-level records for a product |
| Delete local inventory | accounts/{account}/products/{product}/localInventories/{storeCode}:delete | DELETE | Remove store-level entry |
Processing time: Updates take up to 30 minutes to appear in Google Shopping results. The POS should not expect real-time reflection.
POS Location to Google Store Code Mapping
Each POS location must be mapped to a Google storeCode (the unique identifier from Google Business Profile). This mapping is configured in Module 5 and stored in the google_store_mappings table.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
pos_location_id | UUID | Yes | FK to locations.id (Module 5) |
google_store_code | VARCHAR(50) | Yes | Google Business Profile store code |
google_merchant_account_id | VARCHAR(20) | Yes | FK reference to google_merchant_credentials.merchant_account_id |
store_name | VARCHAR(200) | Yes | Display name for the store |
is_lia_enrolled | BOOLEAN | Yes | Whether Local Inventory Ads are enabled for this location – default false |
pickup_method | VARCHAR(30) | No | Default: buy. Options: buy, reserve, ship_to_store, not_supported |
pickup_sla | VARCHAR(20) | No | Default: same_day. Options: same_day, next_day, multi_day, multi_week |
is_active | BOOLEAN | Yes | Whether this mapping is active – default true |
last_sync_at | TIMESTAMPTZ | No | Timestamp of most recent local inventory sync |
created_at | TIMESTAMPTZ | Yes | Row creation timestamp |
updated_at | TIMESTAMPTZ | Yes | Last modification timestamp |
Local Inventory Data Payload
For each product-location combination, the POS pushes:
| Field | Type | Required | Description |
|---|---|---|---|
storeCode | STRING | Yes | Mapped from google_store_mappings.google_store_code |
availability | STRING | Yes | in_stock, out_of_stock, limited_availability |
price | PRICE | No | Store-specific price override (if different from online). Object: { amountMicros, currencyCode } |
salePrice | PRICE | No | Store-specific sale price. Object: { amountMicros, currencyCode } |
salePriceEffectiveDate | STRING | No | ISO 8601 interval: 2026-03-01T00:00:00Z/2026-03-15T23:59:59Z |
quantity | INT64 | No | Exact quantity in stock at this location. Google recommends providing this. |
pickupMethod | STRING | No | buy, reserve, ship_to_store, not_supported |
pickupSla | STRING | No | same_day, next_day, multi_day, multi_week |
Local Inventory Sync Architecture
flowchart TD
INV_EVENT[Inventory Change Event<br/>Module 4] -->|qty changed| SYNC_EVAL{Sync Evaluation}
SYNC_EVAL -->|Google Merchant enabled<br/>& location enrolled| BUILD[Build Local Inventory Payload]
SYNC_EVAL -->|Not enrolled or<br/>provider disabled| SKIP[Skip -- No Action]
BUILD --> MAP[Map POS location_id<br/>to Google storeCode]
MAP --> AVAIL{Compute Availability}
AVAIL -->|qty > threshold| IN_STOCK[availability = in_stock]
AVAIL -->|qty > 0 AND<br/>qty <= threshold| LIMITED[availability = limited_availability]
AVAIL -->|qty == 0| OOS[availability = out_of_stock]
IN_STOCK --> BATCH
LIMITED --> BATCH
OOS --> BATCH
BATCH[Batch Queue<br/>Max 10 per request] -->|Flush every 60s<br/>or batch full| GAPI[POST localInventories:insert]
GAPI -->|200 OK| LOG_OK[Log Success<br/>Update last_sync_at]
GAPI -->|4xx / 5xx| RETRY[Retry Pipeline<br/>Section 6.2.3]
RETRY -->|Exhausted| DLQ[Dead Letter Queue]
Cross-Reference: See Module 4, Section 4.5.2 for the inventory change event schema that triggers local inventory sync.
6.5.4 Push Notifications
Google Merchant Center can send push notifications to a registered HTTPS callback endpoint when product statuses change. This eliminates the need to poll the Product Status API repeatedly.
Notification Configuration
| Parameter | Requirement |
|---|---|
| Callback URL | Must be publicly accessible HTTPS endpoint |
| SSL Certificate | Must be signed by a trusted CA (no self-signed certificates) |
| Response time | Must respond with 200 OK within 10 seconds |
| Authentication | Google sends a JWT in the Authorization header; validate against Google public keys |
Supported Notification Types
| Notification Type | Trigger | Payload Fields |
|---|---|---|
PRODUCT_STATUS_CHANGE | Product approval status changes (approved, disapproved, pending) | productId, accountId, attribute, previousValue, newValue, timestamp |
ACCOUNT_STATUS_CHANGE | Merchant Center account status changes | accountId, attribute, previousValue, newValue, timestamp |
Callback Data Model: google_merchant_notifications
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
notification_id | VARCHAR(100) | Yes | Google-provided unique notification ID (idempotency key) |
notification_type | VARCHAR(50) | Yes | PRODUCT_STATUS_CHANGE, ACCOUNT_STATUS_CHANGE |
product_id | VARCHAR(100) | No | Google product ID (present for product notifications) |
attribute | VARCHAR(100) | Yes | The attribute that changed (e.g., status, approvalStatus) |
previous_value | TEXT | No | Value before the change |
new_value | TEXT | Yes | Value after the change |
raw_payload | JSONB | Yes | Complete raw notification payload for audit |
processed | BOOLEAN | Yes | Whether the notification has been handled – default false |
processed_at | TIMESTAMPTZ | No | When the notification was processed |
received_at | TIMESTAMPTZ | Yes | When the POS received the notification |
Notification Processing Flow
When a PRODUCT_STATUS_CHANGE notification indicates a disapproval:
- Parse the notification payload and extract the
productIdand disapproval reason. - Map the Google
productIdback to the POSskuusing theofferIdfield. - Update the product’s Google sync status in the POS database.
- If the disapproval reason is actionable (e.g., missing GTIN, low image quality), create an alert for the catalog team (Module 3).
- Log the notification in
google_merchant_notificationsfor audit.
6.5.5 Rate Limits
Google Merchant API enforces rate limits at multiple levels. The POS must respect these limits to avoid request rejection and potential account suspension.
Rate Limits by Operation
| Operation | Limit | Strategy |
|---|---|---|
Product insert/update (productInputs:insert) | 2 updates per product per day | Batch all changes for a product; push at most 2x daily. Use change-detection to skip unchanged products. |
Product get (products.get) | Generous (1,000+ per day) | Cache responses locally for 15 minutes. Use bulk products.list instead of individual gets. |
Product list (products.list) | 250 results per page | Implement cursor-based pagination. Store nextPageToken for continuation. |
Local inventory insert (localInventories:insert) | No strict per-product limit | Update on inventory change events. Batch up to 10 entries per request. |
| Account-level daily quota | Varies by account tier | Monitor X-RateLimit-Remaining headers. Schedule bulk syncs during 02:00-06:00 UTC off-peak window. |
| Requests per minute | ~600 for standard accounts | Implement in-memory token bucket per tenant. Queue excess requests. |
Batching Strategy
To stay within the 2-updates-per-product-per-day limit:
- Change Detection: Before syncing, compare the current product data hash against the last-synced hash. Skip unchanged products.
- Batch Window: Accumulate product changes during the business day. Execute sync at two scheduled windows (e.g., 06:00 and 18:00 UTC).
- Priority Queue: If a product is updated more than twice in a day, only the most recent state is synced. Intermediate states are discarded.
- Emergency Override: Critical changes (price corrections, safety-related updates) bypass the batch window and sync immediately, consuming one of the two daily slots.
google_merchant_sync:
batch_windows:
- "06:00 UTC"
- "18:00 UTC"
max_updates_per_product_per_day: 2
change_detection: sha256_hash
emergency_override_enabled: true
emergency_reasons:
- price_correction
- safety_recall
- legal_compliance
off_peak_bulk_window: "02:00-06:00 UTC"
6.5.6 Google Required Product Data Fields
Google Shopping enforces strict data requirements. Products missing required fields or containing policy-violating content will be disapproved and will not appear in Shopping results.
Mandatory Feed Fields – POS Must Provide
| Google Field | POS Source Field | Required | Validation Rule |
|---|---|---|---|
id (offerId) | sku | Yes | Unique per product, max 50 chars, alphanumeric + hyphens + underscores only |
title | name | Yes | Max 150 chars. Prohibited: “Sale!”, “Free Shipping”, “Best Price”, all-caps words, excessive punctuation |
description | long_description | Yes | Max 5,000 chars. Plain text preferred. No HTML tags (Google strips them). No promotional language. |
imageLink | primary_image_url | Yes | Min 100x100px (apparel: 250x250px). No watermarks, no promotional overlays, no placeholder images. Must be publicly accessible HTTPS URL. |
price | base_price + currency_code | Yes | Must match website/landing page price EXACTLY. Object: { amountMicros, currencyCode }. Price discrepancies cause immediate disapproval. |
availability | Computed from inventory | Yes | Enum: in_stock, out_of_stock, preorder, backorder. Must match actual stock. Mismatches cause disapproval. |
brand | brand | Yes (most categories) | Manufacturer brand name, NOT store name. Required for all products with a known brand. |
gtin | barcode (UPC/EAN) | Yes (if exists) | Valid GTIN-8/12/13/14. No dashes or spaces. Check digit validated via mod-10 algorithm. |
condition | product_condition | Yes | Enum: new, refurbished, used. Default: new. Must accurately reflect product condition. |
link | Website product URL | Yes | Must resolve to a live HTTPS page. Product data on page must match feed data. Broken links cause disapproval. |
mpn | manufacturer_part_number | Conditional | Required if no GTIN available. Must be the manufacturer’s own part number. |
Optional but Recommended Fields
| Google Field | POS Source Field | Benefit |
|---|---|---|
additionalImageLink | additional_images[] (up to 10) | Better product presentation; higher click-through rate |
productHighlight | bullet_points[] (up to 10, max 150 chars each) | Enhanced listing appearance in Google Shopping |
color | variant_color | Required for apparel; improves search matching |
size | variant_size | Required for apparel; enables size-based filtering |
material | material_type | Improves product matching and filtering accuracy |
pattern | pattern_type | Improves matching for patterned products (striped, plaid, etc.) |
ageGroup | target_age_group | Required for apparel. Enum: newborn, infant, toddler, kids, adult |
gender | target_gender | Required for apparel. Enum: male, female, unisex |
itemGroupId | parent_product_id | Groups variants together in Shopping results |
salePrice | sale_price + currency_code | Displays original + sale price; strikethrough pricing |
salePriceEffectiveDate | sale_start / sale_end | ISO 8601 interval for automatic sale price activation |
shippingWeight | weight + weight_unit | Required for carrier-calculated shipping rates |
6.5.7 Google Image Requirements
Product images are the single most important factor in Google Shopping performance. Google enforces strict image quality rules; violations result in product disapproval.
Image Specification Table
| Requirement | Specification | POS Validation |
|---|---|---|
| Minimum size | 100x100 pixels (apparel: 250x250px) | Validate dimensions on upload; block submission if below minimum |
| Maximum size | 64 megapixels / 16 MB file size | Validate on upload; reject files exceeding limits |
| Format | JPEG, PNG, GIF (non-animated), BMP, TIFF, WebP | Validate file extension AND MIME type (prevent extension spoofing) |
| Background | White or transparent preferred | Advisory warning if background is non-white; do not block |
| Watermarks | PROHIBITED – causes immediate disapproval | Block upload if watermark metadata detected; warn if image analysis flags overlay text |
| Promotional text | PROHIBITED – no “Sale”, “Free Shipping”, logos, badges | Advisory warning to user; flag for manual review before sync |
| Borders | No decorative borders allowed | Advisory warning if border detected in image analysis |
| Product visibility | Product must occupy 75-90% of image frame | Advisory warning based on object-detection analysis (if available) |
| URL accessibility | Must be publicly accessible via HTTPS | Validate URL reachability (HTTP HEAD request) before every sync |
| Image alt text | Descriptive, non-keyword-stuffed | Auto-generate from product name + colour + size if empty |
| Placeholders | No “image coming soon” or generic placeholder images | Block sync if image URL matches known placeholder patterns |
| Product accuracy | Image must show the exact product being sold | Manual review flag; cannot be automated reliably |
Image Validation Workflow
The POS validates images at two stages:
- Upload time (immediate feedback): File size, dimensions, format, MIME type.
- Pre-sync time (before pushing to Google): URL accessibility, watermark detection, placeholder detection, completeness check.
6.5.8 Google Disapproval Prevention Rules
Product disapprovals directly reduce revenue by removing products from Google Shopping. The POS must implement proactive validation to prevent disapprovals before they occur.
Common Disapproval Reasons & POS Prevention
| Disapproval Reason | Google Policy | POS Prevention Rule |
|---|---|---|
| Price mismatch | Feed price MUST match landing page price exactly | Cross-check feed price vs. website price before every sync. Block sync if prices diverge by > $0.01. |
| Availability mismatch | Feed availability MUST match actual stock | Real-time inventory sync. Automatically set out_of_stock when qty == 0. Never show in_stock for zero-qty items. |
| Missing required attributes | All mandatory fields must be populated | Pre-sync validation gate blocks submission if any required field is empty or null. |
| Low image quality | Below minimum resolution, watermarks, promotional overlays | Image validation on upload (dimensions, format). Pre-sync URL accessibility check. |
| Misrepresentation | No fake urgency, fake scarcity, misleading claims | Block promotional text patterns in title and description: regex filter for “Sale!”, “Limited Time”, “Act Now”, etc. |
| Prohibited content | Restricted product categories (weapons, drugs, counterfeit) | Flag restricted Google product categories during product setup. Require manager approval before sync. |
| Missing landing page | Product URL must resolve to a live page with matching data | HTTP HEAD request to link URL before every sync. Block sync on 404/500 responses. |
| Invalid GTIN | Wrong format, invalid check digit, or mismatched product | GTIN validation algorithm: verify length (8/12/13/14 digits), compute mod-10 check digit, reject on mismatch. |
| Missing business info | Merchant Center must have contact info, return policy | Validate Merchant Center profile completeness via API during initial setup. Alert if incomplete. |
| Insufficient product identifiers | Products with known UPC must include gtin | Require barcode field when product has_manufacturer_upc = true. Warn if GTIN is empty for branded products. |
POS Pre-Sync Validation Checklist (Automated)
Every product must pass all 10 validation checks before being submitted to Google Merchant:
- All required fields populated (non-null, non-empty)?
- Primary image meets quality requirements (dimensions, format, HTTPS)?
- Price matches across all channels (POS, website, feed)?
- GTIN/UPC valid format (correct length, check digit passes mod-10)?
- Product category not in prohibited list?
- Landing page URL accessible (HTTP 200 response)?
- Title length <= 150 chars, no promotional text detected?
- Description length <= 5,000 chars, no HTML tags?
- Brand field populated (non-empty, not equal to store name)?
- Availability computed from current inventory (not stale > 1 hour)?
Pre-Sync Validation Flowchart
flowchart TD
START[Product Queued for<br/>Google Sync] --> V1{1. Required fields<br/>populated?}
V1 -->|No| FAIL_REQ[BLOCK: Missing required<br/>fields -- log which fields]
V1 -->|Yes| V2{2. Image meets<br/>quality requirements?}
V2 -->|No| FAIL_IMG[BLOCK: Image quality<br/>failure -- log reason]
V2 -->|Yes| V3{3. Price matches<br/>across channels?}
V3 -->|No| FAIL_PRICE[BLOCK: Price mismatch<br/>detected -- log discrepancy]
V3 -->|Yes| V4{4. GTIN valid<br/>format & check digit?}
V4 -->|No GTIN & no MPN| FAIL_ID[BLOCK: Missing product<br/>identifier -- GTIN or MPN required]
V4 -->|Invalid check digit| FAIL_GTIN[BLOCK: Invalid GTIN<br/>check digit -- log expected vs actual]
V4 -->|Valid or N/A| V5{5. Category not<br/>prohibited?}
V5 -->|Prohibited| FAIL_CAT[BLOCK: Prohibited<br/>category -- requires override]
V5 -->|Allowed| V6{6. Landing page<br/>URL accessible?}
V6 -->|404 / 500 / timeout| FAIL_URL[BLOCK: Landing page<br/>not accessible -- log HTTP status]
V6 -->|200 OK| V7{7. Title ≤ 150 chars<br/>& no promo text?}
V7 -->|Fails| FAIL_TITLE[BLOCK: Title validation<br/>failure -- log reason]
V7 -->|Passes| V8{8. Description ≤ 5K<br/>chars & no HTML?}
V8 -->|Fails| FAIL_DESC[BLOCK: Description<br/>validation failure]
V8 -->|Passes| V9{9. Brand field<br/>populated & valid?}
V9 -->|Empty or equals store name| FAIL_BRAND[BLOCK: Invalid brand<br/>-- must be manufacturer brand]
V9 -->|Valid| V10{10. Availability<br/>fresh < 1 hour?}
V10 -->|Stale| REFRESH[Refresh inventory<br/>count from Module 4]
REFRESH --> RECOMPUTE[Recompute availability]
RECOMPUTE --> PASS
V10 -->|Fresh| PASS[ALL CHECKS PASSED]
PASS --> SUBMIT[Submit to Google<br/>Merchant API]
SUBMIT -->|200 OK| SUCCESS[Log success<br/>Update sync timestamp]
SUBMIT -->|Error| RETRY[Retry Pipeline<br/>Section 6.2.3]
FAIL_REQ --> LOG[Log validation failure<br/>to google_sync_log]
FAIL_IMG --> LOG
FAIL_PRICE --> LOG
FAIL_ID --> LOG
FAIL_GTIN --> LOG
FAIL_CAT --> LOG
FAIL_URL --> LOG
FAIL_TITLE --> LOG
FAIL_DESC --> LOG
FAIL_BRAND --> LOG
6.5.9 Google Business Profile Integration
Google Business Profile (GBP) integration is required for Local Inventory Ads. The Merchant Center account must be linked to verified GBP listings so that Google can associate product availability with physical store locations.
GBP-to-Merchant Center Linkage
- Verify store ownership in Google Business Profile for each physical location.
- Link GBP to Merchant Center via the Merchant Center settings panel.
- Map POS locations to GBP listings using the
google_store_codeassigned by GBP. - Enrol locations in Local Inventory Ads via the Merchant Center LIA program.
- Sync store hours from POS location settings to GBP to ensure accuracy.
Data Model: google_business_profile_locations
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants.id |
pos_location_id | UUID | Yes | FK to locations.id (Module 5) |
gbp_place_id | VARCHAR(100) | Yes | Google Places API place ID (e.g., ChIJ...) |
google_store_code | VARCHAR(50) | Yes | Store code used in Local Inventory feed (must match Merchant Center) |
store_name | VARCHAR(200) | Yes | Business name as it appears in GBP |
address_line1 | VARCHAR(255) | Yes | Street address |
address_line2 | VARCHAR(255) | No | Suite, unit, floor |
city | VARCHAR(100) | Yes | City name |
state_province | VARCHAR(100) | Yes | State or province |
postal_code | VARCHAR(20) | Yes | ZIP or postal code |
country_code | CHAR(2) | Yes | ISO 3166-1 alpha-2 (e.g., US, CA) |
phone | VARCHAR(20) | Yes | Primary phone number in E.164 format |
hours_json | JSONB | Yes | Structured store hours (see format below) |
is_verified | BOOLEAN | Yes | Whether GBP ownership is verified – default false |
is_enrolled_lia | BOOLEAN | Yes | Whether Local Inventory Ads are enabled – default false |
last_sync_at | TIMESTAMPTZ | No | Timestamp of most recent GBP sync |
created_at | TIMESTAMPTZ | Yes | Row creation timestamp |
updated_at | TIMESTAMPTZ | Yes | Last modification timestamp |
Store Hours JSON Format
# Example hours_json structure
hours:
monday: { open: "09:00", close: "21:00" }
tuesday: { open: "09:00", close: "21:00" }
wednesday: { open: "09:00", close: "21:00" }
thursday: { open: "09:00", close: "21:00" }
friday: { open: "09:00", close: "22:00" }
saturday: { open: "10:00", close: "22:00" }
sunday: { open: "11:00", close: "18:00" }
special_hours:
- date: "2026-12-25"
closed: true
- date: "2026-12-24"
open: "09:00"
close: "15:00"
Cross-Reference: See Module 5, Section 5.3 for the location management screens where POS locations are configured and mapped to Google Business Profile listings.
6.5.10 Content API Migration Plan
The Content API for Shopping (/content/v2.1/) is being fully replaced by the Merchant API. All POS integrations must target the Merchant API exclusively. This section documents the migration path for any legacy Content API usage.
Migration Timeline
| Step | Action | Deadline | Status |
|---|---|---|---|
| 1 | Audit current Content API usage across all endpoints | January 2026 | Required |
| 2 | Map Content API calls to Merchant API v1 equivalents | February 2026 | Required |
| 3 | Update authentication to use Merchant API token endpoints | March 2026 | Required |
| 4 | Update product data submission to ProductInput resource | April 2026 | Required |
| 5 | Update local inventory to new localInventories endpoints | May 2026 | Required |
| 6 | Full regression testing (product sync, local inventory, notifications) | June 2026 | Required |
| 7 | Production cutover: switch all tenants to Merchant API | July 2026 | Required |
| 8 | Content API fully deprecated – all endpoints cease | August 18, 2026 | Hard deadline |
Key API Mapping: Content API to Merchant API
| Content API (v2.1) | Merchant API (v1) | Breaking Changes |
|---|---|---|
products.insert | productInputs:insert | Write resource renamed to ProductInput; read resource is now Product |
products.get | products.get | Response schema changed; now returns Google-processed version only |
products.list | products.list | Pagination uses pageToken instead of startToken |
products.delete | productInputs:delete | Must target ProductInput resource, not Product |
products.custombatch | Individual calls or batch API | custombatch removed; use standard batch request pattern |
localinventory.insert | localInventories:insert | New URL structure under products/{product}/localInventories |
pos.inventory (legacy) | localInventories:insert | POS-specific endpoint removed; use standard local inventory |
productstatuses.get | productStatuses.get | Response schema updated; new status categories |
Risk Mitigation
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Migration not completed before August 18, 2026 | Medium | Critical – all Google Shopping listings go dark | Start migration in Q1 2026; track weekly in sprint reviews |
| Breaking changes in Merchant API v1 before GA | Low | Medium – requires rework of integration code | Pin to v1beta; monitor Google Merchant API changelog weekly |
| Tenant data inconsistency during cutover | Medium | Medium – temporary product disapprovals | Run Content API and Merchant API in parallel for 2 weeks before final cutover |
| Rate limit changes in new API | Low | Low – may require batching strategy adjustment | Monitor rate limit headers during testing; update google_merchant_sync config as needed |
6.5.11 Reports: Google Merchant Integration
The POS provides five standard reports for monitoring Google Merchant integration health, product approval status, and shopping performance.
Report Catalogue
| Report | Purpose | Key Data Fields | Refresh Frequency |
|---|---|---|---|
| Google Product Status | Track approved, pending, and disapproved products across the feed | sku, google_product_id, status (approved/pending/disapproved), disapproval_reasons[], last_updated | Every 6 hours |
| Google Local Inventory | Monitor store-level availability accuracy and sync latency | store_code, store_name, products_synced, avg_sync_latency_min, stale_count (not synced in 24h), accuracy_pct | Every 4 hours |
| Google Shopping Performance | Surface click, impression, and CTR data from Performance Max campaigns (if available) | product_id, sku, impressions, clicks, ctr_pct, avg_cpc, conversions, revenue | Daily (data from Google Ads API) |
| Google Disapproval Tracker | Track disapproved products with reasons, remediation status, and re-approval dates | sku, product_name, disapproval_reason, disapproved_at, remediation_action, remediation_status (open/in_progress/resolved), re_approved_at | Real-time (via push notifications, Section 6.5.4) |
| Google Feed Health | Overall feed quality score with missing-field analysis and image quality audit | total_products, products_with_all_fields_pct, missing_gtin_count, missing_description_count, low_quality_image_count, feed_health_score (0-100), recommendations[] | Daily |
Feed Health Score Calculation
The Feed Health Score is a composite metric (0–100) calculated as follows:
| Component | Weight | Scoring |
|---|---|---|
| Required fields completeness | 40% | 100 if all products have all required fields; deduct 1 point per product with missing fields (min 0) |
| Image quality compliance | 25% | 100 if all images pass validation; deduct 2 points per product with image issues (min 0) |
| GTIN coverage | 15% | 100 if all products with UPCs have valid GTINs; deduct 1 point per missing GTIN (min 0) |
| Price accuracy | 10% | 100 if all feed prices match website prices; 0 if any mismatch detected |
| Disapproval rate | 10% | 100 if 0% disapproved; 0 if > 10% disapproved; linear between |
feed_health_score:
components:
required_fields:
weight: 0.40
deduction_per_violation: 1
image_quality:
weight: 0.25
deduction_per_violation: 2
gtin_coverage:
weight: 0.15
deduction_per_violation: 1
price_accuracy:
weight: 0.10
scoring: binary # 100 or 0
disapproval_rate:
weight: 0.10
max_acceptable_rate: 0.10
thresholds:
excellent: 90
good: 75
needs_attention: 50
critical: 0
Cross-Reference: See Module 5, Section 5.18 for dashboard widget configuration that surfaces the Feed Health Score on the Nexus POS home screen.
6.6 Cross-Platform Product Data Requirements
Scope: Ensuring that all product data managed in the POS system meets the validation requirements of every connected external platform simultaneously. Rather than validating per-platform at sync time, the POS enforces a unified “strictest-rule-wins” validation policy at data entry. This guarantees that any product passing POS validation is immediately eligible for listing on Shopify, Amazon, and Google Merchant Center without remediation.
Cross-Reference: See Module 3, Section 3.6 for multi-channel management and channel visibility. See Module 3, Section 3.7 for Shopify-specific sync and field ownership. See Module 5, Section 5.16 for Integration Hub configuration and health monitoring.
Design Principle: The POS system acts as the single source of truth for product data. By enforcing the strictest requirement from any connected platform at the point of data entry, we eliminate the common pattern of “create now, fix later” that leads to suppressed listings, disapproved products, and lost revenue.
6.6.1 Unified Product Data Validation Matrix
The following matrix compares field-level requirements across all three platforms and documents the POS-enforced rule derived from the strictest constraint.
| POS Field | Shopify Requirement | Amazon Requirement | Google Requirement | POS Enforced Rule (Strictest) |
|---|---|---|---|---|
name (title) | Max 255 chars | Max 500 chars | Max 150 chars | Max 150 chars (Google strictest) |
long_description | HTML allowed, no max | HTML allowed, max 2,000 chars (category-dependent) | Max 5,000 chars, plain text preferred | Max 5,000 chars (Google cap; HTML sanitized for Google feed) |
short_description | N/A (uses body) | Max 1,000 chars (bullet points) | N/A | Max 1,000 chars (Amazon bullet point limit) |
primary_image | Any format, 2048x2048 recommended | Min 1000x1000px for zoom eligibility | Min 250x250px (apparel), no watermarks | Min 1000x1000px, no watermarks (Amazon + Google combined) |
base_price | Required | Required per marketplace | Must match landing page price | Required, must match across all channels |
compare_at_price | Optional (strikethrough) | List price (optional) | Optional (sale_price / sale_price_effective_date) | Optional, must be > base_price if set |
barcode (UPC/EAN) | Optional | Required for most categories | Required (GTIN) if exists for product | Required (treat as mandatory for channel eligibility) |
brand | Optional (vendor field) | Required | Required (most categories) | Required |
weight | Optional | Required for FBA fulfillment | Optional (but needed for shipping) | Required (needed for FBA and shipping calculations) |
weight_unit | g, kg, lb, oz | Pounds or kilograms | g, kg, lb, oz | Required, stored in grams, converted per platform |
condition | N/A | Required (New, Refurbished, Used) | Required (new, refurbished, used) | Required (default: new) |
product_type | Optional (free-text) | Required (Amazon Browse Node ID) | Optional (Google Product Category ID) | Required (must map to both Amazon and Google taxonomies) |
sku | Required, unique per store | Required (seller_sku), unique | Required (offerId), max 50 chars | Required, unique per tenant, max 50 chars (Google strictest) |
manufacturer_part_number | N/A | Optional (MPN) | Required if no GTIN assigned | Required if no barcode/GTIN |
color | Optional (variant option) | Required for apparel | Required for apparel | Required for apparel categories |
size | Optional (variant option) | Required for apparel | Required for apparel | Required for apparel categories |
gender | N/A | Required for apparel | Required for apparel | Required for apparel categories |
age_group | N/A | Required for apparel | Required for apparel | Required for apparel categories (adult, kids, toddler, infant, newborn) |
material | N/A | Optional | Recommended for apparel | Recommended (improves listing quality) |
country_of_origin | N/A | Required for some categories | N/A | Required (needed for customs and Amazon compliance) |
Business Rules:
- All text fields are trimmed of leading/trailing whitespace before validation.
- Title (
name) must not contain promotional text (e.g., “FREE SHIPPING”, “SALE”, “BUY NOW”) per Amazon and Google policies. - Price must be > $0.00 for all channel-listed products. Zero-price items are blocked from channel sync.
- If a product fails unified validation, it can still be used for in-store POS sales but is blocked from external channel sync.
6.6.2 Image Requirements Matrix
Product images are the most common reason for listing suppression across platforms. The POS enforces a unified image standard that satisfies all platforms simultaneously.
| Requirement | Shopify | Amazon | POS Enforced Rule | |
|---|---|---|---|---|
| Min resolution | 2048x2048 recommended | 1000x1000 min (zoom eligible) | 250x250 (apparel) / 100x100 (other) | 1000x1000 minimum |
| Max resolution | 4472x4472 | 10000x10000 | N/A | 10000x10000 maximum |
| Max file size | 20MB | 10MB | 16MB | 10MB maximum (Amazon strictest) |
| Formats | JPEG, PNG, GIF | JPEG, PNG, TIFF, GIF | JPEG, PNG, GIF, WebP, BMP, TIFF | JPEG or PNG (universally supported) |
| Color space | sRGB | sRGB | sRGB | sRGB required |
| Background | Any | Pure white (RGB 255,255,255) required for main | White/transparent preferred | White background required (for main image) |
| Watermarks | Allowed | PROHIBITED | PROHIBITED | PROHIBITED |
| Text overlay | Allowed | PROHIBITED on main image | PROHIBITED | PROHIBITED on main image |
| Borders/frames | Allowed | PROHIBITED | PROHIBITED | PROHIBITED |
| Product coverage | N/A | 85% of frame recommended | 75-90% of frame | 85% of frame (Amazon guideline) |
| Main image count | 1 required | 1 required (up to 9 total) | 1 required (up to 11 additional) | 1 required, up to 9 recommended |
| Aspect ratio | Any | 1:1 preferred | Any (1:1 preferred) | 1:1 recommended (square) |
Image Upload Workflow:
flowchart TD
A[Staff Uploads Image] --> B{File Format Check}
B -->|Not JPEG/PNG| C[REJECT: Convert to JPEG or PNG]
B -->|JPEG or PNG| D{Resolution Check}
D -->|< 1000x1000| E[REJECT: Minimum 1000x1000px required]
D -->|>= 1000x1000| F{File Size Check}
F -->|> 10MB| G[WARN: Compress to under 10MB]
F -->|<= 10MB| H{Main Image?}
H -->|Yes| I{Background Check}
H -->|No - Additional| J[PASS: Store Image]
I -->|Not White| K[WARN: White background recommended for main image]
I -->|White| J
G --> L[Auto-Compress & Re-Check]
L --> H
K --> M[Staff Acknowledges Warning]
M --> J
J --> N[Generate Platform Variants]
N --> O[Store Original + Variants]
style C fill:#d32f2f,color:#fff
style E fill:#d32f2f,color:#fff
style G fill:#ff9800,color:#fff
style K fill:#ff9800,color:#fff
style J fill:#2e7d32,color:#fff
Image Storage Strategy:
- Original image stored at upload resolution.
- Platform-optimized variants generated asynchronously: Shopify (2048x2048), Amazon (2000x2000), Google (1200x1200).
- Variant generation uses lossy JPEG compression at quality 85 for file size compliance.
- Images are served via CDN with platform-specific URL patterns.
6.6.3 Pre-Sync Validation Engine
Before pushing any product to any external platform, the validation engine evaluates the product against the unified rules defined above. Validation runs automatically on product save and on-demand before sync operations.
Validation Result Levels:
| Level | Code | Behavior | Description |
|---|---|---|---|
| PASS | PASS | Sync allowed | All required fields present and valid for this platform |
| WARN | WARN | Sync allowed with advisory | Non-blocking issues detected (e.g., recommended fields missing, suboptimal image) |
| FAIL | FAIL | Sync blocked | Required fields missing or invalid; product cannot be listed on this platform |
Product Sync Validation Record:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | FK to products table |
variant_id | UUID | No | FK to product_variants table (NULL for parent-level validation) |
tenant_id | UUID | Yes | FK to tenants table |
platform | Enum | Yes | SHOPIFY, AMAZON, GOOGLE_MERCHANT |
validation_status | Enum | Yes | PASS, WARN, FAIL |
validation_errors | JSON | No | Array of {field, rule, message, severity} objects |
validation_warnings | JSON | No | Array of {field, rule, message, recommendation} objects |
last_validated_at | DateTime | Yes | Timestamp of last validation run |
last_synced_at | DateTime | No | Timestamp of last successful sync to this platform |
sync_status | Enum | Yes | PENDING, SYNCED, FAILED, BLOCKED, NOT_CONFIGURED |
sync_error_message | String(500) | No | Error message from last failed sync attempt |
external_id | String(100) | No | ID on external platform (Shopify product_id, Amazon ASIN, Google offerId) |
external_status | String(50) | No | Status on external platform (active, suppressed, disapproved, pending) |
external_status_reason | Text | No | Platform-reported reason for non-active status |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Validation Error Object Schema:
validation_error:
field: "barcode"
rule: "REQUIRED_FOR_AMAZON"
message: "Barcode (UPC/EAN) is required for Amazon listings in this category"
severity: "FAIL"
platform: "AMAZON"
remediation: "Add a valid UPC or EAN barcode in the product details"
Validation Engine Flow:
flowchart TD
A[Product Change Detected] --> B[Load Product Data]
B --> C[Load Platform Configurations]
C --> D{For Each Enabled Platform}
D --> E[Check Required Fields]
E --> F[Check Field Length Constraints]
F --> G[Check Image Rules]
G --> H[Check Price Consistency]
H --> I[Check Category Mapping]
I --> J[Check Platform-Specific Attributes]
J --> K[Generate Validation Report]
K --> L{Any FAIL Results?}
L -->|Yes| M[Set sync_status = BLOCKED]
L -->|No| N{Any WARN Results?}
N -->|Yes| O[Set validation_status = WARN\nsync_status = PENDING]
N -->|No| P[Set validation_status = PASS\nsync_status = PENDING]
M --> Q[Store Validation Record]
O --> Q
P --> Q
Q --> R[Notify Admin Dashboard]
R --> S{Auto-Sync Enabled?}
S -->|Yes, status = PASS/WARN| T[Queue for Platform Sync]
S -->|No or BLOCKED| U[Await Manual Action]
style M fill:#d32f2f,color:#fff
style O fill:#ff9800,color:#fff
style P fill:#2e7d32,color:#fff
Validation Triggers:
- Product created or updated (automatic)
- Image added, replaced, or removed (automatic)
- Price changed (automatic)
- Manual “Validate All” button in Nexus POS (on-demand)
- Bulk validation via scheduled job (nightly at 2:00 AM tenant time)
Admin Dashboard - Validation Summary View:
| Metric | Description |
|---|---|
| Total Products | Count of all active products in tenant catalog |
| Shopify Ready | Count where Shopify validation = PASS or WARN |
| Amazon Ready | Count where Amazon validation = PASS or WARN |
| Google Ready | Count where Google Merchant validation = PASS or WARN |
| Blocked (per platform) | Count where validation = FAIL, with top 5 failure reasons |
| Needs Attention | Products with WARN status grouped by warning type |
6.6.4 Platform-Specific Product Attributes
Beyond the unified fields, each platform requires or supports additional attributes that are stored in platform-specific extension fields on the product record.
Amazon-Specific Attributes:
| Attribute | Description | POS Storage | Validation |
|---|---|---|---|
product_type | Amazon Browse Node taxonomy classification | amazon_product_type (String) | Must map to valid Amazon category node ID |
bullet_points | Up to 5 key feature bullet points | amazon_bullet_points (JSON array) | Max 5 entries, max 1,000 chars each |
search_terms | Backend search keywords (not visible to customers) | amazon_search_terms (String) | Max 250 bytes total, no ASINs or brand names |
a_plus_content | Enhanced brand content eligibility | amazon_a_plus_eligible (Boolean) | Brand registered sellers only |
fulfillment_channel | Fulfillment method | amazon_fulfillment (Enum) | FBA, FBM, or BOTH |
item_condition_note | Condition details for non-new items | amazon_condition_note (Text) | Required if condition != new, max 1,000 chars |
max_handling_time | Days to ship after order | amazon_handling_days (Integer) | 1-30 days; required for FBM |
restock_date | Expected restock date if out of stock | amazon_restock_date (Date) | Optional; future date only |
Amazon Attribute Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | FK to products table |
tenant_id | UUID | Yes | FK to tenants table |
amazon_product_type | String(100) | Yes | Amazon Browse Node category |
amazon_bullet_points | JSON | No | Array of up to 5 strings, 1000 chars each |
amazon_search_terms | String(250) | No | Backend keywords, space-separated |
amazon_a_plus_eligible | Boolean | Yes | Default: false |
amazon_fulfillment | Enum | Yes | FBA, FBM, BOTH |
amazon_condition_note | Text | No | Required if condition is not new |
amazon_handling_days | Integer | No | Max handling time for FBM orders |
amazon_restock_date | Date | No | Expected restock date |
amazon_asin | String(10) | No | Amazon Standard Identification Number (assigned by Amazon) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Google-Specific Attributes:
| Attribute | Description | POS Storage | Validation |
|---|---|---|---|
google_product_category | Google taxonomy path (numeric ID) | google_category_id (Integer) | Must map to valid Google Product Category |
mpn | Manufacturer part number | manufacturer_part_number (String) | Required if no GTIN; max 70 chars |
additional_image_link | Up to 10 additional images | product_images array (positions 2-11) | Same quality rules as main image |
product_highlight | Up to 10 key feature bullet points | google_highlights (JSON array) | Max 150 chars each, max 10 entries |
local_inventory_attrs | Store-level availability for Local Inventory Ads | Computed from POS inventory per location | Per-location mapping via storeCode |
pickup_method | How customer picks up in-store | google_pickup_method (Enum) | buy, reserve, ship_to_store, not_supported |
pickup_sla | Pickup time estimate | google_pickup_sla (Enum) | same_day, next_day, 2-day, 3-day, 4-day, 5-day, 6-day, multi-week |
custom_label_0 through custom_label_4 | Custom grouping labels for Shopping campaigns | google_custom_labels (JSON) | Max 100 chars each, up to 5 labels |
ads_redirect | Tracking URL for Google Ads | google_ads_redirect (String) | Valid URL, max 2,000 chars |
Google Attribute Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | FK to products table |
tenant_id | UUID | Yes | FK to tenants table |
google_category_id | Integer | Yes | Google Product Category taxonomy ID |
google_category_path | String(500) | No | Human-readable category path (e.g., “Apparel & Accessories > Clothing > Shirts”) |
google_highlights | JSON | No | Array of up to 10 strings, 150 chars each |
google_pickup_method | Enum | No | buy, reserve, ship_to_store, not_supported |
google_pickup_sla | Enum | No | same_day, next_day, 2-day through 6-day, multi-week |
google_custom_labels | JSON | No | Object with keys label_0 through label_4, max 100 chars each |
google_ads_redirect | String(2000) | No | Tracking URL for Google Ads campaigns |
google_offer_id | String(50) | No | Google Merchant Center offer ID (auto-generated from SKU if blank) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Shopify-Specific Attributes:
These fields are owned by Shopify in bidirectional sync mode (see Module 3, Section 3.7). The POS stores them as read-only references.
| Attribute | Description | POS Storage | Ownership |
|---|---|---|---|
seo_title | Meta title for search engine results | shopify_meta_title (String, max 70 chars) | Shopify-Owned |
seo_description | Meta description for search engine results | shopify_meta_description (String, max 320 chars) | Shopify-Owned |
url_handle | URL slug for the product page | shopify_handle (String, max 255 chars) | Shopify-Owned |
metafields | Custom structured data (JSON metafields) | shopify_metafields (JSON) | Shopify-Owned |
collections | Product collection memberships | shopify_collections (JSON array of IDs) | Shopify-Owned |
sales_channels | Channel publishing scope | shopify_channels (JSON array) | Shopify-Owned |
tags | Comma-separated product tags | shopify_tags (Text) | Configurable (POS or Shopify) |
template_suffix | Product page template override | shopify_template_suffix (String) | Shopify-Owned |
Shopify Attribute Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
product_id | UUID | Yes | FK to products table |
tenant_id | UUID | Yes | FK to tenants table |
shopify_product_id | BigInt | No | Shopify internal product ID (assigned after first sync) |
shopify_meta_title | String(70) | No | SEO title |
shopify_meta_description | String(320) | No | SEO description |
shopify_handle | String(255) | No | URL handle/slug |
shopify_metafields | JSON | No | Custom metafield key-value pairs |
shopify_collections | JSON | No | Array of Shopify collection IDs |
shopify_channels | JSON | No | Array of Shopify sales channel names |
shopify_tags | Text | No | Comma-separated tags |
shopify_template_suffix | String(100) | No | Theme template override |
shopify_published_at | DateTime | No | When product was published on Shopify |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Cross-Reference: See Module 3, Section 3.7.2 for field-level ownership model defining which system (POS or Shopify) is authoritative for each field in bidirectional sync mode.
6.7 Cross-Platform Inventory Sync Rules
Scope: Defining the rules, architecture, and failure handling for real-time inventory synchronization between the POS system (source of truth) and all connected external sales channels. This section covers sync latency targets, safety buffer configuration, oversell prevention, channel-specific inventory rules, and failure recovery procedures.
Cross-Reference: See Module 4, Section 4.1 for the POS inventory status model. See Module 4, Section 4.14 for Shopify-specific inventory sync. See Module 3, Section 3.6.3 for channel inventory allocation modes. See Module 5, Section 5.16 for Integration Hub health monitoring.
Design Principle: The POS system maintains a single, authoritative inventory count per product per location. All external channels receive computed available quantities derived from the POS count minus safety buffers and reservations. No external channel can directly modify POS inventory – all inbound changes (e.g., Shopify admin adjustments) are processed through the sync engine with conflict resolution.
6.7.1 Real-Time Inventory Sync Architecture
flowchart TD
POS["POS System\n(Source of Truth)\nSingle Inventory Record\nPer Product Per Location"]
INV_ENGINE["Inventory Sync Engine\n(Event-Driven)"]
SHOP["Shopify\nTarget: < 5s latency\nBidirectional Webhooks"]
AMZ_FBM["Amazon FBM\nTarget: < 2min latency\nAPI Push + SQS Pull"]
AMZ_FBA["Amazon FBA\nRead-Only Monitoring\nAmazon Manages Stock"]
GOOG["Google Merchant Center\nTarget: < 30min processing\nAPI Push (Content API)"]
POS -->|Inventory Event| INV_ENGINE
INV_ENGINE -->|Webhook Push| SHOP
INV_ENGINE -->|Feeds API Push| AMZ_FBM
INV_ENGINE -->|Content API Push| GOOG
INV_ENGINE -.->|Read via SP-API| AMZ_FBA
SHOP -->|Webhook: inventory_levels/update| INV_ENGINE
AMZ_FBM -->|SQS: ANY_OFFER_CHANGED notification| INV_ENGINE
INV_ENGINE -->|Reconciliation| POS
style POS fill:#1565c0,stroke:#0d47a1,color:#fff
style INV_ENGINE fill:#7b2d8e,stroke:#5a1d6e,color:#fff
style SHOP fill:#2e7d32,stroke:#1b5e20,color:#fff
style AMZ_FBM fill:#e65100,stroke:#bf360c,color:#fff
style AMZ_FBA fill:#6c757d,stroke:#495057,color:#fff
style GOOG fill:#c62828,stroke:#b71c1c,color:#fff
Inventory Events That Trigger Sync:
| Event | Source | Channels Notified |
|---|---|---|
| POS Sale completed | POS Terminal | All enabled channels |
| POS Return processed | POS Terminal | All enabled channels |
| Inventory adjustment | Nexus POS | All enabled channels |
| Inventory count reconciliation | Nexus POS | All enabled channels |
| Inter-store transfer (shipped) | Source location | All enabled channels (source location) |
| Inter-store transfer (received) | Destination location | All enabled channels (destination location) |
| Purchase order received | Receiving module | All enabled channels (receiving location) |
| Online order reserved | Shopify/Amazon | Remaining channels |
| Online order cancelled | Shopify/Amazon | All enabled channels (restore qty) |
Sync Latency Targets:
| Channel | Sync Method | Target Latency | Reconciliation Frequency | Max Acceptable Lag |
|---|---|---|---|---|
| Shopify | Webhooks (bidirectional) | < 5 seconds | Every 15 minutes | 60 seconds |
| Amazon FBM | SP-API push + SQS pull | < 2 minutes | Every 30 minutes | 10 minutes |
| Amazon FBA | Read-only monitoring (SP-API) | N/A (Amazon manages) | Every 4 hours | N/A |
| Google Merchant | Content API push | < 30 minutes (Google processing time) | Every 6 hours | 60 minutes |
Reconciliation Process:
- At each reconciliation interval, the sync engine compares POS quantities against platform-reported quantities.
- Discrepancies are logged in the Integration Sync Log (Module 5, Section 5.16.4).
- If discrepancy exceeds the configured threshold (default: 5 units), an admin alert is triggered.
- Auto-correction pushes POS quantity to the platform (POS always wins in reconciliation).
6.7.2 Safety Buffer Configuration
Safety buffers prevent overselling by withholding a configurable number of units from external channel listings. This provides a cushion for in-store sales, processing delays, and inventory inaccuracies.
Primary Formula:
Channel Available Qty = POS Available Qty - Safety Buffer
If Channel Available Qty < min_channel_qty → Show as out_of_stock on that channel
If max_channel_qty is set → Channel Available Qty = MIN(Channel Available Qty, max_channel_qty)
Safety Buffer Settings:
| Setting | Description | Default | Per-Product Override | Per-Channel Override |
|---|---|---|---|---|
safety_buffer_qty | Fixed units withheld from channel listing | 0 | Yes | Yes |
safety_buffer_pct | Percentage of POS qty withheld (alternative to fixed) | NULL | Yes | Yes |
buffer_calculation | How the buffer is computed | FIXED | Yes | Yes |
channel_warehouse_id | Specific POS location(s) feeding this channel | NULL (all locations) | No | Yes |
min_channel_qty | Minimum qty to display on channel; below this = out_of_stock | 1 | Yes | Yes |
max_channel_qty | Maximum qty shown on channel (cap) | NULL (no cap) | Yes | Yes |
Buffer Calculation Modes:
| Mode | Formula | Use Case | Example (POS Qty = 20, Buffer = 3) |
|---|---|---|---|
FIXED | Channel Qty = POS Qty - buffer_qty | Simple fixed reserve for walk-in customers | 20 - 3 = 17 listed |
PERCENTAGE | Channel Qty = POS Qty - CEIL(POS Qty * buffer_pct / 100) | Proportional reserve that scales with stock level | 20 - CEIL(20 * 15%) = 20 - 3 = 17 listed |
MIN_RESERVE | Channel Qty = MAX(0, POS Qty - buffer_qty) | Floor-based reserve (never goes negative) | 20 - 3 = 17 listed (or 0 if POS Qty < buffer) |
Buffer Priority Resolution:
- Product-specific + Channel-specific override (highest priority)
- Product-specific override (applies to all channels)
- Channel-specific default (applies to all products on that channel)
- Tenant-wide default (lowest priority)
Safety Buffer Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
product_id | UUID | No | FK to products table; NULL = tenant-wide or channel-wide default |
variant_id | UUID | No | FK to product_variants; NULL = applies to all variants of product |
channel | Enum | Yes | SHOPIFY, AMAZON_FBM, GOOGLE_MERCHANT, ALL |
safety_buffer_qty | Integer | Yes | Fixed units to withhold (default: 0) |
safety_buffer_pct | Decimal(5,2) | No | Percentage buffer (alternative to fixed); NULL if using fixed |
buffer_calculation | Enum | Yes | FIXED, PERCENTAGE, MIN_RESERVE |
min_channel_qty | Integer | Yes | Below this threshold = show as out_of_stock (default: 1) |
max_channel_qty | Integer | No | Cap on listed quantity; NULL = unlimited |
channel_warehouse_id | UUID | No | FK to locations; specific location for this channel; NULL = aggregate all locations |
is_active | Boolean | Yes | Whether this buffer rule is active (default: true) |
priority | Integer | Yes | Resolution priority (lower = higher priority); auto-calculated |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Recommended Buffer Defaults by Channel:
| Channel | Recommended Buffer | Rationale |
|---|---|---|
| Shopify | 0-2 units (fixed) | Low latency (< 5s) reduces oversell risk |
| Amazon FBM | 5-10% (percentage) | 2-minute sync lag + high velocity = higher risk |
| Google Merchant | 10-15% (percentage) | 30-minute processing delay = highest risk |
6.7.3 Oversell Prevention Rules
Overselling occurs when two or more channels sell the last available units simultaneously before inventory sync propagates the decrement. The POS system uses a reserve-on-order model with first-commit-wins conflict resolution.
Core Principles:
- All channels sync from a single POS inventory source (no shadow inventory).
- When an order is received from ANY channel, the POS immediately creates a reservation (soft lock) against the inventory.
- If two channels attempt to reserve the last unit simultaneously, the first transaction to commit wins; the second receives an insufficient stock response.
- Safety buffers provide a cushion during sync propagation windows.
- During offline mode, channels receive the last-known inventory quantity; the safety buffer provides protection against stale data.
Oversell Prevention Sequence:
sequenceDiagram
autonumber
participant SHOP as Shopify Store
participant POS as POS Inventory Engine
participant AMZ as Amazon Marketplace
participant DB as Database
Note over SHOP,AMZ: Scenario: 1 unit available, 2 simultaneous orders
SHOP->>POS: Order webhook (product X, qty: 1)
AMZ->>POS: Order notification (product X, qty: 1)
POS->>DB: BEGIN TRANSACTION (Shopify order)
DB-->>POS: Lock acquired on inventory row
POS->>DB: Reserve 1 unit (qty_available: 1 → 0, qty_reserved: +1)
DB-->>POS: COMMIT SUCCESS
POS->>DB: BEGIN TRANSACTION (Amazon order)
DB-->>POS: Lock acquired on inventory row
POS->>DB: Attempt reserve 1 unit (qty_available: 0)
DB-->>POS: INSUFFICIENT STOCK - ROLLBACK
POS-->>SHOP: Order confirmed - fulfillment pending
POS-->>AMZ: Reject order - insufficient stock
POS->>SHOP: Inventory update: qty = 0
POS->>AMZ: Inventory update: qty = 0
Note over SHOP,AMZ: Amazon order auto-cancelled or backordered per tenant config
Conflict Resolution Policies:
| Scenario | Resolution | Tenant Configurable |
|---|---|---|
| Two channels sell last unit simultaneously | First-commit wins (database row lock) | No (system behavior) |
| Losing channel order | Auto-cancel with customer notification OR backorder | Yes (per channel) |
| POS sale conflicts with online order | POS sale always wins (staff has physical product) | No (system behavior) |
| Inventory goes negative (edge case) | Alert admin, freeze channel sync, require manual resolution | No (safety mechanism) |
| Stale inventory during offline mode | Safety buffer absorbs; reconcile on reconnection | Yes (buffer size) |
Backorder Policy Options (per channel, per tenant):
| Policy | Behavior | Use Case |
|---|---|---|
AUTO_CANCEL | Automatically cancel the losing order and notify customer | Default for most retailers |
BACKORDER | Accept the order as backorder; fulfill when stock arrives | High-value or made-to-order products |
MANUAL_REVIEW | Hold the order for staff decision | Conservative approach |
6.7.4 Channel-Specific Inventory Rules
Each external channel has unique inventory sync behaviors, API constraints, and recommended configurations.
Amazon Inventory Rules:
| Rule | Value | Notes |
|---|---|---|
| FBA inventory tracking | Separate – Amazon manages physical stock | POS monitors FBA levels via SP-API getInventorySummaries; does not push to FBA |
| FBM inventory sync | From POS locations via Feeds API | Uses JSON_LISTINGS_FEED for inventory updates |
| FBM sync trigger | Every POS inventory event | Push via SP-API submitFeed or patchListingsItem |
| Order polling frequency | Every 2 minutes | New orders checked via getOrders SP-API endpoint |
| Safety buffer recommendation | 5-10% (percentage mode) | Higher buffer for slow-sync or high-velocity items |
| Handling time | Configurable per product (default: 2 days) | Affects customer delivery expectation |
| Multi-location support | Channel warehouse mapping | Map specific POS location(s) to Amazon FBM fulfillment |
| Throttling limits | 10 requests/sec for Feeds API | Batch updates to stay within rate limits |
| Quantity cap | Amazon shows “In Stock” for qty > 0 | Exact quantity not displayed to customers on most categories |
Amazon Inventory Sync Flow:
sequenceDiagram
autonumber
participant POS as POS System
participant ENGINE as Sync Engine
participant SP as Amazon SP-API
participant SQS as Amazon SQS
Note over POS,SQS: Outbound: POS → Amazon
POS->>ENGINE: Inventory event (product X, location A, new qty: 15)
ENGINE->>ENGINE: Calculate buffer (15 - 10% = 13)
ENGINE->>SP: patchListingsItem (sku: X, qty: 13)
SP-->>ENGINE: 200 OK
Note over POS,SQS: Inbound: Amazon → POS
SQS->>ENGINE: ANY_OFFER_CHANGED notification
ENGINE->>SP: getListingsItem (sku: X)
SP-->>ENGINE: Listing data with fulfillable_qty
ENGINE->>POS: Reconcile if discrepancy detected
Google Merchant Inventory Rules:
| Rule | Value | Notes |
|---|---|---|
| Sync scope | Local inventory per storeCode | Each POS location maps to a Google storeCode for Local Inventory Ads |
| Online inventory | Aggregated or warehouse-specific | Configurable: aggregate all locations or use designated warehouse |
| Processing delay | Up to 30 minutes after API submission | Google processes updates asynchronously |
| Safety buffer recommendation | 10-15% (percentage mode) | Higher buffer to account for processing delay |
| Availability values | in_stock, out_of_stock, preorder, backorder | Computed from POS qty and buffer rules |
| Update method | Content API localInventory.insert for local; products.insert for online | Different endpoints for local vs. online inventory |
| Update frequency | On every POS inventory event + 2x daily full sync | Event-driven + full reconciliation ensures accuracy |
| Quantity precision | Whole numbers only | Fractional quantities rounded down |
| Sale price sync | sale_price + sale_price_effective_date | Must match actual price on landing page |
Google Merchant Availability Mapping:
| POS Qty (after buffer) | Google Availability | Additional Fields |
|---|---|---|
| qty >= min_channel_qty | in_stock | quantity: actual qty |
| qty = 0 and restock date set | backorder | availability_date: restock date |
| qty = 0 and preorder flag | preorder | availability_date: release date |
| qty = 0 (default) | out_of_stock | – |
Shopify Inventory Rules:
| Rule | Value | Notes |
|---|---|---|
| Sync mode | Real-time bidirectional via webhooks | < 5s target latency |
| Granularity | Per-location | Each POS location maps 1:1 to a Shopify location |
| Location mapping | pos_location_id ↔ shopify_location_id | Configured in Integration Hub (Module 5, Section 5.16.3) |
| Webhook events | inventory_levels/update (inbound), Inventory Level Set API (outbound) | Bidirectional sync |
| Reconciliation | Every 15 minutes | Full inventory comparison per location to catch missed webhooks |
| Safety buffer | Optional (low latency reduces need) | Default: 0 for Shopify channel |
| Track inventory | MUST be enabled for all synced products | Products without inventory tracking are skipped |
| Multi-location | Supported natively | Shopify supports multiple inventory locations |
| Negative inventory | Blocked in POS; Shopify setting must match | Ensure “Allow negative inventory” is disabled in Shopify |
| Inventory policy | deny (stop selling at 0) or continue (oversell allowed) | Synced from POS channel config; deny recommended |
Shopify Location Mapping Data Model:
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
pos_location_id | UUID | Yes | FK to POS locations table |
shopify_location_id | BigInt | Yes | Shopify location ID |
shopify_location_name | String(100) | No | Cached Shopify location name for display |
sync_enabled | Boolean | Yes | Whether inventory syncs for this location pair (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
6.7.5 Sync Failure Handling
Inventory sync failures are critical because stale inventory data directly causes overselling or lost sales (showing out-of-stock when units are available). The system implements a tiered failure response with automatic escalation.
Retry Strategy:
- Attempt 1: Immediate retry after 5 seconds
- Attempt 2: Retry after 15 seconds (3x backoff)
- Attempt 3: Retry after 45 seconds (3x backoff)
- After 3 failures: Message moved to dead letter queue (DLQ)
- DLQ processor retries every 5 minutes for up to 2 hours
- After 2 hours: Admin escalation and channel freeze
Failure Escalation Timeline:
| Failure Duration | Action | Channel Effect | Admin Notification |
|---|---|---|---|
| 0-5 seconds | Automatic retry (attempt 1) | No impact | None |
| 5-15 seconds | Retry with backoff (attempt 2) | No impact | None |
| 15-45 seconds | Final retry (attempt 3) | Minimal delay | None |
| 45s - 5 minutes | Dead letter queue, auto-retry every 5 min | Slight staleness possible | None |
| 5 - 30 minutes | DLQ retries continue | Channel qty may be stale | Warning badge on Integration Hub |
| 30 min - 2 hours | Admin alert (email + in-app notification) | Channel qty is stale | Alert: “Inventory sync failing for [channel]” |
| > 2 hours | Channel freeze: set all products to out_of_stock | Products shown as unavailable | Critical alert: “Channel [X] frozen - manual intervention required” |
Sync Failure Sequence Diagram:
sequenceDiagram
autonumber
participant POS as POS System
participant ENGINE as Sync Engine
participant PLATFORM as External Platform
participant DLQ as Dead Letter Queue
participant ADMIN as Admin Dashboard
POS->>ENGINE: Inventory change event
ENGINE->>PLATFORM: Push inventory update (attempt 1)
PLATFORM-->>ENGINE: ERROR (timeout/5xx)
Note over ENGINE: Wait 5 seconds
ENGINE->>PLATFORM: Retry (attempt 2)
PLATFORM-->>ENGINE: ERROR (timeout/5xx)
Note over ENGINE: Wait 15 seconds
ENGINE->>PLATFORM: Retry (attempt 3)
PLATFORM-->>ENGINE: ERROR (timeout/5xx)
ENGINE->>DLQ: Move to dead letter queue
ENGINE->>ENGINE: Log failure in sync_log
loop Every 5 minutes for up to 2 hours
DLQ->>ENGINE: Dequeue message
ENGINE->>PLATFORM: Retry sync
alt Success
PLATFORM-->>ENGINE: 200 OK
ENGINE->>POS: Update sync_status = SYNCED
else Still failing
PLATFORM-->>ENGINE: ERROR
ENGINE->>DLQ: Re-queue message
end
end
Note over ENGINE: 30 minutes elapsed
ENGINE->>ADMIN: Warning: Sync failing for 30+ minutes
Note over ENGINE: 2 hours elapsed
ENGINE->>ADMIN: CRITICAL: Channel frozen
ENGINE->>PLATFORM: Set all products to out_of_stock
ENGINE->>POS: Update sync_status = FROZEN for all products on channel
Failure Types and Handling:
| Failure Type | Detection | Recovery | Auto-Resolve |
|---|---|---|---|
| Network timeout | HTTP timeout (30s) | Retry with backoff | Yes (transient) |
| Rate limiting (429) | HTTP 429 response | Exponential backoff, respect Retry-After header | Yes |
| Authentication expired | HTTP 401/403 | Refresh OAuth token; if fails, alert admin | Partial (token refresh is automatic) |
| Platform outage | HTTP 5xx repeated | DLQ + escalation timeline | Yes (when platform recovers) |
| Invalid data (400) | HTTP 400 with error details | Log error, skip item, alert admin | No (requires data fix) |
| Webhook delivery failure | No acknowledgment from POS | Platform retries (Shopify: up to 19 times over 48h) | Yes (platform-managed retry) |
Manual Recovery Tools:
| Tool | Location | Function |
|---|---|---|
| Resync Single Product | Product Detail > Integration Tab | Push current POS inventory to all channels for one product |
| Resync All Products | Integration Hub > Channel Card | Full inventory push to one channel |
| Unfreeze Channel | Integration Hub > Channel Card | Remove out_of_stock freeze and resume normal sync |
| View Failed Syncs | Integration Hub > Sync Log | Filter by status = FAILED, view error details, retry individually |
| Force Reconciliation | Integration Hub > Channel Card | Trigger immediate full reconciliation outside normal schedule |
Sync Health Monitoring Dashboard Metrics:
| Metric | Description | Alert Threshold |
|---|---|---|
| Sync success rate (1h) | Percentage of successful syncs in last hour | < 95% = Warning, < 80% = Critical |
| Average sync latency | Mean time from POS event to platform confirmation | > 2x target latency = Warning |
| DLQ depth | Number of messages in dead letter queue | > 50 = Warning, > 200 = Critical |
| Reconciliation discrepancies | Count of qty mismatches found in last reconciliation | > 10 = Warning, > 50 = Critical |
| Time since last successful sync | Elapsed time since last confirmed sync per channel | > 15 min (Shopify), > 30 min (Amazon), > 2h (Google) = Warning |
Business Rules:
- Sync failures for a single product do not affect other products on the same channel.
- Channel freeze (out_of_stock) is a safety mechanism, not a punitive one. It prevents overselling during extended outages.
- Unfreezing a channel triggers an immediate full reconciliation to ensure accuracy before resuming normal sync.
- All sync failures are logged in the Integration Sync Log (Module 5, Section 5.16.4) with full error details for troubleshooting.
- Tenants can configure per-channel freeze thresholds (override the default 2-hour threshold) in the Integration Hub settings.
6.7.6 Reports: Cross-Platform Inventory Sync
| Report | Purpose | Key Data Fields |
|---|---|---|
| Sync Health Summary | Overall sync performance across all channels | Channel, success rate %, avg latency, DLQ depth, last successful sync, status |
| Oversell Incident Report | Track instances where overselling occurred despite safeguards | Date, channel, product, POS qty at time, channel qty at time, order details, root cause |
| Safety Buffer Effectiveness | Analyze whether buffers are appropriately sized | Channel, buffer setting, oversell incidents, missed sales (buffer too high), recommendation |
| Sync Failure Analysis | Detailed breakdown of sync failures by type and channel | Channel, failure type, count, avg resolution time, auto-resolved %, manual intervention count |
| Inventory Discrepancy Log | Reconciliation findings showing POS vs. channel qty differences | Product, channel, POS qty, channel qty, discrepancy, reconciliation action, timestamp |
| Channel Freeze History | Track channel freeze events and their impact | Channel, freeze start, freeze end, duration, products affected, estimated lost revenue |
End of Module 6: Integrations – Sections 6.6 and 6.7
6.8 Payment Processor Integration
Scope: Consolidation of payment integration architecture, terminal communication, processor configuration, failure handling, and batch settlement. This section brings together payment-related content previously distributed across Module 1 (Sections 1.18.1-1.18.3) and Module 5 (Section 5.11.3) into a unified integration specification.
Cross-Reference: The payment flow sequence diagram (card tap/insert, refund via token) remains in Module 1, Section 1.18. Payment method registry (CASH, CREDIT_CARD, GIFT_CARD, etc.) and per-location payment configuration remain in Module 5, Section 5.11.1-5.11.2.
6.8.1 SAQ-A Architecture
The POS system uses a semi-integrated payment architecture that achieves PCI SAQ-A compliance – the simplest and least burdensome PCI self-assessment level. In this architecture, card data never enters or traverses the POS application. The payment terminal communicates directly with the payment processor, and only non-sensitive tokens and metadata flow back to the POS system.
Data the POS System Stores (Safe)
| Field | Type | Example | Purpose |
|---|---|---|---|
transaction_id | UUID | a1b2c3d4-... | Internal payment reference |
payment_token | String(255) | tok_1Nq... | Processor-issued token for refunds and voids |
approval_code | String(20) | AUTH4829 | Authorization code from issuing bank |
masked_card_number | String(10) | ****1234 | Last 4 digits only |
card_brand | Enum | VISA | Visa, Mastercard, Amex, Discover |
entry_method | Enum | TAP | CHIP, TAP, SWIPE, MANUAL |
terminal_id | String(50) | TRM-001-GM | Which physical terminal processed the payment |
timestamp | DateTime | 2026-02-17T14:32:00Z | When the authorization was obtained |
amount | Decimal(10,2) | 45.00 | Authorized or settled amount |
PCI Prohibited Data (NEVER Stored)
| Data Element | PCI Requirement | Risk if Stored |
|---|---|---|
| Full PAN (16-digit card number) | Prohibited under SAQ-A | Immediate PCI non-compliance; liability for fraud |
| CVV / CVC (3-4 digit security code) | Prohibited post-authorization | Stored CVV is grounds for PCI ban by acquirer |
| Track 1 / Track 2 data (magnetic stripe) | Prohibited post-authorization | Enables card cloning |
| PIN block (encrypted PIN) | Prohibited post-authorization | Enables unauthorized transactions |
| Raw EMV data (chip cryptogram) | Prohibited post-authorization | Enables replay attacks |
Architecture Principle: The POS backend sends the payment amount and order reference to the terminal. The terminal handles all card interaction. The processor returns a token. The POS stores only the token. This ensures the POS application, its database, and its network are entirely out of PCI cardholder data scope.
6.8.2 Terminal Communication Protocol
terminal_integration:
# Communication method
protocol: "cloud_api" # Terminal vendor's cloud service
# Timeout settings
payment_timeout_seconds: 60
connection_timeout_seconds: 10
# Terminal state machine
states:
- IDLE: "Ready for transaction"
- WAITING_FOR_CARD: "Display amount, await tap/insert"
- PROCESSING: "Communicating with processor"
- APPROVED: "Transaction successful"
- DECLINED: "Transaction declined"
- ERROR: "Communication or hardware error"
- CANCELLED: "Customer or staff cancelled"
# Error handling
on_timeout: "prompt_retry_or_cancel"
on_decline: "display_reason_allow_retry"
on_error: "log_and_alert_manager"
# Void window (same-day before batch)
same_day_void: true
batch_close_time: "23:00" # Auto-batch at 11 PM
Terminal State Machine
stateDiagram-v2
[*] --> IDLE: Terminal powered on
IDLE --> WAITING_FOR_CARD: Payment request received
WAITING_FOR_CARD --> PROCESSING: Card presented
WAITING_FOR_CARD --> CANCELLED: Staff/customer cancels
WAITING_FOR_CARD --> IDLE: Timeout (60s)
PROCESSING --> APPROVED: Processor approves
PROCESSING --> DECLINED: Processor declines
PROCESSING --> ERROR: Communication failure
APPROVED --> IDLE: Transaction complete
DECLINED --> IDLE: Staff acknowledges
CANCELLED --> IDLE: Reset terminal
ERROR --> IDLE: Staff acknowledges
6.8.3 Processor Configuration (from 5.11.3)
External payment processors handle card transactions and third-party financing. Each processor is configured with encrypted credentials, terminal mappings, and environment settings.
Payment Processor Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
name | String(100) | Yes | Processor display name (e.g., “CardConnect”, “Square”, “Affirm”) |
processor_type | Enum | Yes | CARD, FINANCING |
api_key | String(500) | Yes | Encrypted API key or token (AES-256 encrypted at rest) |
api_secret | String(500) | No | Encrypted API secret (for processors requiring key + secret) |
merchant_id | String(100) | Yes | Merchant account identifier with the processor |
webhook_url | String(500) | No | URL for processor to send asynchronous notifications (refund confirmations, chargebacks) |
test_mode | Boolean | Yes | true for sandbox/test environment, false for production (default: true) |
is_active | Boolean | Yes | Soft-delete flag (default: true) |
config_json | JSON | No | Processor-specific configuration (e.g., Affirm min/max order amounts, supported card brands) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Processor Terminal Mapping
Each payment terminal (physical card reader) is associated with a processor and a location.
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
processor_id | UUID | Yes | FK to payment_processors table |
terminal_id | String(50) | Yes | Terminal serial number or identifier assigned by the processor |
ip_address | String(45) | No | Local IP address of the terminal (for IP-connected terminals) |
port | Integer | No | Port number for terminal communication (default: 9000) |
location_id | UUID | Yes | FK to locations table – physical location of this terminal |
is_active | Boolean | Yes | Soft-delete flag (default: true) |
created_at | DateTime | Yes | Record creation timestamp |
Business Rules:
- A tenant may have multiple processors of the same type (e.g., one CARD processor for in-store terminals and another for e-commerce).
test_modeallows the administrator to toggle between sandbox and production without changing API keys (processors typically issue separate sandbox and production keys).- Credentials (
api_key,api_secret) are encrypted at rest using AES-256 and never returned in API responses. Nexus POS displays only a masked preview (e.g.,sk_live_****ABCD). - Before activating a processor (
test_mode = false), the system performs a validation handshake with the processor API to confirm credentials are valid.
6.8.4 Failure Handling
sequenceDiagram
autonumber
participant U as Staff
participant UI as POS UI
participant API as Backend
participant TERM as Payment Terminal
participant PROC as Processor
U->>UI: Click "Pay by Card"
UI->>API: POST /payments/initiate
API->>TERM: Send Payment Request
alt Terminal Timeout (60s)
TERM--xAPI: No Response
API-->>UI: "Terminal not responding"
UI-->>U: Options: Retry | Different Terminal | Cash | Cancel
alt Staff selects Retry
U->>UI: Click "Retry"
UI->>API: POST /payments/initiate (same order)
API->>TERM: Send Payment Request (attempt 2)
else Staff selects Different Terminal
U->>UI: Select alternate terminal
UI->>API: POST /payments/initiate (new terminal_id)
API->>TERM: Send to alternate terminal
else Staff selects Cash
U->>UI: Switch to Cash payment
Note right of UI: Proceeds to cash drawer flow
else Staff selects Cancel
U->>UI: Return to cart
end
else Card Declined
TERM->>PROC: Card data (encrypted)
PROC-->>TERM: DECLINED (reason: Insufficient Funds)
TERM-->>API: Decline response
API-->>UI: "Card Declined: Insufficient Funds"
UI-->>U: Options: Try Another Card | Cash | Cancel
API->>API: Log decline (reason, terminal, timestamp)
else Terminal Hardware Error
TERM-->>API: ERROR (Hardware Issue)
API-->>UI: "Terminal Error"
UI-->>U: Options: Different Terminal | Cash | Cancel
API->>API: Log error, create manager alert
Note right of API: Alert sent to on-duty manager
end
Failure Logging:
- All payment failures are recorded in the
payment_attemptslog with:order_id,terminal_id,attempt_number,failure_type(TIMEOUT, DECLINED, ERROR),decline_reason,timestamp. - Consecutive failures on the same terminal (3+ within 15 minutes) trigger a terminal health alert visible on the Integration Health Dashboard.
6.8.5 Batch Settlement
Batch settlement closes the day’s card transactions and initiates fund transfer from the payment processor to the merchant’s bank account.
Settlement Schedule:
- Auto-batch executes at a configurable time (default: 23:00 local time per location timezone).
- Manual batch close is available to managers via Nexus POS.
- Batch close is per-processor, per-location (each location settles its own terminals).
Settlement Process:
| Step | Action | System Behavior |
|---|---|---|
| 1 | Trigger batch close | System sends batch close command to processor API |
| 2 | Processor responds | Processor returns batch summary (transaction count, total amount) |
| 3 | Reconciliation | POS compares processor batch total against local transaction records |
| 4 | Variance detection | If totals differ by more than $0.01, a reconciliation alert is created |
| 5 | Settlement report | Daily settlement report generated and available in Reports module |
| 6 | Fund transfer | Processor initiates ACH/wire to merchant bank (1-3 business days) |
Reconciliation Rules:
- POS-side total is calculated as: SUM(authorized amounts) - SUM(voided amounts) - SUM(refunded amounts) for the batch period.
- Processor-side total is received via the batch close API response.
- Variance <= $0.01 is auto-accepted (rounding tolerance).
- Variance > $0.01 generates a
RECONCILIATION_VARIANCEalert and requires manager review.
6.8.6 Reports: Payment Integration
| Report | Purpose | Key Data Fields |
|---|---|---|
| Payment Terminal Performance | Monitor terminal health and throughput | Terminal ID, transaction count, avg response time (ms), error rate (%), decline rate (%), uptime (%) |
| Decline Rate Report | Analyze payment failure patterns | Decline reason, frequency, terminal ID, time of day, retry success rate (%), card brand breakdown |
| Batch Settlement Report | Daily batch close summary and reconciliation | Batch date, transaction count, total amount, processor total, variance, settlement status (SETTLED / PENDING / VARIANCE) |
| Chargeback Tracking | Monitor disputes and chargebacks | Chargeback ID, original transaction, amount, reason code, deadline, status (OPEN / WON / LOST) |
6.9 Email Provider Integration
Scope: Consolidation of email provider configuration for all outbound transactional and notification emails. This section brings together provider setup previously in Module 5, Section 5.15.1.
Cross-Reference: Email template registry, template data model, merge field definitions, and per-template enablement controls remain in Module 5, Section 5.15.2-5.15.4. See Module 2, Section 2.9 for customer communication preferences.
6.9.1 Provider Configuration (from 5.15.1)
Each tenant configures exactly one email provider. The provider handles all outbound transactional and notification emails for the tenant. Three provider types are supported.
Email Config Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
provider_type | Enum | Yes | SMTP, SENDGRID, MAILGUN |
smtp_host | String(255) | Conditional | SMTP server hostname (required when provider_type = SMTP) |
smtp_port | Integer | Conditional | SMTP server port: 587 (TLS) or 465 (SSL) (required when provider_type = SMTP) |
smtp_username | String(255) | Conditional | SMTP authentication username (required when provider_type = SMTP) |
smtp_password_encrypted | String(500) | Conditional | AES-256 encrypted SMTP password (required when provider_type = SMTP) |
api_key_encrypted | String(500) | Conditional | AES-256 encrypted API key (required when provider_type = SENDGRID or MAILGUN) |
api_region | Enum | No | US, EU – Mailgun region (default: US) |
sender_email | String(255) | Yes | “From” address for all outbound emails (e.g., noreply@nexusclothing.com) |
sender_name | String(100) | Yes | “From” display name (e.g., “Nexus Clothing”) |
reply_to_email | String(255) | No | “Reply-To” address; defaults to sender_email if NULL |
daily_send_limit | Integer | No | Maximum emails per 24-hour period (0 = unlimited; default: 0) |
is_verified | Boolean | Yes | Whether the configuration has been verified via test email (default: false) |
verified_at | DateTime | No | Timestamp of last successful verification |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Business Rules:
- Only one active email configuration per tenant. Updating the provider replaces the previous configuration; the old record is soft-deleted for audit.
- The
is_verifiedflag is set totrueonly after a successful test email delivery. Nexus POS displays a warning banner if the configuration is unverified. - All sensitive fields (
smtp_password_encrypted,api_key_encrypted) are encrypted at rest using AES-256. They are never returned in API GET responses – only a masked indicator (e.g.,********) is shown. - When
provider_type = SMTP, the system attempts a TLS handshake during verification. If TLS fails, verification fails with a specific error message.
6.9.2 Delivery Monitoring
The system tracks delivery outcomes for all outbound emails to detect provider issues early and ensure transactional emails reach their intended recipients.
Delivery Status Tracking
| Status | Description | Source |
|---|---|---|
QUEUED | Email accepted by POS, awaiting provider submission | Internal |
SENT | Email accepted by provider for delivery | Provider API response |
DELIVERED | Email delivered to recipient inbox | Provider webhook |
BOUNCED | Email rejected by recipient mail server | Provider webhook |
SOFT_BOUNCE | Temporary delivery failure (mailbox full, server down) | Provider webhook |
SPAM_COMPLAINT | Recipient marked email as spam | Provider webhook |
FAILED | Provider rejected email (invalid sender, quota exceeded) | Provider API response |
Delivery Log Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
template_code | String(50) | Yes | Email template code (e.g., TMPL-REFUND-CONFIRMATION) |
recipient_email | String(255) | Yes | Recipient email address |
status | Enum | Yes | Delivery status from table above |
provider_message_id | String(255) | No | Message ID returned by provider (for tracking) |
error_message | String(500) | No | Error details for failed/bounced emails |
sent_at | DateTime | Yes | Timestamp when email was submitted to provider |
delivered_at | DateTime | No | Timestamp when delivery was confirmed |
created_at | DateTime | Yes | Record creation timestamp |
Provider Webhook Integration:
| Provider | Webhook Mechanism | Endpoint | Events Tracked |
|---|---|---|---|
| SendGrid | Event Webhook | /api/webhooks/sendgrid/events | delivered, bounced, dropped, spam_report |
| Mailgun | Events API / Webhooks | /api/webhooks/mailgun/events | delivered, failed, complained |
| SMTP | None (poll-based) | N/A | Delivery inferred from absence of bounce within 24 hours |
Monitoring Alerts:
- Bounce rate exceeding 5% in a rolling 24-hour window triggers a dashboard alert to tenant administrators.
- Three consecutive delivery failures to the same recipient suppress further emails to that address until an administrator reviews and clears the suppression.
- Daily send limit approaching 80% capacity triggers a warning notification.
6.10 Carrier & Shipping Integration
Scope: Abstract carrier interface, supported carrier configuration, and shipping data model for ship-to-customer fulfillment. This section defines the integration framework for rate lookup, label generation, address validation, and shipment tracking.
Cross-Reference: The ship-to-customer sales workflow, store assignment logic, and pick-pack-ship process remain in Module 1, Section 1.7.3. See Module 4, Section 4.14 for online order fulfillment inventory logic.
6.10.1 Abstract Carrier Interface
All carrier integrations implement a common interface that abstracts provider-specific API differences. This enables the POS system to support multiple carriers without changes to business logic.
classDiagram
class ICarrierProvider {
<<interface>>
+GetRates(origin, destination, package) ShippingRate[]
+CreateLabel(shipment) Label
+GetTracking(trackingNumber) TrackingStatus
+CancelShipment(shipmentId) bool
+ValidateAddress(address) AddressValidation
}
class UPSProvider {
+GetRates()
+CreateLabel()
+GetTracking()
+CancelShipment()
+ValidateAddress()
}
class FedExProvider {
+GetRates()
+CreateLabel()
+GetTracking()
+CancelShipment()
+ValidateAddress()
}
class USPSProvider {
+GetRates()
+CreateLabel()
+GetTracking()
+CancelShipment()
+ValidateAddress()
}
class AmazonShippingProvider {
+GetRates()
+CreateLabel()
+GetTracking()
+CancelShipment()
+ValidateAddress()
}
ICarrierProvider <|.. UPSProvider
ICarrierProvider <|.. FedExProvider
ICarrierProvider <|.. USPSProvider
ICarrierProvider <|.. AmazonShippingProvider
Interface Methods:
| Method | Input | Output | Description |
|---|---|---|---|
GetRates | Origin address, destination address, package dimensions/weight | ShippingRate[] (service level, cost, estimated days) | Returns available shipping rates for the given package |
CreateLabel | Shipment details (addresses, package, service level) | Label (label URL, tracking number, cost) | Generates a shipping label and returns tracking number |
GetTracking | Tracking number | TrackingStatus (status, location, events[]) | Returns current shipment tracking status and history |
CancelShipment | Shipment ID | bool (success/failure) | Cancels a shipment and voids the label |
ValidateAddress | Address (street, city, state, zip, country) | AddressValidation (is_valid, suggested_address, corrections[]) | Validates and standardizes a shipping address |
6.10.2 Supported Carriers (Future)
All carrier integrations are planned for v2.0. The integration framework is designed to support the following carriers.
| Carrier | API | Rate Lookup | Label Generation | Tracking | Update Method | Status |
|---|---|---|---|---|---|---|
| UPS | UPS Developer API | Yes | Yes | Yes | Webhook | Planned v2.0 |
| FedEx | FedEx API | Yes | Yes | Yes | Webhook | Planned v2.0 |
| USPS | USPS Web Tools | Yes | Yes | Yes | Poll (hourly) | Planned v2.0 |
| Amazon Buy Shipping | SP-API Shipping | Yes | Yes | Yes | Push (SQS) | Planned v2.0 |
Carrier Configuration Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
carrier_code | Enum | Yes | UPS, FEDEX, USPS, AMAZON_SHIPPING |
display_name | String(100) | Yes | Admin-friendly name (e.g., “UPS Ground”) |
api_key_encrypted | String(500) | Yes | AES-256 encrypted API credentials |
api_secret_encrypted | String(500) | No | AES-256 encrypted API secret |
account_number | String(50) | Yes | Carrier account number |
is_active | Boolean | Yes | Whether this carrier is available for label generation (default: false) |
default_service_level | String(50) | No | Default shipping service (e.g., GROUND, EXPRESS, PRIORITY) |
config_json | JSON | No | Carrier-specific settings (e.g., insurance defaults, signature requirements) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
6.10.3 Shipping Data Model
Shipment Table
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table |
order_id | UUID | Yes | FK to orders table – the sales order being shipped |
carrier_code | Enum | Yes | UPS, FEDEX, USPS, AMAZON_SHIPPING |
service_level | String(50) | Yes | Service level selected (e.g., GROUND, 2DAY, OVERNIGHT) |
tracking_number | String(100) | No | Carrier tracking number (populated after label creation) |
label_url | String(500) | No | URL to printable shipping label (populated after label creation) |
ship_date | Date | No | Actual ship date (populated when carrier scans package) |
estimated_delivery | Date | No | Carrier-estimated delivery date |
actual_delivery | DateTime | No | Confirmed delivery date and time |
shipping_cost | Decimal(10,2) | Yes | Cost charged to customer for shipping |
carrier_cost | Decimal(10,2) | No | Actual cost charged by carrier (for margin reporting) |
insurance_amount | Decimal(10,2) | No | Declared value for insurance (default: 0.00) |
weight_oz | Decimal(8,2) | Yes | Package weight in ounces |
dimensions_json | JSON | No | Package dimensions: { "length": 12, "width": 8, "height": 4, "unit": "in" } |
ship_from_location_id | UUID | Yes | FK to locations table – store fulfilling the shipment |
ship_to_address_json | JSON | Yes | Destination address: { "name", "street1", "street2", "city", "state", "zip", "country" } |
status | Enum | Yes | Shipment lifecycle status (see table below) |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Shipment Status Values
| Status | Description | Trigger |
|---|---|---|
PENDING | Shipment record created, no label yet | Order marked for shipping |
LABEL_CREATED | Shipping label generated | CreateLabel API call succeeds |
SHIPPED | Package handed to carrier | Carrier first scan event |
IN_TRANSIT | Package moving through carrier network | Carrier tracking update |
OUT_FOR_DELIVERY | Package on delivery vehicle | Carrier tracking update |
DELIVERED | Package delivered to recipient | Carrier delivery confirmation |
EXCEPTION | Delivery issue (weather, address error, refused) | Carrier exception event |
RETURNED | Package returned to sender | Carrier return scan |
CANCELLED | Shipment cancelled before carrier pickup | CancelShipment API call |
6.11 Integration Hub (Enhanced)
Scope: Central configuration and health monitoring for all external system integrations. This section enhances the Integration Hub defined in Module 5, Section 5.16 by expanding the integration registry to include Amazon SP-API, Google Merchant Center, and shipping carriers alongside the existing Shopify, payment processor, and email provider integrations.
Cross-Reference: See Module 5, Section 5.16 for the base Integration Hub definition, credential storage, and Shopify-specific configuration. See Module 3, Section 3.7 for Shopify product sync logic. See Module 4, Section 4.14 for Shopify inventory sync.
6.11.1 Integration Registry (Enhanced)
The integration_type enum is expanded to include all platform integrations defined in Module 6.
Enhanced integration_type Enum
| integration_type Enum Value | Description | Status |
|---|---|---|
SHOPIFY | Shopify e-commerce platform (product, inventory, order sync) | v1.0 |
AMAZON | Amazon Selling Partner API (listings, orders, FBA/FBM) | Planned v1.5 |
GOOGLE_MERCHANT | Google Merchant Center (product feeds, local inventory) | Planned v1.5 |
PAYMENT_PROCESSOR | Card and financing processor (authorization, settlement) | v1.0 |
EMAIL_PROVIDER | Email delivery service (SMTP, SendGrid, Mailgun) | v1.0 |
SHIPPING_CARRIER | Carrier rate lookup, label generation, tracking | Planned v2.0 |
ACCOUNTING | QuickBooks Online, Xero (journal entries, invoice sync) | Planned v2.0 |
CUSTOM | Custom webhook integration (tenant-defined endpoints) | Planned v2.0 |
Integration Data Model (Enhanced)
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
tenant_id | UUID | Yes | FK to tenants table – owning tenant |
integration_type | Enum | Yes | Enhanced enum from table above |
provider_name | String(100) | Yes | Provider identifier (e.g., “Shopify”, “Amazon SP-API”, “Google Merchant Center”) |
display_name | String(100) | Yes | Admin-friendly name (e.g., “Nexus Clothing Shopify Store”, “Nexus Amazon US”) |
status | Enum | Yes | CONNECTED, DISCONNECTED, ERROR, NOT_CONFIGURED, RATE_LIMITED |
is_enabled | Boolean | Yes | Whether the integration is actively processing (default: false until verified) |
last_sync_at | DateTime | No | Timestamp of the most recent successful sync operation |
last_error_at | DateTime | No | Timestamp of the most recent error |
last_error_message | String(500) | No | Human-readable error description |
error_count_24h | Integer | Yes | Rolling count of errors in the past 24 hours (default: 0) |
sync_latency_ms | Integer | No | Average sync latency in milliseconds over the last hour |
api_version | String(20) | No | Current API version in use (e.g., “2024-01”, “2022-04-01”) |
rate_limit_remaining | Integer | No | Remaining API calls before rate limit is hit |
rate_limit_reset_at | DateTime | No | Timestamp when rate limit bucket resets |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
New fields vs. Module 5 base model: RATE_LIMITED status, api_version, rate_limit_remaining, rate_limit_reset_at.
6.11.2 Credentials Storage
Credentials for each integration are stored separately with encryption at rest. This model is identical to Module 5, Section 5.16.2.
Integration Credentials Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
integration_id | UUID | Yes | FK to integrations table |
credential_key | String(100) | Yes | Credential identifier (e.g., api_key, api_secret, access_token, refresh_token, shop_url, merchant_id, seller_id) |
credential_value_encrypted | String(1000) | Yes | AES-256 encrypted credential value. Never returned in API responses. |
expires_at | DateTime | No | Credential expiry (e.g., OAuth token, SP-API refresh token). NULL for non-expiring credentials. |
created_at | DateTime | Yes | Record creation timestamp |
updated_at | DateTime | Yes | Last modification timestamp |
Credential Rotation:
- OAuth tokens with
expires_atare automatically refreshed before expiry (5-minute buffer). - Failed token refresh triggers a
DISCONNECTEDstatus and dashboard alert. - Manual credential update requires re-verification handshake before status returns to
CONNECTED.
6.11.3 Sync Log (Enhanced)
The sync log captures every sync operation across all integrations. The sync_type enum is expanded to include new operation types introduced by Amazon (SQS notifications, bulk feeds) and Google Merchant (scheduled product pushes).
Enhanced sync_type Enum
| sync_type Value | Description | Example |
|---|---|---|
WEBHOOK_IN | Incoming webhook from external system | Shopify orders/create webhook |
WEBHOOK_OUT | Outgoing webhook to external system | POS notifies custom endpoint of sale |
SCHEDULED_PULL | Scheduled data pull from external system | Amazon order poll every 120 seconds |
SCHEDULED_PUSH | Scheduled data push to external system | Google Merchant product feed update (2x daily) |
RECONCILIATION | Periodic full data comparison and correction | Shopify inventory reconciliation every 15 minutes |
MANUAL | Admin-triggered manual sync from Integration Hub | Admin clicks “Sync Now” for Shopify products |
BULK_OPERATION | Large-scale data operation via bulk API | Shopify bulk GraphQL operation, Amazon flat-file feed |
NOTIFICATION | Asynchronous notification processing | Amazon SQS message, Google Merchant disapproval alert |
Integration Sync Log Data Model
| Field | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Primary key |
integration_id | UUID | Yes | FK to integrations table |
sync_type | Enum | Yes | Enhanced enum from table above |
direction | Enum | Yes | INBOUND, OUTBOUND |
entity_type | String(50) | Yes | Entity synced (e.g., PRODUCT, INVENTORY, ORDER, CUSTOMER, LISTING, FEED) |
entity_id | UUID | No | FK to the local entity record affected (NULL for bulk syncs) |
external_id | String(100) | No | External system identifier (e.g., Shopify product ID, Amazon ASIN, Google offer ID) |
status | Enum | Yes | SUCCESS, FAILED, PARTIAL, SKIPPED, RETRYING |
records_processed | Integer | Yes | Number of records processed in this sync operation |
records_failed | Integer | Yes | Number of records that failed processing |
error_details | Text | No | Detailed error message and stack trace for failed syncs |
duration_ms | Integer | Yes | Sync operation duration in milliseconds |
started_at | DateTime | Yes | Sync start timestamp |
completed_at | DateTime | Yes | Sync completion timestamp |
New fields vs. Module 5 base model: RETRYING status, LISTING and FEED entity types, BULK_OPERATION and NOTIFICATION sync types.
6.11.4 Health Dashboard (Enhanced)
The Integration Hub dashboard provides a consolidated health view for all configured integrations across all six integration types.
flowchart LR
HUB["Integration Hub\n(Central Management)"]
SHOP["Shopify\nProduct + Inventory\n+ Orders"]
AMZN["Amazon SP-API\nListings + Orders\n+ FBA/FBM"]
GOOG["Google Merchant\nProduct Feeds\n+ Local Inventory"]
PAY["Payment Processor\nCard Processing\n+ Batch Settlement"]
EMAIL["Email Provider\nSMTP / SendGrid\n/ Mailgun"]
SHIP["Shipping Carrier\nRates + Labels\n+ Tracking"]
HUB -->|"Product sync\nInventory sync\nOrder webhooks"| SHOP
HUB -->|"Listings sync\nOrder poll\nSQS notifications"| AMZN
HUB -->|"Product feed\nLocal inventory\nDisapproval alerts"| GOOG
HUB -->|"Authorization\nSettlement\nRefunds"| PAY
HUB -->|"Transactional emails\nDigest notifications"| EMAIL
HUB -.->|"Rate lookup\nLabel generation\nTracking updates"| SHIP
style HUB fill:#7b2d8e,stroke:#5a1d6e,color:#fff
style SHOP fill:#2d6a4f,stroke:#1b4332,color:#fff
style AMZN fill:#ff9900,stroke:#cc7a00,color:#000
style GOOG fill:#4285f4,stroke:#3367d6,color:#fff
style PAY fill:#2d6a4f,stroke:#1b4332,color:#fff
style EMAIL fill:#2d6a4f,stroke:#1b4332,color:#fff
style SHIP fill:#6c757d,stroke:#495057,color:#fff
Enhanced Health Indicators per Integration
| Metric | Green | Yellow | Red |
|---|---|---|---|
| Status | CONNECTED | RATE_LIMITED | DISCONNECTED / ERROR |
| Error Count (24h) | 0 | 1-5 | > 5 |
| Last Sync | < 30 min ago | 30 min – 2 hrs | > 2 hrs |
| Sync Latency | < 2,000ms | 2,000 – 5,000ms | > 5,000ms |
| Rate Limit | > 50% remaining | 10-50% remaining | < 10% remaining |
| Validation Failures | 0 products | 1-10 products | > 10 products |
Dashboard Features:
- Real-time status indicator (green/yellow/red dot) per integration.
- Click-through to detailed sync log filtered by integration.
- “Sync Now” button for manual sync trigger (requires ADMIN role).
- Credential expiry warning banner (30 days before OAuth token expiry).
- Rate limit usage bar showing current consumption vs. limit.
6.12 Integration Business Rules (YAML)
Scope: Consolidated YAML configuration for all integration-related business rules across Module 6. This follows the same pattern as Module 5, Section 5.19 (Consolidated Business Rules). All values shown are defaults and can be overridden at tenant level.
# ============================================================
# Module 6: Integration Business Rules
# ============================================================
# All values shown are defaults and can be overridden at
# tenant level. This file is the single authoritative source
# for all integration-related configurable settings.
# ============================================================
integration_config:
# ----------------------------------------------------------
# SHOPIFY INTEGRATION
# ----------------------------------------------------------
shopify:
sync_mode: "pos_master" # pos_master | bidirectional
api_preference: "graphql" # graphql | rest
idempotency_required: true
third_party_pos: true # Shopify POS channel disabled; POS is external
source_of_truth: "pos" # pos | shopify (for product data)
track_inventory: true # Shopify inventory tracking enabled
bopis_enabled: true # Buy Online, Pick Up In Store
reconciliation_interval_minutes: 15
webhook_hmac_algorithm: "sha256"
max_variants_per_product: 100 # Shopify hard limit
max_option_dimensions: 3 # Shopify hard limit
bulk_concurrent_queries: 5
bulk_concurrent_mutations: 5
rate_limit_rest_bucket: 40 # Shopify REST leak bucket size
rate_limit_rest_leak_per_sec: 2 # Shopify REST leak rate
rate_limit_graphql_points_per_sec: 50
image_sync: "first_publish_only" # first_publish_only | always | never
customer_sync_enabled: true
order_sync_enabled: true
# ----------------------------------------------------------
# AMAZON SP-API INTEGRATION
# ----------------------------------------------------------
amazon:
enabled: false # Disabled by default; tenant must opt-in
sync_mode: "pos_master" # pos_master only (Amazon does not push product edits)
marketplace_id: "ATVPDKIKX0DER" # US marketplace (configurable per tenant)
region: "NA" # NA | EU | FE
order_poll_interval_seconds: 120 # Poll for new orders every 2 minutes
fba_enabled: false # Fulfillment by Amazon
fbm_enabled: true # Fulfillment by Merchant (store ships)
safety_buffer_default_qty: 10 # Reserve 10 units from Amazon availability
safety_buffer_default_pct: null # Percentage-based buffer (overrides qty if set)
notification_delivery: "sqs" # sqs | polling
catalog_api_version: "2022-04-01"
listings_api_version: "2021-08-01"
packaging_label_format: "4x6" # Thermal label format
seller_code_compliance: true # Enforce Amazon seller code rules
max_bullet_points: 5 # Amazon listing bullet point limit
max_bullet_point_length: 1000 # Characters per bullet point
max_search_terms_bytes: 250 # Amazon search term byte limit
fulfillment_default: "FBM" # FBM | FBA
# ----------------------------------------------------------
# GOOGLE MERCHANT CENTER INTEGRATION
# ----------------------------------------------------------
google_merchant:
enabled: false # Disabled by default; tenant must opt-in
sync_mode: "pos_master" # POS is source of truth for product data
api_version: "v1" # Content API version
local_inventory_enabled: false # Local Inventory Ads (LIA)
product_update_frequency: "2x_daily" # 2x_daily | daily | hourly
image_validation_strict: true # Enforce Google image requirements
gtin_required: true # GTIN (barcode) required for all products
price_match_validation: true # Verify POS price matches Google listing
disapproval_prevention: true # Pre-validate before pushing to Google
ssl_required: true # Landing page must be HTTPS
content_api_migration_deadline: "2026-08-18" # Merchant API migration deadline
v1beta_deadline: "2026-02-28" # v1beta sunset date
title_max_length: 150 # Google product title limit
description_max_length: 5000 # Google product description limit
gbp_integration_enabled: false # Google Business Profile integration
# ----------------------------------------------------------
# CROSS-PLATFORM PRODUCT VALIDATION
# ----------------------------------------------------------
product_validation:
title_max_length: 150 # Strictest platform limit
description_max_length: 5000 # Strictest platform limit
image_min_width: 1000 # Pixels (Google requirement)
image_min_height: 1000 # Pixels (Google requirement)
image_max_size_mb: 10
image_formats_allowed:
- "JPEG"
- "PNG"
watermarks_prohibited: true # Google and Amazon both prohibit
text_overlay_prohibited: true # Google prohibits text on images
white_background_required: true # Amazon main image requirement
barcode_required: true # GTIN/UPC/EAN required for marketplace listing
brand_required: true # Required by Google and Amazon
condition_required: true # Required by Google
condition_default: "new" # new | refurbished | used
sku_max_length: 50
weight_required: true # Required for shipping calculation
product_type_required: true # Google product category taxonomy
# ----------------------------------------------------------
# CROSS-PLATFORM INVENTORY SYNC
# ----------------------------------------------------------
inventory_sync:
safety_buffer_enabled: true # Hold back inventory from marketplaces
safety_buffer_default_qty: 0 # Default buffer (0 = no buffer)
oversell_prevention: true # Block sale if available_qty <= 0
reserve_on_order: true # Reserve inventory when order is placed
first_commit_wins: true # First system to commit gets the unit
sync_failure_freeze_minutes: 120 # Freeze marketplace qty on sync failure
dead_letter_retry_hours: 24 # Retry failed sync events for 24 hours
shopify_reconciliation_minutes: 15
amazon_reconciliation_minutes: 30
google_reconciliation_hours: 6
# ----------------------------------------------------------
# PAYMENT INTEGRATION
# ----------------------------------------------------------
payment:
pci_scope: "SAQ-A" # Semi-integrated; card data never touches POS
payment_timeout_seconds: 60
connection_timeout_seconds: 10
same_day_void: true
batch_close_time: "23:00" # Local time, configurable per location
batch_close_auto: true
terminal_failure_alert_threshold: 3 # Consecutive failures before alert
terminal_failure_alert_window_min: 15
reconciliation_variance_tolerance: 0.01 # Dollars
decline_log_retention_days: 90
# ----------------------------------------------------------
# EMAIL INTEGRATION
# ----------------------------------------------------------
email:
provider_type: "SMTP" # SMTP | SENDGRID | MAILGUN
daily_send_limit: 0 # 0 = unlimited
bounce_rate_alert_threshold_pct: 5
consecutive_failure_suppress: 3 # Suppress after N failures to same address
delivery_log_retention_days: 90
test_email_required_before_go_live: true
# ----------------------------------------------------------
# SHIPPING INTEGRATION (Future)
# ----------------------------------------------------------
shipping:
enabled: false # Planned for v2.0
default_carrier: null
address_validation_required: true
insurance_default: false
label_format: "4x6" # Thermal label format
tracking_poll_interval_minutes: 60
# ----------------------------------------------------------
# GLOBAL INTEGRATION SETTINGS
# ----------------------------------------------------------
global:
retry_max_attempts: 3
retry_backoff_base_seconds: 5
retry_backoff_multiplier: 3 # Exponential: 5s, 15s, 45s
circuit_breaker_threshold: 5 # Failures before circuit opens
circuit_breaker_window_seconds: 60
circuit_breaker_cooldown_seconds: 30
idempotency_window_hours: 24 # Idempotency key validity
credential_encryption: "AES-256"
webhook_verification: "HMAC-SHA256"
sync_log_retention_days: 90
health_check_interval_seconds: 60
rate_limit_buffer_pct: 10 # Stop at 90% of rate limit
6.13 Integration User Stories & Gherkin Acceptance Criteria
Scope: All user stories and Gherkin acceptance criteria for Module 6 (Integrations). Stories are organized into 7 epics covering all integration areas. Each epic includes user stories in standard format and Gherkin feature files with acceptance scenarios.
6.13.1 Integration User Story Epics
Epic 6.A: Shopify Integration
US-6.A.1: Product Sync to Shopify
- As a store manager, I want products created in POS to automatically appear on my Shopify store so that my online catalog stays current without manual duplicate entry.
- Constraint: Product must have all required fields (title, price, at least one image, barcode). Sync occurs within 30 seconds of product creation.
US-6.A.2: Real-Time Inventory Sync
- As a store manager, I want real-time inventory sync between POS and Shopify so that online customers never purchase items that are out of stock in-store.
- Constraint: Inventory updates propagate within 30 seconds. Reconciliation runs every 15 minutes.
US-6.A.3: Online Order Fulfillment
- As a store manager, I want online Shopify orders to appear in my POS for fulfillment so that my staff can pick, pack, and ship from the store.
- Constraint: Orders appear within 60 seconds of placement. Inventory is reserved immediately.
US-6.A.4: Sync Mode Configuration
- As a tenant admin, I want to configure Shopify sync mode (POS-master or bidirectional) so that I can control which system is authoritative for product data.
- Constraint: Inventory sync is always bidirectional regardless of product sync mode.
US-6.A.5: BOPIS Order Processing
- As a store manager, I want BOPIS (Buy Online, Pick Up In Store) orders from Shopify to appear as pickup orders in POS so that staff can stage items for customer collection.
- Constraint: BOPIS orders follow the Hold for Pickup workflow (Module 1, Section 1.11).
US-6.A.6: Shopify Conflict Resolution
- As a tenant admin, I want the system to automatically resolve sync conflicts between POS and Shopify so that data remains consistent without manual intervention.
- Constraint: In POS-master mode, POS-owned field changes in Shopify are overwritten on next sync cycle.
Epic 6.B: Amazon SP-API Integration
US-6.B.1: Amazon Account Connection
- As a tenant admin, I want to connect my Amazon Seller Central account to POS via OAuth so that I can manage Amazon listings from within the POS system.
- Constraint: Connection uses SP-API OAuth with LWA (Login with Amazon). Refresh token stored encrypted.
US-6.B.2: Amazon Listing Management
- As a store manager, I want products listed on Amazon to be managed from POS so that pricing, descriptions, and images are maintained in one place.
- Constraint: POS is source of truth. Amazon-specific fields (bullet points, search terms) are editable from POS.
US-6.B.3: Amazon FBM Order Routing
- As a store manager, I want Amazon FBM orders routed to the nearest store for fulfillment so that delivery times are minimized and inventory is balanced.
- Constraint: Store assignment uses the same proximity + stock algorithm as Shopify orders (Module 4, Section 4.14).
US-6.B.4: Safety Buffer Configuration
- As a tenant admin, I want to configure safety buffers for Amazon inventory so that I can reserve stock for in-store customers and prevent overselling.
- Constraint: Buffer can be absolute quantity or percentage. Applied per-product or globally.
US-6.B.5: FBA Inventory Monitoring
- As a store manager, I want to monitor FBA inventory levels from within POS so that I can see total inventory across all fulfillment channels.
- Constraint: FBA quantities are read-only in POS. Displayed separately from FBM/in-store quantities.
US-6.B.6: Amazon Order Polling
- As a tenant admin, I want the system to automatically poll for new Amazon orders so that orders are imported without manual action.
- Constraint: Polling interval configurable (default: 120 seconds). SQS notifications preferred when available.
Epic 6.C: Google Merchant Integration
US-6.C.1: Google Merchant Connection
- As a tenant admin, I want to connect Google Merchant Center to POS via OAuth so that product data feeds to Google Shopping automatically.
- Constraint: Uses Google Content API for Shopping. Service account or OAuth credentials stored encrypted.
US-6.C.2: Local Inventory Ads
- As a store manager, I want local store inventory to appear in Google Shopping searches so that nearby customers can see what is available at my store.
- Constraint: Requires Google Business Profile linked to Merchant Center. Location-level inventory synced.
US-6.C.3: Pre-Publish Validation
- As a tenant admin, I want products validated against Google requirements before sync to prevent disapprovals so that my product listings maintain good standing.
- Constraint: Validation checks GTIN, images (1000x1000 min, no watermarks), price match, and required attributes.
US-6.C.4: Disapproval Dashboard
- As a store manager, I want a disapproval dashboard showing which products failed Google validation so that I can fix issues before they affect visibility.
- Constraint: Dashboard shows disapproval reason, affected product, date, and suggested fix.
US-6.C.5: Product Feed Scheduling
- As a tenant admin, I want to configure product feed update frequency so that Google always has current product data without excessive API usage.
- Constraint: Options are hourly, daily, or 2x daily (default). Changes trigger immediate incremental sync.
US-6.C.6: Price Consistency Enforcement
- As a tenant admin, I want the system to verify that POS prices match Google listing prices so that customers are not misled by stale pricing.
- Constraint: Price mismatch detected during reconciliation triggers an automatic price update push.
Epic 6.D: Cross-Platform Product Validation
US-6.D.1: Unified Validation Engine
- As a product manager, I want products validated against all platform requirements before publishing so that I can fix issues once rather than per-platform.
- Constraint: Validation runs against the strictest requirement across all enabled platforms.
US-6.D.2: Image Validation
- As a product manager, I want image validation that checks dimensions, file size, watermarks, and background requirements so that images pass all platform reviews.
- Constraint: Minimum 1000x1000px, max 10MB, JPEG/PNG only, no watermarks or text overlays, white background for Amazon main image.
US-6.D.3: Unified Validation Dashboard
- As a product manager, I want a unified validation dashboard showing platform readiness per product so that I can see at a glance which products are ready for each channel.
- Constraint: Dashboard shows green/yellow/red status per product per platform with drill-down to specific failures.
US-6.D.4: Validation on Product Save
- As a product manager, I want validation to run automatically when I save a product so that I receive immediate feedback on any issues.
- Constraint: Validation is non-blocking (product saves regardless) but warnings are displayed prominently.
US-6.D.5: Bulk Validation Report
- As a tenant admin, I want to run bulk validation across all products so that I can identify and fix issues before enabling a new marketplace.
- Constraint: Bulk validation runs asynchronously and produces a downloadable report.
Epic 6.E: Cross-Platform Inventory Sync
US-6.E.1: Safety Buffer Management
- As a tenant admin, I want to configure safety buffers per marketplace so that I can reserve stock for in-store customers while selling online.
- Constraint: Buffers are configurable per product, per marketplace. Default is configurable at tenant level.
US-6.E.2: Oversell Prevention
- As a store manager, I want the system to prevent overselling across all channels so that customers never purchase items that are not available.
- Constraint: First-commit-wins arbitration. Inventory reserved at time of order, not payment.
US-6.E.3: Sync Failure Handling
- As a tenant admin, I want inventory quantities frozen on marketplaces when sync fails so that stale data does not cause overselling.
- Constraint: Quantities are frozen for configurable period (default: 120 minutes). Dead letter queue retries for 24 hours.
US-6.E.4: Reconciliation Dashboard
- As a store manager, I want a reconciliation dashboard showing inventory discrepancies across channels so that I can identify and resolve sync issues.
- Constraint: Dashboard shows POS qty vs. each marketplace qty with variance highlighting.
US-6.E.5: Multi-Channel Available Quantity
- As a store manager, I want to see available quantity per channel from the product detail screen so that I understand how inventory is allocated.
- Constraint: Displays: Total On Hand, Shopify Available, Amazon Available, Google Available, In-Store Reserve, Safety Buffer.
Epic 6.F: Payment Integration
US-6.F.1: Card Payment Processing
- As a cashier, I want to process card payments via the payment terminal without handling card data so that transactions are fast and PCI compliant.
- Constraint: Card data never enters POS system. Terminal communicates directly with processor. POS stores only token and masked card.
US-6.F.2: Processor Credential Configuration
- As a tenant admin, I want to configure payment processor credentials with test/production toggle so that I can verify the integration in sandbox before going live.
- Constraint: Credentials encrypted with AES-256. Validation handshake required before activating production mode.
US-6.F.3: Terminal Health Monitoring
- As a store manager, I want to view payment terminal health and decline rates so that I can identify and resolve terminal issues proactively.
- Constraint: Dashboard shows per-terminal metrics: transaction count, avg response time, error rate, decline rate.
US-6.F.4: Batch Settlement Management
- As a store manager, I want to view daily batch settlement reports and reconciliation status so that I can verify all card transactions settled correctly.
- Constraint: Auto-batch at configurable time. Variance > $0.01 triggers reconciliation alert.
Epic 6.G: Integration Hub
US-6.G.1: Integration Management
- As a tenant admin, I want a central dashboard to manage all external integrations so that I can connect, configure, and monitor all third-party services from one place.
- Constraint: Dashboard shows status, last sync, error count, and health indicator per integration.
US-6.G.2: Health Monitoring
- As a tenant admin, I want real-time health indicators for all integrations so that I can immediately see when an integration needs attention.
- Constraint: Green/yellow/red indicators based on status, error count, sync age, latency, and rate limit usage.
US-6.G.3: Sync Log Access
- As a tenant admin, I want to view detailed sync logs for each integration so that I can troubleshoot failed syncs and understand data flow.
- Constraint: Logs filterable by integration, sync type, status, date range. 90-day retention.
US-6.G.4: Manual Sync Trigger
- As a tenant admin, I want to manually trigger a sync for any integration so that I can force a refresh when needed without waiting for the scheduled cycle.
- Constraint: Requires ADMIN or OWNER role. Rate limited to one manual sync per integration per 5 minutes.
6.13.2 Gherkin Acceptance Criteria
Feature: Shopify Product Sync
As a store manager
I want products to sync between POS and Shopify
So that my online store always shows current product data
Background:
Given I am logged in as a user with "MANAGER" role
And Shopify integration is enabled with status "CONNECTED"
And sync_mode is set to "pos_master"
Scenario: New product syncs to Shopify on creation
Given I create a new product with SKU "NXJ-TSHIRT-BLK-M"
And the product has title "Classic Black T-Shirt - Medium"
And the product has base_price "$24.99"
And the product has a valid image (1200x1200px JPEG)
And the product has barcode "195962000123"
When the product is saved
Then the product should appear in Shopify within 30 seconds
And the Shopify product title should match "Classic Black T-Shirt - Medium"
And the Shopify product price should match "$24.99"
And a sync log entry should be created with sync_type "WEBHOOK_OUT" and status "SUCCESS"
Scenario: POS-owned field change in Shopify is overwritten
Given product "NXJ-TSHIRT-BLK-M" exists in both POS and Shopify
And sync_mode is "pos_master"
When someone changes the Shopify title to "Updated Title on Shopify"
And the next reconciliation cycle runs
Then the Shopify title should revert to the POS title "Classic Black T-Shirt - Medium"
And a sync log entry should be created with sync_type "RECONCILIATION"
Scenario: Product missing required fields is excluded from sync
Given I create a new product with SKU "NXJ-DRAFT-001"
And the product has title "Draft Product"
But the product has no image
When the product is saved
Then the product should NOT sync to Shopify
And a validation warning should display "Product excluded from Shopify sync: missing required image"
And the product should be saved locally without error
Feature: Shopify Inventory Sync
As a store manager
I want real-time inventory sync between POS and Shopify
So that online customers see accurate stock levels
Background:
Given Shopify integration is enabled with inventory_sync_enabled = true
And product "NXJ-TSHIRT-BLK-M" exists in both POS and Shopify
And the current POS quantity at "Georgetown Store" is 25
Scenario: POS sale decrements Shopify inventory
When a sale of 1 unit of "NXJ-TSHIRT-BLK-M" is completed at "Georgetown Store"
Then the POS quantity should update to 24
And the Shopify inventory level should update to 24 within 30 seconds
And a sync log entry should be created with entity_type "INVENTORY" and status "SUCCESS"
Scenario: Shopify order decrements POS inventory
When a Shopify order for 2 units of "NXJ-TSHIRT-BLK-M" is placed
Then the POS should receive the order via webhook within 60 seconds
And the POS quantity should decrease by 2 (from 25 to 23)
And the inventory status should show 2 units as "RESERVED" for the online order
Scenario: Reconciliation detects and corrects discrepancy
Given the POS quantity is 20 but the Shopify quantity shows 22
When the scheduled reconciliation runs (every 15 minutes)
Then the Shopify quantity should be corrected to 20 (POS is source of truth)
And a sync log entry should be created with sync_type "RECONCILIATION"
And the discrepancy should be logged with details "Corrected Shopify qty from 22 to 20"
Scenario: Inventory sync failure freezes marketplace quantity
Given the Shopify API is unreachable
When a POS sale occurs reducing quantity from 20 to 19
Then the POS should record the sync as "FAILED"
And the Shopify quantity should remain frozen at its last known value
And a retry should be queued with exponential backoff (5s, 15s, 45s)
And if still failing after 120 minutes, an alert should be sent to the tenant admin
Feature: Amazon Order Import
As a store manager
I want Amazon orders automatically imported into POS
So that I can fulfill them from my store inventory
Background:
Given Amazon SP-API integration is enabled with status "CONNECTED"
And fulfillment_default is "FBM"
And order_poll_interval_seconds is 120
Scenario: New FBM order is imported via polling
Given a customer places an Amazon order for "NXJ-TSHIRT-BLK-M" (qty: 1)
When the next order poll cycle runs
Then the order should appear in POS with source "AMAZON"
And the order status should be "PENDING_FULFILLMENT"
And inventory should be reserved (1 unit) at the assigned store
And a sync log entry should be created with sync_type "SCHEDULED_PULL" and entity_type "ORDER"
Scenario: FBM order is routed to nearest store with stock
Given "Georgetown Store" has 5 units and "HQ Warehouse" has 20 units
And the customer shipping address is in Washington, DC
When an Amazon FBM order is imported
Then the order should be assigned to "Georgetown Store" (nearest with stock)
And the fulfillment team at "Georgetown Store" should see the order in their queue
Scenario: Order for out-of-stock item triggers alert
Given "NXJ-TSHIRT-BLK-M" has 0 units available across all locations
When an Amazon order for this item is imported
Then the order should be created with status "PENDING_FULFILLMENT"
And a "STOCK_ALERT" notification should be sent to the store manager
And the order should be flagged with warning "Insufficient stock for fulfillment"
Feature: Amazon FBM Fulfillment
As a store manager
I want to fulfill Amazon FBM orders from my store
So that customers receive their orders on time
Background:
Given Amazon integration is connected
And I am logged in as a user with "MANAGER" role at "Georgetown Store"
Scenario: Ship and confirm FBM order
Given an Amazon FBM order "114-1234567-8901234" is assigned to my store
And the order contains 1 unit of "NXJ-TSHIRT-BLK-M"
When I pick and pack the item
And I generate a shipping label via the carrier integration
And I confirm shipment with tracking number "1Z999AA10123456784"
Then the order status should update to "SHIPPED" in POS
And Amazon should receive the shipment confirmation via SP-API
And the customer should receive a shipping notification from Amazon
And the inventory should be decremented by 1 unit at "Georgetown Store"
Scenario: FBA inventory is visible but read-only
Given FBA is enabled for product "NXJ-HOODIE-GRY-L"
And Amazon FBA has 50 units in their fulfillment center
When I view the product detail for "NXJ-HOODIE-GRY-L"
Then I should see "FBA Qty: 50" in the inventory breakdown
And the FBA quantity should be read-only (not editable from POS)
And the total network quantity should include FBA units
Feature: Google Local Inventory
As a store manager
I want local store inventory visible in Google Shopping
So that nearby customers can find products at my store
Background:
Given Google Merchant integration is connected
And local_inventory_enabled is true
And Google Business Profile is linked for "Georgetown Store"
Scenario: Local inventory appears in Google Shopping
Given "NXJ-TSHIRT-BLK-M" has 15 units at "Georgetown Store"
And the product passes all Google validation rules
When the scheduled product feed runs
Then Google Merchant should show "In stock" at "Georgetown Store"
And the price shown should match the POS base_price "$24.99"
Scenario: Out-of-stock local inventory updates Google
Given "NXJ-TSHIRT-BLK-M" has 0 units at "Georgetown Store"
When the next inventory sync to Google runs
Then Google Merchant should show "Out of stock" at "Georgetown Store"
And other locations with stock should still show "In stock"
Scenario: Product with missing GTIN is excluded from Google feed
Given product "NXJ-CUSTOM-001" has no barcode/GTIN
And gtin_required is true
When the product feed sync runs
Then "NXJ-CUSTOM-001" should be excluded from the Google feed
And a validation failure should be logged with reason "Missing required GTIN"
And the disapproval dashboard should show this product
Feature: Cross-Platform Product Validation
As a product manager
I want products validated against all platform requirements
So that I can publish to any channel with confidence
Background:
Given Shopify, Amazon, and Google Merchant integrations are all enabled
And I am logged in as a user with "PRODUCT_MANAGER" role
Scenario: Product passes all platform validations
Given product "NXJ-TSHIRT-BLK-M" has:
| Field | Value |
| title | Classic Black T-Shirt - Medium |
| description | Premium cotton crew neck t-shirt |
| price | $24.99 |
| barcode | 195962000123 |
| brand | Nexus Clothing |
| weight | 8 oz |
| image | 1200x1200 JPEG, no watermark, white background |
| condition | new |
When I save the product
Then the validation dashboard should show:
| Platform | Status |
| Shopify | Ready (green) |
| Amazon | Ready (green) |
| Google | Ready (green) |
Scenario: Product fails Amazon image validation
Given product "NXJ-DRESS-RED-S" has a main image with a colored background
And white_background_required is true for Amazon
When I save the product
Then the validation dashboard should show:
| Platform | Status |
| Shopify | Ready (green) |
| Amazon | Warning (yellow) - "Main image requires white background" |
| Google | Warning (yellow) - "Image may not meet quality standards" |
Scenario: Product exceeds Shopify variant limit
Given product "NXJ-MATRIX-SHOE" has 120 variants (sizes x colors x widths)
And max_variants_per_product is 100
When I save the product
Then the validation dashboard should show Shopify status as "Blocked (red)"
And the message should read "Exceeds Shopify 100-variant limit (120 variants)"
And the product should be excluded from Shopify sync
But the product should still be eligible for Amazon and Google sync
Scenario: Bulk validation report generation
Given there are 500 active products in the catalog
When I click "Run Bulk Validation" from the validation dashboard
Then a background job should start processing all 500 products
And I should see a progress indicator
And when complete, a downloadable CSV report should be available
And the report should contain one row per product per platform with validation status
Feature: Safety Buffer Inventory Management
As a tenant admin
I want to configure safety buffers per marketplace
So that I reserve stock for in-store customers while selling online
Background:
Given I am logged in as a user with "ADMIN" role
And product "NXJ-TSHIRT-BLK-M" has 50 total units on hand
Scenario: Safety buffer reduces marketplace available quantity
Given the Amazon safety buffer is set to 10 units
And the Shopify safety buffer is set to 0 units
When inventory sync runs for "NXJ-TSHIRT-BLK-M"
Then Shopify should show 50 available units
And Amazon should show 40 available units (50 - 10 buffer)
Scenario: Percentage-based safety buffer
Given the Amazon safety buffer is set to 20%
And total on-hand quantity is 50
When inventory sync runs
Then Amazon should show 40 available units (50 - 10 buffer, where 10 = 20% of 50)
Scenario: Safety buffer prevents overselling
Given the Amazon safety buffer is 10 units
And total on-hand is 12 units
And Amazon shows 2 available (12 - 10)
When an Amazon order for 3 units is placed
Then the order should be imported with a warning "Ordered qty (3) exceeds Amazon available (2)"
And inventory should be reserved for 3 units (allowing negative available on Amazon)
And the Amazon available quantity should update to 0
Scenario: Buffer recalculated on inventory change
Given the Amazon safety buffer is 10 units
And total on-hand is 50 (Amazon shows 40)
When a POS sale reduces on-hand to 45
Then Amazon available should update to 35 (45 - 10)
And this update should propagate within the sync interval
Feature: Payment Terminal Flow
As a cashier
I want to process card payments securely via the terminal
So that customers can pay by card without handling card data
Background:
Given I am logged in as a user with "CASHIER" role
And a payment terminal "TRM-001-GM" is configured and active at my location
And a cart with total $45.00 is ready for payment
Scenario: Successful card payment via tap
When I click "Pay by Card"
And the terminal displays "Tap or Insert Card - $45.00"
And the customer taps their Visa card
Then the terminal should communicate directly with the processor
And the POS should receive: token, approval_code "AUTH4829", masked_card "****1234", brand "VISA"
And the POS should display "Approved - $45.00"
And the receipt should show "VISA ****1234"
And no card data should be stored in the POS database
Scenario: Card declined - insufficient funds
When I click "Pay by Card"
And the customer inserts their card
And the processor returns "DECLINED - Insufficient Funds"
Then the POS should display "Card Declined: Insufficient Funds"
And I should see options: "Try Another Card" | "Cash" | "Cancel"
And the decline should be logged with reason "Insufficient Funds"
Scenario: Terminal timeout
When I click "Pay by Card"
And the terminal does not respond within 60 seconds
Then the POS should display "Terminal not responding"
And I should see options: "Retry" | "Different Terminal" | "Cash" | "Cancel"
And the timeout should be logged as a terminal failure event
Feature: Integration Health Dashboard
As a tenant admin
I want to monitor the health of all integrations
So that I can quickly identify and resolve connectivity issues
Background:
Given I am logged in as a user with "ADMIN" role
And I navigate to the Integration Hub dashboard
Scenario: All integrations healthy
Given Shopify integration last synced 5 minutes ago with 0 errors
And Payment Processor status is "CONNECTED" with 0 errors
And Email Provider is verified and has 0 bounces
When I view the Integration Hub dashboard
Then Shopify should show a green health indicator
And Payment Processor should show a green health indicator
And Email Provider should show a green health indicator
Scenario: Integration in error state shows red
Given Shopify integration has had 8 errors in the past 24 hours
And the last sync was 3 hours ago
When I view the Integration Hub dashboard
Then Shopify should show a red health indicator
And the error count should display "8 errors (24h)"
And the last sync should display "3 hours ago" in red text
And an "Investigate" button should be available
Scenario: Rate-limited integration shows yellow
Given Amazon SP-API has 8% of rate limit remaining
And the rate_limit_reset_at is in 45 seconds
When I view the Integration Hub dashboard
Then Amazon should show a yellow health indicator
And the rate limit bar should show "8% remaining"
And a tooltip should display "Rate limit resets in 45 seconds"
Feature: Integration Credential Management
As a tenant admin
I want to securely manage integration credentials
So that external services stay connected without exposing sensitive data
Background:
Given I am logged in as a user with "OWNER" role
And I navigate to the Integration Hub settings
Scenario: Configure Shopify credentials
When I click "Configure" on the Shopify integration card
And I enter shop URL "nexus-clothes.myshopify.com"
And I enter API key "shppa_abc123def456"
And I enter API secret "shpss_xyz789"
And I click "Verify & Save"
Then the system should perform a test API call to Shopify
And the credentials should be encrypted with AES-256 before storage
And the integration status should change to "CONNECTED"
And the API key should display as "shppa_****f456" in the UI
Scenario: Credentials never exposed in API response
Given Shopify integration is connected with saved credentials
When I make a GET request to /api/integrations/{shopify_id}
Then the response should contain credential_key values
But credential_value_encrypted should NOT be in the response
And a masked indicator "********" should be shown instead
Scenario: Failed credential verification
When I enter invalid Shopify API credentials
And I click "Verify & Save"
Then the system should display "Verification failed: Invalid API credentials"
And the integration status should remain "NOT_CONFIGURED"
And the invalid credentials should NOT be saved
Scenario: OAuth token auto-refresh
Given Amazon integration has a refresh token expiring in 4 minutes
When the token refresh check runs (5-minute buffer)
Then the system should automatically request a new access token
And the new token should be encrypted and stored
And the integration status should remain "CONNECTED"
And a sync log entry should record the token refresh
End of Module 6: Integrations & External Systems (Sections 6.1 – 6.13)
7. State Machine Reference
This section consolidates all entity state machines for quick reference.
7.1 Order States
| State | Description | Transitions To |
|---|---|---|
| DRAFT | Cart in progress | PENDING |
| PENDING | Awaiting payment | PAID, PARTIAL_PAID |
| PARTIAL_PAID | Partial payment received | PAID, PENDING |
| PAID | Full payment received | COMPLETED, HOLD_FOR_PICKUP |
| HOLD_FOR_PICKUP | Paid, awaiting pickup staging | READY_FOR_PICKUP |
| READY_FOR_PICKUP | Items staged for pickup | COMPLETED, HOLD_EXPIRED |
| HOLD_EXPIRED | Pickup deadline passed | CONTACT_CUSTOMER |
| CONTACT_CUSTOMER | Staff attempting contact | READY_FOR_PICKUP (extended), REFUNDED |
| COMPLETED | Transaction finalized | VOIDED (same day), PARTIALLY_RETURNED |
| VOIDED | Transaction reversed (same day) | Terminal |
| PARTIALLY_RETURNED | Some items returned | FULLY_RETURNED |
| FULLY_RETURNED | All items returned | Terminal |
| REFUNDED | Customer refunded (expired hold) | Terminal |
7.2 Parked Sale States
| State | Description | Transitions To |
|---|---|---|
| ACTIVE | Cart in progress | PARKED, PENDING |
| PARKED | Sale parked for later | ACTIVE (retrieved), EXPIRED |
| EXPIRED | TTL exceeded (4 hours) | Terminal (inventory released) |
7.3 Gift Card States
| State | Description | Transitions To |
|---|---|---|
| INACTIVE | Card manufactured, not sold | ACTIVE |
| ACTIVE | Balance > $0, within expiry | DEPLETED, EXPIRED (where allowed) |
| DEPLETED | Balance = $0 | ACTIVE (reload), CASHED_OUT |
| CASHED_OUT | Cash out processed (CA) | Terminal |
| EXPIRED | Past expiry date (where allowed) | Terminal |
7.4 Layaway States
| State | Description | Transitions To |
|---|---|---|
| DEPOSIT_PAID | Initial deposit received | RESERVED |
| RESERVED | Inventory held | PAID_IN_FULL, CANCELLED, FORFEITED |
| PAID_IN_FULL | All payments complete | COMPLETED |
| COMPLETED | Items released to customer | Terminal |
| CANCELLED | Customer cancelled | Terminal |
| FORFEITED | Payment deadline missed | Terminal |
7.5 Special Order States
| State | Description | Transitions To |
|---|---|---|
| CREATED | Order initiated | DEPOSIT_PAID, CANCELLED |
| DEPOSIT_PAID | Deposit received | ORDERED, CANCELLED_REFUND |
| ORDERED | Sent to vendor | RECEIVED |
| RECEIVED | Item arrived | READY_FOR_PICKUP |
| READY_FOR_PICKUP | Staged for customer | COMPLETED, ABANDONED |
| COMPLETED | Customer picked up | Terminal |
| CANCELLED | No deposit, cancelled | Terminal |
| CANCELLED_REFUND | Deposit refunded | Terminal |
| ABANDONED | No pickup after 30 days | Terminal |
7.6 Transfer States
| State | Description | Transitions To |
|---|---|---|
| REQUESTED | Transfer initiated | PAID, CANCELLED |
| PAID | Customer paid in full | PICKING |
| PICKING | Source store processing | SHIPPED |
| SHIPPED | Handed to carrier | IN_TRANSIT |
| IN_TRANSIT | Carrier confirmed pickup | RECEIVED |
| RECEIVED | Arrived at destination | COMPLETED |
| COMPLETED | Customer notified/picked up | Terminal |
| CANCELLED | Cancelled before payment | Terminal |
| CANCELLED_REFUND | Cancelled after payment | Terminal |
7.7 Reservation States
| State | Description | Transitions To |
|---|---|---|
| REQUESTED | Reservation initiated | PAID, CANCELLED |
| PAID | Customer paid in full | RESERVED |
| RESERVED | Item held at store | PICKED_UP, EXPIRED |
| PICKED_UP | Customer collected | Terminal |
| EXPIRED | Deadline passed | REFUND_PENDING |
| REFUND_PENDING | Auto-refund triggered | REFUNDED |
| CANCELLED | Cancelled before payment | Terminal |
| REFUNDED | Refund processed | Terminal |
7.8 Ship-to-Customer States
| State | Description | Transitions To |
|---|---|---|
| REQUESTED | Shipment initiated | PAID, CANCELLED |
| PAID | Customer paid item + shipping | PICKING, CANCELLED_REFUND |
| PICKING | Source store processing | PACKED |
| PACKED | Items packed, awaiting label | SHIPPED |
| SHIPPED | Label generated, handed to carrier | IN_TRANSIT |
| IN_TRANSIT | Carrier pickup confirmed | DELIVERED |
| DELIVERED | Delivery confirmed | Terminal |
| CANCELLED | Cancelled before payment | Terminal |
| CANCELLED_REFUND | Cancelled after payment, full refund | Terminal |
7.9 Cash Drawer States
| State | Description | Transitions To |
|---|---|---|
| CLOSED | Drawer secured | OPENING |
| OPENING | Manager initiating open | OPEN |
| OPEN | Accepting transactions | COUNTING |
| COUNTING | End-of-day count in progress | BALANCED, VARIANCE_DETECTED |
| BALANCED | Count matches expected | CLOSED |
| VARIANCE_DETECTED | Count doesn’t match | MANAGER_REVIEW |
| MANAGER_REVIEW | Awaiting approval | BALANCED |
7.10 Coupon States
| State | Description | Transitions To |
|---|---|---|
| CREATED | Coupon generated | ACTIVE |
| ACTIVE | Available for use | REDEEMED, EXPIRED, DEPLETED |
| REDEEMED | Single-use completed | Terminal |
| EXPIRED | Past expiry date | Terminal |
| DEPLETED | Multi-use limit reached | Terminal |
7.11 Customer Tier States
| State | Description | Transitions To |
|---|---|---|
| BRONZE | New/base tier | SILVER |
| SILVER | Mid tier ($1,000+ annual) | GOLD, BRONZE |
| GOLD | Top tier ($5,000+ annual) | SILVER |
7.12 Connectivity States
BRD Amendment (v6.3.0): CONFLICT_REVIEW state removed per ADR-048. DEGRADED state added per Ch 04 L.10A.1G 3-state model. Server is authoritative; discrepancies are flagged, not blocking.
| State | Description | Transitions To |
|---|---|---|
| ONLINE | API reachable, all operations available | DEGRADED |
| DEGRADED | Intermittent connectivity; API attempted first, falls back to local queue | ONLINE, OFFLINE |
| OFFLINE | API unreachable (3 consecutive failures); sales queued to sales_queue, inventory read-only from product_cache | SYNCING |
| SYNCING | Network restored, flushing sales_queue in FIFO order | ONLINE, OFFLINE |
7.13 Integration Sync States
| State | Description | Transitions To |
|---|---|---|
| IDLE | No sync operation in progress | SYNCING |
| SYNCING | Active sync operation underway | COMPLETED, FAILED, PARTIAL |
| COMPLETED | Sync finished successfully | IDLE |
| FAILED | Sync failed after all retries exhausted | IDLE (manual retry), SYNCING (auto-retry) |
| PARTIAL | Some records synced, others failed | IDLE (accept partial), SYNCING (retry failed) |
7.14 Integration Connection States
| State | Description | Transitions To |
|---|---|---|
| NOT_CONFIGURED | Integration not set up | CONNECTING |
| CONNECTING | Validating credentials and testing connection | CONNECTED, ERROR |
| CONNECTED | Active and operational | DISCONNECTED, ERROR, RATE_LIMITED |
| DISCONNECTED | Manually disabled by admin | CONNECTING |
| ERROR | Connection failed or credentials invalid | CONNECTING (re-validate), NOT_CONFIGURED (reset) |
| RATE_LIMITED | API rate limit exceeded, temporarily paused | CONNECTED (after cooldown) |
7.15 Amazon Order Fulfillment States (FBM)
| State | Description | Transitions To |
|---|---|---|
| PENDING | Order received from Amazon, awaiting payment confirmation | ASSIGNED, CANCELLED |
| ASSIGNED | Routed to a POS store location for fulfillment | PICKING, CANCELLED |
| PICKING | Store staff locating and scanning items | PACKED, CANCELLED |
| PACKED | Items packaged and ready for shipment | SHIPPED |
| SHIPPED | Carrier picked up, tracking number provided to Amazon | DELIVERED, EXCEPTION |
| DELIVERED | Carrier confirms delivery | (terminal) |
| CANCELLED | Order cancelled by customer or seller | (terminal) |
| EXCEPTION | Delivery exception (lost, damaged, refused) | SHIPPED (re-attempt), CANCELLED (refund) |
7.16 Product Sync Validation States
| State | Description | Transitions To |
|---|---|---|
| DRAFT | Product created, not yet validated for any channel | VALIDATING |
| VALIDATING | Running cross-platform validation rules | VALID, INVALID |
| VALID | All required fields pass validation for target platform(s) | SYNCING, INVALID (data changed) |
| INVALID | One or more validation rules failed | VALIDATING (after fix) |
| SYNCING | Pushing product data to external platform | SYNCED, SYNC_FAILED |
| SYNCED | Successfully published to external platform | VALID (re-validate on change), SYNCING (update) |
| SYNC_FAILED | Push to platform failed | SYNCING (retry), VALID (re-queue) |
| BLOCKED | Product excluded from sync (e.g., exceeds variant limits) | VALIDATING (after fix) |
Field Specifications Reference
This section summarizes the 44 field-level specifications gathered through requirements interviews.
(Companion document
Technical-User-Stories-Field-Specs.mdremoved in v6.0.0 restructure; field specifications are inline in user stories above.)
M.1 SKU & Barcode Specifications
| Specification | Value |
|---|---|
| SKU Max Length | 20 characters |
| SKU Allowed Characters | Alphanumeric + dash + underscore ([A-Z0-9\-_]) |
| SKU Uniqueness | Globally unique per tenant |
| Barcode Types Supported | UPC-A (12 digit), EAN-13 (13 digit), Internal |
| Invalid Barcode Behavior | Show error + manual SKU entry option |
M.2 Customer Contact Specifications
| Specification | Value |
|---|---|
| Phone Format | E.164 international (+1-555-123-4567) |
| Required Customer Fields | First name + Last name + (Phone OR Email) |
| Address Format | Structured fields (Street/City/State/ZIP) |
| ZIP Code Validation | 5 digits or 9 digits (12345 or 12345-6789) |
| Customer Notes Max Length | 500 characters |
M.3 Pricing Specifications
| Specification | Value |
|---|---|
| Price Data Type | DECIMAL(10,2) |
| Price Range | $0.00 - $99,999.99 |
| Tax Rate Format | Percentage with 2 decimals (stored as 8.25) |
| Zero Price Handling | Allowed with mandatory reason code (SAMPLE, DONATION, PROMO, OTHER) |
M.4 Inventory Reason Codes
| Category | Values |
|---|---|
| Adjustment | SHRINKAGE, DAMAGE, COUNT_CORRECTION, VENDOR_ERROR, FOUND_STOCK, SAMPLE, DONATION, EMPLOYEE_PURCHASE, OTHER |
| Transfer | REBALANCE, REPLENISHMENT, CUSTOMER_REQUEST, OVERSTOCK, CONSOLIDATION, OTHER |
| Non-PO Receive | SAMPLE, REPLACEMENT, FOUND_STOCK, CONSIGNMENT, DONATION, VENDOR_CREDIT_RETURN, RMA_RETURN, OTHER |
| Custom Codes | Predefined + Admin-created custom codes allowed |
M.5 Authentication Specifications
| Specification | Value |
|---|---|
| POS PIN Format | 4 digits numeric only (regex: ^\d{4}$) |
| Lockout Policy | 5 failed attempts → 15-minute lock, admin can unlock |
| Password Requirements | 8+ chars, 1 uppercase, 1 number |
| Manager Override | Manager must enter their own PIN (creates audit trail) |
M.6 Payment Specifications
| Specification | Value |
|---|---|
| Card Minimum | None (accept any amount) |
| Supported Card Types | Visa, Mastercard, Amex, Discover |
| Payment Decline Message | “Payment declined. Please try another payment method.” |
| Cash Maximum | $10,000 (IRS reporting threshold) |
M.7 Product Variant Specifications
| Specification | Value |
|---|---|
| Size Values | Predefined by category (Apparel: XS-3XL, Shoes: 5-15, Pants: 28-44), admin can add |
| Color Values | Predefined list (20+ standard) + admin custom additions |
| Max Dimension Values | 50 per dimension |
| Dimension Value Max Length | 30 characters |
M.8 Custom Field Specifications
| Specification | Value |
|---|---|
| Dropdown Max Options | 25 options per dropdown |
| Text Field Max Length | 255 characters |
| Number Field Precision | DECIMAL(10,4), max 999,999.9999 |
| Fields Per Entity | 50 max per entity type (Product/Customer/Order/Vendor) |
M.9 Approval Workflow Specifications
| Specification | Value |
|---|---|
| Threshold Type | Configurable per action ($ or %) |
| Timeout Action | Auto-reject after 48 hours |
| Self-Approval | Not allowed — different person must approve |
| Default Thresholds | Fully configurable by Tenant Admin during setup |
M.10 Receipt & Email Specifications
| Specification | Value |
|---|---|
| Receipt Line Length | 40 characters per line (80mm thermal) |
| Email Subject Max Length | 100 characters |
| Email Body Max Size | 50 KB (HTML) |
| Merge Field Syntax | {{FIELD_NAME}} |
M.11 Time & Date Specifications
| Specification | Value |
|---|---|
| Time Format Display | 12-hour AM/PM (e.g., 9:00 AM) |
| Time Storage | 24-hour format |
| Clock-In Duration | Maximum 16 hours; auto-alert to manager if clock-out not recorded within 16 hours |
| Default Timezone | America/New_York (Eastern) |
| Date Format Display | MM/DD/YYYY |
| Date Storage | ISO format (YYYY-MM-DD) |
M.12 Error Message Specifications
| Specification | Value |
|---|---|
| Error Max Length | 80 characters |
| Error Code Format | Prefix with [ERR-XXXX] |
| Error Tone | Direct and instructive (e.g., “Enter a valid email address.”) |
| Error Display | Inline below each field |
M.13 Error Code Ranges
| Module | Error Code Range |
|---|---|
| Module 1: Sales | ERR-1001 to ERR-1099 |
| Module 2: Customers | ERR-2001 to ERR-2099 |
| Module 3: Catalog | ERR-3001 to ERR-3099 |
| Module 4: Inventory | ERR-4001 to ERR-4099 |
| Module 5: Setup | ERR-5001 to ERR-5099 |
| Module 6: Integrations (General) | ERR-6001 to ERR-6009 |
| Module 6: Shopify | ERR-6010 to ERR-6029 |
| Module 6: Amazon SP-API | ERR-6030 to ERR-6049 |
| Module 6: Google Merchant | ERR-6050 to ERR-6069 |
| Module 6: Product Validation | ERR-6070 to ERR-6079 |
| Module 6: Inventory Sync | ERR-6080 to ERR-6089 |
| Module 6: Payment Integration | ERR-6090 to ERR-6094 |
| Module 6: Email Integration | ERR-6095 to ERR-6097 |
| Module 6: Shipping Integration | ERR-6098 to ERR-6099 |
(Companion document
Technical-User-Stories-Field-Specs.mdremoved in v6.0.0 restructure; field specifications are inline in user stories above.)
Document History
| Version | Date | Changes |
|---|---|---|
| 10.0 | 2026-01-26 | Initial unified specification |
| 11.0 | 2026-01-26 | Added: Gift Cards, Dedicated Exchanges, Price Tiers, Special Orders, Multi-Store Inventory (full payment required), Commissions, Return Policy Engine, Serial Numbers, Hold for Pickup, Cash Drawer Management, Price Check Mode, Coupons, Flexible Loyalty, Customer Groups, Customer Notes, Communication Preferences |
| 11.1 | 2026-01-26 | Added: State Machine Diagrams (8 entities), Gherkin Acceptance Criteria (Sales & Customers), Business Rules Configuration (YAML), State Machine Reference section |
| 12.0 | 2026-01-26 | Major Update: Added Section 1.16 Offline Operations (queue-and-sync), Section 1.17 Tax Calculation Engine (custom, Virginia-first with expansion design), Section 1.18 Payment Integration (SAQ-A semi-integrated). Fixed inconsistencies: Hold for Pickup state machine reconciled, Discount calculation order clarified (added loyalty redemptions), Credit limit calculation documented (includes pending layaways), Void vs Return distinction clarified (same-day vs after). Added missing user stories: Payment failures, Receipt reprinting, Privacy compliance (GDPR-style). Added Parked Sale state machine. Updated Commission rules for proportional reversal on returns. Added Gift Card jurisdiction compliance (California cash-out). Added Customer self-service and privacy workflows. New state machines: Parked Sales (3.2), Offline Mode (3.11). Updated all Gherkin acceptance criteria. |
| 13.0 | 2026-02-01 | Major Update: (1) Renamed all RFID references to Scanner for technology-agnostic input. (2) Removed Cash Rounding feature. (3) Added Affirm third-party financing (BNPL) as payment method. (4) Receipt scanning now required for return validation. (5) Added X-Report for mid-shift cash audits. (6) New Section 1.7.3 Ship to Customer from Other Location with carrier integration. (7) Card refund now offers staff choice between manual on terminal or automatic via token. (8) Multiple credit cards and cash+card(s) combinations supported. (9) Renamed Integrated Card to Credit Card for clarity. (10) Added Exchange to toggle mode (Sale/Return/Exchange). (11) Loyalty Redemption now applies after tax calculation. (12) Return/exchange policy is configurable in Settings/Setup, not hardcoded. (13) Offline mode notifies customers via email when transfer/ship/reserve items sold at source. (14) Added Reports & Email Templates sub-sections under all major sections (50 reports, 6 email templates). (15) Clarified Reservation vs Hold for Pickup distinction with BOPIS examples. (16) Added Online/In-Store return and exchange policy examples. |
| 14.0 | 2026-02-02 | Major Catalog Expansion: Expanded Section 3 (Catalog Module) from 8 subsections to 23 subsections. (1) New Pricing Engine with 5-level price hierarchy, price books, 4 promotion types, markdown workflows with accountability controls. (2) New Multi-Channel Management with visibility controls, inventory allocation, and channel-specific pricing. (3) New Shopify Integration with POS-master sync strategy, field-level ownership model, and optional bi-directional mode. (4) New Vendor RMA workflow (8-state machine). (5) New Reorder Management with velocity-based dynamic reorder points and auto-generated draft POs. (6) New Inventory Control with 6 statuses, 5 counting methods, approval-gated adjustments, and unified receiving workflow. (7) New Inter-Store Transfers with state machine and auto-rebalancing. (8) New Serial & Lot Tracking. (9) New Landed Cost & Weighted Average Costing. (10) New Product Movement History with stock ledger. (11) New Product Search & Discovery with full-text search, filters, substitutions. (12) New Label & Price Tag Printing with templates. (13) New Product Media management (images + video). (14) New Product Notes & Attachments with structured types. (15) New Catalog Permissions & Approvals (RBAC, field-level, approval workflows). (16) New Product Performance Analytics (ABC classification, embedded metrics). (17) Expanded Product Data Model with retail attributes, custom fields, UoM, shipping, templates, matrix management. (18) Expanded Categories to include formal seasons and reporting dimensions. (19) Expanded User Stories with 14 new epics (F through S) and comprehensive Gherkin acceptance criteria. (20) Added “Features Not Needed” section documenting explicit exclusions (warranties, consignment, expiration, assembly, recalls, product-level tax). |
| 15.0 | 2026-02-04 | New Inventory Module: (1) Created Module 4: Inventory Management with 19 sections (4.1-4.19). (2) Moved inventory content (PO, RMA, Transfers, Counts, Costing, Movement History) from Catalog Module 3 to dedicated Inventory Module 4. (3) Reduced Catalog Module from 23 to 15 sections (3.1-3.15). (4) New sections: POS & Sales Integration (reserve/commit model), Online Order Fulfillment (nearest-store), Offline Inventory Operations (queue + conflict resolution), Alerts & Notifications (5 types + 4 email templates), Inventory Dashboard & Reports (dedicated KPIs + 33 reports), Inventory Business Rules YAML. (5) Added 16 user story epics (4.A-4.P) with 42 stories and 10 Gherkin feature files (52 scenarios). (6) Added cross-references between Modules 1, 3, and 4. (7) Renumbered State Machine Reference to Section 5 and Business Rules to Section 6. (8) Added 20 new decisions (#35-54) covering receiving, counting, transfers, POS integration, offline ops, fulfillment, alerts, and dashboard. |
| 16.0 | 2026-02-04 | New Setup & Configuration Module: (1) Created Module 5: Setup & Configuration with 21 sections (5.1-5.21). (2) System settings with core, operational, and branding configuration. (3) Multi-currency support with USD base and manual exchange rates. (4) Flat location hierarchy (Location → Zones) with predefined and custom zones. (5) User profiles with predefined roles (Staff/Manager/Admin/Buyer/Owner) and configurable feature toggles. (6) Register management with device pairing and two profiles (Full POS, Mobile Checkout). (7) Central printer registry with register linking. (8) Simple flat tax rate per location. (9) Predefined + custom UoMs with conversion factors. (10) Payment method configuration per location with processor settings. (11) Per-entity custom fields (Product/Customer/Order/Vendor). (12) Per-action approval workflows with escalation. (13) Full receipt builder with email receipt template. (14) Central email template registry with SMTP configuration. (15) Integration hub for Shopify, payments, and email providers. (16) Loyalty settings split from Module 2 (rules in M2, settings in M5). (17) Configurable audit logging with retention and export. (18) Consolidated all Business Rules YAML from old Section 6 and Section 4.18 into Module 5.19, organized by module. (19) 14-step tenant onboarding wizard with go-live validation. (20) 17 user story epics (5.A-5.Q) with 51 Gherkin scenarios. (21) Removed old Section 6 (Business Rules Configuration). (22) Renumbered State Machine Reference to Section 6. (23) Added 17 new decisions (#55-71). |
| 17.0 | 2026-02-06 | Field Specifications & Technical User Stories: (1) Completed 12-round requirements interview gathering 44 field-level specifications. (2) Created 60 Technical User Stories with field-level acceptance criteria in companion document. (3) Added Appendix M: Field Specifications Reference summarizing all validation rules, data types, formats, and error codes. (4) Established error code ranges: ERR-1xxx (Sales), ERR-2xxx (Customers), ERR-3xxx (Catalog), ERR-4xxx (Inventory), ERR-5xxx (Setup). (5) Documented reason codes for inventory adjustments, transfers, and non-PO receiving. (6) Defined authentication specs (4-digit PIN, 8+ char password, 5 failures = 15-min lockout). (7) Specified product variant constraints (50 values per dimension, 30 chars each). (8) Set approval timeout to auto-reject after 48 hours. (9) Added 12 new decisions (#72-83) for field-level specifications. |
| 18.0 | 2026-02-17 | New Integrations Module & Multi-Platform Expansion: (1) Created Module 6: Integrations & External Systems with 13 sections (6.1-6.13). (2) Moved integration content from Modules 1, 3, 4, and 5 into Module 6 with redirect stubs at original locations. Moved sections: 3.7 Shopify Integration → 6.3, 4.14.3 Inventory Sync with Shopify → 6.3.14, 5.11.3 Payment Processor Configuration → 6.8.3, 5.15.1 Email Provider Configuration → 6.9.1, 5.16 Integrations Hub → 6.11. (3) New Section 6.2: Integration Architecture with provider abstraction, retry/backoff, circuit breaker, idempotency framework, rate limit management, and webhook processing pipeline. (4) Enhanced Shopify Integration (Section 6.3) with GraphQL API preference, @idempotent directive (mandatory 2026-04), Bulk Operations API, POS UI Extensions, 2026 rate limits, webhook topics catalog, third-party POS integration rules, sync rules & best practices (single source of truth, location config, real-time sync, omnichannel/BOPIS, staff security), and hardware compatibility. (5) New Amazon SP-API Integration (Section 6.4) with OAuth via LWA, Catalog Items API, Listings Items API, Orders API (2-min polling), FBA Inventory API (ASIN/SKU/FNSKU mapping, 7 inventory states), SQS/EventBridge notifications, per-endpoint rate limits, and comprehensive compliance & seller requirements (seller code of conduct, packaging/labeling, FBA vs FBM support, safety buffers, order routing). (6) New Google Merchant API Integration (Section 6.5) with service account auth, ProductInput/Product resource split, local inventory sync (storeCode-level), push notifications, rate limits, required product data fields (11 mandatory + 8 recommended), image quality requirements (8 rules), disapproval prevention rules (10 policies with automated pre-sync validation), Google Business Profile integration, and Content API migration plan (EOL 2026-08-18). (7) New Cross-Platform Product Data Requirements (Section 6.6) with unified validation matrix (strictest-rule-wins), image requirements matrix, pre-sync validation engine with data model, and platform-specific product attributes for Amazon, Google, and Shopify. (8) New Cross-Platform Inventory Sync Rules (Section 6.7) with real-time sync architecture, safety buffer configuration (3 calculation modes: FIXED/PERCENTAGE/MIN_RESERVE), oversell prevention (reserve-on-order, first-commit-wins), channel-specific inventory rules, and graduated sync failure handling. (9) Consolidated Payment Processor Integration (Section 6.8), Email Provider Integration (Section 6.9), Carrier & Shipping Integration (Section 6.10), and enhanced Integration Hub (Section 6.11) with AMAZON and GOOGLE_MERCHANT added to integration_type enum. (10) New Integration Business Rules YAML (Section 6.12) covering shopify, amazon, google_merchant, product_validation, inventory_sync, and global settings. (11) New Integration User Stories (Section 6.13) with 7 epics (6.A-6.G) and 10 Gherkin feature files covering Shopify sync, Amazon orders, Google local inventory, cross-platform validation, safety buffers, payment terminals, and integration hub. (12) Renumbered State Machine Reference from Section 6 to Section 7. (13) Added 4 new state machines: Integration Sync States (7.13), Integration Connection States (7.14), Amazon Order Fulfillment States (7.15), Product Sync Validation States (7.16). (14) Added ERR-6001 to ERR-6099 error code range to Appendix M.13 with sub-ranges per provider. (15) Added 16 new decisions (#84-99) covering module structure, API choices, compliance rules, validation strategy, safety buffers, and dual fulfillment support. |
| 19.0 | 2026-02-19 | Tax Redesign & Simplification Update: (1) Replaced flat tax_rate with tax_jurisdiction_id FK supporting 3-level compound taxes (State/County/City). (2) Added is_franchise Boolean to locations. (3) Removed zone tracking from all modules. (4) Removed role-based location access enforcement; user_locations informational only. (5) Simplified shift management to clock-in/clock-out. (6) Added register IP modification limit (2/365 days). (7) Restricted register retirement to OWNER with type-to-confirm. (8) Added Decisions #100-107. |
Decision Log
Decisions captured during BRD review and refinement:
| # | Decision | Choice | Rationale | Date |
|---|---|---|---|---|
| 1 | Offline Strategy | Queue-and-sync | Allows continued operation, sync on reconnect | 2026-01-26 |
| 2 | Tax Engine | Build custom | Full control over jurisdiction rules, expansion flexibility | 2026-01-26 |
| 3 | Payment PCI Scope | SAQ-A | Simplest compliance, card data never touches system | 2026-01-26 |
| 4 | Multi-tenant Isolation | Row-Level Security (RLS) | PostgreSQL RLS policies with app.current_tenant session variable (see ADR-001, superseded schema-per-tenant approach) | 2026-01-26 |
| 5 | Commission Reversal | Proportional on returns | Fair to employees, full reversal only on voids | 2026-01-26 |
| 6 | Geographic Scope | Virginia → US → International | Design for most restrictive jurisdiction from start | 2026-01-26 |
| 7 | Gift Card Default | No expiry (California rules) | Most restrictive as baseline, enable where permitted | 2026-01-26 |
| 8 | Discount Order | Added loyalty redemptions before tax | Complete calculation order documented | 2026-01-26 |
| 9 | Credit Limit | Include pending layaways | Accurate available credit calculation | 2026-01-26 |
| 10 | Void vs Return | Void = same day only | Clear distinction for commission handling | 2026-01-26 |
| 11 | Scanner Terminology | Replace RFID with Scanner | Technology-agnostic input device naming | 2026-02-01 |
| 12 | Cash Rounding | Removed | Not required for business operations | 2026-02-01 |
| 13 | Third-Party Financing | Affirm as BNPL provider | Customer financing option, store receives full payment immediately | 2026-02-01 |
| 14 | Receipt Validation | Mandatory scanning before returns | Prevents fraudulent returns, system validates receipt authenticity | 2026-02-01 |
| 15 | X-Report | Mid-shift cash audit (does not close drawer) | Enables shift handoffs and spot-checks without closing drawer | 2026-02-01 |
| 16 | Ship to Customer | Direct shipping from source store | Carrier API integration for real-time shipping cost calculation | 2026-02-01 |
| 17 | Card Refund Method | Staff choice: manual or auto via token | Flexibility for customer-present and customer-absent scenarios | 2026-02-01 |
| 18 | Multi-Card Payment | Multiple cards + cash+card(s) allowed | Each card token stored separately for individual refund processing | 2026-02-01 |
| 19 | Loyalty After Tax | Redemption applies after tax calculation | Loyalty discount reduces final total including tax | 2026-02-01 |
| 20 | Return Policy Config | Manually configured in Settings/Setup | Per-tenant, per-store, per-channel (online vs in-store) | 2026-02-01 |
| 21 | Offline Sold Notification | Email customer via TMPL-OFFLINE-SOLD | Customer informed when transfer/ship/reserve item unavailable | 2026-02-01 |
| 22 | Hold for Pickup Scope | In-store holds + BOPIS | Clear distinction from Reservation (different store) | 2026-02-01 |
| 23 | Pricing Model | Centralized + Price Books + Channel overrides | 5-level hierarchy enables flexible pricing without complexity | 2026-02-02 |
| 24 | Shopify Sync | POS-master default, optional bi-directional | Industry standard (Lightspeed, Retail Pro, SKU IQ all use POS-master); bi-directional option for 3rd party Shopify editors | 2026-02-02 |
| 25 | Shopify Field Ownership | Per-field direction model | Eliminates conflicts by assigning clear ownership; SEO stays in Shopify, product data stays in POS | 2026-02-02 |
| 26 | Inventory Sync | Always bidirectional | Inventory quantities sync both ways regardless of catalog sync mode | 2026-02-02 |
| 27 | Consignment | Not supported | All inventory purchased outright; no consignment tracking needed | 2026-02-02 |
| 28 | Warranties | Not supported | Warranty tracking handled outside POS system | 2026-02-02 |
| 29 | Product Expiration | Not applicable | Clothing/accessories business; no expiration dates needed | 2026-02-02 |
| 30 | Product Assembly | Not needed | Bundles are virtual pricing groupings only, not physical assembly | 2026-02-02 |
| 31 | Product Tax | Location-based only | No product-level tax variation; tax determined by store jurisdiction | 2026-02-02 |
| 32 | Reorder Strategy | Velocity-based dynamic | Dynamic reorder points from sales velocity, not static thresholds; seasonal adjustment | 2026-02-02 |
| 33 | Costing Method | Weighted average | Recalculated on every PO receive; used for COGS and margin | 2026-02-02 |
| 34 | Markdown Accountability | Formal workflow + approval | All price changes tracked with who/when/old/new/reason; manager approval required | 2026-02-02 |
| 35 | Receive Mode | Open receive | Staff sees expected qty; faster workflow; variances still recorded | 2026-02-04 |
| 36 | Receiving Discrepancies | Triple approach | Note variance + auto-RMA draft + quarantine damaged goods | 2026-02-04 |
| 37 | Non-PO Receiving | Allow with reason code | Supports samples, replacements, return-to-stock, found stock | 2026-02-04 |
| 38 | Over-shipment | Threshold-based | Allow up to configurable %; above requires manager approval | 2026-02-04 |
| 39 | Adjustment Approval | All require manager | Strongest control; every adjustment must be reviewed and approved | 2026-02-04 |
| 40 | Custom Reason Codes | Standard + tenant-defined | Standard set plus ability to add custom codes per tenant | 2026-02-04 |
| 41 | Count Freeze | Configurable per count | Manager chooses freeze or snapshot per count session | 2026-02-04 |
| 42 | Count Input | Scanner-primary | Barcode scan increments by 1; manual override for damaged barcodes | 2026-02-04 |
| 43 | Transfer Initiation | Both directions + auto-suggest | HQ push + store pull + system auto-suggests from supply imbalances | 2026-02-04 |
| 44 | Allocation Strategy | Manager manual | Manual allocation when multiple stores need scarce item | 2026-02-04 |
| 45 | REMOVED in v19.0 — zones eliminated; inventory tracked per-location only | 2026-02-04 | ||
| 46 | Overstock Returns | Supported | Negotiated return of seasonal/end-of-line unsold goods to vendor | 2026-02-04 |
| 47 | Sale Decrement | Reserve + commit | Reserve on cart add, commit on payment; reserve for parked/held | 2026-02-04 |
| 48 | Offline Inventory | Online-first with offline fallback (ADR-048) | API-primary via React Query; sales queued to sales_queue (FIFO) when offline; server authoritative on reconnect with flag-on-sync discrepancy detection | 2026-02-04 |
| 49 | Min Display Qty | Advisory only | Soft warning; doesn’t block sales or transfers | 2026-02-04 |
| 50 | Return to Stock | Auto to available | Customer returns auto-go to Available; staff marks damaged separately | 2026-02-04 |
| 51 | Online Fulfillment | Nearest store | Reserve inventory from store closest to customer shipping address | 2026-02-04 |
| 52 | PO Approval | Threshold-based | Auto-approve under configurable $; manager approval above | 2026-02-04 |
| 53 | Inventory Dashboard | Dedicated | Standalone dashboard with inventory KPIs separate from main admin | 2026-02-04 |
| 54 | Dead Stock | Alert + report only | Flag no-sales items; manager decides action manually | 2026-02-04 |
| 55 | Permission Model | Predefined roles + feature toggles | Balance between simplicity and flexibility; roles are fixed, feature access is configurable | 2026-02-04 |
| 56 | Location Hierarchy | Flat (Locations only) | Simple single-level sufficient for current scale; no multi-region complexity | 2026-02-04 |
| 57 | Register Config | Full (list + device + profiles) | Complete hardware management; two profiles (Full POS, Mobile) cover all use cases | 2026-02-04 |
| 58 | Tax Model | Compound Tax Model (3-level: State/County/City) | Superseded original flat rate per Decision #100; 3-level compound model via tax_jurisdictions + tax_rates tables | 2026-02-04 |
| 59 | UoM Approach | Predefined + custom with conversions | Standard units provided; custom UoMs with conversion factors for flexible product measurement | 2026-02-04 |
| 60 | Supplier Config | Payment terms + lead times only | Lean setup; full vendor data stays in Module 3 Section 3.8 | 2026-02-04 |
| 61 | Custom Fields | Per-entity, no field groups | Simple field management per entity type; no grouping or validation rules needed | 2026-02-04 |
| 62 | Approval Workflows | Per-action rules | Each approvable action configured independently; granular control without matrix complexity | 2026-02-04 |
| 63 | Email Templates | Central registry + SMTP | Single management point for all templates; SMTP/provider config centralized | 2026-02-04 |
| 64 | System Branding | Full suite (core + operational + branding) | Complete tenant customization including login page, receipt branding, report headers | 2026-02-04 |
| 65 | YAML Consolidation | All into Module 5 | Single source of truth; removes Section 6 and absorbs Section 4.18 | 2026-02-04 |
| 66 | Receipt Config | Full builder | Field selection, ordering, sizing, paper width — maximum receipt customization | 2026-02-04 |
| 67 | Payment Config | Methods + processors in Module 5 | Centralizes payment setup with per-location enable/disable and processor credentials | 2026-02-04 |
| 68 | Currency | Multi-currency, USD base, manual rates | Supports vendor POs in vendor currency; manual rate management sufficient | 2026-02-04 |
| 69 | Audit Logging | Configurable categories | Admin toggles which actions are logged; configurable retention and export | 2026-02-04 |
| 70 | Loyalty Settings | Split (rules M2, settings M5) | Business logic stays with customer module; configurable values centralized in Setup | 2026-02-04 |
| 71 | Tenant Onboarding | Step-by-step wizard | Documented 13-step setup flow for new tenant provisioning | 2026-02-04 |
| 72 | SKU Format | 20 chars, alphanumeric + dash/underscore | Industry standard; covers most retail SKU schemes | 2026-02-06 |
| 73 | Barcode Types | UPC-A, EAN-13, Internal only | Standard retail barcodes; internal for custom use | 2026-02-06 |
| 74 | Phone Format | E.164 international | Supports global customers with standardized format | 2026-02-06 |
| 75 | Price Precision | DECIMAL(10,2), max $99,999.99 | Standard retail pricing; sufficient for high-value items | 2026-02-06 |
| 76 | POS PIN Format | 4 digits numeric | Fast entry on touchscreen; familiar to retail staff | 2026-02-06 |
| 77 | Password Rules | 8+ chars, 1 upper, 1 number | Standard strength; balances security with usability | 2026-02-06 |
| 78 | Card Decline Message | Generic “Payment declined” | Protects customer privacy; reduces fraud hints | 2026-02-06 |
| 79 | Variant Dimensions | 50 values max, 30 chars each | Covers extensive size/color ranges with reasonable limits | 2026-02-06 |
| 80 | Custom Field Limits | 50 fields/entity, 25 dropdown options | Generous limits without performance impact | 2026-02-06 |
| 81 | Approval Timeout | Auto-reject after 48 hours | Prevents stuck requests; requester re-submits if needed | 2026-02-06 |
| 82 | Error Code Format | [ERR-XXXX] prefix | Easy support reference; traceable in logs | 2026-02-06 |
| 83 | Error Display | Inline below each field | Immediate visual feedback; industry standard UX | 2026-02-06 |
| 84 | Dedicated Integration Module | Create Module 6 for all integrations | Consolidates scattered integration content from 5 modules into single authoritative source; reduces duplication and cross-reference complexity | 2026-02-17 |
| 85 | Amazon SP-API Authentication | OAuth 2.0 via Login with Amazon (LWA) | Amazon’s required auth method; 1-hour tokens with automatic refresh; regional endpoint support | 2026-02-17 |
| 86 | Google Merchant API Version | Merchant API v1 (migrate from Content API) | Content API for Shopping EOL August 18, 2026; v1 is the successor API with ProductInput/Product resource split | 2026-02-17 |
| 87 | Shopify API Preference | GraphQL over REST | GraphQL has higher rate limits (50pts/sec vs 2req/sec), more efficient queries, better pagination; Shopify’s recommended approach | 2026-02-17 |
| 88 | Shopify Idempotency | @idempotent directive mandatory on all mutations | Required by Shopify starting 2026-04; prevents duplicate operations on retry; uses SHA-256 idempotency key | 2026-02-17 |
| 89 | Amazon Notifications | SQS push notifications | Real-time event delivery for order changes, inventory events, listing status; avoids polling overhead | 2026-02-17 |
| 90 | Google Local Inventory | Store-level local inventory sync | Google requires storeCode-level granularity; maps directly to POS per-location inventory model | 2026-02-17 |
| 91 | Integration Error Codes | ERR-6xxx range | Dedicated error code range for integration module; sub-ranges per provider (6010-6029 Shopify, 6030-6049 Amazon, 6050-6069 Google) | 2026-02-17 |
| 92 | Circuit Breaker | 5 failures in 60 seconds triggers OPEN | Standard resilience pattern; 30-second cooldown before HALF_OPEN probe; prevents cascade failures to external APIs | 2026-02-17 |
| 93 | Provider Abstraction | IntegrationProvider interface | Common interface for all providers (connect, sync, getStatus, validateCredentials); enables consistent error handling and monitoring | 2026-02-17 |
| 94 | Amazon Sync Direction | POS-master default | Consistent with Decision #24 (Shopify POS-master); all external channels receive product data from POS as source of truth | 2026-02-17 |
| 95 | Redirect Stubs | Cross-reference stubs in original locations | Moved sections leave redirect stubs (e.g., “See Module 6, Section 6.3”) to prevent broken references; stubs include brief scope statement | 2026-02-17 |
| 96 | Cross-Platform Validation | Strictest-rule-wins approach | POS enforces the most restrictive requirement across all platforms (e.g., 150-char title from Google, 1000x1000px image from Amazon); ensures products valid everywhere | 2026-02-17 |
| 97 | Safety Buffer | Configurable per-product per-channel inventory buffer | Formula: Channel Available = POS Available - Safety Buffer; prevents overselling during sync delays; critical for Amazon (2-min) and Google (30-min) latency | 2026-02-17 |
| 98 | Dual Fulfillment | Support both FBA and FBM for Amazon | FBA inventory tracked separately (Amazon manages); FBM uses POS pick-pack-ship workflow; per-product fulfillment method configuration | 2026-02-17 |
| 99 | Third-Party POS Rules | Shopify third-party POS integration compliance | Non-native POS must use OAuth, support real-time sync, not bypass checkout; POS is source of truth with field ownership model controlling data flow | 2026-02-17 |
| 100 | Compound Tax Model | 3-level compound (State/County/City) replaces flat rate per location | Virginia model requires state + regional + local stacking; single flat rate insufficient for multi-jurisdiction compliance | 2026-02-19 |
| 101 | Tax Jurisdiction FK | Location references tax_jurisdictions table instead of storing flat rate | Decouples tax configuration from location entity; enables shared jurisdictions and compound rate management | 2026-02-19 |
| 102 | Franchise Flag | is_franchise boolean on locations table | Distinguishes franchise vs company-owned locations for operational rules, reporting, and fee structures | 2026-02-19 |
| 103 | Location Access Informational | user_locations no longer restricts transaction processing; assignments used for defaults and reporting only | Simplifies permission model; all users can operate at any tenant location | 2026-02-19 |
| 104 | Register IP Limit | Max 2 IP address changes per rolling 365 days, tracked in register_ip_changes audit table | Prevents frequent device swapping; ensures hardware stability and audit traceability | 2026-02-19 |
| 105 | Register Retire Safety | OWNER-only with type-to-confirm ‘RETIRE’ | Prevents accidental permanent decommission; strongest safety for irreversible action | 2026-02-19 |
| 106 | Shift Simplification | Simple clock-in/clock-out replaces full shift management (shift types, assignments, handoff notes removed) | Full shift scheduling adds complexity without proportional value; clock-in/out sufficient for time tracking and payroll | 2026-02-19 |
| 107 | Zone Removal | Zones within locations removed; per-location inventory tracking only | Zone sub-divisions added complexity without sufficient operational value; per-location granularity sufficient for current scale | 2026-02-19 |
| 108 | RFID Scope | Counting only — strip lifecycle fields (sold_at, transferred_at) | RFID used exclusively for inventory counting; sales and receiving handled by barcode Scanner; simplifies tag status to (active, void, lost) | 2026-02-25 |
| 109 | Build Custom Raptag | Build React Native app (not buy off-shelf) | Full control over RFID counting UX, offline-first with SQLite, Zebra SDK integration, multi-operator support | 2026-02-25 |
| 110 | Chunked Upload | 5,000 events per chunk with idempotent dedup | Enterprise scale (100K+ tags) requires chunked sync; UNIQUE(session_id, epc) constraint makes retries safe; resume via upload-status endpoint | 2026-02-25 |
| 111 | Multi-Operator Sessions | Up to 10 operators per session with section assignment | Large stores need parallel counting; session_operators table tracks who scanned where; server deduplicates by highest RSSI | 2026-02-25 |
| 112 | Auto-Save | 30-second SQLite checkpoint flush + session recovery | Protects against data loss from app crashes, battery death; recovery dialog on restart with Resume/Discard options | 2026-02-25 |
| 113 | EPC Serial Numbering | PostgreSQL SEQUENCE per tenant (not column-based) | Concurrent-safe serial assignment; avoids race conditions from last_serial_number column approach | 2026-02-25 |
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2026-02-25 |
| Updated | 2026-03-02 |
| Source | BRD v20.0 (19,900+ lines, 7 modules, 113 decisions) |
| Author | Claude Code |
| Status | Active |
| Part | II - Architecture |
| Chapter | 05 of 9 |
Change Log
| Version | Date | Changes |
|---|---|---|
| 7.0.0 | 2026-03-02 | Unified web app: 33 “Nexus Admin” references replaced with “Nexus POS” or role-based descriptions. 2 sequence diagram participants updated (Nexus Admin→Nexus POS). POS Client participant removed from markdown workflow diagram. Footer updated to 7.0.0. |
| 4.0.0 | 2026-02-25 | BRD v20.0 integrated as Chapter 08: Architecture Components |
Next Chapter: Chapter 06: Database Strategy
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 06: Database Strategy
PostgreSQL 16 on Shared Infrastructure
6.1 Overview
This chapter defines the database strategy for the POS Platform, using PostgreSQL 16 on shared infrastructure. The strategy balances performance, isolation, and operational simplicity for a multi-tenant SaaS application.
Key Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Database Engine | PostgreSQL 16 | JSONB support, excellent concurrency, mature ecosystem |
| Multi-Tenancy | Row-Level Security (RLS) | Database-enforced isolation, simpler ops, no schema sprawl |
| Shared Tables | shared. schema | Platform-wide tenants, subscription plans, feature flags |
| Connection Pooling | PgBouncer | Essential for multi-tenant connection efficiency |
| Hosting | Shared container (postgres16) | Existing infrastructure, reduced ops complexity |
6.2 Infrastructure Architecture
Physical Deployment
┌─────────────────────────────────────────────────────────────────────────────┐
│ SYNOLOGY NAS (192.168.1.26) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL 16 Container │ │
│ │ (postgres16) │ │
│ │ │ │
│ │ Port: 5433 (external) → 5432 (internal) │ │
│ │ Data: /volume1/docker/postgres/data │ │
│ │ Network: postgres_default │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────────────┐ │ │
│ │ │ pos_platform Database │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────┐ ┌─────────────────────────────────────┐ │ │ │
│ │ │ │ shared │ │ public schema │ │ │ │
│ │ │ │ schema │ │ (all tenant tables with RLS) │ │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ │ tenants │ │ products (tenant_id + RLS) │ │ │ │
│ │ │ │ plans │ │ orders (tenant_id + RLS) │ │ │ │
│ │ │ │ features │ │ customers (tenant_id + RLS) │ │ │ │
│ │ │ │ │ │ inventory (tenant_id + RLS) │ │ │ │
│ │ │ │ │ │ ... all other tenant tables │ │ │ │
│ │ │ └──────────────┘ └─────────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ Other Databases: salessight_db, stanly_db, shopsyncflow_db, ... │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ PgBouncer │◄───────►│ Application Containers │ │
│ │ Port: 6432 │ │ (pos-api, pos-admin, etc.) │ │
│ └─────────────────────┘ └──────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Database Creation
-- Create the main POS platform database
CREATE DATABASE pos_platform
WITH OWNER = postgres
ENCODING = 'UTF8'
LC_COLLATE = 'en_US.UTF-8'
LC_CTYPE = 'en_US.UTF-8'
TEMPLATE = template0;
-- Connect to the new database
\c pos_platform
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Cryptographic functions
CREATE EXTENSION IF NOT EXISTS "btree_gist"; -- GiST index support
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Trigram text search
-- Create the shared schema for platform-wide tables
CREATE SCHEMA IF NOT EXISTS shared;
-- Grant usage to application role
GRANT USAGE ON SCHEMA shared TO pos_app;
GRANT USAGE ON SCHEMA public TO pos_app;
6.3 Row-Level Security (RLS) Architecture
Why Row-Level Security?
| Approach | Pros | Cons | Our Choice |
|---|---|---|---|
| Row-level (RLS) | Single schema, simpler ops, database-enforced isolation, no schema sprawl | All tenants share tables | Yes |
| Schema-per-tenant | Strong logical isolation, easy per-tenant backup | Many schemas, complex migrations per schema, connection overhead | No |
| Database-per-tenant | Maximum physical isolation | High resource usage, complex management | No |
Row-Level Security was selected because the BRD v19.0 data models already include tenant_id UUID on every tenant-scoped table (135+ occurrences across all modules). RLS enforces isolation at the database level as defense-in-depth, preventing accidental cross-tenant data access even if application code has bugs.
How RLS Works
All tenants share the same tables in the public schema. Every tenant-scoped table includes a tenant_id UUID NOT NULL column. PostgreSQL RLS policies automatically filter rows so each tenant can only see and modify their own data.
┌─────────────────────────────────────────────────────────────────┐
│ RLS Data Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Request arrives for tenant "nexus" │
│ ┌─────────────────────────────────┐ │
│ │ POST /api/products │ │
│ │ Authorization: Bearer <jwt> │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ 2. Middleware extracts tenant_id from JWT │
│ │ │
│ 3. Application sets PostgreSQL session variable │
│ ┌─────────────────────────────────┐ │
│ │ SET app.current_tenant = │ │
│ │ 'a1b2c3d4-...-tenant-uuid' │ │
│ └──────────────┬──────────────────┘ │
│ │ │
│ 4. Query executes — RLS policy filters automatically │
│ ┌─────────────────────────────────┐ │
│ │ SELECT * FROM products; │ │
│ │ -- RLS adds: WHERE tenant_id │ │
│ │ -- = 'a1b2c3d4-...' │ │
│ └─────────────────────────────────┘ │
│ │
│ Result: Only nexus's products returned. Other tenants │
│ invisible even without WHERE clause in application code. │
│ │
└─────────────────────────────────────────────────────────────────┘
RLS Policy Implementation
-- Step 1: Add tenant_id to every tenant-scoped table
-- (Already present in schema design — see Chapter 07)
-- Step 2: Enable RLS on each tenant-scoped table
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE products FORCE ROW LEVEL SECURITY;
-- Step 3: Create isolation policy
CREATE POLICY tenant_isolation ON products
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- The USING clause applies to SELECT, UPDATE, DELETE
-- For INSERT, add a WITH CHECK clause to prevent inserting for wrong tenant
CREATE POLICY tenant_insert ON products
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Application Tenant Context (TypeScript Middleware)
// middleware/tenantMiddleware.ts
import { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
import { redis } from '../cache';
interface TenantInfo {
id: string;
name: string;
subdomain: string;
isActive: boolean;
plan: string;
}
export function createTenantMiddleware(prisma: PrismaClient) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
// 1. Extract subdomain from request
const subdomain = extractSubdomain(req.hostname);
if (!subdomain) {
return res.status(400).json({
error: 'ERR-5001',
message: 'Tenant subdomain required'
});
}
// 2. Resolve tenant (cache-first, then DB)
let tenant = await redis.get(`tenant:${subdomain}`);
if (!tenant) {
const dbTenant = await prisma.tenant.findUnique({
where: { subdomain }
});
if (!dbTenant || !dbTenant.isActive) {
return res.status(404).json({
error: 'ERR-5002',
message: 'Tenant not found or inactive'
});
}
tenant = JSON.stringify(dbTenant);
await redis.set(`tenant:${subdomain}`, tenant, 'EX', 300);
}
const tenantInfo: TenantInfo = JSON.parse(tenant as string);
// 3. Set PostgreSQL RLS context for this request
await prisma.$executeRawUnsafe(
`SET app.current_tenant = '${tenantInfo.id}'`
);
// 4. Attach tenant to request
req.tenantId = tenantInfo.id;
req.tenantInfo = tenantInfo;
next();
} catch (error) {
next(error);
}
};
}
function extractSubdomain(hostname: string): string | null {
const parts = hostname.split('.');
// nexus.pos-platform.com → 'nexus'
if (parts.length >= 3) {
return parts[0];
}
return null;
}
Benefits of RLS
- Simpler operations: Single schema, no per-tenant schema migrations
- No schema sprawl: 100 tenants = same number of tables (not 100x)
- Simpler connection pooling: Shared pool for all tenants (no search_path switching)
- Database-enforced isolation: Even buggy application code cannot leak data
- Easier migrations: ALTER TABLE once, applies to all tenants
- Matches BRD data models: 135+
tenant_idFK occurrences already in BRD v19.0
Trade-offs
- Less physical isolation than schema-per-tenant (mitigated by RLS enforcement)
- All tenants share the same table structure (schema flexibility limited)
- RLS policies must be applied to every tenant-scoped table (automated via migration scripts)
- Per-tenant backup requires
WHERE tenant_id = Xexports instead ofpg_dump -n schema
Reference: See Chapter 04, Section L.10A.4 for the full multi-tenancy decision analysis, comparison matrix, and TypeScript middleware implementation details.
6.4 Connection Pooling Strategy
PgBouncer Configuration
; /etc/pgbouncer/pgbouncer.ini
[databases]
; Route all connections through pooler
pos_platform = host=postgres16 port=5432 dbname=pos_platform
[pgbouncer]
; Pool mode: transaction (best for multi-tenant with RLS)
pool_mode = transaction
; Pooler ports
listen_addr = 0.0.0.0
listen_port = 6432
; Authentication
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; Pool sizing
; With RLS, all tenants share the same pool (no per-schema pools needed)
default_pool_size = 20
max_client_conn = 1000
min_pool_size = 5
; Reserve connections for admin
reserve_pool_size = 5
reserve_pool_timeout = 3
; Connection limits
max_db_connections = 100
max_user_connections = 100
; Timeouts
server_connect_timeout = 5
server_idle_timeout = 60
server_lifetime = 3600
query_timeout = 30
; Logging
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
stats_period = 60
Pool Mode Comparison
| Mode | Behavior | Use Case |
|---|---|---|
| Session | Connection per session | Long-running sessions |
| Transaction | Connection per transaction | Multi-tenant APIs |
| Statement | Connection per statement | Read replicas only |
Recommendation: Use transaction mode for the POS API to maximize connection reuse across tenants. With RLS, the tenant context is set via SET app.current_tenant at the start of each transaction, so connection reuse is safe.
Docker Compose Integration
# docker-compose.yml
services:
pgbouncer:
image: bitnami/pgbouncer:latest
container_name: pos-pgbouncer
environment:
- PGBOUNCER_DATABASE=pos_platform
- PGBOUNCER_PORT=6432
- PGBOUNCER_POOL_MODE=transaction
- PGBOUNCER_MAX_CLIENT_CONN=1000
- PGBOUNCER_DEFAULT_POOL_SIZE=20
- POSTGRESQL_HOST=postgres16
- POSTGRESQL_PORT=5432
- POSTGRESQL_USERNAME=pos_app
- POSTGRESQL_PASSWORD=${DB_PASSWORD}
ports:
- "6432:6432"
networks:
- postgres_default
depends_on:
- postgres16
healthcheck:
test: ["CMD", "pg_isready", "-h", "localhost", "-p", "6432"]
interval: 10s
timeout: 5s
retries: 5
networks:
postgres_default:
external: true
6.5 Backup and Restore
Backup Strategy Overview
| Backup Type | Frequency | Retention | Purpose |
|---|---|---|---|
| Full Database | Daily | 30 days | Disaster recovery |
| Tenant Data Export | On-demand | 90 days | Tenant migration, recovery, compliance |
| WAL Archives | Continuous | 7 days | Point-in-time recovery |
With RLS architecture, all tenant data resides in the same database and schema. Full database backups capture everything. Tenant-specific exports use WHERE tenant_id = X to extract individual tenant data.
Full Database Backup
#!/bin/bash
# /volume1/docker/scripts/backup-pos-full.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/volume1/backup/pos_platform"
CONTAINER="postgres16"
DB_NAME="pos_platform"
# Create backup directory
mkdir -p "$BACKUP_DIR/full"
# Full database dump with compression
docker exec $CONTAINER pg_dump \
-U postgres \
-d $DB_NAME \
-Fc \
-Z 9 \
-f /tmp/pos_platform_${DATE}.dump
# Copy to backup location
docker cp $CONTAINER:/tmp/pos_platform_${DATE}.dump \
"$BACKUP_DIR/full/"
# Cleanup container temp file
docker exec $CONTAINER rm /tmp/pos_platform_${DATE}.dump
# Remove backups older than 30 days
find "$BACKUP_DIR/full" -name "*.dump" -mtime +30 -delete
echo "Full backup completed: pos_platform_${DATE}.dump"
Tenant-Specific Data Export
With RLS, per-tenant backup is a data export using SQL queries filtered by tenant_id:
#!/bin/bash
# /volume1/docker/scripts/export-tenant.sh
# Usage: ./export-tenant.sh <tenant-uuid>
TENANT_ID=$1
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/volume1/backup/pos_platform/tenants"
CONTAINER="postgres16"
DB_NAME="pos_platform"
if [ -z "$TENANT_ID" ]; then
echo "Usage: $0 <tenant-uuid>"
exit 1
fi
# Create backup directory
mkdir -p "$BACKUP_DIR/$TENANT_ID"
# Export tenant data using COPY with WHERE clause
# This exports each tenant-scoped table filtered by tenant_id
docker exec $CONTAINER psql -U postgres -d $DB_NAME -c "
-- Export products for this tenant
COPY (SELECT * FROM products WHERE tenant_id = '$TENANT_ID')
TO '/tmp/tenant_products.csv' WITH CSV HEADER;
-- Export customers for this tenant
COPY (SELECT * FROM customers WHERE tenant_id = '$TENANT_ID')
TO '/tmp/tenant_customers.csv' WITH CSV HEADER;
-- Export orders for this tenant
COPY (SELECT * FROM orders WHERE tenant_id = '$TENANT_ID')
TO '/tmp/tenant_orders.csv' WITH CSV HEADER;
-- ... repeat for all tenant-scoped tables
"
# Package into tar archive
docker exec $CONTAINER tar czf /tmp/tenant_${TENANT_ID}_${DATE}.tar.gz \
/tmp/tenant_*.csv
# Copy to backup location
docker cp $CONTAINER:/tmp/tenant_${TENANT_ID}_${DATE}.tar.gz \
"$BACKUP_DIR/$TENANT_ID/"
# Cleanup
docker exec $CONTAINER rm /tmp/tenant_*.csv /tmp/tenant_${TENANT_ID}_${DATE}.tar.gz
echo "Tenant export completed: tenant_${TENANT_ID}_${DATE}.tar.gz"
Tenant Data Restore
-- Restore tenant data from export
-- Step 1: Delete existing tenant data (if re-importing)
BEGIN;
DELETE FROM order_items WHERE order_id IN (
SELECT id FROM orders WHERE tenant_id = 'target-tenant-uuid'
);
DELETE FROM orders WHERE tenant_id = 'target-tenant-uuid';
DELETE FROM inventory_levels WHERE tenant_id = 'target-tenant-uuid';
DELETE FROM variants WHERE product_id IN (
SELECT id FROM products WHERE tenant_id = 'target-tenant-uuid'
);
DELETE FROM products WHERE tenant_id = 'target-tenant-uuid';
DELETE FROM customers WHERE tenant_id = 'target-tenant-uuid';
-- ... repeat for all tenant-scoped tables in dependency order
-- Step 2: Import from CSV
COPY products FROM '/tmp/tenant_products.csv' WITH CSV HEADER;
COPY customers FROM '/tmp/tenant_customers.csv' WITH CSV HEADER;
COPY orders FROM '/tmp/tenant_orders.csv' WITH CSV HEADER;
-- ... repeat for all tables
COMMIT;
Tenant Migration (Between Databases)
-- Export tenant to SQL for migration to a different server
-- Uses pg_dump with row-level filter via a view
-- Step 1: Create temporary view filtered by tenant
CREATE TEMP VIEW export_products AS
SELECT * FROM products WHERE tenant_id = 'source-tenant-uuid';
-- Step 2: Use COPY to export
COPY (SELECT * FROM export_products) TO '/tmp/migration_products.csv' WITH CSV HEADER;
-- Step 3: On target server, COPY FROM with updated tenant_id if needed
-- Step 4: Update shared.tenants registry on target
6.6 Performance Considerations
RLS Performance
RLS policy overhead is minimal. PostgreSQL evaluates the USING clause as part of the query plan, effectively adding a WHERE tenant_id = X filter. With proper indexing, this has negligible impact:
-- Composite indexes with tenant_id as leading column
-- These are critical for RLS performance
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE INDEX idx_products_tenant_sku ON products(tenant_id, sku);
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at);
CREATE INDEX idx_inventory_tenant_location ON inventory_levels(tenant_id, location_id);
CREATE INDEX idx_customers_tenant_email ON customers(tenant_id, email);
-- The query planner uses these indexes to efficiently filter by tenant
-- before applying any additional WHERE clauses
RLS Performance Benchmarks (expected):
| Scenario | Without RLS | With RLS | Overhead |
|---|---|---|---|
| Simple SELECT | 0.5ms | 0.6ms | ~20% |
| JOIN 3 tables | 2.1ms | 2.3ms | ~10% |
| Aggregate query | 5.0ms | 5.2ms | ~4% |
The overhead decreases proportionally as query complexity increases, since the tenant_id filter is a simple equality check on an indexed column.
Table Partitioning Strategy
For high-volume time-series tables, use declarative partitioning:
-- Partition inventory_transactions by month
CREATE TABLE inventory_transactions (
id BIGSERIAL,
tenant_id UUID NOT NULL,
variant_id INT NOT NULL,
location_id INT NOT NULL,
transaction_type VARCHAR(20) NOT NULL,
quantity_change INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
-- ... other columns
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- Create monthly partitions
CREATE TABLE inventory_transactions_2025_01
PARTITION OF inventory_transactions
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE inventory_transactions_2025_02
PARTITION OF inventory_transactions
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- RLS policies apply to the parent table and are inherited by partitions
ALTER TABLE inventory_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory_transactions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON inventory_transactions
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Automate partition creation
CREATE OR REPLACE FUNCTION create_monthly_partitions()
RETURNS VOID AS $$
DECLARE
next_month DATE;
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
-- Create partitions for next 3 months
FOR i IN 0..2 LOOP
next_month := date_trunc('month', CURRENT_DATE + (i || ' months')::interval);
start_date := next_month;
end_date := next_month + '1 month'::interval;
partition_name := 'inventory_transactions_' || to_char(next_month, 'YYYY_MM');
-- Check if partition exists
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = 'public'
AND c.relname = partition_name
) THEN
EXECUTE format(
'CREATE TABLE %I PARTITION OF inventory_transactions
FOR VALUES FROM (%L) TO (%L)',
partition_name, start_date, end_date
);
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
Connection Pooling Metrics
Monitor these key metrics in PgBouncer:
-- Connect to PgBouncer admin console
-- psql -h localhost -p 6432 -U postgres pgbouncer
-- Show pool status
SHOW POOLS;
-- Key metrics to monitor:
-- cl_active: Active client connections
-- cl_waiting: Clients waiting for connection
-- sv_active: Active server connections
-- sv_idle: Idle server connections
-- sv_used: Server connections in use
-- Show statistics
SHOW STATS;
-- Alert thresholds:
-- cl_waiting > 10: Pool exhaustion risk
-- sv_active / default_pool_size > 0.8: Near capacity
Query Timeout Configuration
-- Set statement timeout to prevent long-running queries
SET statement_timeout = '30s';
-- For specific operations, extend timeout
SET LOCAL statement_timeout = '5m';
-- Connection-level setting in PgBouncer
; query_timeout = 30
-- PostgreSQL server-level (postgresql.conf)
statement_timeout = 30000 -- 30 seconds
lock_timeout = 10000 -- 10 seconds
idle_in_transaction_session_timeout = 60000 -- 1 minute
Memory Configuration
# postgresql.conf optimizations for multi-tenant RLS
# Shared memory (25% of RAM)
shared_buffers = 4GB
# Work memory per query (be conservative with many concurrent tenants)
work_mem = 64MB
# Maintenance operations
maintenance_work_mem = 512MB
# Effective cache size (75% of RAM)
effective_cache_size = 12GB
# Connection limits
max_connections = 200 # Higher limit, pooler handles distribution
# WAL settings
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 2GB
min_wal_size = 512MB
# Query planning
random_page_cost = 1.1 # SSD storage
effective_io_concurrency = 200 # SSD storage
6.7 High Availability Considerations
Replication Setup (Future)
# docker-compose-ha.yml (for future HA deployment)
services:
postgres-primary:
image: postgres:16-alpine
environment:
- POSTGRES_REPLICATION_MODE=master
- POSTGRES_REPLICATION_USER=replicator
- POSTGRES_REPLICATION_PASSWORD=${REPL_PASSWORD}
volumes:
- postgres-primary-data:/var/lib/postgresql/data
postgres-replica:
image: postgres:16-alpine
environment:
- POSTGRES_REPLICATION_MODE=slave
- POSTGRES_MASTER_HOST=postgres-primary
- POSTGRES_REPLICATION_USER=replicator
- POSTGRES_REPLICATION_PASSWORD=${REPL_PASSWORD}
volumes:
- postgres-replica-data:/var/lib/postgresql/data
depends_on:
- postgres-primary
Read Replica Routing
# PgBouncer configuration for read replicas
[databases]
pos_platform = host=postgres-primary port=5432 dbname=pos_platform
pos_platform_ro = host=postgres-replica port=5432 dbname=pos_platform
Application routing:
// db/tenantPrisma.ts
import { PrismaClient } from '@prisma/client';
const globalPrisma = new PrismaClient({
datasources: {
db: { url: process.env.DATABASE_URL }
},
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error']
});
/**
* Creates a tenant-scoped Prisma client extension.
* Every query automatically sets the RLS tenant context.
*/
export function createTenantClient(tenantId: string) {
return globalPrisma.$extends({
query: {
async $allOperations({ args, query }) {
// Set RLS context before every query
await globalPrisma.$executeRawUnsafe(
`SET app.current_tenant = '${tenantId}'`
);
return query(args);
}
}
});
}
// Connection pool managed by Prisma + PgBouncer
// PgBouncer config in pgbouncer.ini (see Section 6.4)
6.8 Monitoring and Alerting
Key Database Metrics
-- Database size per tenant (RLS approach)
SELECT
tenant_id,
t.name AS tenant_name,
pg_size_pretty(SUM(pg_column_size(p.*))) AS products_size
FROM products p
JOIN shared.tenants t ON t.id = p.tenant_id
GROUP BY tenant_id, t.name
ORDER BY SUM(pg_column_size(p.*)) DESC;
-- Approximate tenant data size across all tables
SELECT
t.name AS tenant_name,
COUNT(DISTINCT p.id) AS product_count,
COUNT(DISTINCT o.id) AS order_count,
COUNT(DISTINCT c.id) AS customer_count
FROM shared.tenants t
LEFT JOIN products p ON p.tenant_id = t.id
LEFT JOIN orders o ON o.tenant_id = t.id
LEFT JOIN customers c ON c.tenant_id = t.id
GROUP BY t.name
ORDER BY COUNT(DISTINCT o.id) DESC;
-- Active connections (all tenants share the same pool)
SELECT
COUNT(*) AS total_connections,
COUNT(*) FILTER (WHERE state = 'active') AS active,
COUNT(*) FILTER (WHERE state = 'idle') AS idle,
COUNT(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity
WHERE datname = 'pos_platform';
-- Table bloat check
SELECT
schemaname || '.' || relname AS table_name,
pg_size_pretty(pg_relation_size(schemaname || '.' || relname)) AS size,
n_dead_tup AS dead_tuples,
last_autovacuum
FROM pg_stat_user_tables
WHERE schemaname = 'public'
AND n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
-- Slow queries (requires pg_stat_statements)
SELECT
query,
calls,
mean_exec_time::numeric(10,2) AS avg_ms,
total_exec_time::numeric(10,2) AS total_ms
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
Alerting Thresholds
| Metric | Warning | Critical | Action |
|---|---|---|---|
| Connection usage | 70% | 90% | Scale pool, investigate |
| Disk usage | 70% | 85% | Cleanup, expand storage |
| Replication lag | 10s | 60s | Check network, replica health |
| Long-running queries | 30s | 60s | Investigate, possibly kill |
| Dead tuples | 1M | 5M | Force vacuum |
| Cache hit ratio | <95% | <90% | Increase shared_buffers |
6.9 Security Configuration
Role-Based Access
-- Create application role (minimal privileges)
CREATE ROLE pos_app WITH LOGIN PASSWORD 'secure_password';
GRANT CONNECT ON DATABASE pos_platform TO pos_app;
GRANT USAGE ON SCHEMA shared TO pos_app;
GRANT USAGE ON SCHEMA public TO pos_app;
-- Grant table access in public schema (RLS enforces tenant isolation)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO pos_app;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO pos_app;
-- Default privileges for future tables
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pos_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON SEQUENCES TO pos_app;
-- RLS enforces that pos_app can only see rows for the current tenant
-- This is defense-in-depth: even with full table access, RLS filters rows
-- Create admin role (elevated privileges, bypasses RLS)
CREATE ROLE pos_admin WITH LOGIN PASSWORD 'admin_password' BYPASSRLS;
GRANT ALL PRIVILEGES ON DATABASE pos_platform TO pos_admin;
-- Create read-only role (for reporting, respects RLS)
CREATE ROLE pos_readonly WITH LOGIN PASSWORD 'readonly_password';
GRANT CONNECT ON DATABASE pos_platform TO pos_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA shared TO pos_readonly;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO pos_readonly;
-- RLS policies still apply — readonly user must SET app.current_tenant
RLS Security Notes
-- FORCE ROW LEVEL SECURITY ensures RLS applies even to table owners
-- Without FORCE, the table owner bypasses RLS
ALTER TABLE products FORCE ROW LEVEL SECURITY;
-- The pos_admin role has BYPASSRLS for administrative operations
-- (tenant migration, cross-tenant reporting, data cleanup)
-- This role should ONLY be used for administrative tasks, never by the API
-- Verify RLS is enabled on all tenant-scoped tables
SELECT
schemaname,
tablename,
rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
SSL/TLS Configuration
# postgresql.conf
ssl = on
ssl_cert_file = '/var/lib/postgresql/server.crt'
ssl_key_file = '/var/lib/postgresql/server.key'
ssl_ca_file = '/var/lib/postgresql/root.crt'
ssl_min_protocol_version = 'TLSv1.2'
# Require SSL for external connections
# pg_hba.conf
hostssl pos_platform pos_app 0.0.0.0/0 scram-sha-256
6.10 Quick Reference
Connection Strings
# Direct PostgreSQL connection
postgres://pos_app:password@192.168.1.26:5433/pos_platform
# Through PgBouncer (recommended)
postgres://pos_app:password@192.168.1.26:6432/pos_platform
# Application environment variables
DATABASE_URL=postgres://pos_app:password@pgbouncer:6432/pos_platform
DATABASE_URL_READONLY=postgres://pos_readonly:password@pgbouncer:6432/pos_platform_ro
Common Operations
# Connect to database
docker exec -it postgres16 psql -U postgres -d pos_platform
# List all tenants
docker exec postgres16 psql -U postgres -d pos_platform -c \
"SELECT id, name, slug, status FROM shared.tenants;"
# Check row counts per tenant for a table
docker exec postgres16 psql -U postgres -d pos_platform -c \
"SELECT tenant_id, COUNT(*) FROM products GROUP BY tenant_id;"
# Verify RLS is enabled
docker exec postgres16 psql -U postgres -d pos_platform -c \
"SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public';"
# Export specific tenant data
./export-tenant.sh <tenant-uuid>
# Vacuum full (maintenance window only)
docker exec postgres16 psql -U postgres -d pos_platform -c "VACUUM FULL ANALYZE products;"
RLS Quick Setup for New Tables
-- Template: Enable RLS on a new tenant-scoped table
ALTER TABLE <table_name> ENABLE ROW LEVEL SECURITY;
ALTER TABLE <table_name> FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON <table_name>
USING (tenant_id = current_setting('app.current_tenant')::uuid);
CREATE POLICY tenant_insert ON <table_name>
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
CREATE INDEX idx_<table_name>_tenant ON <table_name>(tenant_id);
Next Chapter: Chapter 07: Schema Design - Detailed schema structure with 69 tables across 16 domains.
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | III - Database |
| Chapter | 06 of 9 |
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 07: Schema Design
69 Tables Across 16 Domains
7.1 Overview
The POS Platform database consists of 69 tables organized into 16 functional domains. This chapter provides the complete schema design using a single-database, single-schema architecture with Row-Level Security (RLS) for tenant isolation.
All tenant-scoped tables include a tenant_id UUID NOT NULL column. PostgreSQL RLS policies automatically filter rows per tenant, ensuring data isolation at the database level.
Domain Summary
| Domain | Tenant-Scoped | Tables | Purpose |
|---|---|---|---|
| 1-2. Catalog (Products, Categories, Tags, Attributes, Pricing) | Yes | 13 | Product catalog with SKU/variant model, categories, tags, attributes, pricing rules |
| 3. Inventory & Locations | Yes | 7 | Multi-location inventory tracking, purchase orders, transfers |
| 4. Sales (Orders & Customers) | Yes | 3 | Transactions and customer profiles |
| 5. Customer Loyalty & Gift Cards | Yes | 5 | Loyalty tiers, points, gift card management, store credits |
| 6-7. Returns & Reporting | Yes | 3 | Return processing and saved report configs |
| 8. User Preferences | Yes | 1 | Per-user view settings |
| 9. Tenant Management | No (shared) | 6 | Platform tenant registry, users, sessions |
| 10. Authentication & Authorization | Yes | 4 | Roles, permissions, tenant-user mapping, settings |
| 11. Offline Sync Infrastructure | Yes | 3 | Device sync and checkpoints |
| 12. Event Infrastructure | Yes | 2 | Transactional outbox and state machines |
| 13. Cash Drawer Operations | Yes | 6 | Shift and cash management |
| 14. Payment Processing | Yes | 4 | Terminals and settlements |
| 15. Tax Configuration | Yes | 3 | Compound tax jurisdictions (State/County/City) |
| 16. RFID Module (Optional) | Yes | 9 | Tag printing, scanning, counting subsystem |
| TOTAL | 69 |
7.2 Schema Architecture
Visual Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ pos_platform │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ shared SCHEMA (6 tables) │ │
│ │ Platform-wide, NO tenant_id, NO RLS │ │
│ │ │ │
│ │ ┌─────────────┐ ┌───────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ tenants │ │tenant_subscriptions│ │ tenant_modules │ │ │
│ │ │ (registry) │ │ (billing) │ │ (feature add-ons) │ │ │
│ │ └─────────────┘ └───────────────────┘ └─────────────────────────┘ │ │
│ │ ┌─────────────────┐ ┌─────────────────────┐ ┌───────────────────┐ │ │
│ │ │ users │ │ user_sessions │ │ password_resets │ │ │
│ │ │ (platform auth) │ │ (session tracking) │ │ (recovery) │ │ │
│ │ └─────────────────┘ └─────────────────────┘ └───────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ All tenant-scoped tables reference │
│ shared.tenants(id) via tenant_id FK │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ public SCHEMA (63 tables, all with tenant_id + RLS) │ │
│ │ │ │
│ │ Domain 1-2: Catalog (13) Domain 3: Inventory (7) │ │
│ │ ┌────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ products [T] │ │ locations [T] │ │ │
│ │ │ variants [T] │ │ inventory_levels [T] │ │ │
│ │ │ brands [T] │ │ inventory_trans [T] │ │ │
│ │ │ categories [T] │ │ purchase_orders [T] │ │ │
│ │ │ collections [T] │ │ purchase_order_ [T] │ │ │
│ │ │ tags [T] │ │ items │ │ │
│ │ │ product_coll [T] │ │ transfer_orders [T] │ │ │
│ │ │ product_tag [T] │ │ transfer_order_ [T] │ │ │
│ │ │ product_groups[T] │ │ items │ │ │
│ │ │ genders [T] │ └─────────────────────────┘ │ │
│ │ │ origins [T] │ │ │
│ │ │ fabrics [T] │ Domain 4: Sales (3) │ │
│ │ │ pricing_rules [T] │ ┌─────────────────────────┐ │ │
│ │ └────────────────────┘ │ customers [T] │ │ │
│ │ │ orders [T] │ │ │
│ │ Domain 10: Auth (4) │ order_items [T] │ │ │
│ │ ┌────────────────────┐ └─────────────────────────┘ │ │
│ │ │ roles [T] │ │ │
│ │ │ role_perms [T] │ Domain 5: Loyalty (5) │ │
│ │ │ tenant_users [T] │ ┌─────────────────────────┐ │ │
│ │ │ tenant_settings[T] │ │ loyalty_accounts [T] │ │ │
│ │ └────────────────────┘ │ loyalty_trans [T] │ │ │
│ │ │ gift_cards [T] │ │ │
│ │ Domain 8: Prefs (1) │ gift_card_trans [T] │ │ │
│ │ ┌────────────────────┐ │ store_credits [T] │ │ │
│ │ │ item_view_ [T] │ └─────────────────────────┘ │ │
│ │ │ settings │ │ │
│ │ └────────────────────┘ Domain 6-7: Returns (3) │ │
│ │ ┌─────────────────────────┐ │ │
│ │ Domain 12: Events (2) │ returns [T] │ │ │
│ │ ┌────────────────────┐ │ return_items [T] │ │ │
│ │ │ event_outbox [T] │ │ reports [T] │ │ │
│ │ │ state_trans [T] │ └─────────────────────────┘ │ │
│ │ └────────────────────┘ │ │
│ │ Domain 11: Sync (3) │ │
│ │ Domain 13: Cash (6) ┌─────────────────────────┐ │ │
│ │ ┌────────────────────┐ │ devices [T] │ │ │
│ │ │ shifts [T] │ │ sync_queue [T] │ │ │
│ │ │ cash_drawers [T] │ │ sync_checkpoints [T] │ │ │
│ │ │ cash_counts [T] │ └─────────────────────────┘ │ │
│ │ │ cash_movements[T] │ │ │
│ │ │ cash_drops [T] │ Domain 14: Payment (4) │ │
│ │ │ cash_pickups [T] │ ┌─────────────────────────┐ │ │
│ │ └────────────────────┘ │ payment_terminals [T] │ │ │
│ │ │ payment_attempts [T] │ │ │
│ │ Domain 16: RFID (9) │ payment_batches [T] │ │ │
│ │ ┌────────────────────┐ │ payment_recon [T] │ │ │
│ │ │ rfid_config [T] │ └─────────────────────────┘ │ │
│ │ │ rfid_printers [T] │ │ │
│ │ │ rfid_templates[T] │ Domain 15: Tax (3) │ │
│ │ │ rfid_print_ [T] │ ┌─────────────────────────┐ │ │
│ │ │ jobs │ │ tax_jurisdictions [T] │ │ │
│ │ │ rfid_tags [T] │ │ tax_rates [T] │ │ │
│ │ │ rfid_sessions [T] │ │ location_tax_juris [T] │ │ │
│ │ │ rfid_events [T] │ └─────────────────────────┘ │ │
│ │ │ rfid_mappings [T] │ │ │
│ │ │ session_ops [T] │ [T] = has tenant_id column + RLS policy │ │
│ │ └────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
7.3 Complete CREATE TABLE Statements
All tables reside in a single database. The shared schema holds platform-wide tables (no tenant_id). The public schema holds all tenant-scoped tables with tenant_id UUID NOT NULL and RLS policies.
Enable Required Extensions
-- Run once on database creation
\c pos_platform
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Create shared schema
CREATE SCHEMA IF NOT EXISTS shared;
Shared Schema Tables (6 tables — no tenant_id)
Table: tenants
-- Tenant registry for multi-tenant SaaS architecture
CREATE TABLE shared.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
tier VARCHAR(20) NOT NULL DEFAULT 'standard',
contact_email VARCHAR(255) NOT NULL,
contact_phone VARCHAR(20),
billing_email VARCHAR(255),
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC',
currency_code CHAR(3) NOT NULL DEFAULT 'USD',
locale VARCHAR(10) NOT NULL DEFAULT 'en-US',
trial_ends_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT tenants_slug_unique UNIQUE (slug),
CONSTRAINT tenants_status_check CHECK (status IN ('provisioning', 'active', 'suspended', 'cancelled', 'trial')),
CONSTRAINT tenants_tier_check CHECK (tier IN ('free', 'starter', 'standard', 'enterprise'))
);
-- Indexes
CREATE INDEX idx_tenants_status ON shared.tenants(status);
CREATE INDEX idx_tenants_tier ON shared.tenants(tier);
CREATE INDEX idx_tenants_trial ON shared.tenants(trial_ends_at) WHERE trial_ends_at IS NOT NULL;
COMMENT ON TABLE shared.tenants IS 'Tenant/organization registry for multi-tenant SaaS architecture';
COMMENT ON COLUMN shared.tenants.slug IS 'URL-safe identifier used for subdomain routing';
COMMENT ON COLUMN shared.tenants.tier IS 'Subscription tier determining feature access';
Table: tenant_subscriptions
-- Billing and subscription plan tracking
CREATE TABLE shared.tenant_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
plan_id VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
billing_cycle VARCHAR(20) NOT NULL,
price_cents INT NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'USD',
location_limit INT NOT NULL DEFAULT 5,
user_limit INT NOT NULL DEFAULT 10,
device_limit INT NOT NULL DEFAULT 20,
external_subscription_id VARCHAR(255),
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT subscriptions_status_check CHECK (status IN ('active', 'past_due', 'cancelled', 'paused')),
CONSTRAINT subscriptions_cycle_check CHECK (billing_cycle IN ('monthly', 'annual'))
);
-- Indexes
CREATE INDEX idx_tenant_subscriptions_tenant ON shared.tenant_subscriptions(tenant_id);
CREATE INDEX idx_tenant_subscriptions_status ON shared.tenant_subscriptions(status);
CREATE INDEX idx_tenant_subscriptions_period ON shared.tenant_subscriptions(current_period_end);
CREATE INDEX idx_tenant_subscriptions_external ON shared.tenant_subscriptions(external_subscription_id)
WHERE external_subscription_id IS NOT NULL;
COMMENT ON TABLE shared.tenant_subscriptions IS 'Billing and subscription plan tracking for each tenant';
COMMENT ON COLUMN shared.tenant_subscriptions.external_subscription_id IS 'Stripe/PayPal subscription ID for payment integration';
Table: tenant_modules
-- Optional module subscriptions (RFID, promotions, gift cards, etc.)
CREATE TABLE shared.tenant_modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE,
module_code VARCHAR(50) NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
monthly_fee_cents INT,
trial_days_remaining INT,
configuration JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT tenant_modules_unique UNIQUE (tenant_id, module_code),
CONSTRAINT tenant_modules_code_check CHECK (module_code IN (
'rfid', 'promotions', 'gift_cards', 'scheduling',
'loyalty_advanced', 'analytics', 'ecommerce', 'b2b'
))
);
-- Indexes
CREATE INDEX idx_tenant_modules_code ON shared.tenant_modules(module_code) WHERE is_enabled = TRUE;
CREATE INDEX idx_tenant_modules_expiring ON shared.tenant_modules(expires_at)
WHERE expires_at IS NOT NULL;
COMMENT ON TABLE shared.tenant_modules IS 'Optional add-on modules subscribed by each tenant';
COMMENT ON COLUMN shared.tenant_modules.module_code IS 'Identifier for the module (rfid, promotions, etc.)';
COMMENT ON COLUMN shared.tenant_modules.configuration IS 'Module-specific settings in JSON format';
Table: users
-- Platform-wide user accounts (can belong to multiple tenants)
CREATE TABLE shared.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
phone VARCHAR(20),
avatar_url VARCHAR(500),
is_platform_admin BOOLEAN DEFAULT FALSE,
email_verified BOOLEAN DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
last_login_at TIMESTAMPTZ,
failed_login_count INT DEFAULT 0,
locked_until TIMESTAMPTZ,
mfa_enabled BOOLEAN DEFAULT FALSE,
mfa_secret VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_email_format CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
-- Indexes
CREATE INDEX idx_users_name ON shared.users(last_name, first_name);
CREATE INDEX idx_users_locked ON shared.users(locked_until) WHERE locked_until IS NOT NULL;
CREATE INDEX idx_users_login ON shared.users(last_login_at);
COMMENT ON TABLE shared.users IS 'Platform-wide user accounts supporting multi-tenant membership';
COMMENT ON COLUMN shared.users.password_hash IS 'Argon2id password hash (memory-hard algorithm)';
COMMENT ON COLUMN shared.users.mfa_secret IS 'Encrypted TOTP secret for 2FA';
Table: user_sessions
-- Active user sessions across all tenants
CREATE TABLE shared.user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES shared.tenants(id) ON DELETE CASCADE,
session_token VARCHAR(255) NOT NULL,
refresh_token VARCHAR(255),
device_id UUID,
ip_address INET NOT NULL,
user_agent VARCHAR(500),
device_type VARCHAR(20) NOT NULL DEFAULT 'web',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
last_activity_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT sessions_token_unique UNIQUE (session_token),
CONSTRAINT sessions_refresh_unique UNIQUE (refresh_token),
CONSTRAINT sessions_device_type_check CHECK (device_type IN ('web', 'mobile', 'pos_terminal', 'api'))
);
-- Indexes
CREATE INDEX idx_sessions_user ON shared.user_sessions(user_id);
CREATE INDEX idx_sessions_tenant ON shared.user_sessions(tenant_id) WHERE tenant_id IS NOT NULL;
CREATE INDEX idx_sessions_expiry ON shared.user_sessions(expires_at) WHERE is_active = TRUE;
CREATE INDEX idx_sessions_device ON shared.user_sessions(device_id) WHERE device_id IS NOT NULL;
CREATE INDEX idx_sessions_activity ON shared.user_sessions(last_activity_at);
COMMENT ON TABLE shared.user_sessions IS 'Active session tracking with multi-tenant context';
COMMENT ON COLUMN shared.user_sessions.tenant_id IS 'Current tenant context (NULL for platform-level sessions)';
Table: password_resets
-- Password reset token management
CREATE TABLE shared.password_resets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
ip_address INET NOT NULL,
-- Constraints
CONSTRAINT password_resets_token_unique UNIQUE (token_hash)
);
-- Indexes
CREATE INDEX idx_password_resets_user ON shared.password_resets(user_id);
CREATE INDEX idx_password_resets_expiry ON shared.password_resets(expires_at) WHERE used_at IS NULL;
COMMENT ON TABLE shared.password_resets IS 'Password reset token management with expiration';
COMMENT ON COLUMN shared.password_resets.token_hash IS 'SHA-256 hash of reset token (token sent to user)';
Public Schema Tables (58 tables — all with tenant_id + RLS)
The public schema contains all tenant-scoped tables. Every table includes:
tenant_id UUID NOT NULL— referencesshared.tenants(id)id UUID PRIMARY KEY DEFAULT gen_random_uuid()— UUID primary keys- A composite index with
tenant_idas the leading column - RLS policies applied (see Section 7.4)
Below are representative examples. For complete CREATE TABLE statements for all 64 tables, see Chapter 08 (Entity Specifications).
Example: products
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
sku VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
product_group_id UUID REFERENCES product_groups(id) ON DELETE SET NULL,
gender_id UUID REFERENCES genders(id) ON DELETE SET NULL,
origin_id UUID REFERENCES origins(id) ON DELETE SET NULL,
fabric_id UUID REFERENCES fabrics(id) ON DELETE SET NULL,
base_price DECIMAL(19,4) NOT NULL,
cost_price DECIMAL(19,4) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
has_variants BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT products_price_positive CHECK (base_price >= 0),
CONSTRAINT products_cost_positive CHECK (cost_price >= 0)
);
-- Tenant isolation index (CRITICAL for RLS performance)
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE UNIQUE INDEX idx_products_tenant_sku ON products(tenant_id, sku) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_tenant_brand ON products(tenant_id, brand_id);
CREATE INDEX idx_products_tenant_active ON products(tenant_id, is_active)
WHERE is_active = TRUE AND deleted_at IS NULL;
COMMENT ON TABLE products IS 'Product catalog — tenant-scoped with RLS';
Example: variants
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
sku VARCHAR(50) NOT NULL,
size VARCHAR(20),
color VARCHAR(50),
price_adjustment DECIMAL(19,4) DEFAULT 0.00,
weight DECIMAL(10,3),
barcode VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tenant isolation indexes
CREATE INDEX idx_variants_tenant ON variants(tenant_id);
CREATE UNIQUE INDEX idx_variants_tenant_sku ON variants(tenant_id, sku) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_variants_tenant_barcode ON variants(tenant_id, barcode)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_variants_tenant_product ON variants(tenant_id, product_id);
Example: orders
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
order_number VARCHAR(50) NOT NULL,
customer_id UUID REFERENCES customers(id),
location_id UUID NOT NULL REFERENCES locations(id),
employee_id UUID NOT NULL REFERENCES shared.users(id),
status VARCHAR(20) NOT NULL DEFAULT 'open',
subtotal DECIMAL(19,4) NOT NULL DEFAULT 0,
tax_total DECIMAL(19,4) NOT NULL DEFAULT 0,
discount_total DECIMAL(19,4) NOT NULL DEFAULT 0,
grand_total DECIMAL(19,4) NOT NULL DEFAULT 0,
payment_status VARCHAR(20) NOT NULL DEFAULT 'unpaid',
notes TEXT,
voided_at TIMESTAMPTZ,
voided_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT orders_status_check CHECK (status IN ('pending', 'completed', 'refunded', 'voided', 'on_hold')),
CONSTRAINT orders_payment_status_check CHECK (payment_status IN ('unpaid', 'partial', 'paid', 'refunded')),
CONSTRAINT orders_amounts_positive CHECK (subtotal >= 0 AND tax_total >= 0 AND discount_total >= 0 AND grand_total >= 0)
);
-- Tenant isolation indexes
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE UNIQUE INDEX idx_orders_tenant_number ON orders(tenant_id, order_number);
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_customer ON orders(tenant_id, customer_id);
CREATE INDEX idx_orders_tenant_location ON orders(tenant_id, location_id);
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status);
Example: customers
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(255),
phone VARCHAR(20),
loyalty_points INT DEFAULT 0,
total_spent DECIMAL(19,4) DEFAULT 0,
visit_count INT DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT customers_points_positive CHECK (loyalty_points >= 0),
CONSTRAINT customers_spent_positive CHECK (total_spent >= 0)
);
-- Tenant isolation indexes
CREATE INDEX idx_customers_tenant ON customers(tenant_id);
CREATE UNIQUE INDEX idx_customers_tenant_email ON customers(tenant_id, email)
WHERE email IS NOT NULL;
CREATE INDEX idx_customers_tenant_name ON customers(tenant_id, last_name, first_name);
CREATE INDEX idx_customers_tenant_phone ON customers(tenant_id, phone)
WHERE phone IS NOT NULL;
Example: locations
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
code VARCHAR(20) NOT NULL,
type VARCHAR(20) NOT NULL,
address VARCHAR(255),
city VARCHAR(100),
state VARCHAR(50),
postal_code VARCHAR(20),
country CHAR(2) DEFAULT 'US',
phone VARCHAR(20),
is_active BOOLEAN DEFAULT TRUE,
timezone VARCHAR(50) DEFAULT 'UTC',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT locations_type_check CHECK (type IN ('store', 'warehouse', 'online', 'popup'))
);
-- Tenant isolation indexes
CREATE INDEX idx_locations_tenant ON locations(tenant_id);
CREATE UNIQUE INDEX idx_locations_tenant_code ON locations(tenant_id, code);
Example: inventory_levels
CREATE TABLE inventory_levels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
variant_id UUID NOT NULL REFERENCES variants(id),
location_id UUID NOT NULL REFERENCES locations(id),
quantity_on_hand INT NOT NULL DEFAULT 0,
quantity_committed INT NOT NULL DEFAULT 0,
quantity_reserved INT NOT NULL DEFAULT 0,
-- quantity_available is NEVER stored — computed at query time:
-- available = on_hand - committed - reserved
reorder_point INT DEFAULT 0,
reorder_quantity INT DEFAULT 0,
average_cost DECIMAL(19,4) DEFAULT 0.00,
last_counted_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT inventory_unique UNIQUE (tenant_id, variant_id, location_id),
CONSTRAINT inventory_levels_reserved_check CHECK (quantity_reserved >= 0),
CONSTRAINT inventory_levels_committed_check CHECK (quantity_committed >= 0)
);
-- Tenant isolation indexes
CREATE INDEX idx_inventory_tenant ON inventory_levels(tenant_id);
CREATE INDEX idx_inventory_tenant_location ON inventory_levels(tenant_id, location_id);
CREATE INDEX idx_inventory_tenant_variant ON inventory_levels(tenant_id, variant_id);
7.4 RLS Policy Definitions
Every tenant-scoped table in the public schema must have RLS enabled with isolation policies. The application sets app.current_tenant via middleware before any query executes.
Master RLS Setup Script
-- ============================================================
-- RLS POLICY SETUP
-- Apply to all tenant-scoped tables in public schema
-- ============================================================
-- Helper function to apply RLS to a table
CREATE OR REPLACE FUNCTION apply_rls_policy(p_table_name TEXT)
RETURNS VOID AS $$
BEGIN
-- Enable RLS
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', p_table_name);
-- Force RLS even for table owners (defense-in-depth)
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', p_table_name);
-- SELECT, UPDATE, DELETE policy
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I
USING (tenant_id = current_setting(''app.current_tenant'')::uuid)',
p_table_name
);
-- INSERT policy (prevent inserting rows for wrong tenant)
EXECUTE format(
'CREATE POLICY tenant_insert ON %I
FOR INSERT
WITH CHECK (tenant_id = current_setting(''app.current_tenant'')::uuid)',
p_table_name
);
RAISE NOTICE 'RLS policies applied to: %', p_table_name;
END;
$$ LANGUAGE plpgsql;
-- Apply RLS to all 63 tenant-scoped tables in public schema
-- Domain 1-2: Catalog (13 tables)
SELECT apply_rls_policy('products');
SELECT apply_rls_policy('variants');
SELECT apply_rls_policy('brands');
SELECT apply_rls_policy('product_groups');
SELECT apply_rls_policy('genders');
SELECT apply_rls_policy('origins');
SELECT apply_rls_policy('fabrics');
SELECT apply_rls_policy('categories');
SELECT apply_rls_policy('collections');
SELECT apply_rls_policy('tags');
SELECT apply_rls_policy('product_collection');
SELECT apply_rls_policy('product_tag');
SELECT apply_rls_policy('pricing_rules');
-- Domain 3: Inventory & Locations (7 tables)
SELECT apply_rls_policy('locations');
SELECT apply_rls_policy('inventory_levels');
SELECT apply_rls_policy('inventory_transactions');
SELECT apply_rls_policy('purchase_orders');
SELECT apply_rls_policy('purchase_order_items');
SELECT apply_rls_policy('transfer_orders');
SELECT apply_rls_policy('transfer_order_items');
-- Domain 4: Sales (3 tables)
SELECT apply_rls_policy('customers');
SELECT apply_rls_policy('orders');
SELECT apply_rls_policy('order_items');
-- Domain 5: Customer Loyalty & Gift Cards (5 tables)
SELECT apply_rls_policy('loyalty_accounts');
SELECT apply_rls_policy('loyalty_transactions');
SELECT apply_rls_policy('gift_cards');
SELECT apply_rls_policy('gift_card_transactions');
SELECT apply_rls_policy('store_credits');
-- Domain 6-7: Returns & Reporting (3 tables)
SELECT apply_rls_policy('returns');
SELECT apply_rls_policy('return_items');
SELECT apply_rls_policy('reports');
-- Domain 8: User Preferences (1 table)
SELECT apply_rls_policy('item_view_settings');
-- Domain 10: Auth (4 tenant-specific tables)
SELECT apply_rls_policy('roles');
SELECT apply_rls_policy('role_permissions');
SELECT apply_rls_policy('tenant_users');
SELECT apply_rls_policy('tenant_settings');
-- Domain 11: Offline Sync (3 tables)
SELECT apply_rls_policy('devices');
SELECT apply_rls_policy('sync_queue');
SELECT apply_rls_policy('sync_checkpoints');
-- Domain 12: Event Infrastructure (2 tables)
SELECT apply_rls_policy('event_outbox');
SELECT apply_rls_policy('state_transitions');
-- Domain 13: Cash Drawer Operations (6 tables)
SELECT apply_rls_policy('shifts');
SELECT apply_rls_policy('cash_drawers');
SELECT apply_rls_policy('cash_counts');
SELECT apply_rls_policy('cash_movements');
SELECT apply_rls_policy('cash_drops');
SELECT apply_rls_policy('cash_pickups');
-- Domain 14: Payment Processing (4 tables)
SELECT apply_rls_policy('payment_terminals');
SELECT apply_rls_policy('payment_attempts');
SELECT apply_rls_policy('payment_batches');
SELECT apply_rls_policy('payment_reconciliation');
-- Domain 15: Tax Configuration (3 tables)
SELECT apply_rls_policy('tax_jurisdictions');
SELECT apply_rls_policy('tax_rates');
SELECT apply_rls_policy('location_tax_jurisdictions');
-- Domain 16: RFID Module (9 tables)
SELECT apply_rls_policy('rfid_config');
SELECT apply_rls_policy('rfid_printers');
SELECT apply_rls_policy('rfid_tag_templates');
SELECT apply_rls_policy('rfid_print_jobs');
SELECT apply_rls_policy('rfid_tags');
SELECT apply_rls_policy('rfid_scan_sessions');
SELECT apply_rls_policy('rfid_scan_events');
SELECT apply_rls_policy('rfid_tag_mappings');
SELECT apply_rls_policy('session_operators');
Verification Query
-- Verify RLS is enabled on all tenant-scoped tables
SELECT
schemaname,
tablename,
rowsecurity AS rls_enabled
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
-- Verify policies exist
SELECT
schemaname,
tablename,
policyname,
cmd AS applies_to,
qual AS using_expression,
with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;
RLS Bypass for Admin Operations
-- The pos_admin role bypasses RLS for cross-tenant operations
-- ONLY use for: reporting, data migration, tenant cleanup
-- Example: Cross-tenant product count (admin only)
SET ROLE pos_admin;
SELECT tenant_id, COUNT(*) AS product_count
FROM products
GROUP BY tenant_id;
-- Reset to application role
RESET ROLE;
7.5 Seed Data
When a new tenant is provisioned, default data is inserted with the tenant’s tenant_id:
-- Seed default data for a new tenant
CREATE OR REPLACE FUNCTION seed_tenant_data(p_tenant_id UUID)
RETURNS VOID AS $$
DECLARE
v_owner_role_id UUID;
v_admin_role_id UUID;
v_manager_role_id UUID;
v_staff_role_id UUID;
v_buyer_role_id UUID;
BEGIN
-- Seed default roles
INSERT INTO roles (tenant_id, name, display_name, description, is_system)
VALUES
(p_tenant_id, 'owner', 'Owner', 'Full access to all features and settings', TRUE),
(p_tenant_id, 'admin', 'Administrator', 'Administrative access excluding billing', TRUE),
(p_tenant_id, 'manager', 'Manager', 'Store management and reporting access', TRUE),
(p_tenant_id, 'staff', 'Staff', 'Sales and basic customer operations', TRUE),
(p_tenant_id, 'buyer', 'Buyer', 'Purchasing and vendor management access', TRUE);
-- Get role IDs for permission assignment
SELECT id INTO v_owner_role_id FROM roles WHERE tenant_id = p_tenant_id AND name = 'owner';
SELECT id INTO v_admin_role_id FROM roles WHERE tenant_id = p_tenant_id AND name = 'admin';
SELECT id INTO v_manager_role_id FROM roles WHERE tenant_id = p_tenant_id AND name = 'manager';
SELECT id INTO v_staff_role_id FROM roles WHERE tenant_id = p_tenant_id AND name = 'staff';
SELECT id INTO v_buyer_role_id FROM roles WHERE tenant_id = p_tenant_id AND name = 'buyer';
-- Seed role permissions (Owner gets all)
INSERT INTO role_permissions (tenant_id, role_id, permission, granted) VALUES
-- Owner permissions (all)
(p_tenant_id, v_owner_role_id, 'products.*', TRUE),
(p_tenant_id, v_owner_role_id, 'inventory.*', TRUE),
(p_tenant_id, v_owner_role_id, 'orders.*', TRUE),
(p_tenant_id, v_owner_role_id, 'customers.*', TRUE),
(p_tenant_id, v_owner_role_id, 'reports.*', TRUE),
(p_tenant_id, v_owner_role_id, 'settings.*', TRUE),
(p_tenant_id, v_owner_role_id, 'users.*', TRUE),
(p_tenant_id, v_owner_role_id, 'billing.*', TRUE),
-- Manager permissions
(p_tenant_id, v_manager_role_id, 'products.view', TRUE),
(p_tenant_id, v_manager_role_id, 'products.edit', TRUE),
(p_tenant_id, v_manager_role_id, 'inventory.*', TRUE),
(p_tenant_id, v_manager_role_id, 'orders.*', TRUE),
(p_tenant_id, v_manager_role_id, 'customers.*', TRUE),
(p_tenant_id, v_manager_role_id, 'reports.view', TRUE),
(p_tenant_id, v_manager_role_id, 'shifts.*', TRUE),
-- Staff permissions
(p_tenant_id, v_staff_role_id, 'products.view', TRUE),
(p_tenant_id, v_staff_role_id, 'orders.create', TRUE),
(p_tenant_id, v_staff_role_id, 'orders.view', TRUE),
(p_tenant_id, v_staff_role_id, 'customers.view', TRUE),
(p_tenant_id, v_staff_role_id, 'customers.create', TRUE),
(p_tenant_id, v_staff_role_id, 'shifts.open', TRUE),
(p_tenant_id, v_staff_role_id, 'shifts.close', TRUE),
-- Buyer permissions
(p_tenant_id, v_buyer_role_id, 'products.view', TRUE),
(p_tenant_id, v_buyer_role_id, 'products.edit', TRUE),
(p_tenant_id, v_buyer_role_id, 'inventory.view', TRUE),
(p_tenant_id, v_buyer_role_id, 'inventory.receive', TRUE),
(p_tenant_id, v_buyer_role_id, 'inventory.transfer', TRUE),
(p_tenant_id, v_buyer_role_id, 'reports.view', TRUE);
-- Seed default genders
INSERT INTO genders (tenant_id, name) VALUES
(p_tenant_id, 'Men'), (p_tenant_id, 'Women'), (p_tenant_id, 'Unisex'),
(p_tenant_id, 'Kids'), (p_tenant_id, 'Boys'), (p_tenant_id, 'Girls');
-- Seed default tenant settings
INSERT INTO tenant_settings (tenant_id, category, key, value, value_type, description) VALUES
(p_tenant_id, 'general', 'business_name', '"New Business"', 'string', 'Business display name'),
(p_tenant_id, 'general', 'timezone', '"UTC"', 'string', 'Default timezone'),
(p_tenant_id, 'pos', 'require_customer', 'false', 'boolean', 'Require customer for sales'),
(p_tenant_id, 'pos', 'allow_negative_inventory', 'false', 'boolean', 'Allow selling without stock'),
(p_tenant_id, 'pos', 'receipt_footer', '"Thank you for your business!"', 'string', 'Receipt footer message'),
(p_tenant_id, 'inventory', 'low_stock_threshold', '5', 'number', 'Low stock alert threshold'),
(p_tenant_id, 'cash', 'require_drawer_count', 'true', 'boolean', 'Require cash count at shift open/close'),
(p_tenant_id, 'loyalty', 'points_per_dollar', '1', 'number', 'Loyalty points earned per dollar spent');
RAISE NOTICE 'Seed data inserted for tenant: %', p_tenant_id;
END;
$$ LANGUAGE plpgsql;
7.6 Tenant Provisioning
With RLS architecture, provisioning a new tenant is significantly simpler than schema-per-tenant. No schema creation is needed — just insert a tenant record and seed default data.
Provisioning Script
-- ============================================================
-- TENANT PROVISIONING SCRIPT (RLS)
-- Much simpler than schema-per-tenant: no CREATE SCHEMA needed
-- ============================================================
-- Variables (replace with actual values)
\set tenant_name 'Acme Retail'
\set tenant_slug 'acme-retail'
\set contact_email 'admin@acmeretail.com'
-- Begin transaction
BEGIN;
-- Step 1: Create tenant record in shared schema
INSERT INTO shared.tenants (
name, slug, status, tier, contact_email
) VALUES (
:'tenant_name',
:'tenant_slug',
'provisioning',
'standard',
:'contact_email'
) RETURNING id AS tenant_id \gset
-- Step 2: Create subscription record
INSERT INTO shared.tenant_subscriptions (
tenant_id,
plan_id,
status,
billing_cycle,
price_cents,
location_limit,
user_limit,
device_limit,
current_period_start,
current_period_end
) VALUES (
:'tenant_id',
'standard_monthly',
'active',
'monthly',
9900, -- $99.00
5,
10,
20,
CURRENT_DATE,
CURRENT_DATE + INTERVAL '1 month'
);
-- Step 3: Seed default data (roles, permissions, settings)
-- The seed function uses RLS-compatible tenant_id on every row
SELECT seed_tenant_data(:'tenant_id'::uuid);
-- Step 4: Activate tenant
UPDATE shared.tenants
SET status = 'active'
WHERE id = :'tenant_id'::uuid;
-- Step 5: Verify creation
SELECT
t.name,
t.slug,
t.status,
(SELECT COUNT(*) FROM roles WHERE tenant_id = t.id) AS roles_created,
(SELECT COUNT(*) FROM tenant_settings WHERE tenant_id = t.id) AS settings_created
FROM shared.tenants t
WHERE t.id = :'tenant_id'::uuid;
COMMIT;
-- Success message
\echo 'Tenant provisioned successfully!'
\echo 'Tenant ID: ' :'tenant_id'
TypeScript Provisioning Service
// services/tenantProvisioning.ts
import { PrismaClient } from '@prisma/client';
import { randomUUID } from 'crypto';
interface ProvisionTenantInput {
name: string;
subdomain: string;
plan: 'starter' | 'professional' | 'enterprise';
ownerEmail: string;
ownerName: string;
}
export class TenantProvisioningService {
constructor(private readonly prisma: PrismaClient) {}
async provisionTenant(input: ProvisionTenantInput): Promise<string> {
const tenantId = randomUUID();
// Use transaction for atomic provisioning
await this.prisma.$transaction(async (tx) => {
// 1. Create tenant record
await tx.tenant.create({
data: {
id: tenantId,
name: input.name,
subdomain: input.subdomain,
plan: input.plan,
isActive: true,
}
});
// 2. Set RLS context for seeding tenant-specific data
await tx.$executeRawUnsafe(
`SET app.current_tenant = '${tenantId}'`
);
// 3. Create owner user
const { hash } = await import('argon2');
const tempPassword = generateTempPassword();
await tx.user.create({
data: {
id: randomUUID(),
tenantId,
email: input.ownerEmail,
displayName: input.ownerName,
passwordHash: await hash(tempPassword),
role: 'OWNER',
isActive: true,
}
});
// 4. Seed default data (roles, permissions, tax jurisdictions)
await this.seedDefaults(tx, tenantId);
// 5. Create RFID EPC sequence for tenant
await tx.$executeRawUnsafe(
`CREATE SEQUENCE IF NOT EXISTS epc_serial_${tenantId.replace(/-/g, '_')} START 1`
);
});
return tenantId;
}
private async seedDefaults(
tx: PrismaClient,
tenantId: string
): Promise<void> {
// Seed default roles, permissions, settings
// See Chapter 08 for entity specifications
}
}
RLS vs Schema-Per-Tenant Provisioning Comparison
| Step | Schema-Per-Tenant | RLS (Current) |
|---|---|---|
| 1. Create tenant record | INSERT into shared.tenants | INSERT into shared.tenants |
| 2. Create schema | CREATE SCHEMA tenant_XXXX | Not needed |
| 3. Create tables | Run DDL for 45 tables in new schema | Not needed (tables already exist) |
| 4. Set permissions | GRANT on new schema | Not needed (RLS handles isolation) |
| 5. Seed data | INSERT into tenant_XXXX.roles etc. | INSERT into roles with tenant_id |
| 6. Activate | UPDATE status | UPDATE status |
| Total time | ~5-10 seconds | ~500ms |
| Rollback complexity | DROP SCHEMA CASCADE | DELETE WHERE tenant_id = X |
7.7 Table Count by Domain
| Domain | Tables | Shared | Tenant (public + RLS) |
|---|---|---|---|
| 1-2. Catalog (Products, Categories, Tags, Attributes, Pricing) | 13 | 0 | 13 |
| 3. Inventory & Locations | 7 | 0 | 7 |
| 4. Sales (Orders & Customers) | 3 | 0 | 3 |
| 5. Customer Loyalty & Gift Cards | 5 | 0 | 5 |
| 6-7. Returns & Reporting | 3 | 0 | 3 |
| 8. User Preferences | 1 | 0 | 1 |
| 9. Tenant Management | 6 | 6 | 0 |
| 10. Auth & Authorization | 4 | 0 | 4 |
| 11. Offline Sync | 3 | 0 | 3 |
| 12. Event Infrastructure | 2 | 0 | 2 |
| 13. Cash Drawer | 6 | 0 | 6 |
| 14. Payment Processing | 4 | 0 | 4 |
| 15. Tax Configuration | 3 | 0 | 3 |
| 16. RFID Module | 9 | 0 | 9 |
| TOTAL | 69 | 6 | 63 |
7.8 Quick Reference: Table List
Shared Schema Tables (6)
shared.tenants
shared.tenant_subscriptions
shared.tenant_modules
shared.users
shared.user_sessions
shared.password_resets
Public Schema Tables (63, all with tenant_id + RLS)
-- Domain 1-2: Catalog (13)
products, variants, brands, product_groups, genders, origins, fabrics,
categories, collections, tags, product_collection, product_tag, pricing_rules
-- Domain 3: Inventory (7)
locations, inventory_levels, inventory_transactions,
purchase_orders, purchase_order_items, transfer_orders, transfer_order_items
-- Domain 4: Sales (3)
customers, orders, order_items
-- Domain 5: Customer Loyalty & Gift Cards (5)
loyalty_accounts, loyalty_transactions, gift_cards, gift_card_transactions,
store_credits
-- Domain 6-7: Returns & Reporting (3)
returns, return_items, reports
-- Domain 8: Preferences (1)
item_view_settings
-- Domain 10: Auth (4 tenant-specific)
roles, role_permissions, tenant_users, tenant_settings
-- Domain 11: Sync (3)
devices, sync_queue, sync_checkpoints
-- Domain 12: Event Infrastructure (2)
event_outbox, state_transitions
-- Domain 13: Cash (6)
shifts, cash_drawers, cash_counts, cash_movements, cash_drops, cash_pickups
-- Domain 14: Payment (4)
payment_terminals, payment_attempts, payment_batches, payment_reconciliation
-- Domain 15: Tax (3)
tax_jurisdictions, tax_rates, location_tax_jurisdictions
-- Domain 16: RFID (9)
rfid_config, rfid_printers, rfid_tag_templates, rfid_print_jobs,
rfid_tags, rfid_scan_sessions, rfid_scan_events, rfid_tag_mappings,
session_operators
Index Naming Convention
All tenant-scoped tables follow this composite index pattern:
-- Primary tenant isolation index
CREATE INDEX idx_<table>_tenant ON <table>(tenant_id);
-- Composite indexes for common queries (tenant_id always first)
CREATE INDEX idx_<table>_tenant_<column> ON <table>(tenant_id, <column>);
-- Unique constraints include tenant_id for proper scoping
CREATE UNIQUE INDEX idx_<table>_tenant_<column> ON <table>(tenant_id, <column>);
Next Chapter: Chapter 08: Entity Specifications - Complete CREATE TABLE statements for all 69 tables organized by domain.
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | III - Database |
| Chapter | 07 of 9 |
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 08: Entity Specifications
Complete SQL for All 69 Tables
8.1 Overview
This chapter provides complete CREATE TABLE statements for all 69 tables in the POS Platform database (6 shared + 63 tenant-scoped). Each table includes:
- Column definitions with data types
- Constraints (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK)
- Default values
- Comments explaining purpose
Usage: Copy-paste these statements to create the database schema.
Note: This chapter combines complete SQL CREATE TABLE statements with Domain Model entity field references (see Ch 04: Architecture Styles, Section L.9C). Domain Model sections provide business context, validation rules, and field descriptions. SQL sections provide implementation-ready schema.
Domain 1-2: Catalog (Products, Categories, Tags)
Domain Model: Product
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| PRODUCT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| sku | String(50) | Unique stock keeping unit |
| barcode | String(50) | UPC/EAN barcode (nullable) |
| name | String(255) | Display name |
| description | Text | Full description |
| category_id | UUID | FK to Category |
| brand | String(100) | Brand name |
| vendor | String(100) | Supplier/vendor name |
| cost | Decimal | Wholesale cost |
| price | Decimal | Retail price |
| compare_at_price| Decimal | Original price (for discounts) |
| tax_code | String(20) | Tax category code |
| is_taxable | Boolean | Subject to sales tax |
| track_inventory | Boolean | Enable inventory tracking |
| is_active | Boolean | Available for sale |
| shopify_id | String(50) | Shopify product ID (if synced) |
| image_url | String(500) | Primary product image |
| weight | Decimal | Weight in default unit |
| weight_unit | String(10) | lb, kg, oz, g |
| tags | String[] | Searchable tags |
| metadata | JSONB | Custom attributes |
| created_at | Timestamp | Creation timestamp |
| updated_at | Timestamp | Last update timestamp |
+------------------------------------------------------------------+
Domain Model: ProductVariant
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| PRODUCT_VARIANT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| product_id | UUID | FK to Product (required) |
| sku | String(50) | Unique variant SKU |
| barcode | String(50) | Variant barcode |
| name | String(255) | Variant name (e.g., "Large/Blue") |
| option1_name | String(50) | First option name (e.g., "Size") |
| option1_value | String(100) | First option value (e.g., "Large")|
| option2_name | String(50) | Second option name |
| option2_value | String(100) | Second option value |
| option3_name | String(50) | Third option name |
| option3_value | String(100) | Third option value |
| cost | Decimal | Variant cost (overrides product) |
| price | Decimal | Variant price (overrides product) |
| weight | Decimal | Variant weight |
| image_url | String(500) | Variant-specific image |
| shopify_variant_id | String(50) | Shopify variant ID |
| is_active | Boolean | Available for sale |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: Category
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| CATEGORY |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| name | String(255) | Category name |
| slug | String(255) | URL-friendly identifier |
| parent_id | UUID | FK to parent Category (nullable) |
| description | Text | Category description |
| image_url | String(500) | Category image |
| sort_order | Integer | Display order |
| is_active | Boolean | Show in UI |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: PricingRule
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| PRICING_RULE |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| name | String(255) | Rule name |
| type | String(50) | percentage, fixed, buy_x_get_y |
| value | Decimal | Discount value or percentage |
| product_id | UUID | Apply to specific product |
| category_id | UUID | Apply to category |
| customer_group | String(50) | Apply to customer group |
| min_quantity | Integer | Minimum quantity required |
| start_date | Timestamp | Rule start date |
| end_date | Timestamp | Rule end date |
| priority | Integer | Rule priority (higher wins) |
| is_active | Boolean | Rule is enabled |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
brands
-- Brand/manufacturer reference data
CREATE TABLE brands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
logo_url VARCHAR(500),
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT brands_tenant_name_unique UNIQUE (tenant_id, name)
);
CREATE INDEX idx_brands_tenant ON brands(tenant_id);
CREATE INDEX idx_brands_active ON brands(tenant_id, is_active) WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE brands IS 'Brand/manufacturer reference data for product categorization';
product_groups
-- High-level product type categorization
CREATE TABLE product_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(50) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT product_groups_tenant_name_unique UNIQUE (tenant_id, name)
);
CREATE INDEX idx_product_groups_tenant ON product_groups(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE product_groups IS 'High-level product types (Tops, Bottoms, Accessories, etc.)';
genders
-- Target demographic for products
CREATE TABLE genders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(20) NOT NULL,
CONSTRAINT genders_tenant_name_unique UNIQUE (tenant_id, name)
);
CREATE INDEX idx_genders_tenant ON genders(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE genders IS 'Target demographic (Men, Women, Unisex, Kids)';
origins
-- Country of origin for compliance tracking
CREATE TABLE origins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
country VARCHAR(100) NOT NULL,
code VARCHAR(3),
CONSTRAINT origins_tenant_country_unique UNIQUE (tenant_id, country),
CONSTRAINT origins_tenant_code_unique UNIQUE (tenant_id, code)
);
CREATE INDEX idx_origins_tenant ON origins(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE origins IS 'Country of origin for compliance and import tracking';
COMMENT ON COLUMN origins.code IS 'ISO 3166-1 alpha-3 country code';
fabrics
-- Material composition and care instructions
CREATE TABLE fabrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
care_instructions TEXT,
CONSTRAINT fabrics_tenant_name_unique UNIQUE (tenant_id, name)
);
CREATE INDEX idx_fabrics_tenant ON fabrics(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE fabrics IS 'Fabric/material composition (100% Cotton, Polyester Blend, etc.)';
products
-- Master product record containing shared attributes
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
sku VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
product_group_id UUID REFERENCES product_groups(id) ON DELETE SET NULL,
gender_id UUID REFERENCES genders(id) ON DELETE SET NULL,
origin_id UUID REFERENCES origins(id) ON DELETE SET NULL,
fabric_id UUID REFERENCES fabrics(id) ON DELETE SET NULL,
base_price DECIMAL(10,2) NOT NULL,
cost_price DECIMAL(10,2) NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
has_variants BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT products_price_positive CHECK (base_price >= 0),
CONSTRAINT products_cost_positive CHECK (cost_price >= 0)
);
-- Indexes
CREATE UNIQUE INDEX idx_products_tenant_sku ON products(tenant_id, sku) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE INDEX idx_products_brand ON products(tenant_id, brand_id);
CREATE INDEX idx_products_group ON products(tenant_id, product_group_id);
CREATE INDEX idx_products_active ON products(tenant_id, is_active) WHERE is_active = TRUE AND deleted_at IS NULL;
CREATE INDEX idx_products_deleted ON products(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_products_name_search ON products USING gin(to_tsvector('english', name));
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE products IS 'Master product catalog with shared attributes';
COMMENT ON COLUMN products.has_variants IS 'TRUE if product has size/color variants; inventory tracked at variant level';
COMMENT ON COLUMN products.deleted_at IS 'Soft delete timestamp (NULL = active)';
variants
-- Product variations (size, color) with own SKUs and inventory
CREATE TABLE variants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
sku VARCHAR(50) NOT NULL,
size VARCHAR(20),
color VARCHAR(50),
price_adjustment DECIMAL(10,2) DEFAULT 0.00,
weight DECIMAL(10,3),
barcode VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes (tenant_id included in unique constraints for RLS compatibility)
CREATE UNIQUE INDEX idx_variants_tenant_sku ON variants(tenant_id, sku) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_variants_tenant_barcode ON variants(tenant_id, barcode)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_variants_tenant ON variants(tenant_id);
CREATE INDEX idx_variants_product ON variants(tenant_id, product_id);
CREATE INDEX idx_variants_size ON variants(size) WHERE size IS NOT NULL;
CREATE INDEX idx_variants_color ON variants(color) WHERE color IS NOT NULL;
CREATE INDEX idx_variants_deleted ON variants(deleted_at) WHERE deleted_at IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE variants IS 'Product variants with size/color combinations and unique SKUs';
COMMENT ON COLUMN variants.price_adjustment IS 'Price modifier from base (can be negative for discounts)';
COMMENT ON COLUMN variants.barcode IS 'UPC/EAN barcode for POS scanning';
categories
-- Hierarchical product categories
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
description TEXT,
display_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT categories_tenant_name_unique UNIQUE (tenant_id, name)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_categories_tenant ON categories(tenant_id);
CREATE INDEX idx_categories_tenant_parent ON categories(tenant_id, parent_id);
CREATE INDEX idx_categories_tenant_display ON categories(tenant_id, display_order);
CREATE INDEX idx_categories_tenant_active ON categories(tenant_id, is_active) WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE categories IS 'Hierarchical product categories (Clothing > Mens > Shirts)';
COMMENT ON COLUMN categories.parent_id IS 'Self-reference for hierarchy; NULL = root category';
collections
-- Marketing/seasonal product groupings
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
description TEXT,
image_url VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE,
start_date TIMESTAMPTZ,
end_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT collections_tenant_name_unique UNIQUE (tenant_id, name),
CONSTRAINT collections_date_order CHECK (end_date IS NULL OR start_date IS NULL OR end_date > start_date)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_collections_tenant ON collections(tenant_id);
CREATE INDEX idx_collections_tenant_active ON collections(tenant_id, is_active, start_date, end_date);
CREATE INDEX idx_collections_tenant_current ON collections(tenant_id, start_date, end_date)
WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE collections IS 'Marketing collections (Summer 2025, Clearance, New Arrivals)';
tags
-- Flexible product tagging
CREATE TABLE tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(50) NOT NULL,
color VARCHAR(7),
CONSTRAINT tags_tenant_name_unique UNIQUE (tenant_id, name),
CONSTRAINT tags_color_hex CHECK (color IS NULL OR color ~ '^#[0-9A-Fa-f]{6}$')
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_tags_tenant ON tags(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE tags IS 'Freeform product tags for quick filtering';
COMMENT ON COLUMN tags.color IS 'Hex color code for UI display (#FF5733)';
product_collection
-- Junction table: products to collections (many-to-many)
CREATE TABLE product_collection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
display_order INT DEFAULT 0,
CONSTRAINT product_collection_unique UNIQUE (tenant_id, product_id, collection_id)
);
CREATE INDEX idx_product_collection_tenant ON product_collection(tenant_id);
CREATE INDEX idx_product_collection_tenant_product ON product_collection(tenant_id, product_id);
CREATE INDEX idx_product_collection_tenant_collection ON product_collection(tenant_id, collection_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE product_collection IS 'Links products to marketing collections';
product_tag
-- Junction table: products to tags (many-to-many)
CREATE TABLE product_tag (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
CONSTRAINT product_tag_unique UNIQUE (tenant_id, product_id, tag_id)
);
CREATE INDEX idx_product_tag_tenant ON product_tag(tenant_id);
CREATE INDEX idx_product_tag_tenant_product ON product_tag(tenant_id, product_id);
CREATE INDEX idx_product_tag_tenant_tag ON product_tag(tenant_id, tag_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE product_tag IS 'Links products to tags for flexible categorization';
pricing_rules
-- Promotion and pricing rule engine
CREATE TABLE pricing_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(200) NOT NULL,
description TEXT,
rule_type VARCHAR(50) NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
conditions JSONB NOT NULL DEFAULT '{}',
actions JSONB NOT NULL DEFAULT '{}',
start_date TIMESTAMPTZ,
end_date TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT true,
is_combinable BOOLEAN NOT NULL DEFAULT false,
max_uses INTEGER,
current_uses INTEGER NOT NULL DEFAULT 0,
created_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pricing_rules_type_check CHECK (rule_type IN (
'DISCOUNT', 'BOGO', 'BUNDLE', 'TIERED', 'LOYALTY'
)),
CONSTRAINT pricing_rules_date_order CHECK (
end_date IS NULL OR start_date IS NULL OR end_date > start_date
),
CONSTRAINT pricing_rules_uses_check CHECK (
max_uses IS NULL OR current_uses <= max_uses
)
);
CREATE INDEX idx_pricing_rules_tenant ON pricing_rules(tenant_id);
CREATE INDEX idx_pricing_rules_tenant_active ON pricing_rules(tenant_id, is_active, start_date, end_date)
WHERE is_active = TRUE;
CREATE INDEX idx_pricing_rules_tenant_type ON pricing_rules(tenant_id, rule_type)
WHERE is_active = TRUE;
CREATE INDEX idx_pricing_rules_tenant_priority ON pricing_rules(tenant_id, priority DESC)
WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE pricing_rules IS 'Promotion and pricing rule engine for discounts, BOGO, bundles, and tiered pricing';
COMMENT ON COLUMN pricing_rules.conditions IS 'JSON rule conditions: product, category, customer segment, min quantity, etc.';
COMMENT ON COLUMN pricing_rules.actions IS 'JSON discount actions: amount/percentage, free items, bundle pricing, etc.';
Domain 3: Inventory
Domain Model: InventoryItem
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| INVENTORY_ITEM |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| product_id | UUID | FK to Product |
| variant_id | UUID | FK to ProductVariant |
| location_id | UUID | FK to Location (required) |
| quantity_on_hand| Integer | Current stock quantity |
| quantity_committed | Integer | Reserved for pending orders |
| quantity_available | Integer | Calculated: on_hand - committed |
| quantity_incoming | Integer | Expected from purchase orders |
| reorder_point | Integer | Alert when below this level |
| reorder_quantity| Integer | Default reorder amount |
| bin_location | String(50) | Physical bin/shelf location |
| last_counted_at | Timestamp | Last physical count |
| last_received_at| Timestamp | Last inventory receipt |
| last_sold_at | Timestamp | Last sale of this item |
| created_at | Timestamp | Creation timestamp |
| updated_at | Timestamp | Last update |
+------------------------------------------------------------------+
Domain Model: InventoryAdjustment
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| INVENTORY_ADJUSTMENT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| adjustment_number | String(50)| Human-readable ID |
| location_id | UUID | FK to Location |
| employee_id | UUID | FK to Employee (who adjusted) |
| reason | String(50) | count, damage, theft, return, etc.|
| notes | Text | Adjustment notes |
| status | String(20) | draft, pending, completed |
| created_at | Timestamp | Adjustment timestamp |
| completed_at | Timestamp | When finalized |
+------------------------------------------------------------------+
Domain Model: InventoryTransfer
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| INVENTORY_TRANSFER |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| transfer_number | String(50) | Human-readable ID |
| from_location_id| UUID | FK to source Location |
| to_location_id | UUID | FK to destination Location |
| employee_id | UUID | FK to Employee (initiator) |
| status | String(20) | draft, pending, in_transit, received |
| notes | Text | Transfer notes |
| shipped_at | Timestamp | When shipped |
| received_at | Timestamp | When received |
| received_by | UUID | FK to Employee (receiver) |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
locations
-- Physical stores, warehouses, and fulfillment centers
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
code VARCHAR(10) NOT NULL,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL,
address VARCHAR(255),
city VARCHAR(100),
state VARCHAR(50),
postal_code VARCHAR(20),
phone VARCHAR(20),
timezone VARCHAR(50) NOT NULL DEFAULT 'UTC', -- Tenant timezone for display; all timestamps stored as TIMESTAMPTZ (UTC)
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT locations_tenant_code_unique UNIQUE (tenant_id, code),
CONSTRAINT locations_type_check CHECK (type IN ('store', 'warehouse', 'online', 'popup'))
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_locations_tenant ON locations(tenant_id);
CREATE INDEX idx_locations_tenant_type ON locations(tenant_id, type);
CREATE INDEX idx_locations_tenant_active ON locations(tenant_id, is_active) WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE locations IS 'Physical and virtual locations for inventory tracking';
COMMENT ON COLUMN locations.code IS 'Short code (GM, HM, LM, NM, HQ)';
COMMENT ON COLUMN locations.type IS 'Location type: store, warehouse, online, popup';
inventory_levels
-- Current stock quantity per variant per location
-- available = on_hand - committed - reserved (computed, NEVER stored — see Architecture decision)
CREATE TABLE inventory_levels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
quantity_on_hand INT DEFAULT 0,
quantity_committed INT DEFAULT 0,
quantity_reserved INT DEFAULT 0,
-- available = on_hand - committed - reserved (computed at query time, NEVER stored)
reorder_point INT DEFAULT 0,
reorder_quantity INT DEFAULT 0,
average_cost DECIMAL(19,4) DEFAULT 0.00,
last_counted_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT inventory_levels_tenant_unique UNIQUE (tenant_id, variant_id, location_id),
CONSTRAINT inventory_levels_reserved_check CHECK (quantity_reserved >= 0),
CONSTRAINT inventory_levels_committed_check CHECK (quantity_committed >= 0)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_inventory_levels_tenant ON inventory_levels(tenant_id);
CREATE INDEX idx_inventory_levels_tenant_lookup ON inventory_levels(tenant_id, variant_id, location_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_tenant_location ON inventory_levels(tenant_id, location_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_tenant_low_stock ON inventory_levels(tenant_id, location_id, quantity_on_hand)
WHERE quantity_on_hand <= reorder_point AND deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_tenant_variant ON inventory_levels(tenant_id, variant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE inventory_levels IS 'Current inventory quantities per variant per location';
COMMENT ON COLUMN inventory_levels.average_cost IS 'Weighted average cost: ((existing_qty * avg_cost) + (new_qty * new_cost)) / (existing_qty + new_qty)';
COMMENT ON COLUMN inventory_levels.reorder_point IS 'Low stock alert threshold';
inventory_transactions
-- Audit log for all inventory movements (append-only)
CREATE TABLE inventory_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
transaction_type VARCHAR(20) NOT NULL,
quantity_change INT NOT NULL,
quantity_before INT NOT NULL,
quantity_after INT NOT NULL,
reference_type VARCHAR(50),
reference_id UUID,
notes TEXT,
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT inventory_trans_type_check CHECK (transaction_type IN (
'sale', 'return', 'purchase', 'transfer_in', 'transfer_out',
'adjustment', 'count', 'damage', 'theft', 'found'
)),
CONSTRAINT inventory_trans_math CHECK (quantity_after = quantity_before + quantity_change)
);
-- Indexes (BRIN for time-series, B-tree with tenant_id for lookups)
CREATE INDEX idx_inventory_trans_date ON inventory_transactions USING BRIN (created_at);
CREATE INDEX idx_inventory_trans_tenant ON inventory_transactions(tenant_id);
CREATE INDEX idx_inventory_trans_tenant_variant ON inventory_transactions(tenant_id, variant_id, created_at DESC);
CREATE INDEX idx_inventory_trans_tenant_location ON inventory_transactions(tenant_id, location_id, created_at DESC);
CREATE INDEX idx_inventory_trans_tenant_reference ON inventory_transactions(tenant_id, reference_type, reference_id)
WHERE reference_type IS NOT NULL;
CREATE INDEX idx_inventory_trans_tenant_type ON inventory_transactions(tenant_id, transaction_type, created_at DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE inventory_transactions IS 'Immutable audit log of all inventory changes';
COMMENT ON COLUMN inventory_transactions.transaction_type IS 'Type of movement: sale, return, purchase, transfer, adjustment';
COMMENT ON COLUMN inventory_transactions.reference_type IS 'Source document type (order, transfer, adjustment)';
purchase_orders
-- Purchase orders to suppliers for inventory replenishment
CREATE TABLE purchase_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
po_number VARCHAR(50) NOT NULL,
supplier_id UUID NOT NULL REFERENCES brands(id),
location_id UUID NOT NULL REFERENCES locations(id),
status VARCHAR(30) NOT NULL DEFAULT 'DRAFT',
order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expected_date TIMESTAMPTZ,
received_date TIMESTAMPTZ,
subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
shipping_cost NUMERIC(12,2) NOT NULL DEFAULT 0,
total_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
notes TEXT,
created_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT purchase_orders_tenant_po_unique UNIQUE (tenant_id, po_number),
CONSTRAINT purchase_orders_status_check CHECK (status IN (
'DRAFT', 'SUBMITTED', 'CONFIRMED', 'PARTIALLY_RECEIVED', 'RECEIVED', 'CANCELLED'
)),
CONSTRAINT purchase_orders_amounts_positive CHECK (
subtotal >= 0 AND tax_amount >= 0 AND shipping_cost >= 0 AND total_amount >= 0
)
);
CREATE INDEX idx_purchase_orders_tenant ON purchase_orders(tenant_id);
CREATE INDEX idx_purchase_orders_tenant_status ON purchase_orders(tenant_id, status, order_date DESC);
CREATE INDEX idx_purchase_orders_tenant_supplier ON purchase_orders(tenant_id, supplier_id);
CREATE INDEX idx_purchase_orders_tenant_location ON purchase_orders(tenant_id, location_id);
CREATE INDEX idx_purchase_orders_tenant_date ON purchase_orders(tenant_id, order_date DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE purchase_orders IS 'Purchase orders to suppliers for inventory replenishment';
COMMENT ON COLUMN purchase_orders.po_number IS 'Format: PO-YYYYMMDD-SEQUENCE';
purchase_order_items
-- Line items for purchase orders
CREATE TABLE purchase_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
purchase_order_id UUID NOT NULL REFERENCES purchase_orders(id) ON DELETE CASCADE,
variant_id UUID NOT NULL REFERENCES variants(id),
quantity_ordered INTEGER NOT NULL,
quantity_received INTEGER NOT NULL DEFAULT 0,
unit_cost NUMERIC(12,2) NOT NULL,
total_cost NUMERIC(12,2) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT po_items_quantity_positive CHECK (quantity_ordered > 0),
CONSTRAINT po_items_received_check CHECK (quantity_received >= 0),
CONSTRAINT po_items_cost_positive CHECK (unit_cost >= 0 AND total_cost >= 0)
);
CREATE INDEX idx_purchase_order_items_tenant ON purchase_order_items(tenant_id);
CREATE INDEX idx_purchase_order_items_tenant_po ON purchase_order_items(tenant_id, purchase_order_id);
CREATE INDEX idx_purchase_order_items_tenant_variant ON purchase_order_items(tenant_id, variant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE purchase_order_items IS 'Line items for purchase orders with receiving tracking';
transfer_orders
-- Inter-location inventory transfer orders
CREATE TABLE transfer_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
transfer_number VARCHAR(50) NOT NULL,
source_location_id UUID NOT NULL REFERENCES locations(id),
destination_location_id UUID NOT NULL REFERENCES locations(id),
status VARCHAR(30) NOT NULL DEFAULT 'DRAFT',
requested_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
shipped_date TIMESTAMPTZ,
received_date TIMESTAMPTZ,
notes TEXT,
requested_by UUID REFERENCES shared.users(id),
approved_by UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT transfer_orders_tenant_number_unique UNIQUE (tenant_id, transfer_number),
CONSTRAINT transfer_orders_status_check CHECK (status IN (
'DRAFT', 'SUBMITTED', 'IN_TRANSIT', 'PARTIALLY_RECEIVED', 'RECEIVED', 'CANCELLED'
)),
CONSTRAINT transfer_orders_different_locations CHECK (
source_location_id != destination_location_id
)
);
CREATE INDEX idx_transfer_orders_tenant ON transfer_orders(tenant_id);
CREATE INDEX idx_transfer_orders_tenant_status ON transfer_orders(tenant_id, status, requested_date DESC);
CREATE INDEX idx_transfer_orders_tenant_source ON transfer_orders(tenant_id, source_location_id);
CREATE INDEX idx_transfer_orders_tenant_dest ON transfer_orders(tenant_id, destination_location_id);
CREATE INDEX idx_transfer_orders_tenant_date ON transfer_orders(tenant_id, requested_date DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE transfer_orders IS 'Inter-location inventory transfer orders';
COMMENT ON COLUMN transfer_orders.transfer_number IS 'Format: TRF-YYYYMMDD-SEQUENCE';
transfer_order_items
-- Line items for transfer orders
CREATE TABLE transfer_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
transfer_order_id UUID NOT NULL REFERENCES transfer_orders(id) ON DELETE CASCADE,
variant_id UUID NOT NULL REFERENCES variants(id),
quantity_requested INTEGER NOT NULL,
quantity_shipped INTEGER NOT NULL DEFAULT 0,
quantity_received INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT transfer_items_quantity_positive CHECK (quantity_requested > 0),
CONSTRAINT transfer_items_shipped_check CHECK (quantity_shipped >= 0),
CONSTRAINT transfer_items_received_check CHECK (quantity_received >= 0)
);
CREATE INDEX idx_transfer_order_items_tenant ON transfer_order_items(tenant_id);
CREATE INDEX idx_transfer_order_items_tenant_order ON transfer_order_items(tenant_id, transfer_order_id);
CREATE INDEX idx_transfer_order_items_tenant_variant ON transfer_order_items(tenant_id, variant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE transfer_order_items IS 'Line items for transfer orders with ship/receive tracking';
Domain 4: Sales
Domain Model: Sale
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| SALE |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| sale_number | String(50) | Human-readable sale ID |
| location_id | UUID | FK to Location (required) |
| register_id | String(20) | Register identifier |
| employee_id | UUID | FK to Employee (cashier) |
| customer_id | UUID | FK to Customer (nullable) |
| status | String(20) | draft, completed, voided, refunded|
| subtotal | Decimal | Sum of line items before tax |
| discount_total | Decimal | Total discounts applied |
| tax_total | Decimal | Total tax amount |
| total | Decimal | Final total (subtotal-discount+tax)|
| payment_status | String(20) | pending, partial, paid, refunded |
| source | String(20) | pos, online, mobile |
| notes | Text | Sale notes |
| voided_at | Timestamp | When sale was voided |
| voided_by | UUID | Employee who voided |
| void_reason | Text | Reason for void |
| created_at | Timestamp | Sale timestamp |
| updated_at | Timestamp | Last update |
+------------------------------------------------------------------+
Domain Model: SaleLineItem
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| SALE_LINE_ITEM |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| sale_id | UUID | FK to Sale (required) |
| product_id | UUID | FK to Product |
| variant_id | UUID | FK to ProductVariant |
| sku | String(50) | SKU at time of sale |
| name | String(255) | Product name at time of sale |
| quantity | Integer | Quantity sold |
| unit_price | Decimal | Price per unit |
| unit_cost | Decimal | Cost per unit (for profit calc) |
| discount_amount | Decimal | Discount on this line |
| discount_reason | String(100) | Reason for discount |
| tax_amount | Decimal | Tax on this line |
| total | Decimal | Line total |
| is_refunded | Boolean | Line was refunded |
| refunded_at | Timestamp | When refunded |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: Payment
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| PAYMENT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| sale_id | UUID | FK to Sale (required) |
| payment_method | String(50) | cash, credit, debit, gift, store_credit |
| amount | Decimal | Payment amount |
| tendered | Decimal | Amount tendered (for cash) |
| change_given | Decimal | Change returned |
| reference | String(100) | Card last 4, check #, etc. |
| card_type | String(20) | visa, mastercard, amex, discover |
| auth_code | String(50) | Authorization code |
| status | String(20) | pending, completed, failed, refunded |
| gateway_response| JSONB | Full payment gateway response |
| created_at | Timestamp | Payment timestamp |
+------------------------------------------------------------------+
Domain Model: Refund
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| REFUND |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| sale_id | UUID | FK to original Sale |
| refund_number | String(50) | Human-readable refund ID |
| employee_id | UUID | FK to Employee (who processed) |
| reason | String(100) | Refund reason |
| subtotal | Decimal | Refund subtotal |
| tax_refunded | Decimal | Tax refunded |
| total | Decimal | Total refund amount |
| refund_method | String(50) | original, cash, store_credit |
| notes | Text | Additional notes |
| created_at | Timestamp | Refund timestamp |
+------------------------------------------------------------------+
Domain Model: RefundLineItem
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| REFUND_LINE_ITEM |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| refund_id | UUID | FK to Refund |
| sale_line_item_id | UUID | FK to original SaleLineItem |
| quantity | Integer | Quantity refunded |
| amount | Decimal | Refund amount for this line |
| restock | Boolean | Add back to inventory |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
customers
-- Customer profiles with loyalty tracking
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
loyalty_number VARCHAR(20),
first_name VARCHAR(50) NOT NULL,
last_name VARCHAR(50) NOT NULL,
email VARCHAR(255),
phone VARCHAR(20),
address TEXT,
loyalty_points INT DEFAULT 0,
total_spent DECIMAL(19,4) DEFAULT 0,
visit_count INT DEFAULT 0,
first_visit TIMESTAMPTZ,
last_visit TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
anonymized_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT customers_points_positive CHECK (loyalty_points >= 0),
CONSTRAINT customers_spent_positive CHECK (total_spent >= 0)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_customers_tenant ON customers(tenant_id);
CREATE UNIQUE INDEX idx_customers_tenant_loyalty ON customers(tenant_id, loyalty_number)
WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX idx_customers_tenant_email ON customers(tenant_id, email)
WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_customers_tenant_name ON customers(tenant_id, last_name, first_name) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_tenant_phone ON customers(tenant_id, phone) WHERE phone IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_customers_tenant_last_visit ON customers(tenant_id, last_visit DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE customers IS 'Customer profiles with loyalty program tracking';
COMMENT ON COLUMN customers.anonymized_at IS 'GDPR: timestamp when PII was scrubbed';
orders
-- Transaction header with payment and status
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
order_number VARCHAR(20) NOT NULL,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
shift_id UUID REFERENCES shifts(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
subtotal DECIMAL(19,4) NOT NULL,
tax_amount DECIMAL(19,4) NOT NULL,
discount_amount DECIMAL(19,4) DEFAULT 0,
total_amount DECIMAL(19,4) NOT NULL,
payment_method VARCHAR(20) NOT NULL,
payment_reference VARCHAR(100),
notes TEXT,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
void_reason VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT orders_tenant_number_unique UNIQUE (tenant_id, order_number),
CONSTRAINT orders_status_check CHECK (status IN ('pending', 'completed', 'refunded', 'voided', 'on_hold')),
CONSTRAINT orders_payment_check CHECK (payment_method IN (
'cash', 'credit', 'debit', 'mobile', 'gift_card', 'store_credit', 'split', 'check'
)),
CONSTRAINT orders_amounts_positive CHECK (
subtotal >= 0 AND tax_amount >= 0 AND discount_amount >= 0 AND total_amount >= 0
),
CONSTRAINT orders_total_math CHECK (
total_amount = subtotal + tax_amount - discount_amount
)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE INDEX idx_orders_tenant_date ON orders(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_location ON orders(tenant_id, location_id, created_at DESC);
CREATE INDEX idx_orders_tenant_customer ON orders(tenant_id, customer_id) WHERE customer_id IS NOT NULL;
CREATE INDEX idx_orders_tenant_shift ON orders(tenant_id, shift_id) WHERE shift_id IS NOT NULL;
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status, created_at DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE orders IS 'Sales transaction headers with payment info';
COMMENT ON COLUMN orders.order_number IS 'Format: LOC-YYYYMMDD-SEQUENCE';
COMMENT ON COLUMN orders.void_reason IS 'Required explanation when status = voided';
order_items
-- Line items with snapshots of product data at time of sale
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
sku VARCHAR(50) NOT NULL,
product_name VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(19,4) NOT NULL,
discount_amount DECIMAL(19,4) DEFAULT 0,
tax_amount DECIMAL(19,4) NOT NULL,
line_total DECIMAL(19,4) NOT NULL,
is_returned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT order_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT order_items_amounts_positive CHECK (
unit_price >= 0 AND discount_amount >= 0 AND tax_amount >= 0
),
CONSTRAINT order_items_total_math CHECK (
line_total = (unit_price * quantity) - discount_amount + tax_amount
)
);
-- Indexes (tenant_id as leading column for RLS performance)
CREATE INDEX idx_order_items_tenant ON order_items(tenant_id);
CREATE INDEX idx_order_items_tenant_order ON order_items(tenant_id, order_id);
CREATE INDEX idx_order_items_tenant_variant ON order_items(tenant_id, variant_id);
CREATE INDEX idx_order_items_tenant_sku ON order_items(tenant_id, sku);
CREATE INDEX idx_order_items_tenant_returned ON order_items(tenant_id, order_id) WHERE is_returned = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE order_items IS 'Order line items with point-in-time price snapshots';
COMMENT ON COLUMN order_items.sku IS 'SKU snapshot at time of sale (product may change)';
COMMENT ON COLUMN order_items.product_name IS 'Name snapshot at time of sale';
Domain 5: Customer Loyalty & Gift Cards
Domain Model: Customer
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| CUSTOMER |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| customer_number | String(20) | Human-readable customer ID |
| first_name | String(100) | First name |
| last_name | String(100) | Last name |
| email | String(255) | Email address (unique) |
| phone | String(20) | Phone number |
| company | String(255) | Company name |
| date_of_birth | Date | Birthday (for loyalty) |
| tax_exempt | Boolean | Tax exempt status |
| tax_exempt_id | String(50) | Tax exemption certificate |
| notes | Text | Customer notes |
| loyalty_points | Integer | Current loyalty points |
| loyalty_tier | String(20) | bronze, silver, gold, platinum |
| total_spent | Decimal | Lifetime spending |
| visit_count | Integer | Total visits |
| average_order | Decimal | Average order value |
| last_visit_at | Timestamp | Last visit timestamp |
| tags | String[] | Customer tags |
| marketing_consent | Boolean | Opted in for marketing |
| shopify_id | String(50) | Shopify customer ID |
| created_at | Timestamp | Creation timestamp |
| updated_at | Timestamp | Last update |
+------------------------------------------------------------------+
Domain Model: CustomerAddress
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| CUSTOMER_ADDRESS |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| customer_id | UUID | FK to Customer |
| address_type | String(20) | billing, shipping |
| is_default | Boolean | Default address for type |
| first_name | String(100) | Recipient first name |
| last_name | String(100) | Recipient last name |
| company | String(255) | Company name |
| address_line1 | String(255) | Street address line 1 |
| address_line2 | String(255) | Street address line 2 |
| city | String(100) | City |
| state | String(50) | State/Province |
| postal_code | String(20) | ZIP/Postal code |
| country | String(2) | Country code (ISO 3166-1) |
| phone | String(20) | Contact phone |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: StoreCredit
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| STORE_CREDIT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| customer_id | UUID | FK to Customer |
| code | String(50) | Unique credit code |
| original_amount | Decimal | Initial credit amount |
| current_balance | Decimal | Remaining balance |
| reason | String(100) | Reason for credit |
| issued_by | UUID | FK to Employee |
| expires_at | Timestamp | Expiration date (nullable) |
| is_active | Boolean | Credit is usable |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: LoyaltyTransaction
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| LOYALTY_TRANSACTION |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| customer_id | UUID | FK to Customer |
| sale_id | UUID | FK to Sale (if earned from sale) |
| type | String(20) | earn, redeem, adjustment, expire |
| points | Integer | Points (positive or negative) |
| balance_after | Integer | Balance after transaction |
| description | String(255) | Transaction description |
| created_by | UUID | FK to Employee |
| created_at | Timestamp | Transaction timestamp |
+------------------------------------------------------------------+
loyalty_accounts
-- Customer loyalty program accounts
CREATE TABLE loyalty_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
tier VARCHAR(20) NOT NULL DEFAULT 'bronze',
points_balance INT DEFAULT 0,
lifetime_points INT DEFAULT 0,
tier_start_date DATE,
tier_expiry_date DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT loyalty_accounts_tenant_customer_unique UNIQUE (tenant_id, customer_id),
CONSTRAINT loyalty_tier_check CHECK (tier IN ('bronze', 'silver', 'gold', 'platinum')),
CONSTRAINT loyalty_points_positive CHECK (points_balance >= 0 AND lifetime_points >= 0)
);
CREATE INDEX idx_loyalty_tenant ON loyalty_accounts(tenant_id);
CREATE INDEX idx_loyalty_tenant_tier ON loyalty_accounts(tenant_id, tier) WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE loyalty_accounts IS 'Customer loyalty program tier and points tracking';
loyalty_transactions
-- Loyalty points earn/redeem history
CREATE TABLE loyalty_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
loyalty_account_id UUID NOT NULL REFERENCES loyalty_accounts(id) ON DELETE CASCADE,
order_id UUID REFERENCES orders(id) ON DELETE SET NULL,
transaction_type VARCHAR(20) NOT NULL,
points INT NOT NULL,
points_balance_after INT NOT NULL,
description VARCHAR(255),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT loyalty_trans_type_check CHECK (transaction_type IN (
'earn', 'redeem', 'expire', 'adjust', 'bonus', 'transfer'
))
);
CREATE INDEX idx_loyalty_trans_tenant ON loyalty_transactions(tenant_id);
CREATE INDEX idx_loyalty_trans_tenant_account ON loyalty_transactions(tenant_id, loyalty_account_id, created_at DESC);
CREATE INDEX idx_loyalty_trans_tenant_order ON loyalty_transactions(tenant_id, order_id) WHERE order_id IS NOT NULL;
CREATE INDEX idx_loyalty_trans_expiry ON loyalty_transactions(expires_at)
WHERE expires_at IS NOT NULL AND transaction_type = 'earn';
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE loyalty_transactions IS 'Audit trail of loyalty point changes';
gift_cards
-- Gift card issuance and balance tracking
CREATE TABLE gift_cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
card_number VARCHAR(20) NOT NULL,
pin_hash VARCHAR(255),
initial_balance DECIMAL(19,4) NOT NULL,
current_balance DECIMAL(19,4) NOT NULL,
currency_code CHAR(3) DEFAULT 'USD',
issued_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ,
issued_by UUID REFERENCES shared.users(id),
issued_location_id UUID REFERENCES locations(id),
purchased_order_id UUID REFERENCES orders(id),
is_active BOOLEAN DEFAULT TRUE,
deactivated_at TIMESTAMPTZ,
deactivated_reason VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT gift_cards_tenant_number_unique UNIQUE (tenant_id, card_number),
CONSTRAINT gift_cards_balance_positive CHECK (initial_balance > 0 AND current_balance >= 0),
CONSTRAINT gift_cards_balance_max CHECK (current_balance <= initial_balance)
);
CREATE INDEX idx_gift_cards_tenant ON gift_cards(tenant_id);
CREATE INDEX idx_gift_cards_tenant_number ON gift_cards(tenant_id, card_number);
CREATE INDEX idx_gift_cards_tenant_active ON gift_cards(tenant_id, is_active, expires_at);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE gift_cards IS 'Gift card issuance and balance management';
COMMENT ON COLUMN gift_cards.pin_hash IS 'Optional PIN for additional security (hashed)';
gift_card_transactions
-- Gift card usage history
CREATE TABLE gift_card_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
gift_card_id UUID NOT NULL REFERENCES gift_cards(id) ON DELETE CASCADE,
order_id UUID REFERENCES orders(id) ON DELETE SET NULL,
transaction_type VARCHAR(20) NOT NULL,
amount DECIMAL(19,4) NOT NULL,
balance_after DECIMAL(19,4) NOT NULL,
location_id UUID REFERENCES locations(id),
user_id UUID REFERENCES shared.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT gift_card_trans_type CHECK (transaction_type IN (
'issue', 'redeem', 'reload', 'refund', 'adjust', 'expire'
)),
CONSTRAINT gift_card_trans_amount CHECK (amount > 0)
);
CREATE INDEX idx_gift_card_trans_tenant ON gift_card_transactions(tenant_id);
CREATE INDEX idx_gift_card_trans_tenant_card ON gift_card_transactions(tenant_id, gift_card_id, created_at DESC);
CREATE INDEX idx_gift_card_trans_tenant_order ON gift_card_transactions(tenant_id, order_id) WHERE order_id IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE gift_card_transactions IS 'Audit trail of gift card balance changes';
store_credits
-- Store credit issuance and balance tracking (from returns or manual)
CREATE TABLE store_credits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
customer_id UUID NOT NULL REFERENCES customers(id),
credit_number VARCHAR(50) NOT NULL,
original_amount NUMERIC(12,2) NOT NULL,
remaining_amount NUMERIC(12,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
issued_from_return_id UUID REFERENCES returns(id),
issued_by UUID REFERENCES shared.users(id),
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT store_credits_tenant_number_unique UNIQUE (tenant_id, credit_number),
CONSTRAINT store_credits_status_check CHECK (status IN (
'ACTIVE', 'FULLY_REDEEMED', 'EXPIRED', 'VOIDED'
)),
CONSTRAINT store_credits_remaining_positive CHECK (remaining_amount >= 0),
CONSTRAINT store_credits_remaining_max CHECK (remaining_amount <= original_amount),
CONSTRAINT store_credits_original_positive CHECK (original_amount > 0)
);
CREATE INDEX idx_store_credits_tenant ON store_credits(tenant_id);
CREATE INDEX idx_store_credits_tenant_customer ON store_credits(tenant_id, customer_id);
CREATE INDEX idx_store_credits_tenant_status ON store_credits(tenant_id, status)
WHERE status = 'ACTIVE';
CREATE INDEX idx_store_credits_tenant_expiry ON store_credits(tenant_id, expires_at)
WHERE expires_at IS NOT NULL AND status = 'ACTIVE';
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE store_credits IS 'Store credit issuance and balance tracking from returns or manual';
COMMENT ON COLUMN store_credits.credit_number IS 'Unique credit identifier for lookup at POS';
Domain 6-7: Returns & Reporting
returns
-- Return/exchange header
CREATE TABLE returns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
return_number VARCHAR(20) NOT NULL,
original_order_id UUID NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
return_type VARCHAR(20) NOT NULL,
subtotal DECIMAL(19,4) NOT NULL,
tax_amount DECIMAL(19,4) NOT NULL,
refund_amount DECIMAL(19,4) NOT NULL,
refund_method VARCHAR(20) NOT NULL,
reason VARCHAR(255),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
CONSTRAINT returns_tenant_number_unique UNIQUE (tenant_id, return_number),
CONSTRAINT returns_status_check CHECK (status IN ('pending', 'approved', 'completed', 'rejected')),
CONSTRAINT returns_type_check CHECK (return_type IN ('refund', 'exchange', 'store_credit')),
CONSTRAINT returns_method_check CHECK (refund_method IN (
'original_payment', 'cash', 'store_credit', 'gift_card'
))
);
CREATE INDEX idx_returns_tenant ON returns(tenant_id);
CREATE INDEX idx_returns_tenant_order ON returns(tenant_id, original_order_id);
CREATE INDEX idx_returns_tenant_date ON returns(tenant_id, created_at DESC);
CREATE INDEX idx_returns_tenant_status ON returns(tenant_id, status);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE returns IS 'Return and exchange transaction headers';
return_items
-- Individual items being returned
CREATE TABLE return_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
return_id UUID NOT NULL REFERENCES returns(id) ON DELETE CASCADE,
order_item_id UUID NOT NULL REFERENCES order_items(id) ON DELETE RESTRICT,
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
quantity INT NOT NULL,
unit_price DECIMAL(19,4) NOT NULL,
refund_amount DECIMAL(19,4) NOT NULL,
reason VARCHAR(50),
condition VARCHAR(20) DEFAULT 'sellable',
restocked BOOLEAN DEFAULT FALSE,
restocked_location_id UUID REFERENCES locations(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT return_items_quantity_positive CHECK (quantity > 0),
CONSTRAINT return_items_condition_check CHECK (condition IN (
'sellable', 'damaged', 'defective', 'other'
))
);
CREATE INDEX idx_return_items_tenant ON return_items(tenant_id);
CREATE INDEX idx_return_items_tenant_return ON return_items(tenant_id, return_id);
CREATE INDEX idx_return_items_tenant_variant ON return_items(tenant_id, variant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE return_items IS 'Individual items in a return transaction';
reports (User Preferences)
-- Saved report configurations
CREATE TABLE reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
report_type VARCHAR(50) NOT NULL,
description TEXT,
query_config JSONB NOT NULL,
schedule_config JSONB,
is_system BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT FALSE,
created_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_reports_tenant ON reports(tenant_id);
CREATE INDEX idx_reports_tenant_type ON reports(tenant_id, report_type);
CREATE INDEX idx_reports_tenant_public ON reports(tenant_id, is_public) WHERE is_public = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE reports IS 'Saved report configurations and schedules';
Domain 8: User Preferences
item_view_settings
-- Personalized view preferences for inventory screens
CREATE TABLE item_view_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
view_type VARCHAR(10) DEFAULT 'list',
visible_columns JSONB,
sort_preferences JSONB,
filter_defaults JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT item_view_settings_tenant_user_unique UNIQUE (tenant_id, user_id),
CONSTRAINT item_view_settings_type_check CHECK (view_type IN ('list', 'grid', 'compact'))
);
CREATE INDEX idx_item_view_settings_tenant ON item_view_settings(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE item_view_settings IS 'User-specific inventory view preferences';
Domain 9: Tenant Management (Shared Schema)
See Chapter 07 (Schema Design) for complete shared schema tables: tenants, tenant_subscriptions, tenant_modules, users, user_sessions, password_resets
Domain 10: Authentication & Authorization
Domain Model: Employee
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| EMPLOYEE |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| employee_number | String(20) | Human-readable employee ID |
| first_name | String(100) | First name |
| last_name | String(100) | Last name |
| email | String(255) | Email address (unique) |
| phone | String(20) | Phone number |
| pin_hash | String(255) | Hashed PIN for clock-in |
| role_id | UUID | FK to Role |
| home_location_id| UUID | FK to primary Location |
| hire_date | Date | Date of hire |
| termination_date| Date | Date of termination |
| hourly_rate | Decimal | Hourly pay rate |
| commission_rate | Decimal | Commission percentage |
| is_active | Boolean | Employee is active |
| last_login_at | Timestamp | Last login timestamp |
| created_at | Timestamp | Creation timestamp |
| updated_at | Timestamp | Last update |
+------------------------------------------------------------------+
Domain Model: Role
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| ROLE |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| name | String(100) | Role name |
| code | String(50) | Role code (admin, manager, etc.) |
| description | Text | Role description |
| is_system | Boolean | System role (cannot delete) |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: Permission
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| PERMISSION |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| code | String(100) | Permission code |
| name | String(255) | Permission name |
| category | String(50) | Grouping category |
| description | Text | What this permission allows |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: RolePermission
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| ROLE_PERMISSION |
+------------------------------------------------------------------+
| role_id | UUID | FK to Role |
| permission_id | UUID | FK to Permission |
| created_at | Timestamp | When assigned |
| PRIMARY KEY (role_id, permission_id) |
+------------------------------------------------------------------+
Domain Model: Shift
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| SHIFT |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| employee_id | UUID | FK to Employee |
| location_id | UUID | FK to Location |
| clock_in | Timestamp | Clock in time |
| clock_out | Timestamp | Clock out time |
| break_minutes | Integer | Total break time |
| notes | Text | Shift notes |
| status | String(20) | active, completed, edited |
| edited_by | UUID | FK to Employee (if edited) |
| edit_reason | Text | Reason for edit |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
roles (RLS-Protected)
-- Role definitions per tenant
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100) NOT NULL,
description TEXT,
is_system BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT roles_tenant_name_unique UNIQUE (tenant_id, name)
);
CREATE INDEX idx_roles_tenant ON roles(tenant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE roles IS 'Role definitions customizable per tenant';
COMMENT ON COLUMN roles.is_system IS 'System roles (Owner, Admin, etc.) cannot be deleted';
role_permissions
-- Permission matrix linking roles to permissions
CREATE TABLE role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission VARCHAR(100) NOT NULL,
granted BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT role_permissions_tenant_unique UNIQUE (tenant_id, role_id, permission)
);
CREATE INDEX idx_role_permissions_tenant ON role_permissions(tenant_id);
CREATE INDEX idx_role_permissions_tenant_role ON role_permissions(tenant_id, role_id);
CREATE INDEX idx_role_permissions_tenant_perm ON role_permissions(tenant_id, permission);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE role_permissions IS 'Fine-grained permission assignments per role';
COMMENT ON COLUMN role_permissions.permission IS 'Permission ID (products.view, orders.create, etc.)';
tenant_users
-- User-tenant-role mapping
CREATE TABLE tenant_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
employee_id VARCHAR(20),
pin_hash VARCHAR(255),
hourly_rate DECIMAL(19,4),
commission_rate DECIMAL(5,4),
default_location_id UUID REFERENCES locations(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT TRUE,
hired_at DATE,
terminated_at DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT tenant_users_tenant_user_unique UNIQUE (tenant_id, user_id),
CONSTRAINT tenant_users_tenant_employee_unique UNIQUE (tenant_id, employee_id)
);
CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id);
CREATE INDEX idx_tenant_users_tenant_role ON tenant_users(tenant_id, role_id);
CREATE INDEX idx_tenant_users_tenant_location ON tenant_users(tenant_id, default_location_id);
CREATE INDEX idx_tenant_users_tenant_active ON tenant_users(tenant_id, is_active) WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE tenant_users IS 'Links platform users to tenant with role assignment';
COMMENT ON COLUMN tenant_users.employee_id IS 'Short ID for quick POS login';
COMMENT ON COLUMN tenant_users.pin_hash IS 'Quick login PIN (not primary auth)';
tenant_settings
-- Per-tenant configuration settings
CREATE TABLE tenant_settings (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
category VARCHAR(50) NOT NULL,
key VARCHAR(100) NOT NULL,
value TEXT NOT NULL,
value_type VARCHAR(20) NOT NULL,
description TEXT,
is_secret BOOLEAN DEFAULT FALSE,
updated_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT tenant_settings_tenant_key_unique UNIQUE (tenant_id, category, key),
CONSTRAINT tenant_settings_type_check CHECK (value_type IN ('string', 'number', 'boolean', 'json'))
);
CREATE INDEX idx_tenant_settings_tenant ON tenant_settings(tenant_id);
CREATE INDEX idx_tenant_settings_tenant_category ON tenant_settings(tenant_id, category);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE tenant_settings IS 'Key-value configuration settings per tenant';
COMMENT ON COLUMN tenant_settings.is_secret IS 'Mask value in UI (API keys, passwords)';
Domain 11: Offline Sync Infrastructure
devices
-- POS terminals and device registration
CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
device_type VARCHAR(30) NOT NULL,
hardware_id VARCHAR(255) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
cash_drawer_id UUID REFERENCES cash_drawers(id) ON DELETE SET NULL,
payment_terminal_id UUID REFERENCES payment_terminals(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
last_sync_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
app_version VARCHAR(20),
os_version VARCHAR(50),
ip_address INET,
push_token VARCHAR(500),
settings JSONB,
registered_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT devices_tenant_hardware_unique UNIQUE (tenant_id, hardware_id),
CONSTRAINT devices_type_check CHECK (device_type IN ('pos_terminal', 'tablet', 'mobile', 'kiosk')),
CONSTRAINT devices_status_check CHECK (status IN ('pending', 'active', 'disabled', 'lost'))
);
CREATE INDEX idx_devices_tenant ON devices(tenant_id);
CREATE INDEX idx_devices_tenant_location ON devices(tenant_id, location_id);
CREATE INDEX idx_devices_tenant_status ON devices(tenant_id, status);
CREATE INDEX idx_devices_tenant_last_seen ON devices(tenant_id, last_seen_at);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE devices IS 'Registered POS devices and tablets';
COMMENT ON COLUMN devices.hardware_id IS 'Unique hardware fingerprint to prevent cloning';
sync_queue
-- Pending sync operations from offline devices
CREATE TABLE sync_queue (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(),
operation_type VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
checksum VARCHAR(64) NOT NULL,
sequence_number BIGINT NOT NULL,
priority INT DEFAULT 5,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INT DEFAULT 0,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
CONSTRAINT sync_queue_idempotency_unique UNIQUE (idempotency_key),
CONSTRAINT sync_queue_status_check CHECK (status IN (
'pending', 'processing', 'completed', 'failed', 'conflict'
)),
CONSTRAINT sync_queue_priority_check CHECK (priority BETWEEN 1 AND 10)
);
CREATE INDEX idx_sync_queue_tenant ON sync_queue(tenant_id);
CREATE INDEX idx_sync_queue_tenant_device ON sync_queue(tenant_id, device_id, sequence_number);
CREATE INDEX idx_sync_queue_tenant_status ON sync_queue(tenant_id, status, priority, created_at);
CREATE INDEX idx_sync_queue_tenant_entity ON sync_queue(tenant_id, entity_type, entity_id);
CREATE INDEX idx_sync_queue_tenant_pending ON sync_queue(tenant_id, device_id, processed_at) WHERE processed_at IS NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE sync_queue IS 'Pending sync operations from offline devices';
COMMENT ON COLUMN sync_queue.idempotency_key IS 'Prevents duplicate processing on replay';
sync_checkpoints
-- Sync progress tracking per device
CREATE TABLE sync_checkpoints (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
entity_type VARCHAR(50) NOT NULL,
direction VARCHAR(10) NOT NULL,
last_sync_at TIMESTAMPTZ NOT NULL,
last_sequence BIGINT NOT NULL,
last_server_timestamp TIMESTAMPTZ,
records_synced INT DEFAULT 0,
error_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT sync_checkpoints_unique UNIQUE (device_id, entity_type, direction),
CONSTRAINT sync_checkpoints_direction_check CHECK (direction IN ('push', 'pull'))
);
CREATE INDEX idx_sync_checkpoints_tenant ON sync_checkpoints(tenant_id);
CREATE INDEX idx_sync_checkpoints_tenant_device ON sync_checkpoints(tenant_id, device_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE sync_checkpoints IS 'Tracks sync progress for incremental synchronization';
Domain 12: Event Infrastructure
event_outbox
-- Transactional Outbox pattern: domain events published reliably via polling
-- Events are inserted in the same transaction as the business operation,
-- then a background worker publishes them to LISTEN/NOTIFY (v1) or Kafka (v2).
CREATE TABLE event_outbox (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
metadata JSONB DEFAULT '{}',
idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 5,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
CONSTRAINT event_outbox_status_check CHECK (status IN (
'pending', 'published', 'failed', 'dead_letter'
)),
CONSTRAINT event_outbox_idempotency_unique UNIQUE (idempotency_key)
);
-- BRIN index on created_at for time-range scans (append-only table)
CREATE INDEX idx_event_outbox_pending ON event_outbox(status, created_at)
WHERE status = 'pending';
CREATE INDEX idx_event_outbox_tenant ON event_outbox(tenant_id);
CREATE INDEX idx_event_outbox_aggregate ON event_outbox(tenant_id, aggregate_type, aggregate_id);
CREATE INDEX idx_event_outbox_created ON event_outbox USING BRIN (created_at);
CREATE INDEX idx_event_outbox_failed ON event_outbox(status, retry_count)
WHERE status = 'failed' AND retry_count < max_retries;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE event_outbox IS 'Transactional Outbox: domain events queued for reliable async publishing';
COMMENT ON COLUMN event_outbox.idempotency_key IS 'SHA-256 or UUID for 24h deduplication window';
COMMENT ON COLUMN event_outbox.aggregate_type IS 'e.g., Order, Product, InventoryLevel, RfidScanSession';
state_transitions
-- DB-driven state machine transitions for all 16 aggregate state machines
-- Used by the state machine engine instead of hardcoded if/else logic.
-- Reference: Ch 04 Section L.4A.4 (Domain Events Catalog) for all state machines.
CREATE TABLE state_transitions (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
aggregate_type VARCHAR(100) NOT NULL,
from_state VARCHAR(50) NOT NULL,
to_state VARCHAR(50) NOT NULL,
trigger_event VARCHAR(100) NOT NULL,
guard_condition TEXT,
side_effects TEXT[],
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT state_transitions_unique UNIQUE (tenant_id, aggregate_type, from_state, trigger_event),
CONSTRAINT state_transitions_no_self CHECK (from_state != to_state)
);
CREATE INDEX idx_state_transitions_tenant ON state_transitions(tenant_id);
CREATE INDEX idx_state_transitions_lookup ON state_transitions(tenant_id, aggregate_type, from_state)
WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE state_transitions IS 'DB-driven state machine definitions for 16 aggregate types';
COMMENT ON COLUMN state_transitions.guard_condition IS 'Optional TypeScript expression evaluated before transition';
COMMENT ON COLUMN state_transitions.side_effects IS 'Array of domain event types emitted on transition';
-- Example seed data (Order state machine):
-- INSERT INTO state_transitions (tenant_id, aggregate_type, from_state, to_state, trigger_event, side_effects)
-- VALUES
-- ('{tid}', 'Order', 'draft', 'open', 'OrderConfirmed', ARRAY['OrderConfirmedEvent']),
-- ('{tid}', 'Order', 'open', 'completed', 'OrderCompleted', ARRAY['OrderCompletedEvent', 'InventoryDeductedEvent']),
-- ('{tid}', 'Order', 'open', 'voided', 'OrderVoided', ARRAY['OrderVoidedEvent', 'InventoryRestoredEvent']),
-- ('{tid}', 'Order', 'completed', 'returning', 'ReturnInitiated', ARRAY['ReturnInitiatedEvent']);
Domain 13: Cash Drawer Operations
Domain Model: Location
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| LOCATION |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| code | String(10) | Short code (HQ, GM, LM) |
| name | String(255) | Full location name |
| type | String(20) | store, warehouse, popup |
| address_line1 | String(255) | Street address |
| address_line2 | String(255) | Suite/unit |
| city | String(100) | City |
| state | String(50) | State/Province |
| postal_code | String(20) | ZIP/Postal code |
| country | String(2) | Country code |
| phone | String(20) | Phone number |
| email | String(255) | Email address |
| timezone | String(50) | IANA timezone |
| currency | String(3) | Currency code |
| shopify_location_id | String(50) | Shopify location ID |
| is_active | Boolean | Location is operational |
| can_fulfill | Boolean | Can fulfill online orders |
| is_visible_online | Boolean | Show inventory online |
| fulfillment_priority | Integer| Order for fulfillment routing |
| opening_hours | JSONB | Weekly schedule |
| created_at | Timestamp | Creation timestamp |
| updated_at | Timestamp | Last update |
+------------------------------------------------------------------+
Domain Model: Register
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| REGISTER |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| location_id | UUID | FK to Location |
| register_number | String(20) | Register identifier |
| name | String(100) | Display name |
| receipt_footer | Text | Custom receipt message |
| is_active | Boolean | Register is operational |
| last_opened_at | Timestamp | Last opened |
| last_closed_at | Timestamp | Last closed |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: CashDrawer
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| CASH_DRAWER |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| register_id | UUID | FK to Register |
| employee_id | UUID | FK to Employee (opened by) |
| opened_at | Timestamp | When opened |
| closed_at | Timestamp | When closed |
| opening_balance | Decimal | Starting cash amount |
| closing_balance | Decimal | Ending cash amount |
| expected_balance| Decimal | Expected based on transactions |
| variance | Decimal | Difference (closing - expected) |
| notes | Text | Drawer notes |
| status | String(20) | open, closed, reconciled |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
Domain Model: TaxRate
Business context reference — see Ch 04: Architecture Styles, Section L.9C
+------------------------------------------------------------------+
| TAX_RATE |
+------------------------------------------------------------------+
| id | UUID | Primary key |
| location_id | UUID | FK to Location (nullable=all) |
| name | String(100) | Tax name |
| rate | Decimal | Tax rate percentage |
| tax_code | String(20) | Tax category code |
| is_compound | Boolean | Calculated on tax-inclusive total |
| priority | Integer | Order of application |
| is_active | Boolean | Tax is active |
| created_at | Timestamp | Creation timestamp |
+------------------------------------------------------------------+
shifts
-- Shift lifecycle management
CREATE TABLE shifts (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
shift_number VARCHAR(20) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
cash_drawer_id UUID NOT NULL REFERENCES cash_drawers(id) ON DELETE RESTRICT,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
opened_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
closed_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ,
opening_cash DECIMAL(12,2) NOT NULL,
expected_cash DECIMAL(12,2),
actual_cash DECIMAL(12,2),
cash_variance DECIMAL(12,2),
total_sales DECIMAL(12,2) DEFAULT 0,
total_refunds DECIMAL(12,2) DEFAULT 0,
total_voids DECIMAL(12,2) DEFAULT 0,
transaction_count INT DEFAULT 0,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT shifts_tenant_number_unique UNIQUE (tenant_id, shift_number),
CONSTRAINT shifts_status_check CHECK (status IN ('open', 'closing', 'closed', 'reconciled')),
CONSTRAINT shifts_opening_positive CHECK (opening_cash >= 0)
);
CREATE INDEX idx_shifts_tenant ON shifts(tenant_id);
CREATE INDEX idx_shifts_tenant_location ON shifts(tenant_id, location_id, opened_at DESC);
CREATE INDEX idx_shifts_tenant_drawer_open ON shifts(tenant_id, cash_drawer_id, status) WHERE status = 'open';
CREATE INDEX idx_shifts_tenant_date ON shifts(tenant_id, opened_at DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE shifts IS 'Employee shift lifecycle with cash accountability';
COMMENT ON COLUMN shifts.shift_number IS 'Format: LOC-YYYYMMDD-SEQUENCE';
cash_drawers
-- Physical cash drawer registration
CREATE TABLE cash_drawers (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(50) NOT NULL,
drawer_number VARCHAR(20) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
current_shift_id INT, -- FK added after shifts table exists
status VARCHAR(20) NOT NULL DEFAULT 'available',
is_active BOOLEAN DEFAULT TRUE,
last_counted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT cash_drawers_tenant_number_unique UNIQUE (tenant_id, drawer_number),
CONSTRAINT cash_drawers_status_check CHECK (status IN ('available', 'in_use', 'maintenance'))
);
-- Add FK after shifts table exists
ALTER TABLE cash_drawers ADD CONSTRAINT cash_drawers_shift_fk
FOREIGN KEY (current_shift_id) REFERENCES shifts(id) ON DELETE SET NULL;
CREATE INDEX idx_cash_drawers_tenant ON cash_drawers(tenant_id);
CREATE INDEX idx_cash_drawers_tenant_location ON cash_drawers(tenant_id, location_id);
CREATE INDEX idx_cash_drawers_tenant_status ON cash_drawers(tenant_id, status, location_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE cash_drawers IS 'Physical cash drawer registration and status';
cash_counts
-- Detailed cash denomination counts
CREATE TABLE cash_counts (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
shift_id INT NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
count_type VARCHAR(20) NOT NULL,
counted_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
count_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Coins
pennies INT DEFAULT 0,
nickels INT DEFAULT 0,
dimes INT DEFAULT 0,
quarters INT DEFAULT 0,
half_dollars INT DEFAULT 0,
dollar_coins INT DEFAULT 0,
-- Bills
ones INT DEFAULT 0,
twos INT DEFAULT 0,
fives INT DEFAULT 0,
tens INT DEFAULT 0,
twenties INT DEFAULT 0,
fifties INT DEFAULT 0,
hundreds INT DEFAULT 0,
-- Other
rolled_coins DECIMAL(10,2) DEFAULT 0,
other_amount DECIMAL(10,2) DEFAULT 0,
total_amount DECIMAL(12,2) NOT NULL,
notes TEXT,
CONSTRAINT cash_counts_type_check CHECK (count_type IN (
'opening', 'closing', 'drop', 'pickup', 'audit', 'mid_shift'
)),
CONSTRAINT cash_counts_total_positive CHECK (total_amount >= 0)
);
CREATE INDEX idx_cash_counts_tenant ON cash_counts(tenant_id);
CREATE INDEX idx_cash_counts_tenant_shift ON cash_counts(tenant_id, shift_id, count_type);
CREATE INDEX idx_cash_counts_tenant_timestamp ON cash_counts(tenant_id, count_timestamp DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE cash_counts IS 'Denomination-level cash counts for accountability';
cash_movements
-- Cash audit trail for all drawer operations
CREATE TABLE cash_movements (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
shift_id INT NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
cash_drawer_id INT NOT NULL REFERENCES cash_drawers(id) ON DELETE RESTRICT,
movement_type VARCHAR(30) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
reference_type VARCHAR(50),
reference_id INT,
performed_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
approved_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
reason VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT cash_movements_type_check CHECK (movement_type IN (
'sale_cash', 'refund_cash', 'paid_in', 'paid_out',
'cash_drop', 'cash_pickup', 'opening_float', 'closing_count', 'no_sale'
))
);
CREATE INDEX idx_cash_movements_tenant ON cash_movements(tenant_id);
CREATE INDEX idx_cash_movements_tenant_shift ON cash_movements(tenant_id, shift_id);
CREATE INDEX idx_cash_movements_tenant_drawer ON cash_movements(tenant_id, cash_drawer_id, created_at DESC);
CREATE INDEX idx_cash_movements_tenant_type ON cash_movements(tenant_id, movement_type);
CREATE INDEX idx_cash_movements_tenant_reference ON cash_movements(tenant_id, reference_type, reference_id)
WHERE reference_type IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE cash_movements IS 'Immutable audit trail of all cash drawer operations';
cash_drops
-- Cash drops from drawer to safe
CREATE TABLE cash_drops (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
shift_id INT NOT NULL REFERENCES shifts(id) ON DELETE CASCADE,
cash_drawer_id INT NOT NULL REFERENCES cash_drawers(id) ON DELETE RESTRICT,
drop_number VARCHAR(20) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
dropped_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
witnessed_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
safe_bag_number VARCHAR(50),
counted_amount DECIMAL(12,2),
variance DECIMAL(12,2),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
notes TEXT,
dropped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ,
CONSTRAINT cash_drops_tenant_number_unique UNIQUE (tenant_id, drop_number),
CONSTRAINT cash_drops_amount_positive CHECK (amount > 0),
CONSTRAINT cash_drops_status_check CHECK (status IN ('pending', 'verified', 'variance'))
);
CREATE INDEX idx_cash_drops_tenant ON cash_drops(tenant_id);
CREATE INDEX idx_cash_drops_tenant_shift ON cash_drops(tenant_id, shift_id);
CREATE INDEX idx_cash_drops_tenant_status ON cash_drops(tenant_id, status) WHERE status = 'pending';
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE cash_drops IS 'Cash drops from drawer to safe with verification tracking';
cash_pickups
-- Armored car pickup tracking
CREATE TABLE cash_pickups (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
pickup_date DATE NOT NULL,
carrier VARCHAR(100) NOT NULL,
driver_name VARCHAR(100),
driver_id VARCHAR(50),
pickup_number VARCHAR(50),
expected_amount DECIMAL(12,2) NOT NULL,
bag_count INT NOT NULL,
bag_numbers TEXT[],
received_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
verified_amount DECIMAL(12,2),
variance DECIMAL(12,2),
status VARCHAR(20) NOT NULL DEFAULT 'picked_up',
notes TEXT,
picked_up_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deposited_at TIMESTAMPTZ,
CONSTRAINT cash_pickups_tenant_number_unique UNIQUE (tenant_id, pickup_number),
CONSTRAINT cash_pickups_status_check CHECK (status IN (
'picked_up', 'in_transit', 'deposited', 'variance'
))
);
CREATE INDEX idx_cash_pickups_tenant ON cash_pickups(tenant_id);
CREATE INDEX idx_cash_pickups_tenant_location ON cash_pickups(tenant_id, location_id, pickup_date DESC);
CREATE INDEX idx_cash_pickups_tenant_status ON cash_pickups(tenant_id, status);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE cash_pickups IS 'Armored car pickup and bank deposit tracking';
Domain 14: Payment Processing
payment_terminals
-- Payment device registration
CREATE TABLE payment_terminals (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
terminal_id VARCHAR(50) NOT NULL,
name VARCHAR(100) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
processor VARCHAR(50) NOT NULL,
terminal_type VARCHAR(30) NOT NULL,
model VARCHAR(100),
serial_number VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'active',
supports_contactless BOOLEAN DEFAULT TRUE,
supports_emv BOOLEAN DEFAULT TRUE,
supports_swipe BOOLEAN DEFAULT TRUE,
last_transaction_at TIMESTAMPTZ,
last_batch_at TIMESTAMPTZ,
configuration JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT payment_terminals_tenant_id_unique UNIQUE (tenant_id, terminal_id),
CONSTRAINT payment_terminals_type_check CHECK (terminal_type IN (
'integrated', 'standalone', 'virtual', 'mobile'
)),
CONSTRAINT payment_terminals_status_check CHECK (status IN (
'active', 'offline', 'maintenance', 'disabled'
))
);
CREATE INDEX idx_payment_terminals_tenant ON payment_terminals(tenant_id);
CREATE INDEX idx_payment_terminals_tenant_location ON payment_terminals(tenant_id, location_id);
CREATE INDEX idx_payment_terminals_tenant_status ON payment_terminals(tenant_id, status);
CREATE INDEX idx_payment_terminals_device ON payment_terminals(device_id) WHERE device_id IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE payment_terminals IS 'Payment device registration and configuration';
payment_attempts
-- Payment processing attempt tracking
CREATE TABLE payment_attempts (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
terminal_id UUID REFERENCES payment_terminals(id) ON DELETE SET NULL,
payment_method VARCHAR(30) NOT NULL,
card_type VARCHAR(20),
card_last_four CHAR(4),
card_entry_mode VARCHAR(20),
amount DECIMAL(12,2) NOT NULL,
tip_amount DECIMAL(10,2) DEFAULT 0,
total_amount DECIMAL(12,2) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'USD',
status VARCHAR(20) NOT NULL,
processor_response_code VARCHAR(10),
processor_response_text VARCHAR(255),
authorization_code VARCHAR(20),
processor_transaction_id VARCHAR(100),
avs_result VARCHAR(10),
cvv_result VARCHAR(10),
emv_application_id VARCHAR(32),
emv_cryptogram VARCHAR(64),
risk_score INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
CONSTRAINT payment_attempts_method_check CHECK (payment_method IN (
'card', 'cash', 'gift_card', 'store_credit', 'check', 'mobile_pay'
)),
CONSTRAINT payment_attempts_status_check CHECK (status IN (
'pending', 'approved', 'declined', 'error', 'voided', 'refunded'
)),
CONSTRAINT payment_attempts_entry_check CHECK (card_entry_mode IS NULL OR card_entry_mode IN (
'chip', 'swipe', 'contactless', 'manual', 'ecommerce', 'fallback'
))
);
CREATE INDEX idx_payment_attempts_tenant ON payment_attempts(tenant_id);
CREATE INDEX idx_payment_attempts_tenant_order ON payment_attempts(tenant_id, order_id);
CREATE INDEX idx_payment_attempts_tenant_status ON payment_attempts(tenant_id, status, created_at DESC);
CREATE INDEX idx_payment_attempts_processor ON payment_attempts(processor_transaction_id)
WHERE processor_transaction_id IS NOT NULL;
CREATE INDEX idx_payment_attempts_tenant_date ON payment_attempts(tenant_id, created_at DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE payment_attempts IS 'Payment processing attempts with full audit trail';
COMMENT ON COLUMN payment_attempts.emv_cryptogram IS 'EMV TC/ARQC for chip transactions';
payment_batches
-- End-of-day settlement batch tracking
CREATE TABLE payment_batches (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
batch_number VARCHAR(50) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
terminal_id INT REFERENCES payment_terminals(id) ON DELETE SET NULL,
processor VARCHAR(50) NOT NULL,
batch_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
transaction_count INT NOT NULL DEFAULT 0,
gross_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
net_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
fee_amount DECIMAL(10,2),
deposit_amount DECIMAL(12,2),
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
submitted_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
deposit_reference VARCHAR(100),
notes TEXT,
CONSTRAINT payment_batches_tenant_number_unique UNIQUE (tenant_id, batch_number),
CONSTRAINT payment_batches_status_check CHECK (status IN (
'open', 'pending', 'settled', 'rejected'
)),
CONSTRAINT payment_batches_net_math CHECK (net_amount = gross_amount - refund_amount)
);
CREATE INDEX idx_payment_batches_tenant ON payment_batches(tenant_id);
CREATE INDEX idx_payment_batches_tenant_location ON payment_batches(tenant_id, location_id, batch_date DESC);
CREATE INDEX idx_payment_batches_tenant_status ON payment_batches(tenant_id, status) WHERE status IN ('open', 'pending');
CREATE INDEX idx_payment_batches_tenant_date ON payment_batches(tenant_id, batch_date DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE payment_batches IS 'End-of-day settlement batch tracking';
payment_reconciliation
-- Variance tracking between POS and processor/bank
CREATE TABLE payment_reconciliation (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
batch_id INT NOT NULL REFERENCES payment_batches(id) ON DELETE CASCADE,
reconciliation_date DATE NOT NULL,
pos_transaction_count INT NOT NULL,
processor_transaction_count INT NOT NULL,
transaction_count_variance INT NOT NULL,
pos_gross_amount DECIMAL(12,2) NOT NULL,
processor_gross_amount DECIMAL(12,2) NOT NULL,
gross_variance DECIMAL(12,2) NOT NULL,
pos_net_amount DECIMAL(12,2) NOT NULL,
bank_deposit_amount DECIMAL(12,2),
deposit_variance DECIMAL(12,2),
fee_variance DECIMAL(10,2),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
variance_reason TEXT,
resolved_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT payment_recon_status_check CHECK (status IN (
'pending', 'matched', 'variance', 'resolved'
))
);
CREATE INDEX idx_payment_recon_tenant ON payment_reconciliation(tenant_id);
CREATE INDEX idx_payment_recon_tenant_batch ON payment_reconciliation(tenant_id, batch_id);
CREATE INDEX idx_payment_recon_tenant_date ON payment_reconciliation(tenant_id, reconciliation_date DESC);
CREATE INDEX idx_payment_recon_tenant_status ON payment_reconciliation(tenant_id, status)
WHERE status IN ('pending', 'variance');
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE payment_reconciliation IS 'Payment reconciliation with variance tracking';
Domain 15: Tax Configuration
tax_jurisdictions
-- Compound tax jurisdictions (State/County/City per ADR mandate)
CREATE TABLE tax_jurisdictions (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
code VARCHAR(20) NOT NULL,
jurisdiction_level VARCHAR(20) NOT NULL,
parent_jurisdiction_id INT REFERENCES tax_jurisdictions(id) ON DELETE SET NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT tax_jurisdictions_tenant_code_unique UNIQUE (tenant_id, code),
CONSTRAINT tax_jurisdictions_level_check CHECK (jurisdiction_level IN (
'state', 'county', 'city', 'district'
))
);
CREATE INDEX idx_tax_jurisdictions_tenant ON tax_jurisdictions(tenant_id);
CREATE INDEX idx_tax_jurisdictions_tenant_level ON tax_jurisdictions(tenant_id, jurisdiction_level);
CREATE INDEX idx_tax_jurisdictions_parent ON tax_jurisdictions(parent_jurisdiction_id)
WHERE parent_jurisdiction_id IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE tax_jurisdictions IS 'Compound tax jurisdictions: State > County > City hierarchy';
tax_rates
-- Tax rates per jurisdiction with effective date ranges
CREATE TABLE tax_rates (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
jurisdiction_id INT NOT NULL REFERENCES tax_jurisdictions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
rate DECIMAL(5,4) NOT NULL,
is_compound BOOLEAN DEFAULT FALSE,
applies_to VARCHAR(20) NOT NULL DEFAULT 'all',
effective_from DATE NOT NULL,
effective_to DATE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT tax_rates_rate_check CHECK (rate >= 0 AND rate <= 1),
CONSTRAINT tax_rates_dates CHECK (effective_to IS NULL OR effective_to > effective_from),
CONSTRAINT tax_rates_applies_check CHECK (applies_to IN ('all', 'goods', 'services', 'clothing'))
);
-- Compound tax example: State 6.25% + County 1.00% + City 1.00% = 8.25%
-- Each level is a separate row linked to its jurisdiction
CREATE INDEX idx_tax_rates_tenant ON tax_rates(tenant_id);
CREATE INDEX idx_tax_rates_tenant_jurisdiction ON tax_rates(tenant_id, jurisdiction_id);
CREATE INDEX idx_tax_rates_effective ON tax_rates(tenant_id, effective_from, effective_to)
WHERE is_active = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE tax_rates IS 'Tax rates per jurisdiction with effective date ranges (rate as decimal: 0.0825 = 8.25%)';
location_tax_jurisdictions
-- Assigns tax jurisdictions to locations (which taxes apply where)
CREATE TABLE location_tax_jurisdictions (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
jurisdiction_id INT NOT NULL REFERENCES tax_jurisdictions(id) ON DELETE CASCADE,
effective_from DATE NOT NULL,
effective_to DATE,
CONSTRAINT location_tax_juris_unique UNIQUE (tenant_id, location_id, jurisdiction_id),
CONSTRAINT location_tax_juris_dates CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE INDEX idx_location_tax_juris_tenant ON location_tax_jurisdictions(tenant_id);
CREATE INDEX idx_location_tax_juris_location ON location_tax_jurisdictions(tenant_id, location_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE location_tax_jurisdictions IS 'Assigns compound tax jurisdictions to store locations';
Domain 16: RFID Module (Optional)
rfid_config
-- Tenant RFID configuration (counting subsystem)
CREATE TABLE rfid_config (
id SERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
epc_company_prefix VARCHAR(24) NOT NULL,
epc_indicator CHAR(1) NOT NULL DEFAULT '0',
epc_filter CHAR(1) NOT NULL DEFAULT '3',
epc_partition INT NOT NULL DEFAULT 5,
min_rssi_threshold SMALLINT NOT NULL DEFAULT -70,
auto_save_interval_seconds INT NOT NULL DEFAULT 30,
chunk_upload_size INT NOT NULL DEFAULT 5000,
default_template_id UUID,
default_printer_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_config_tenant_unique UNIQUE (tenant_id)
);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
-- Serial numbers use PostgreSQL SEQUENCE per tenant:
-- CREATE SEQUENCE rfid_epc_serial_{tenant_short_id} START 1 INCREMENT 1 NO CYCLE;
-- Application calls nextval('rfid_epc_serial_{tenant_short_id}') during tag encoding.
COMMENT ON TABLE rfid_config IS 'Tenant RFID configuration for EPC encoding and scanning';
COMMENT ON COLUMN rfid_config.min_rssi_threshold IS 'Minimum RSSI (dBm) to accept tag reads; default -70';
rfid_printers
-- Registered RFID printers
CREATE TABLE rfid_printers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
printer_type VARCHAR(50) NOT NULL,
connection_type VARCHAR(20) NOT NULL,
ip_address INET,
port INT DEFAULT 9100,
mac_address VARCHAR(17),
serial_number VARCHAR(100),
firmware_version VARCHAR(50),
dpi INT DEFAULT 203,
label_width_mm INT NOT NULL,
label_height_mm INT NOT NULL,
rfid_position VARCHAR(20) DEFAULT 'center',
status VARCHAR(20) NOT NULL DEFAULT 'offline',
last_seen_at TIMESTAMPTZ,
error_message TEXT,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_printers_type_check CHECK (printer_type IN (
'zebra_zd621r', 'zebra_zd500r', 'sato_cl4nx', 'tsc_mx240p'
)),
CONSTRAINT rfid_printers_conn_check CHECK (connection_type IN ('network', 'usb', 'bluetooth'))
);
CREATE INDEX idx_rfid_printers_location ON rfid_printers(location_id);
CREATE INDEX idx_rfid_printers_status ON rfid_printers(status);
CREATE UNIQUE INDEX idx_rfid_printers_default ON rfid_printers(location_id) WHERE is_default = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_printers IS 'RFID-enabled printers registered per location';
rfid_print_jobs
-- Print job queue
CREATE TABLE rfid_print_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
job_number VARCHAR(20) NOT NULL,
printer_id UUID NOT NULL REFERENCES rfid_printers(id) ON DELETE RESTRICT,
template_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'queued',
priority INT DEFAULT 5,
total_tags INT NOT NULL,
printed_tags INT DEFAULT 0,
failed_tags INT DEFAULT 0,
error_message TEXT,
job_data JSONB NOT NULL,
created_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_print_jobs_number_unique UNIQUE (job_number),
CONSTRAINT rfid_print_jobs_status_check CHECK (status IN (
'queued', 'printing', 'completed', 'failed', 'cancelled'
)),
CONSTRAINT rfid_print_jobs_priority_check CHECK (priority BETWEEN 1 AND 10)
);
CREATE INDEX idx_rfid_print_jobs_status ON rfid_print_jobs(status, priority, created_at)
WHERE status IN ('queued', 'printing');
CREATE INDEX idx_rfid_print_jobs_printer ON rfid_print_jobs(printer_id, created_at DESC);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_print_jobs IS 'RFID tag print job queue with progress tracking';
rfid_tags
-- Individual RFID tags linked to variants (counting subsystem — no lifecycle fields)
CREATE TABLE rfid_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
epc VARCHAR(24) NOT NULL,
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
serial_number BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
print_job_id UUID REFERENCES rfid_print_jobs(id) ON DELETE SET NULL,
printed_at TIMESTAMPTZ,
printed_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
current_location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
last_scanned_at TIMESTAMPTZ,
last_scanned_session_id UUID,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Scope: counting only — no sold_at, sold_order_id, transferred_at fields
-- Sales and transfers tracked by core inventory system via barcode, not RFID
CONSTRAINT rfid_tags_epc_unique UNIQUE (tenant_id, epc),
CONSTRAINT rfid_tags_epc_format CHECK (epc ~ '^[0-9A-F]{24}$'),
CONSTRAINT rfid_tags_status_check CHECK (status IN ('active', 'void', 'lost'))
);
CREATE INDEX idx_rfid_tags_variant ON rfid_tags(tenant_id, variant_id, status) WHERE status = 'active';
CREATE INDEX idx_rfid_tags_location ON rfid_tags(tenant_id, current_location_id, status) WHERE status = 'active';
CREATE INDEX idx_rfid_tags_serial ON rfid_tags(tenant_id, serial_number);
CREATE INDEX idx_rfid_tags_scan ON rfid_tags(last_scanned_at DESC) WHERE last_scanned_at IS NOT NULL;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_tags IS 'Individual RFID tags for inventory counting (EPC-level tracking)';
COMMENT ON COLUMN rfid_tags.epc IS 'SGTIN-96 Electronic Product Code (24 hex chars)';
rfid_scan_sessions
-- Inventory scan sessions (counting subsystem — no 'receiving' type)
CREATE TABLE rfid_scan_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
session_number VARCHAR(20) NOT NULL,
location_id UUID NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
session_type VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
started_by UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
completed_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
total_reads INT DEFAULT 0,
unique_tags INT DEFAULT 0,
matched_tags INT DEFAULT 0,
unmatched_tags INT DEFAULT 0,
expected_count INT,
variance INT,
variance_value DECIMAL(12,2),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_sessions_number_unique UNIQUE (tenant_id, session_number),
CONSTRAINT rfid_sessions_type_check CHECK (session_type IN (
'full_inventory', 'cycle_count', 'spot_check', 'find_item'
)),
-- NOTE: 'receiving' removed — RFID is counting only (see BRD Section 5.16)
CONSTRAINT rfid_sessions_status_check CHECK (status IN (
'in_progress', 'completed', 'cancelled', 'uploaded'
))
);
CREATE INDEX idx_rfid_sessions_location ON rfid_scan_sessions(tenant_id, location_id, started_at DESC);
CREATE INDEX idx_rfid_sessions_status ON rfid_scan_sessions(status) WHERE status = 'in_progress';
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_scan_sessions IS 'RFID inventory count sessions with variance calculation';
rfid_scan_events
-- Aggregated tag reads during scan sessions (one row per unique EPC per session)
CREATE TABLE rfid_scan_events (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
session_id UUID NOT NULL REFERENCES rfid_scan_sessions(id) ON DELETE CASCADE,
epc VARCHAR(24) NOT NULL,
rfid_tag_id UUID REFERENCES rfid_tags(id) ON DELETE SET NULL,
rssi SMALLINT,
read_count INT DEFAULT 1,
antenna SMALLINT,
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
-- Idempotency: same EPC can only appear once per session
-- On duplicate upload (retry), use UPSERT:
-- ON CONFLICT (session_id, epc) DO UPDATE SET
-- rssi = GREATEST(excluded.rssi, rfid_scan_events.rssi),
-- read_count = rfid_scan_events.read_count + excluded.read_count,
-- last_seen_at = GREATEST(excluded.last_seen_at, rfid_scan_events.last_seen_at)
CONSTRAINT rfid_events_idempotent UNIQUE (session_id, epc)
);
-- Indexes
CREATE INDEX idx_rfid_events_session ON rfid_scan_events(session_id);
CREATE INDEX idx_rfid_events_epc ON rfid_scan_events(epc);
CREATE INDEX idx_rfid_events_tag ON rfid_scan_events(rfid_tag_id) WHERE rfid_tag_id IS NOT NULL;
CREATE INDEX idx_rfid_events_unknown ON rfid_scan_events(session_id) WHERE rfid_tag_id IS NULL;
CREATE INDEX idx_rfid_events_time ON rfid_scan_events USING BRIN (first_seen_at);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_scan_events IS 'Aggregated RFID tag reads per session (pre-deduplicated by EPC)';
COMMENT ON COLUMN rfid_scan_events.rssi IS 'Best signal strength (-127 to 0 dBm)';
rfid_tag_templates
-- ZPL label templates for RFID tag printing
CREATE TABLE rfid_tag_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
name VARCHAR(100) NOT NULL,
template_type VARCHAR(20) NOT NULL,
zpl_content TEXT NOT NULL,
variables JSONB NOT NULL DEFAULT '[]',
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_templates_type_check CHECK (template_type IN (
'hang_tag', 'price_tag', 'label'
))
);
CREATE INDEX idx_rfid_templates_tenant ON rfid_tag_templates(tenant_id);
CREATE UNIQUE INDEX idx_rfid_templates_default ON rfid_tag_templates(tenant_id, template_type)
WHERE is_default = TRUE;
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_tag_templates IS 'ZPL label templates for RFID tag printing';
rfid_tag_mappings
-- EPC prefix to product variant mappings for offline decoding
CREATE TABLE rfid_tag_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
epc_prefix VARCHAR(20) NOT NULL,
variant_id UUID NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
sku VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT rfid_mappings_prefix_unique UNIQUE (tenant_id, epc_prefix)
);
CREATE INDEX idx_rfid_mappings_sku ON rfid_tag_mappings(tenant_id, sku);
CREATE INDEX idx_rfid_mappings_variant ON rfid_tag_mappings(variant_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE rfid_tag_mappings IS 'Maps EPC prefix ranges to product variants for offline decoding on Raptag mobile app';
session_operators
-- Multiple operators per RFID scan session with section assignments
CREATE TABLE session_operators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES shared.tenants(id),
session_id UUID NOT NULL REFERENCES rfid_scan_sessions(id) ON DELETE CASCADE,
operator_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE RESTRICT,
assigned_section TEXT,
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
left_at TIMESTAMPTZ,
CONSTRAINT session_operators_unique UNIQUE (session_id, operator_id)
);
CREATE INDEX idx_session_operators_session ON session_operators(session_id);
CREATE INDEX idx_session_operators_user ON session_operators(operator_id);
-- RLS: tenant_id = current_setting('app.current_tenant')::uuid
COMMENT ON TABLE session_operators IS 'Multiple operators per RFID scan session with section assignments (max 10 per session)';
8.2 Table Count Summary
| Domain | Tables | Schema Location |
|---|---|---|
| 1-2. Catalog (Products, Categories, Tags, Attributes, Pricing) | 13 | tenant |
| 3. Inventory & Locations | 7 | tenant |
| 4. Sales (Orders & Customers) | 3 | tenant |
| 5. Customer Loyalty & Gift Cards | 5 | tenant |
| 6-7. Returns & Reporting | 3 | tenant |
| 8. User Preferences | 1 | tenant |
| 9. Tenant Management | 6 | shared |
| 10. Auth & Authorization | 4 | tenant |
| 11. Offline Sync | 3 | tenant |
| 12. Event Infrastructure | 2 | tenant |
| 13. Cash Drawer Operations | 6 | tenant |
| 14. Payment Processing | 4 | tenant |
| 15. Tax Configuration | 3 | tenant |
| 16. RFID Module (Optional) | 9 | tenant |
| TOTAL | 69 | 6 shared + 63 tenant |
Next Chapter: Chapter 09: Indexes & Performance - Index strategy and query optimization.
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | III - Database |
| Chapter | 08 of 9 |
This chapter is part of the POS Blueprint Book. All content is self-contained.
Chapter 09: Indexes & Performance
Query Optimization and Index Strategy
9.1 Overview
This chapter provides the complete indexing strategy for the POS Platform database, organized by query pattern. Proper indexing is critical for a multi-tenant POS system where response times directly impact customer experience.
Note: The indexes defined in this chapter are supplemental to the inline indexes in Ch 08 (Entity Specifications). If an index appears in both chapters, the Ch 08 definition embedded in the CREATE TABLE is authoritative.
Performance Targets
| Operation | Target | Critical Threshold |
|---|---|---|
| Product lookup by SKU | < 5ms | 20ms |
| Product lookup by barcode | < 5ms | 20ms |
| Inventory check (single location) | < 10ms | 50ms |
| Order creation | < 50ms | 200ms |
| Customer search by name | < 20ms | 100ms |
| Daily sales report | < 500ms | 2s |
| Inventory count by location | < 100ms | 500ms |
9.2 Index Types and When to Use Them
B-Tree Indexes (Default)
Best for: Equality comparisons, range queries, sorting
-- Equality lookup (most common, tenant_id leads for RLS)
CREATE INDEX idx_products_sku ON products(tenant_id, sku);
-- Range query support
CREATE INDEX idx_orders_date ON orders(tenant_id, created_at);
-- Composite for multiple conditions
CREATE INDEX idx_inventory_lookup ON inventory_levels(tenant_id, variant_id, location_id);
BRIN Indexes (Block Range)
Best for: Time-series data, append-only tables, large datasets
-- Inventory transactions (append-only, ordered by time)
CREATE INDEX idx_inventory_trans_date ON inventory_transactions USING BRIN (created_at);
-- RFID scan events (high-volume, time-ordered)
CREATE INDEX idx_rfid_events_created ON rfid_scan_events USING BRIN (first_seen_at);
-- Sync queue (sequential inserts)
CREATE INDEX idx_sync_queue_created ON sync_queue USING BRIN (created_at);
-- Event outbox (transactional outbox, append-only)
CREATE INDEX idx_event_outbox_created ON event_outbox USING BRIN (created_at);
BRIN Benefits:
- 100x smaller than B-tree for time-series
- Faster inserts (less index maintenance)
- Perfect for audit/event tables
BRIN Limitations:
- Only useful when data is physically ordered
- Less precise (scans blocks, not rows)
GIN Indexes (Generalized Inverted)
Best for: JSONB columns, full-text search, arrays
-- JSONB configuration columns
CREATE INDEX idx_devices_settings ON devices USING GIN (settings);
CREATE INDEX idx_tenant_modules_config ON tenant_modules USING GIN (configuration);
-- Full-text product search
CREATE INDEX idx_products_search ON products USING GIN (
to_tsvector('english', name || ' ' || COALESCE(description, ''))
);
-- Array columns
CREATE INDEX idx_cash_pickups_bags ON cash_pickups USING GIN (bag_numbers);
Partial Indexes
Best for: Queries with consistent WHERE clauses, reducing index size
-- Only active products (common filter, tenant_id leads for RLS)
CREATE INDEX idx_products_active ON products(tenant_id, name)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Only pending sync items
CREATE INDEX idx_sync_queue_pending ON sync_queue(tenant_id, device_id, priority, created_at)
WHERE status = 'pending';
-- Only open shifts
CREATE INDEX idx_shifts_open ON shifts(tenant_id, location_id, cash_drawer_id)
WHERE status = 'open';
Covering Indexes (INCLUDE)
Best for: Avoiding table lookups for frequently accessed columns
-- Product lookup returns name and price without table access
CREATE INDEX idx_products_sku_covering ON products(tenant_id, sku)
INCLUDE (name, base_price, is_active)
WHERE deleted_at IS NULL;
-- Inventory lookup includes quantity
CREATE INDEX idx_inventory_covering ON inventory_levels(tenant_id, variant_id, location_id)
INCLUDE (quantity_on_hand, quantity_reserved)
WHERE deleted_at IS NULL;
-- Customer lookup by loyalty number
CREATE INDEX idx_customers_loyalty_covering ON customers(tenant_id, loyalty_number)
INCLUDE (first_name, last_name, loyalty_points)
WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;
9.3 Index Strategy by Domain
Domain 1-2: Catalog (Products, Categories)
-- ============================================================
-- PRODUCT LOOKUP INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Primary product lookup by SKU (unique per tenant, filtered for soft delete)
CREATE UNIQUE INDEX idx_products_tenant_sku ON products(tenant_id, sku)
WHERE deleted_at IS NULL;
-- Product search by name (full-text — GIN does not use tenant_id prefix,
-- RLS policy handles tenant filtering automatically)
CREATE INDEX idx_products_name_search ON products
USING GIN (to_tsvector('english', name));
-- Filter by brand (common in category pages)
CREATE INDEX idx_products_tenant_brand ON products(tenant_id, brand_id)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- Filter by product group (department browsing)
CREATE INDEX idx_products_tenant_group ON products(tenant_id, product_group_id)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- ============================================================
-- VARIANT LOOKUP INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Variant lookup by SKU (unique per tenant)
CREATE UNIQUE INDEX idx_variants_tenant_sku ON variants(tenant_id, sku)
WHERE deleted_at IS NULL;
-- POS barcode scan (unique per tenant, critical for checkout speed)
CREATE UNIQUE INDEX idx_variants_tenant_barcode ON variants(tenant_id, barcode)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
-- Product's variants list
CREATE INDEX idx_variants_tenant_product ON variants(tenant_id, product_id, size, color)
WHERE is_active = TRUE AND deleted_at IS NULL;
-- ============================================================
-- CATEGORY NAVIGATION INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Category hierarchy traversal
CREATE INDEX idx_categories_tenant_parent ON categories(tenant_id, parent_id)
WHERE is_active = TRUE;
-- Category sort order for UI
CREATE INDEX idx_categories_tenant_display ON categories(tenant_id, display_order, name)
WHERE is_active = TRUE;
-- ============================================================
-- COLLECTION & TAG INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Active collections (marketing pages)
CREATE INDEX idx_collections_tenant_active ON collections(tenant_id, is_active, start_date, end_date)
WHERE is_active = TRUE;
-- Products in collection
CREATE INDEX idx_product_collection_tenant_coll ON product_collection(tenant_id, collection_id, display_order);
-- Products with tag
CREATE INDEX idx_product_tag_tenant_tag ON product_tag(tenant_id, tag_id);
Domain 3: Inventory
-- ============================================================
-- INVENTORY LEVEL INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Primary lookup: tenant + variant + location (covered)
CREATE UNIQUE INDEX idx_inventory_tenant_lookup ON inventory_levels(tenant_id, variant_id, location_id)
INCLUDE (quantity_on_hand, quantity_reserved, reorder_point)
WHERE deleted_at IS NULL;
-- Location inventory list (for inventory screens)
CREATE INDEX idx_inventory_tenant_location ON inventory_levels(tenant_id, location_id, variant_id)
WHERE deleted_at IS NULL;
-- Low stock alerts (filtered, ordered by severity)
CREATE INDEX idx_inventory_tenant_low_stock ON inventory_levels(tenant_id, location_id, quantity_on_hand)
WHERE quantity_on_hand <= reorder_point
AND deleted_at IS NULL
AND reorder_point > 0;
-- Out of stock items
CREATE INDEX idx_inventory_tenant_out_of_stock ON inventory_levels(tenant_id, location_id)
WHERE quantity_on_hand <= 0
AND deleted_at IS NULL;
-- ============================================================
-- INVENTORY TRANSACTION INDEXES (BRIN + B-Tree, tenant_id for RLS)
-- ============================================================
-- Time-series primary index (BRIN for efficiency on append-only)
CREATE INDEX idx_inventory_trans_date ON inventory_transactions
USING BRIN (created_at);
-- Variant history (for product page history)
CREATE INDEX idx_inventory_trans_tenant_variant ON inventory_transactions(tenant_id, variant_id, created_at DESC);
-- Location activity (for location reports)
CREATE INDEX idx_inventory_trans_tenant_location ON inventory_transactions(tenant_id, location_id, created_at DESC);
-- Reference document lookup
CREATE INDEX idx_inventory_trans_tenant_ref ON inventory_transactions(tenant_id, reference_type, reference_id)
WHERE reference_type IS NOT NULL;
-- Transaction type filtering
CREATE INDEX idx_inventory_trans_tenant_type ON inventory_transactions(tenant_id, transaction_type, created_at DESC);
Domain 4: Sales (Orders, Customers)
-- ============================================================
-- ORDER INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Order number lookup (receipt reprint)
CREATE UNIQUE INDEX idx_orders_tenant_number ON orders(tenant_id, order_number);
-- Orders by date (primary reporting index)
CREATE INDEX idx_orders_tenant_date ON orders(tenant_id, created_at DESC);
-- Orders by location + date (store reports)
CREATE INDEX idx_orders_tenant_location_date ON orders(tenant_id, location_id, created_at DESC);
-- Customer order history
CREATE INDEX idx_orders_tenant_customer ON orders(tenant_id, customer_id, created_at DESC)
WHERE customer_id IS NOT NULL;
-- Shift reconciliation
CREATE INDEX idx_orders_tenant_shift ON orders(tenant_id, shift_id, status)
WHERE shift_id IS NOT NULL;
-- Order status filtering
CREATE INDEX idx_orders_tenant_status ON orders(tenant_id, status, created_at DESC)
WHERE status != 'completed'; -- Completed is default, filter for exceptions
-- ============================================================
-- ORDER ITEMS INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Line items for order
CREATE INDEX idx_order_items_tenant_order ON order_items(tenant_id, order_id);
-- Sales by variant (product performance)
CREATE INDEX idx_order_items_tenant_variant ON order_items(tenant_id, variant_id, created_at DESC);
-- Returns tracking
CREATE INDEX idx_order_items_tenant_returned ON order_items(tenant_id, order_id)
WHERE is_returned = TRUE;
-- ============================================================
-- CUSTOMER INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Loyalty card lookup (POS checkout)
CREATE UNIQUE INDEX idx_customers_tenant_loyalty ON customers(tenant_id, loyalty_number)
WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;
-- Email lookup (unique per tenant)
CREATE UNIQUE INDEX idx_customers_tenant_email ON customers(tenant_id, email)
WHERE email IS NOT NULL AND deleted_at IS NULL;
-- Phone lookup
CREATE INDEX idx_customers_tenant_phone ON customers(tenant_id, phone)
WHERE phone IS NOT NULL AND deleted_at IS NULL;
-- Name search (partial match supported)
CREATE INDEX idx_customers_tenant_name ON customers(tenant_id, last_name, first_name)
WHERE deleted_at IS NULL;
-- Customer value ranking
CREATE INDEX idx_customers_tenant_value ON customers(tenant_id, total_spent DESC)
WHERE deleted_at IS NULL;
-- Recent visitors
CREATE INDEX idx_customers_tenant_last_visit ON customers(tenant_id, last_visit DESC)
WHERE deleted_at IS NULL;
Domain 10: Offline Sync
-- ============================================================
-- SYNC QUEUE INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Idempotency check (unique per tenant, critical for exactly-once processing)
CREATE UNIQUE INDEX idx_sync_queue_tenant_idempotency ON sync_queue(tenant_id, idempotency_key);
-- Device sync sequence (primary sync ordering)
CREATE INDEX idx_sync_queue_tenant_device_seq ON sync_queue(tenant_id, device_id, sequence_number);
-- Pending queue (worker polling)
CREATE INDEX idx_sync_queue_tenant_pending ON sync_queue(tenant_id, status, priority, created_at)
WHERE status = 'pending';
-- Failed items for retry
CREATE INDEX idx_sync_queue_tenant_failed ON sync_queue(tenant_id, status, attempts, created_at)
WHERE status = 'failed' AND attempts < 5;
-- Entity lookup for conflict detection
CREATE INDEX idx_sync_queue_tenant_entity ON sync_queue(tenant_id, entity_type, entity_id);
-- ============================================================
-- DEVICE INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Hardware ID (unique per tenant, device registration)
CREATE UNIQUE INDEX idx_devices_tenant_hardware ON devices(tenant_id, hardware_id);
-- Devices by location
CREATE INDEX idx_devices_tenant_location ON devices(tenant_id, location_id, status);
-- Stale devices (monitoring)
CREATE INDEX idx_devices_tenant_last_seen ON devices(tenant_id, last_seen_at)
WHERE status = 'active';
-- ============================================================
-- EVENT OUTBOX INDEXES (Transactional Outbox pattern)
-- ============================================================
-- Pending events for background worker polling
CREATE INDEX idx_event_outbox_pending ON event_outbox(tenant_id, status, created_at)
WHERE status = 'pending';
-- Aggregate event history
CREATE INDEX idx_event_outbox_tenant_aggregate ON event_outbox(tenant_id, aggregate_type, aggregate_id);
-- Time-series BRIN for append-only outbox
CREATE INDEX idx_event_outbox_created ON event_outbox USING BRIN (created_at);
-- Failed events eligible for retry
CREATE INDEX idx_event_outbox_failed ON event_outbox(tenant_id, status, retry_count)
WHERE status = 'failed' AND retry_count < max_retries;
-- ============================================================
-- STATE TRANSITIONS INDEXES (DB-driven state machines)
-- ============================================================
-- State machine lookup (aggregate + current state)
CREATE INDEX idx_state_transitions_lookup ON state_transitions(tenant_id, aggregate_type, from_state)
WHERE is_active = TRUE;
Domain 11-12: Cash & Payment
-- ============================================================
-- SHIFT INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Shift number lookup (unique per tenant)
CREATE UNIQUE INDEX idx_shifts_tenant_number ON shifts(tenant_id, shift_number);
-- Open shifts by drawer (prevent duplicates per tenant)
CREATE UNIQUE INDEX idx_shifts_tenant_drawer_open ON shifts(tenant_id, cash_drawer_id)
WHERE status = 'open';
-- Shifts by location + date (reports)
CREATE INDEX idx_shifts_tenant_location_date ON shifts(tenant_id, location_id, opened_at DESC);
-- Unreconciled shifts
CREATE INDEX idx_shifts_tenant_unreconciled ON shifts(tenant_id, location_id, opened_at)
WHERE status IN ('closed', 'closing');
-- ============================================================
-- CASH MOVEMENT INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Movements by shift (reconciliation)
CREATE INDEX idx_cash_movements_tenant_shift ON cash_movements(tenant_id, shift_id, created_at);
-- Movements by type (auditing)
CREATE INDEX idx_cash_movements_tenant_type ON cash_movements(tenant_id, movement_type, created_at DESC);
-- Reference lookup
CREATE INDEX idx_cash_movements_tenant_ref ON cash_movements(tenant_id, reference_type, reference_id)
WHERE reference_type IS NOT NULL;
-- ============================================================
-- PAYMENT ATTEMPT INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Payments by order
CREATE INDEX idx_payment_attempts_tenant_order ON payment_attempts(tenant_id, order_id);
-- Payment status monitoring
CREATE INDEX idx_payment_attempts_tenant_status ON payment_attempts(tenant_id, status, created_at DESC);
-- Processor transaction lookup (chargebacks)
CREATE INDEX idx_payment_attempts_tenant_processor ON payment_attempts(tenant_id, processor_transaction_id)
WHERE processor_transaction_id IS NOT NULL;
-- Daily payment activity
CREATE INDEX idx_payment_attempts_tenant_date ON payment_attempts(tenant_id, created_at DESC);
-- ============================================================
-- PAYMENT BATCH INDEXES (tenant_id leading column for RLS)
-- ============================================================
-- Batch number lookup (unique per tenant)
CREATE UNIQUE INDEX idx_payment_batches_tenant_number ON payment_batches(tenant_id, batch_number);
-- Open batches (auto-close job)
CREATE INDEX idx_payment_batches_tenant_open ON payment_batches(tenant_id, location_id, batch_date)
WHERE status = 'open';
-- Pending settlement
CREATE INDEX idx_payment_batches_tenant_pending ON payment_batches(tenant_id, submitted_at)
WHERE status = 'pending';
Domain 13: RFID Module (Counting Subsystem)
-- ============================================================
-- RFID TAG INDEXES (all include tenant_id for RLS performance)
-- ============================================================
-- EPC lookup (unique per tenant, critical for scan performance)
CREATE UNIQUE INDEX idx_rfid_tags_epc ON rfid_tags(tenant_id, epc);
-- Tags by variant (product inventory counts)
CREATE INDEX idx_rfid_tags_variant ON rfid_tags(tenant_id, variant_id, status)
WHERE status = 'active';
-- Tags by location (location inventory counts)
CREATE INDEX idx_rfid_tags_location ON rfid_tags(tenant_id, current_location_id, status)
WHERE status = 'active';
-- Serial number sequence
CREATE INDEX idx_rfid_tags_serial ON rfid_tags(tenant_id, serial_number);
-- Recently scanned (for scan recency queries)
CREATE INDEX idx_rfid_tags_scanned ON rfid_tags(tenant_id, last_scanned_at DESC)
WHERE last_scanned_at IS NOT NULL;
-- ============================================================
-- RFID SCAN EVENT INDEXES (High-Volume, idempotent uploads)
-- ============================================================
-- Idempotency: one row per (session, epc) — prevents duplicate uploads
CREATE UNIQUE INDEX idx_rfid_events_idempotent ON rfid_scan_events(session_id, epc);
-- Session events
CREATE INDEX idx_rfid_events_session ON rfid_scan_events(tenant_id, session_id);
-- EPC lookup (match to tag)
CREATE INDEX idx_rfid_events_epc ON rfid_scan_events(tenant_id, epc);
-- Unknown tags (for investigation)
CREATE INDEX idx_rfid_events_unknown ON rfid_scan_events(tenant_id, session_id)
WHERE rfid_tag_id IS NULL;
-- Time-based partition key (if partitioning by first_seen_at)
CREATE INDEX idx_rfid_events_time ON rfid_scan_events USING BRIN (first_seen_at);
-- ============================================================
-- RFID PRINT JOB INDEXES
-- ============================================================
-- Job queue (worker polling)
CREATE INDEX idx_rfid_jobs_queue ON rfid_print_jobs(tenant_id, status, priority, created_at)
WHERE status IN ('queued', 'printing');
-- Jobs by printer
CREATE INDEX idx_rfid_jobs_printer ON rfid_print_jobs(tenant_id, printer_id, created_at DESC);
-- ============================================================
-- RFID TAG TEMPLATES & MAPPINGS
-- ============================================================
-- Templates by tenant
CREATE INDEX idx_rfid_templates_tenant ON rfid_tag_templates(tenant_id);
-- Default template per type per tenant
CREATE UNIQUE INDEX idx_rfid_templates_default ON rfid_tag_templates(tenant_id, template_type)
WHERE is_default = TRUE;
-- Tag mappings by SKU
CREATE INDEX idx_rfid_mappings_sku ON rfid_tag_mappings(tenant_id, sku);
-- Tag mappings by variant
CREATE INDEX idx_rfid_mappings_variant ON rfid_tag_mappings(tenant_id, variant_id);
-- ============================================================
-- SESSION OPERATORS (Multi-Operator Counting)
-- ============================================================
-- Operators by session
CREATE INDEX idx_session_operators_session ON session_operators(tenant_id, session_id);
-- Sessions by operator
CREATE INDEX idx_session_operators_user ON session_operators(tenant_id, operator_id);
9.4 Query Optimization Examples
Example 1: Product Lookup by Barcode
Query:
SELECT v.id, v.sku, p.name, p.base_price + v.price_adjustment AS price
FROM variants v
JOIN products p ON v.product_id = p.id
WHERE v.barcode = '012345678901'
AND v.deleted_at IS NULL
AND p.deleted_at IS NULL;
Optimization:
-- Covering index avoids table lookup for common columns
CREATE INDEX idx_variants_barcode_covering ON variants(tenant_id, barcode)
INCLUDE (sku, product_id, price_adjustment)
WHERE barcode IS NOT NULL AND deleted_at IS NULL;
-- Result: Index-only scan, < 1ms
EXPLAIN ANALYZE:
Index Only Scan using idx_variants_barcode_covering on variants v
Index Cond: (barcode = '012345678901'::character varying)
Heap Fetches: 0
Planning Time: 0.1 ms
Execution Time: 0.05 ms
Example 2: Daily Sales Report
Query:
SELECT
l.name AS location,
COUNT(o.id) AS transactions,
SUM(o.total_amount) AS sales,
SUM(o.tax_amount) AS tax
FROM orders o
JOIN locations l ON o.location_id = l.id
WHERE o.created_at >= '2025-01-01'
AND o.created_at < '2025-01-02'
AND o.status = 'completed'
GROUP BY l.name
ORDER BY sales DESC;
Optimization:
-- Composite index for date range + status + location
CREATE INDEX idx_orders_reporting ON orders(tenant_id, created_at, location_id)
INCLUDE (total_amount, tax_amount)
WHERE status = 'completed';
-- Result: Index scan with aggregate pushdown
EXPLAIN ANALYZE:
HashAggregate (cost=150..155 rows=5)
Group Key: l.name
-> Nested Loop (cost=0.5..140 rows=1200)
-> Index Scan using idx_orders_reporting on orders o
Index Cond: (created_at >= '...' AND created_at < '...')
Filter: (status = 'completed')
-> Index Scan using idx_locations_pkey on locations l
Index Cond: (id = o.location_id)
Planning Time: 0.5 ms
Execution Time: 12 ms
Example 3: Inventory Low Stock Alert
Query:
SELECT
v.sku,
p.name,
l.code AS location,
il.quantity_on_hand,
il.reorder_point,
il.reorder_quantity
FROM inventory_levels il
JOIN variants v ON il.variant_id = v.id
JOIN products p ON v.product_id = p.id
JOIN locations l ON il.location_id = l.id
WHERE il.quantity_on_hand <= il.reorder_point
AND il.reorder_point > 0
AND il.deleted_at IS NULL
AND l.is_active = TRUE
ORDER BY (il.reorder_point - il.quantity_on_hand) DESC
LIMIT 100;
Optimization:
-- Partial index for low stock condition
CREATE INDEX idx_inventory_low_stock_alert ON inventory_levels(
tenant_id,
location_id,
(reorder_point - quantity_on_hand) DESC
)
INCLUDE (variant_id, quantity_on_hand, reorder_point, reorder_quantity)
WHERE quantity_on_hand <= reorder_point
AND reorder_point > 0
AND deleted_at IS NULL;
Example 4: Sync Queue Processing
Query:
SELECT id, device_id, operation_type, entity_type, entity_id, payload
FROM sync_queue
WHERE status = 'pending'
ORDER BY priority ASC, created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;
Optimization:
-- Partial index for pending items only
CREATE INDEX idx_sync_queue_worker ON sync_queue(tenant_id, priority, created_at)
INCLUDE (device_id, operation_type, entity_type, entity_id)
WHERE status = 'pending';
-- Result: Index-only scan, no lock contention
Example 5: RFID Tag Count by Location
Query:
SELECT
l.code,
l.name,
COUNT(*) FILTER (WHERE rt.status = 'active') AS active_tags,
COUNT(*) FILTER (WHERE rt.status = 'void') AS void_tags,
COUNT(*) AS total_tags
FROM locations l
LEFT JOIN rfid_tags rt ON rt.current_location_id = l.id
WHERE l.is_active = TRUE
GROUP BY l.id, l.code, l.name
ORDER BY active_tags DESC;
Optimization:
-- Pre-aggregated materialized view for dashboard
CREATE MATERIALIZED VIEW rfid_tag_counts AS
SELECT
current_location_id,
status,
COUNT(*) AS tag_count
FROM rfid_tags
GROUP BY current_location_id, status;
CREATE UNIQUE INDEX ON rfid_tag_counts(current_location_id, status);
-- Refresh periodically
REFRESH MATERIALIZED VIEW CONCURRENTLY rfid_tag_counts;
9.5 Performance Monitoring Queries
Index Usage Statistics
-- Most used indexes
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
idx_scan AS scans,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC
LIMIT 20;
Unused Indexes
-- Indexes never used (candidates for removal)
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
Slow Queries
-- Enable pg_stat_statements extension first
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Top 20 slowest queries by mean time
SELECT
substring(query, 1, 100) AS query_preview,
calls,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms,
rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
Table Bloat Check
-- Tables with significant dead tuples
SELECT
schemaname,
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
Index Bloat Estimation
-- Estimate index bloat
SELECT
current_database() AS db,
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS scans,
idx_tup_read AS tuples_read
FROM pg_stat_user_indexes
JOIN pg_index ON indexrelid = pg_index.indexrelid
WHERE NOT indisunique -- Exclude unique indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
9.6 Index Maintenance
Routine Maintenance Commands
-- Reindex a specific table (all tables in public schema)
REINDEX TABLE products;
-- Reindex entire public schema
REINDEX SCHEMA public;
-- Concurrent reindex (no lock, PostgreSQL 12+)
REINDEX TABLE CONCURRENTLY products;
-- Vacuum and analyze (update statistics)
VACUUM ANALYZE products;
-- Full vacuum (reclaim space, requires exclusive lock)
VACUUM FULL products;
Automated Maintenance Configuration
-- postgresql.conf settings
autovacuum = on
autovacuum_vacuum_scale_factor = 0.1 -- Vacuum when 10% of rows are dead
autovacuum_analyze_scale_factor = 0.05 -- Analyze when 5% of rows change
autovacuum_vacuum_cost_delay = 2ms -- Reduce I/O impact
autovacuum_max_workers = 4 -- Parallel workers
-- For high-update tables (sync_queue, rfid_scan_events)
ALTER TABLE sync_queue SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.01
);
Index Creation Best Practices
-- Create indexes concurrently (no table lock)
CREATE INDEX CONCURRENTLY idx_orders_new ON orders(created_at);
-- Check for invalid indexes after concurrent creation
SELECT indexrelid::regclass AS index_name, indisvalid
FROM pg_index
WHERE NOT indisvalid;
-- If invalid, drop and recreate
DROP INDEX CONCURRENTLY idx_orders_new;
CREATE INDEX CONCURRENTLY idx_orders_new ON orders(created_at);
9.7 Performance Checklist
Before Deployment
- All primary key columns have indexes (automatic)
- All foreign key columns have indexes (manual)
- Unique constraints have backing indexes (automatic)
- High-frequency query patterns have covering indexes
- BRIN indexes on time-series tables
- Partial indexes for filtered queries
- GIN indexes on JSONB columns with queries
- Full-text indexes if text search is used
Regular Monitoring
- Check pg_stat_statements for slow queries weekly
- Review unused indexes monthly (remove if truly unused)
- Monitor table bloat (vacuum if > 20% dead tuples)
- Verify index usage after schema changes
- Run ANALYZE after bulk data loads
Query Optimization Workflow
- Identify slow query via pg_stat_statements or application logs
- Run EXPLAIN ANALYZE to see execution plan
- Check for sequential scans on large tables
- Identify missing indexes or suboptimal index choice
- Create index (CONCURRENTLY for production)
- Verify improvement with EXPLAIN ANALYZE
- Monitor for regression
9.8 Quick Reference: Common Index Patterns
| Pattern | Index Type | Example |
|---|---|---|
| Unique lookup | B-tree UNIQUE | CREATE UNIQUE INDEX ... ON orders(order_number) |
| Foreign key | B-tree | CREATE INDEX ... ON order_items(order_id) |
| Range query | B-tree | CREATE INDEX ... ON orders(created_at) |
| Time-series | BRIN | CREATE INDEX ... USING BRIN (created_at) |
| Full-text | GIN | CREATE INDEX ... USING GIN (to_tsvector(...)) |
| JSONB | GIN | CREATE INDEX ... USING GIN (settings) |
| Soft delete | Partial B-tree | CREATE INDEX ... WHERE deleted_at IS NULL |
| Status filter | Partial B-tree | CREATE INDEX ... WHERE status = 'pending' |
| Covering | INCLUDE | CREATE INDEX ...(sku) INCLUDE (name, price) |
End of Part III: Database
End of Part III: Database. (Part IV: Backend chapters — planned future rewrite)
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Part | III - Database |
| Chapter | 09 of 9 |
This chapter is part of the POS Blueprint Book. All content is self-contained.
Appendix F: BRD-to-Code Module Mapping
Version: 7.0.0 Last Updated: March 2, 2026 BRD Version: 20.0 (19,900+ lines, 7 modules, 113 decisions)
F.1 Purpose & How to Use This Document
This appendix maps every business capability in the Business Requirements Document (BRD v20.0) to a specific code-level service in the POS Platform implementation. It bridges the gap between business requirements (written for stakeholders) and the modular monolith implementation (written for developers).
Who Should Use This
| Audience | Use Case |
|---|---|
| Developers | Find which service to create/modify when implementing a BRD feature |
| Architects | Verify module boundaries and dependency direction rules |
| QA Engineers | Trace test coverage back to BRD sections |
| Product Owners | Understand how business features map to technical components |
How to Read the Tables
Each service entry includes:
- Service Name: Technology-agnostic logical name (e.g.,
sale.cart.command.service) - BRD Section(s): Which BRD section(s) this service implements
- Capability: What business function this service performs
- Pattern: CQRS Command, Query, CRUD, Event Handler, Rule Engine, etc.
- Owns Tables: Database tables this service is the authoritative writer for
- Publishes/Consumes Events: Domain events for inter-service communication
F.2 Architecture Context
The POS Platform follows an Event-Driven Modular Monolith architecture (selected in Chapter 04) with the following pattern assignments per module:
| Module | CQRS | Event Sourcing | Pattern |
|---|---|---|---|
| Module 1: Sales | Full CQRS | Full ES | Separate command/query services |
| Module 2: Customers | Standard CRUD | None | Repository pattern with caching |
| Module 3: Catalog | Standard CRUD | None | Read-heavy, Redis cache |
| Module 4: Inventory | Materialized read model | ES for audit trail | Command/query split for PO, transfers |
| Module 5: Setup | Standard CRUD | None | Configuration data, direct access |
| Module 6: Integrations | Standard CRUD | Audit-trail-only ES | Extractable gateway |
Design Principles
- Maximum Granularity: One service per business capability, not one service per module
- Single Responsibility: Each service does ONE thing well
- DDD Boundaries: Services own their aggregates; cross-module access via events or public API
- No God Services: Break coarse-grained services (e.g.,
IOrderService) into focused capabilities
Reference: Chapter 04 (Architecture Styles Analysis), Section L.4 for full architecture rationale.
F.3 Module Overview
The BRD defines 6 business modules. The code architecture maps these to 7 code modules plus cross-cutting concerns and an optional RFID module:
| # | BRD Module | Code Module | Services | Pattern |
|---|---|---|---|---|
| 1 | Sales (1.1-1.20) | modules/sales/ | 37 | Full CQRS + ES |
| 2 | Customers (2.1-2.8) | modules/customers/ | 7 | CRUD |
| 3 | Catalog (3.1-3.15) | modules/catalog/ | 20 | CRUD + Cache |
| 4 | Inventory (4.1-4.19) | modules/inventory/ | 23 | Materialized + ES audit |
| 5 | Setup (5.1-5.21) | modules/setup/ | 21 | CRUD |
| 6 | Integrations (6.1-6.13) | modules/integrations/ | 20 | CRUD + Audit ES |
| X | Cross-cutting (Ch 04, 11) | cross-cutting/ | 8 | Mixed |
| R | RFID/Raptag (Ch 05 D13) | modules/rfid/ | 6 | CRUD + Command |
| TOTAL | 142 |
F.4 Module 1: Sales – Service Breakdown (37 Services, Full CQRS+ES)
BRD Sections: 1.1-1.20
F.4.1 Cart & Checkout Commands
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 1 | sale.cart.command.service | 1.1 | Create cart, add/remove line items, attach customer | Command | orders (draft state) | SaleCreated, SaleLineItemAdded, SaleLineItemRemoved | – |
| 2 | sale.cart.query.service | 1.1 | Get active cart, list items, calculate running totals | Query | (reads orders, order_items) | – | SaleLineItemAdded, SaleLineItemRemoved |
| 3 | sale.park.command.service | 1.1, 1.1.1 | Park/retrieve/expire held sales, manage TTL, soft-reserve inventory | Command | orders (parked state) | SaleParked, SaleRetrieved, SaleExpired | – |
| 4 | sale.park.query.service | 1.1 | List parked sales for terminal/location | Query | (reads orders WHERE status=parked) | – | SaleParked, SaleRetrieved |
F.4.2 Discount & Pricing Commands
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 5 | sale.discount.command.service | 1.2 | Apply/remove line discounts, global discounts, enforce calculation order | Command | (writes to order discount fields) | DiscountApplied, DiscountRemoved | – |
| 6 | sale.promotion.engine.service | 1.2, 1.14 | Evaluate automatic promos (Buy X Get Y), validate coupon codes, stack rules | Rule Engine | pricing_rules (read) | PromotionTriggered, CouponRedeemed | SaleLineItemAdded |
| 7 | sale.price-override.command.service | 1.2 | Manual price override with manager auth, reason code | Command | (writes to order_items.unit_price) | PriceOverridden | – |
F.4.3 Payment & Settlement Commands
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 8 | sale.payment.command.service | 1.3 | Process split tenders, validate payment covers total, calculate change | Command | payment_attempts | PaymentReceived, PaymentFailed | – |
| 9 | sale.payment.card.service | 1.18 | SAQ-A semi-integrated card flow: initiate terminal, receive token + auth | Integration | payment_attempts (card entries) | CardPaymentAuthorized, CardPaymentDeclined | – |
| 10 | sale.payment.cash.service | 1.3 | Cash tendering, change calculation, drawer interaction | Stateful | (writes to cash_movements) | CashPaymentReceived | – |
| 11 | sale.payment.giftcard.service | 1.3, 1.5 | Check GC balance, apply partial/full, deduct | Command | gift_card_transactions | GiftCardRedeemed | – |
| 12 | sale.payment.storecredit.service | 1.3 | Check credit balance, apply on-account, validate credit limit | Command | (reads/writes customer store_credit) | StoreCreditApplied | – |
| 13 | sale.payment.affirm.service | 1.3 | Third-party financing flow: create session, handle webhook | Integration | payment_attempts (affirm entries) | AffirmLoanApproved | – |
| 14 | sale.finalize.command.service | 1.3 | Finalize order: write record, deduct inventory, award loyalty, record commission | Command (Orchestrator) | orders (completed state) | SaleCompleted | PaymentReceived |
F.4.4 Post-Sale Commands
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 15 | sale.void.command.service | 1.4, 1.4.1 | Void same-day order: reverse inventory, loyalty, commission; check eligibility | Command | orders (voided state) | SaleVoided | – |
| 16 | sale.return.command.service | 1.4 | Process return: validate receipt, apply policy, issue refund | Command | returns, return_items | ReturnInitiated, ReturnCompleted | – |
| 17 | sale.exchange.command.service | 1.4 | Dedicated exchange: items OUT + items IN, calculate difference | Command | returns (exchange type), orders | ExchangeProcessed | – |
| 18 | sale.return-policy.engine.service | 1.9 | Evaluate return eligibility: time window, receipt validation, manager override | Rule Engine | (reads tenant_settings, return policy config) | ReturnPolicyEvaluated | – |
F.4.5 Gift Card Commands
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 19 | sale.giftcard.command.service | 1.5 | Sell/activate gift cards, reload, deactivate, check compliance | Command | gift_cards, gift_card_transactions | GiftCardIssued, GiftCardActivated, GiftCardReloaded | – |
| 20 | sale.giftcard.query.service | 1.5 | Balance lookup, transaction history, expiration check | Query | (reads gift_cards, gift_card_transactions) | – | GiftCardRedeemed, GiftCardIssued |
F.4.6 Special Order & Layaway
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 21 | sale.specialorder.command.service | 1.6 | Create/manage special orders and back orders | Command | orders (special_order type) | SpecialOrderCreated, SpecialOrderFulfilled | InventoryReceived |
| 22 | sale.layaway.command.service | 1.3.2 | Create layaway, accept deposits, release inventory on final payment | Stateful | orders (layaway state) | LayawayCreated, LayawayPaymentReceived, LayawayCompleted, LayawayCancelled | – |
| 23 | sale.hold-for-pickup.command.service | 1.11 | Hold/stage/expire pickup orders including BOPIS | Stateful | orders (hold states) | HoldCreated, HoldStaged, HoldPickedUp, HoldExpired | – |
F.4.7 Cash Drawer Operations
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 24 | sale.cashdrawer.command.service | 1.12 | Open/close drawer, paid in/out, cash drops, no-sale | Command | cash_drawers, cash_movements, cash_drops | DrawerOpened, DrawerClosed, DrawerCashDrop, DrawerPaidIn, DrawerPaidOut | CashPaymentReceived |
| 25 | sale.cashdrawer.count.service | 1.12 | Denomination-level cash counts (opening, closing, mid-shift, audit) | Command | cash_counts | CashCounted | – |
| 26 | sale.cashdrawer.pickup.service | 1.12 | Armored car pickup tracking, bank deposit reconciliation | Command | cash_pickups | CashPickupCompleted | – |
| 27 | sale.shift.command.service | 1.12 | Clock-in/out to shift, link to drawer, track totals | Stateful | shifts | ShiftOpened, ShiftClosed | DrawerOpened, DrawerClosed, SaleCompleted |
F.4.8 Tax Engine
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 28 | sale.tax.calculation.service | 1.17 | Calculate compound 3-level tax (State/County/City), handle exemptions | Calculation | (reads taxes, location_tax) | TaxCalculated | – |
F.4.9 Commission & Loyalty
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 29 | sale.commission.command.service | 1.8 | Calculate and record commissions per sale, proportional reversal on return | Command | (commission fields on orders) | CommissionRecorded, CommissionReversed | SaleCompleted, ReturnCompleted, SaleVoided |
| 30 | sale.loyalty.command.service | 1.15 | Award/redeem loyalty points, tier calculation, bonus rules | Command | loyalty_transactions, loyalty_accounts | LoyaltyPointsEarned, LoyaltyPointsRedeemed, LoyaltyTierChanged | SaleCompleted |
F.4.10 Queries & Read Models
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 31 | sale.history.query.service | 1.4 | Sales history with filters (date, user, status, location) | Query | (reads orders, order_items) | – | SaleCompleted, SaleVoided, ReturnCompleted |
| 32 | sale.receipt.query.service | 1.4 | Generate/reprint receipt data, email receipt | Query | (reads orders, order_items, payments) | ReceiptEmailed | – |
| 33 | sale.receipt.validate.service | 1.4 | Validate receipt barcode authenticity, match to order | Query | (reads orders) | – | – |
| 34 | sale.daily-summary.projection.service | 1.1.2, 1.3.4 | Materialized daily sales summary, hourly heatmap | Event Handler | (writes read model views) | – | SaleCompleted, SaleVoided, ReturnCompleted |
| 35 | sale.price-check.query.service | 1.13 | Price check mode: lookup product price without sale context | Query | (reads products, pricing_rules) | – | – |
F.4.11 Offline & Serial Tracking
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 36 | sale.offline.sync.service | 1.16 | Queue offline transactions, FIFO flush on reconnect, flag-on-sync price discrepancy detection | Stateful | sync_queue (sale entries) | OfflineSaleSynced, SyncDiscrepancyFlagged | – |
| 37 | sale.serial-tracking.command.service | 1.10 | Associate serial numbers with sale line items, validate uniqueness | Command | (serial_number field on order_items) | SerialNumberSold | – |
F.5 Module 2: Customers – Service Breakdown (7 Services, CRUD)
BRD Sections: 2.1-2.8
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 38 | customer.profile.crud.service | 2.1 | Create/update/delete customer profiles, manage PII | CRUD | customers | CustomerCreated, CustomerUpdated, CustomerDeleted | – |
| 39 | customer.search.query.service | 2.1 | Search customers by name, email, phone, loyalty number | Query | (reads customers) | – | CustomerCreated, CustomerUpdated |
| 40 | customer.group.crud.service | 2.2 | Manage customer groups/tiers (VIP, Wholesale, etc.), auto-tier rules | CRUD | (customer group/tier fields) | CustomerGroupAssigned, CustomerTierChanged | SaleCompleted |
| 41 | customer.notes.crud.service | 2.3 | Customer notes, preferences, internal flags | CRUD | (notes fields on customers) | – | – |
| 42 | customer.communication.crud.service | 2.4 | Marketing consent, preferred channels, opt-in/out | CRUD | (communication preference fields) | CommunicationPreferenceChanged | – |
| 43 | customer.merge.command.service | 2.5 | Merge duplicate customer records, reassign history | Command | customers (merge target) | CustomersMerged | – |
| 44 | customer.privacy.command.service | 2.5, 2.6 | GDPR anonymization, data export, deletion request | Command | customers (anonymized_at) | CustomerAnonymized, CustomerDataExported | – |
F.6 Module 3: Catalog – Service Breakdown (20 Services, CRUD+Cache)
BRD Sections: 3.1-3.15
F.6.1 Product Management
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 45 | catalog.product.crud.service | 3.1 | Create/update/delete products, manage attributes, soft delete | CRUD | products | ProductCreated, ProductUpdated, ProductDeleted | – |
| 46 | catalog.variant.crud.service | 3.1 | Create/update/delete variants (size/color), matrix management | CRUD | variants | VariantCreated, VariantUpdated, VariantDeleted | – |
| 47 | catalog.product.query.service | 3.1 | Get product by ID/SKU/barcode, list with pagination/filters | Query | (reads products, variants) | – | ProductCreated, ProductUpdated |
| 48 | catalog.bulk-import.command.service | 3.1 | Bulk CSV/Excel import of products and variants | Command | products, variants | BulkImportCompleted | – |
| 49 | catalog.product.lifecycle.service | 3.2 | Manage product lifecycle: draft, active, discontinued, archived | Stateful | products (lifecycle states) | ProductActivated, ProductDiscontinued, ProductArchived | – |
F.6.2 Categorization & Tagging
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 50 | catalog.category.crud.service | 3.5 | Manage hierarchical categories, sort order | CRUD | categories | CategoryCreated, CategoryUpdated | – |
| 51 | catalog.collection.crud.service | 3.5 | Marketing/seasonal collections with date ranges | CRUD | collections, product_collection | CollectionCreated, CollectionUpdated | – |
| 52 | catalog.tag.crud.service | 3.5 | Freeform product tags | CRUD | tags, product_tag | – | – |
| 53 | catalog.brand.crud.service | 3.1 | Brand reference data management | CRUD | brands | – | – |
F.6.3 Pricing
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 54 | catalog.pricing.crud.service | 3.3 | Manage pricing rules, price books, tier pricing | CRUD | pricing_rules | PricingRuleCreated, PricingRuleUpdated | – |
| 55 | catalog.pricing.calculation.service | 3.3 | Calculate effective price (hierarchy: price book > tier > promo > base) | Calculation | (reads pricing_rules, products) | – | – |
| 56 | catalog.markdown.command.service | 3.3 | Schedule markdowns, automatic clearance pricing | Command | pricing_rules (markdown type) | MarkdownApplied, MarkdownExpired | – |
F.6.4 Barcode & Label
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 57 | catalog.barcode.service | 3.4 | Generate/validate/lookup UPC/EAN barcodes | CRUD | (barcode fields on products/variants) | – | – |
| 58 | catalog.label.print.service | 3.10 | Generate label/price tag print jobs, template selection | Command | (label print queue) | LabelPrintJobCreated | – |
F.6.5 Search, Media & Vendor
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 59 | catalog.search.service | 3.9 | Full-text product search, faceted filtering, suggestions | Query | (reads products via FTS indexes) | – | ProductCreated, ProductUpdated |
| 60 | catalog.media.crud.service | 3.11 | Product image management, upload, reorder | CRUD | (image fields on products/variants) | – | – |
| 61 | catalog.vendor.crud.service | 3.8 | Vendor/supplier management, lead times, min order quantities | CRUD | (vendor reference tables) | VendorCreated, VendorUpdated | – |
| 62 | catalog.notes.crud.service | 3.12 | Product notes and attachments | CRUD | (notes/attachments on products) | – | – |
| 63 | catalog.permissions.service | 3.13 | Catalog approval workflows, permission checks | Rule Engine | (reads role_permissions) | CatalogChangeApproved, CatalogChangeRejected | ProductUpdated |
| 64 | catalog.analytics.query.service | 3.14 | Product performance analytics, sales velocity, margin analysis | Query | (reads products, order_items, inventory) | – | SaleCompleted |
F.7 Module 4: Inventory – Service Breakdown (23 Services, Materialized+ES Audit)
BRD Sections: 4.1-4.19
F.7.1 Stock Level Queries
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 65 | inventory.level.query.service | 4.1, 4.2 | Get current stock by variant/location, available qty calculation | Query | (reads inventory_levels materialized view) | – | InventoryAdjusted, InventorySold, InventoryReceived |
| 66 | inventory.level.adjustment.service | 4.7 | Manual adjustments: count, damage, theft, found, with reason codes | Command | inventory_levels, inventory_transactions | InventoryAdjusted | – |
| 67 | inventory.status-model.service | 4.2 | Manage inventory statuses: available, reserved, committed, in_transit, damaged | Stateful | inventory_levels (status fields) | InventoryStatusChanged | – |
F.7.2 Purchase Orders
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 68 | inventory.po.command.service | 4.3 | Create/edit/approve/cancel purchase orders | Command | (purchase_orders table) | PurchaseOrderCreated, PurchaseOrderApproved, PurchaseOrderCancelled | – |
| 69 | inventory.po.query.service | 4.3 | List/search POs, status tracking, ETA display | Query | (reads purchase_orders) | – | PurchaseOrderCreated, PurchaseOrderApproved |
| 70 | inventory.receiving.command.service | 4.4 | Receive against PO: full/partial, inspection, discrepancy handling | Command | inventory_levels, inventory_transactions | InventoryReceived, ReceivingDiscrepancyLogged | PurchaseOrderApproved |
F.7.3 Reorder Management
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 71 | inventory.reorder.engine.service | 4.5 | Auto-reorder point monitoring, suggested PO generation | Rule Engine | (reads inventory_levels, reorder configs) | ReorderPointReached, ReorderSuggested | InventoryAdjusted, InventorySold |
F.7.4 Counting & Auditing
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 72 | inventory.count.command.service | 4.6 | Physical inventory counts: full, cycle, spot check | Command | inventory_transactions (count type) | InventoryCounted | – |
| 73 | inventory.count.query.service | 4.6 | Count session management, variance reports | Query | (reads count sessions/results) | – | InventoryCounted |
F.7.5 Transfers
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 74 | inventory.transfer.command.service | 4.8 | Create/ship/receive inter-store transfers | Command | (transfer tables), inventory_transactions | InventoryTransferred, TransferShipped, TransferReceived | – |
| 75 | inventory.transfer.query.service | 4.8 | List transfers, track in-transit, ETA | Query | (reads transfer tables) | – | TransferShipped, TransferReceived |
F.7.6 Vendor Returns & Costing
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 76 | inventory.rma.command.service | 4.9 | Vendor RMA: create, ship back, track credit | Command | (RMA tables) | VendorRMACreated, VendorRMAShipped | – |
| 77 | inventory.costing.calculation.service | 4.11 | Landed cost calculation: freight, duty, insurance allocation | Calculation | (cost fields on inventory_transactions) | LandedCostCalculated | InventoryReceived |
| 78 | inventory.serial-lot.command.service | 4.10 | Serial/lot number assignment, tracking, recall support | Command | (serial/lot fields) | SerialNumberAssigned, LotCreated | – |
F.7.7 Movement History & Dashboard
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 79 | inventory.movement.query.service | 4.12 | Stock ledger, movement history by variant/location | Query | (reads inventory_transactions) | – | all Inventory* events |
| 80 | inventory.dashboard.projection.service | 4.17 | Inventory dashboard materialized views: stock value, aging, velocity | Event Handler | (writes dashboard read models) | – | InventoryAdjusted, InventorySold, InventoryReceived |
F.7.8 POS Integration & Fulfillment
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 81 | inventory.sale-deduction.event-handler.service | 4.13 | Deduct inventory on sale completion, restore on void/return | Event Handler | inventory_levels, inventory_transactions | InventorySold, InventoryRestored | SaleCompleted, SaleVoided, ReturnCompleted |
| 82 | inventory.reservation.command.service | 4.13 | Soft-reserve inventory for pending sales, holds, layaways | Command | inventory_levels (quantity_reserved) | InventoryReserved, InventoryReservationReleased | SaleParked, HoldCreated, LayawayCreated |
| 83 | inventory.fulfillment.command.service | 4.14 | Online order fulfillment: pick, pack, ship from store | Command | (fulfillment fields on orders) | FulfillmentStarted, FulfillmentShipped | – |
F.7.9 Offline & Alerts
| # | Service Name | BRD Section(s) | Capability | Pattern | Owns Tables | Publishes Events | Consumes Events |
|---|---|---|---|---|---|---|---|
| 84 | inventory.offline.sync.service | 4.15 | Offline inventory operations queue, sync, conflict resolution | Stateful | sync_queue (inventory entries) | OfflineInventorySynced | – |
| 85 | inventory.alert.service | 4.16 | Low stock alerts, reorder notifications, expiring lot alerts | Event Handler | – | LowStockAlert, ReorderAlert | InventoryAdjusted, InventorySold |
| 86 | inventory.rules.engine.service | 4.18 | Business rules evaluation from YAML config (negative stock, auto-transfer) | Rule Engine | (reads tenant_settings) | – | – |
| 87 | inventory.report.query.service | 4.17 | Inventory reports: valuation, aging, shrinkage, turnover | Query | (reads inventory_levels, inventory_transactions) | – | – |
F.8 Module 5: Setup & Configuration – Service Breakdown (21 Services, CRUD)
BRD Sections: 5.1-5.21
F.8.1 System Settings
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 88 | setup.settings.crud.service | 5.2 | System settings, branding, locale, defaults | CRUD |
| 89 | setup.currency.crud.service | 5.3 | Multi-currency configuration, exchange rates | CRUD |
F.8.2 Location Management
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 90 | setup.location.crud.service | 5.4 | Create/update/deactivate locations, assign type, set hours | CRUD |
F.8.3 User & Role Management
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 91 | setup.user.crud.service | 5.5 | User profile management, activation/deactivation | CRUD |
| 92 | setup.role.crud.service | 5.5 | Role management, permission assignment matrix | CRUD |
| 93 | setup.timetracking.command.service | 5.6 | Clock-in/clock-out, break management | Command |
F.8.4 Register & Hardware
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 94 | setup.register.crud.service | 5.7 | Register management, IP limits (max 2/365 days), retire (OWNER-only) | CRUD |
| 95 | setup.printer.crud.service | 5.8 | Printer/peripheral registration, connection management | CRUD |
| 96 | setup.device.crud.service | 5.7 | Device registration, hardware fingerprint, status management | CRUD |
F.8.5 Tax Configuration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 97 | setup.tax.crud.service | 5.9 | Tax rate definitions, location-tax assignments, effective dates | CRUD |
F.8.6 Payment & UoM Configuration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 98 | setup.payment-method.crud.service | 5.11 | Payment method configuration, processor settings | CRUD |
| 99 | setup.uom.crud.service | 5.10 | Units of measure management, conversion rules | CRUD |
F.8.7 Custom Fields & Workflows
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 100 | setup.customfield.crud.service | 5.12 | Custom field definitions, validation rules, entity assignment | CRUD |
| 101 | setup.approval-workflow.crud.service | 5.13 | Approval workflow configuration, threshold rules | CRUD |
F.8.8 Receipt & Email
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 102 | setup.receipt.crud.service | 5.14 | Receipt template configuration, header/footer customization | CRUD |
| 103 | setup.email-template.crud.service | 5.15 | Email template management, variable substitution | CRUD |
F.8.9 Audit & Onboarding
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 104 | setup.audit.config.service | 5.18 | Audit log configuration, retention policies | CRUD |
| 105 | setup.rules.engine.service | 5.19 | Business rules YAML configuration, validation | CRUD |
| 106 | setup.loyalty.config.service | 5.17 | Loyalty program configuration: earn rate, tiers, expiry | CRUD |
| 107 | setup.onboarding.wizard.service | 5.20 | Tenant onboarding wizard: step tracking, initial data seeding | Stateful |
| 108 | setup.integrations-hub.config.service | 5.16 | Integration connections configuration (Setup side of Module 6) | CRUD |
F.9 Module 6: Integrations – Service Breakdown (20 Services, CRUD+Audit ES)
BRD Sections: 6.1-6.13
F.9.1 Core Integration Infrastructure
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 109 | integration.provider.registry.service | 6.2 | Provider registration, IIntegrationProvider management | CRUD |
| 110 | integration.circuit-breaker.service | 6.2 | Circuit breaker state machine (CLOSED/OPEN/HALF_OPEN) per provider | Stateful |
| 111 | integration.outbox.relay.service | 6.2 | Transactional outbox polling, event publication via LISTEN/NOTIFY | Event Handler |
| 112 | integration.idempotency.service | 6.2 | Idempotency key tracking for at-least-once delivery | Stateful |
| 113 | integration.webhook.pipeline.service | 6.2 | Inbound webhook receipt, signature validation, routing | Integration |
| 114 | integration.dead-letter.service | 6.2 | Failed integration message capture, retry, replay | Event Handler |
F.9.2 Shopify Integration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 115 | integration.shopify.product-sync.service | 6.3 | Bidirectional product/variant sync via GraphQL, bulk operations | Integration |
| 116 | integration.shopify.inventory-sync.service | 6.3 | Inventory level sync with safety buffers, oversell prevention | Integration |
| 117 | integration.shopify.order-sync.service | 6.3 | Online order ingestion, BOPIS flow, fulfillment updates | Integration |
| 118 | integration.shopify.webhook-handler.service | 6.3 | Shopify webhook processing: orders/create, products/update, etc. | Event Handler |
F.9.3 Amazon SP-API Integration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 119 | integration.amazon.catalog-sync.service | 6.4 | Amazon catalog/listings management, compliance validation | Integration |
| 120 | integration.amazon.order-sync.service | 6.4 | Amazon order polling (2-min interval), FBM fulfillment | Integration |
| 121 | integration.amazon.inventory-sync.service | 6.4 | Amazon inventory feed, FBA + FBM channel quantities | Integration |
F.9.4 Google Merchant Integration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 122 | integration.google.product-sync.service | 6.5 | Google Merchant product data feed, disapproval prevention | Integration |
| 123 | integration.google.inventory-sync.service | 6.5 | Local inventory ads feed (2x/day batch) | Integration |
F.9.5 Cross-Platform Orchestration
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 124 | integration.cross-platform.validation.service | 6.6 | Strictest-rule-wins validation across all channels | Rule Engine |
| 125 | integration.cross-platform.inventory-orchestrator.service | 6.7 | Safety buffer computation, channel allocation, saga compensation | Stateful (Saga) |
F.9.6 Payment, Email & Shipping
| # | Service Name | BRD Section(s) | Capability | Pattern |
|---|---|---|---|---|
| 126 | integration.payment-processor.service | 6.8 | Payment processor gateway abstraction (Stripe, Square) | Integration |
| 127 | integration.email.service | 6.9 | Email sending via provider abstraction (SendGrid, SES) | Integration |
| 128 | integration.shipping.service | 6.10 | Carrier rate lookup, label generation, tracking | Integration |
F.10 Cross-Cutting Services (8 Services)
These services satisfy architecture requirements from the Blueprint (Ch 04, Ch 05) rather than direct BRD business sections. They provide infrastructure that all modules depend on.
| # | Service Name | Blueprint Reference | Capability | Pattern |
|---|---|---|---|---|
| 129 | crosscutting.event-store.service | Ch 04 L.4A.1 | Append events, optimistic concurrency, snapshot management | Stateful |
| 130 | crosscutting.tenant.middleware.service | Ch 04 L.10A.4 | Tenant resolution from JWT, RLS policy enforcement | Stateful |
| 131 | crosscutting.auth.service | Ch 04 L.8 | JWT validation, PIN authentication, permission checks | Stateful |
| 132 | crosscutting.audit-log.service | Ch 04 L.4A | Cross-cutting audit trail: who, what, when, before/after | Event Handler |
| 133 | crosscutting.notification.service | Ch 04 | Push notifications, in-app alerts, Socket.io real-time | Event Handler |
| 134 | crosscutting.sync.orchestrator.service | Ch 04 L.10A.1 | Offline sync coordination: device registration, FIFO flush with flag-on-sync discrepancy detection | Stateful |
| 135 | crosscutting.report.engine.service | Various | Saved report execution, scheduling, export (CSV/PDF) | Query |
| 136 | crosscutting.state-machine.service | Ch 04 L.4A | Database-driven state machine: validate transitions, log history | Rule Engine |
Optional: RFID Module (Raptag, 6 Services)
| # | Service Name | Blueprint Reference | Capability | Pattern |
|---|---|---|---|---|
| 137 | rfid.config.crud.service | Ch 05 D13 | RFID tenant configuration (EPC prefix, serial counter) | CRUD |
| 138 | rfid.tag.crud.service | Ch 05 D13 | Tag lifecycle: create, activate, sell, transfer, void | CRUD |
| 139 | rfid.printer.crud.service | Ch 05 D13 | Printer registration, status monitoring | CRUD |
| 140 | rfid.printjob.command.service | Ch 05 D13 | Print job queue management, progress tracking | Command |
| 141 | rfid.scan.command.service | Ch 05 D13 | Scan session management: start, record reads, complete | Command |
| 142 | rfid.inventory-reconciliation.service | Ch 05 D13 | Compare RFID scan results against expected inventory | Calculation |
F.10A Module 7: State Machines – Service Breakdown (16 Services)
BRD Chapter 05, Module 7 (Sections 7.1-7.16) defines 16 consolidated state machine reference tables. Each maps to a dedicated state service responsible for validating transitions and logging state history.
Reference: Chapter 04, Section L.4A.4 (Domain Events Catalog) for event definitions.
| # | BRD Section | State Machine | Service Name | Pattern | Domain Events |
|---|---|---|---|---|---|
| 1 | 7.1 | Order Lifecycle | sales.order-state.service | State Machine | SaleCreated, SaleCompleted, SaleVoided |
| 2 | 7.2 | Payment Processing | sales.payment-state.service | State Machine | PaymentReceived, PaymentFailed, CardPaymentAuthorized |
| 3 | 7.3 | Return/Exchange | sales.return-state.service | State Machine | ReturnInitiated, ReturnCompleted, ExchangeProcessed |
| 4 | 7.4 | Layaway | sales.layaway-state.service | State Machine | LayawayCreated, LayawayPaymentReceived, LayawayCompleted, LayawayCancelled |
| 5 | 7.5 | Customer Account | customers.account-state.service | State Machine | CustomerCreated, CustomerUpdated, CustomerAnonymized |
| 6 | 7.6 | Product Lifecycle | catalog.product-state.service | State Machine | ProductCreated, ProductActivated, ProductDiscontinued, ProductArchived |
| 7 | 7.7 | Inventory Count | inventory.count-state.service | State Machine | InventoryCounted |
| 8 | 7.8 | Purchase Order | inventory.po-state.service | State Machine | PurchaseOrderCreated, PurchaseOrderApproved, PurchaseOrderCancelled |
| 9 | 7.9 | Transfer Order | inventory.transfer-state.service | State Machine | InventoryTransferred, TransferShipped, TransferReceived |
| 10 | 7.10 | Receiving | inventory.receiving-state.service | State Machine | InventoryReceived, ReceivingDiscrepancyLogged |
| 11 | 7.11 | RFID Session | inventory.rfid-session-state.service | State Machine | RFIDSessionStarted, RFIDSessionCompleted |
| 12 | 7.12 | Offline Mode | system.connection-state.service | State Machine | ConnectionStateChanged (ONLINE/DEGRADED/OFFLINE) |
| 13 | 7.13 | Tenant Provisioning | system.tenant-state.service | State Machine | TenantProvisioned, TenantSuspended, TenantDeactivated |
| 14 | 7.14 | User Account | system.user-state.service | State Machine | UserActivated, UserDeactivated, UserLocked |
| 15 | 7.15 | Integration Sync | integrations.sync-state.service | State Machine | SyncStarted, SyncCompleted, SyncFailed |
| 16 | 7.16 | Report Generation | system.report-state.service | State Machine | ReportQueued, ReportGenerated, ReportFailed |
Note: These state services delegate to crosscutting.state-machine.service (#136) for database-driven transition validation and history logging. Each service above owns the domain-specific transition rules for its aggregate.
F.11 Module Dependency Matrix
F.11.1 Inter-Module Dependencies
Module 1 (Sales) --> Module 3 (Catalog):
sale.cart.command.service --> catalog.product.query.service (lookup product/variant)
sale.promotion.engine.service --> catalog.pricing.calculation.service (resolve price)
sale.price-check.query.service --> catalog.product.query.service (read product)
Module 1 (Sales) --> Module 4 (Inventory):
sale.finalize.command.service --> inventory.sale-deduction (via SaleCompleted event)
sale.park.command.service --> inventory.reservation (soft-reserve on park)
sale.void.command.service --> inventory.sale-deduction (via SaleVoided, restores)
sale.return.command.service --> inventory.sale-deduction (via ReturnCompleted, restores)
Module 1 (Sales) --> Module 2 (Customers):
sale.cart.command.service --> customer.profile.crud.service (attach customer)
sale.loyalty.command.service --> customer.profile.crud.service (read loyalty)
sale.payment.storecredit.service --> customer.profile.crud.service (check credit)
Module 1 (Sales) --> Module 5 (Setup):
sale.tax.calculation.service --> setup.tax.crud.service (read tax rates)
sale.shift.command.service --> setup.user.crud.service (validate employee)
sale.cashdrawer.command.service --> setup.register.crud.service (validate register)
Module 1 (Sales) --> Module 6 (Integrations):
sale.payment.card.service --> integration.payment-processor.service (card auth)
sale.receipt.query.service --> integration.email.service (email receipt)
Module 3 (Catalog) --> Module 4 (Inventory):
catalog.product.lifecycle.service --> inventory.level.query.service (check stock)
Module 4 (Inventory) --> Module 3 (Catalog):
inventory.po.command.service --> catalog.vendor.crud.service (vendor details)
inventory.reorder.engine.service --> catalog.product.query.service (product details)
Module 4 (Inventory) --> Module 5 (Setup):
inventory.level.query.service --> setup.location.crud.service (location details)
inventory.rules.engine.service --> setup.settings.crud.service (business rules)
Module 6 (Integrations) --> Module 3 (Catalog):
integration.shopify.product-sync --> catalog.product.query.service (read products)
integration.amazon.catalog-sync --> catalog.product.query.service (read products)
integration.google.product-sync --> catalog.product.query.service (read products)
integration.cross-platform.validation --> catalog.product.query.service (validate)
Module 6 (Integrations) --> Module 4 (Inventory):
integration.shopify.inventory-sync --> inventory.level.query.service (read stock)
integration.amazon.inventory-sync --> inventory.level.query.service (read stock)
integration.google.inventory-sync --> inventory.level.query.service (read stock)
integration.cross-platform.inventory-orchestrator --> inventory.level.query (allocate)
Module 6 (Integrations) --> Module 1 (Sales):
integration.shopify.order-sync --> sale.finalize.command.service (create order)
Cross-Cutting --> All Modules:
crosscutting.tenant.middleware.service --> ALL (RLS enforcement)
crosscutting.auth.service --> ALL (permission checks)
crosscutting.audit-log.service --> ALL (via event subscription)
crosscutting.event-store.service --> Module 1, 4, 6 (ES-enabled modules)
F.11.2 Dependency Direction Rules
| Rule | Description |
|---|---|
| Allowed | Module 1 –> Module 2, 3, 4, 5 (sales orchestrates) |
| Allowed | Module 6 –> Module 3, 4 (integrations read catalog/inventory) |
| Allowed | Module 4 –> Module 3 (inventory references catalog) |
| Forbidden | Module 2 –> Module 1 (customers cannot call sales) |
| Forbidden | Module 3 –> Module 1 (catalog cannot call sales) |
| Forbidden | Module 5 –> Module 1, 2, 3, 4 (setup is pure configuration) |
| Event-Only | Module 4 <– Module 1 (inventory reacts to sale events, not direct calls) |
F.12 Service-to-BRD Traceability Matrix
Every BRD top-level section (x.y) maps to at least one service. Full bidirectional traceability:
Coverage Statistics
| BRD Module | Sections | Services | Coverage |
|---|---|---|---|
| Module 1: Sales (1.1-1.20) | 59 subsections | 37 services | 100% |
| Module 2: Customers (2.1-2.8) | 10 subsections | 7 services | 100% |
| Module 3: Catalog (3.1-3.15) | 48 subsections | 20 services | 100% |
| Module 4: Inventory (4.1-4.19) | 55 subsections | 23 services | 100% |
| Module 5: Setup (5.1-5.21) | 63 subsections | 21 services | 100% |
| Module 6: Integrations (6.1-6.13) | 28 subsections | 20 services | 100% |
| TOTAL | 263 subsections | 128 services | 100% |
Orphaned Capabilities: None
All BRD sections 1.1-6.13 have at least one mapped service.
Architecture-Only Services (14)
Cross-cutting (#129-136) and RFID (#137-142) services are justified by Blueprint architecture requirements (Ch 04, Ch 05) rather than BRD sections.
F.13 CQRS Pattern Reference
F.13.1 When to Split Command/Query
| Criterion | Split (CQRS) | Keep Together (CRUD) |
|---|---|---|
| Write and read models differ significantly | Yes | – |
| Audit trail required (Event Sourcing) | Yes | – |
| Read-heavy with denormalized views | Yes (materialized projections) | – |
| Simple entity CRUD with no complex reads | – | Yes |
| Configuration data | – | Yes |
F.13.2 CQRS Event Flow
1. Client sends Command (e.g., CreateSaleCommand)
|
v
2. Command Handler validates business rules
|
v
3. Aggregate produces Domain Events (e.g., SaleCreated, SaleLineItemAdded)
|
v
4. Events appended to Event Store (events table)
|
+---> 5a. Projection Handlers update Read Models (materialized views)
+---> 5b. Audit Log Handler writes to audit_log
+---> 5c. Outbox Relay publishes to external subscribers
+---> 5d. Integration Handler triggers sync (Module 6)
|
v
6. Query reads from optimized Read Model (not event store)
F.13.3 Domain Events Summary
| Aggregate | Module | Event Count |
|---|---|---|
| Sale | 1 (Sales) | 25 |
| Return | 1 (Sales) | 5 |
| Gift Card | 1 (Sales) | 4 |
| Layaway/Hold | 1 (Sales) | 6 |
| Cash Drawer | 1 (Sales) | 6 |
| Inventory | 4 (Inventory) | 12 |
| Customer | 2 (Customers) | 8 |
| Employee | 5 (Setup) | 4 |
| Integration | 6 (Integrations) | 10 |
| TOTAL | 80 |
F.14 Folder Structure Reference (Technology-Agnostic)
src/
+-- modules/
| +-- sales/
| | +-- commands/
| | | +-- cart/
| | | +-- checkout/
| | | +-- payment/
| | | +-- return/
| | | +-- cash-drawer/
| | | +-- gift-card/
| | | +-- layaway/
| | | +-- hold/
| | +-- queries/
| | +-- events/
| | +-- event-handlers/
| | +-- domain/
| | | +-- aggregates/
| | | +-- value-objects/
| | | +-- rules/
| | +-- repositories/
| | +-- dtos/
| |
| +-- customers/
| | +-- commands/
| | +-- queries/
| | +-- domain/
| | +-- repositories/
| |
| +-- catalog/
| | +-- commands/
| | | +-- product/
| | | +-- variant/
| | | +-- pricing/
| | | +-- category/
| | | +-- bulk-import/
| | +-- queries/
| | +-- domain/
| | +-- cache/
| |
| +-- inventory/
| | +-- commands/
| | | +-- adjustment/
| | | +-- purchase-order/
| | | +-- receiving/
| | | +-- transfer/
| | | +-- count/
| | | +-- reservation/
| | | +-- fulfillment/
| | +-- queries/
| | +-- events/
| | +-- event-handlers/
| | +-- domain/
| |
| +-- setup/
| | +-- commands/
| | +-- queries/
| |
| +-- integrations/
| | +-- core/
| | | +-- provider-registry/
| | | +-- circuit-breaker/
| | | +-- outbox-relay/
| | | +-- idempotency/
| | | +-- webhook-pipeline/
| | | +-- dead-letter/
| | +-- providers/
| | | +-- shopify/
| | | +-- amazon/
| | | +-- google/
| | | +-- payment/
| | | +-- email/
| | | +-- shipping/
| | +-- orchestration/
| | +-- anti-corruption-layer/
| |
| +-- rfid/
| +-- commands/
| +-- queries/
| +-- domain/
|
+-- cross-cutting/
| +-- event-store/
| +-- tenant/
| +-- auth/
| +-- audit/
| +-- sync/
| +-- notifications/
| +-- reporting/
| +-- state-machine/
|
+-- shared/
| +-- domain/
| +-- interfaces/
| +-- infrastructure/
|
+-- api/
+-- controllers/
+-- startup/
F.15 Summary Statistics
| Metric | Value |
|---|---|
| BRD Modules | 6 |
| Code Modules | 7 + cross-cutting + RFID |
| Total Services | 142 |
| Module 1 (Sales) | 37 services |
| Module 2 (Customers) | 7 services |
| Module 3 (Catalog) | 20 services |
| Module 4 (Inventory) | 23 services |
| Module 5 (Setup) | 21 services |
| Module 6 (Integrations) | 20 services |
| Cross-Cutting | 8 services |
| RFID (Optional) | 6 services |
| Domain Events | 80 |
| State Machines | 19 |
| BRD Decisions Mapped | 107/107 (100%) |
| BRD Coverage | 100% (all sections mapped) |
| Orphaned Capabilities | 0 |
Service Pattern Distribution
| Pattern | Count | % |
|---|---|---|
| CRUD | 35 | 24.6% |
| Command | 28 | 19.7% |
| Query | 14 | 9.9% |
| Integration | 13 | 9.2% |
| Stateful | 12 | 8.5% |
| Event Handler | 10 | 7.0% |
| Rule Engine | 7 | 4.9% |
| Calculation | 5 | 3.5% |
| Other (Orchestrator, Saga) | 18 | 12.7% |
| TOTAL | 142 | 100% |
Migration from Current Service Layer
The original Service Layer design defined 5 coarse-grained services. This mapping decomposes them:
| Original Service | Decomposed Into | Count |
|---|---|---|
IOrderService | sale.cart.*, sale.park.*, sale.discount.*, sale.payment.*, sale.finalize.*, sale.void.*, sale.return.*, sale.exchange.*, sale.layaway.*, sale.hold-for-pickup.*, sale.giftcard.*, sale.receipt.*, sale.history.* | 23 |
IInventoryService | inventory.level.*, inventory.po.*, inventory.receiving.*, inventory.transfer.*, inventory.count.*, inventory.rma.*, inventory.reservation.*, inventory.fulfillment.* | 23 |
ICustomerService | customer.profile.*, customer.search.*, customer.group.*, customer.merge.*, customer.privacy.*, sale.loyalty.* | 7 |
IItemService | catalog.product.*, catalog.variant.*, catalog.search.*, catalog.bulk-import.* | 20 |
IReportService | crosscutting.report.engine.service, sale.daily-summary.*, inventory.dashboard.*, catalog.analytics.* | 4 |
| (new services) | setup.*, integration.*, cross-cutting, RFID | 55 |
RFID Decisions (BRD v20.0, #108-113)
BRD v20.0 added 6 new decisions for the RFID Counting Subsystem:
| # | Decision | Summary |
|---|---|---|
| 108 | RFID scope limited to counting only | No lifecycle tracking (sold_at, transferred_at stripped). Tag status limited to: active, void, lost |
| 109 | EPC serial generation via PostgreSQL SEQUENCE | Per-tenant SEQUENCE for serial numbers, not column-based last_serial_number |
| 110 | Chunked sync with 5,000 events per chunk | UNIQUE(session_id, epc) idempotency, resume via upload-status endpoint |
| 111 | RSSI-based multi-operator dedup | When multiple operators scan the same tag, highest RSSI wins for section assignment |
| 112 | Auto-save with 30-second SQLite checkpoint | Crash recovery dialog, battery-triggered saves on Nexus Raptag |
| 113 | Maximum 10 operators per counting session | Section assignment per operator, session_operators join table |
Related services: rfid.tag.crud.service, rfid.session.command.service, rfid.scan.command.service, rfid.sync.command.service, rfid.config.crud.service, rfid.encoding.command.service
Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | 2026-02-24 |
| Updated | 2026-03-02 |
| Author | Claude Code |
| Status | Active |
| Section | Appendix F |
| BRD Version | 20.0 |
This appendix is part of the POS Blueprint Book. All content is self-contained.
Appendix G: Application Screen Reference
Version: 7.0.0 Last Updated: March 2, 2026 BRD Version: 20.0 (19,900+ lines, 7 modules, 113 decisions) Total Screens: 118
G.1 Purpose & How to Use This Document
This appendix provides a complete catalog of every user-facing screen in the POS Platform, derived from the Business Requirements Document (BRD v20.0, Chapter 05). Each screen is mapped back to its source BRD section, database tables, Appendix F services, and state machines — creating full traceability from business requirement to UI surface.
Who Should Use This
| Audience | Use Case |
|---|---|
| Frontend Developers | Find the complete specification for any screen they need to build |
| UI/UX Designers | Understand screen inventory, element requirements, and navigation flows |
| QA Engineers | Trace test scenarios back to specific screens and their business rules |
| Product Owners | Review screen coverage to ensure all BRD requirements have a UI surface |
| Architects | Verify that screen boundaries align with module boundaries and ADR decisions |
How to Read Screen Entries
Each screen entry includes:
| Field | Description |
|---|---|
| Screen ID | Unique identifier: SCR-MXX-YY (M=module 01-06), SCR-RXX (Raptag), SCR-XXX (cross-cutting) |
| Product(s) | Role-based access within Nexus POS (React web app) or Raptag (React Native mobile). Values: All Roles, CASHIER+, MANAGER+, OWNER, BUYER+, Raptag |
| BRD Section(s) | Chapter 05 section number(s) this screen implements |
| Database Tables | Tables from Ch 07/08 this screen reads (R) or writes (W) |
| State Machine(s) | BRD Module 7 state machines that govern this screen’s behavior |
| Appendix F Services | Code services from Appendix F that power this screen |
| User Roles | Which roles can access: OWNER, MANAGER, CASHIER, BUYER, AUDITOR |
| Offline Capable | Whether the screen functions in DEGRADED/OFFLINE mode (ADR-048). SQLite WASM (sql.js/wa-sqlite + OPFS) provides 2-table fallback in the browser. |
| Route | Suggested URL route path |
Cross-Reference Guide
| To Find… | Look At… |
|---|---|
| Business rules for a screen | Ch 05, section listed in BRD Section(s) |
| Database schema for a table | Ch 08, entity specification for the table |
| Service implementation details | Appendix F, service listed in Appendix F Services |
| State transitions | Ch 05, Module 7 (sections 7.1-7.16) |
| Architecture decisions | Ch 02, ADR listed in screen notes |
| Offline behavior patterns | Ch 04, Section L.10A.1 (ADR-048, ADR-052) |
G.2 Product Context (2 Products)
The POS Platform delivers two product experiences from two codebases, governed by ADR-052 (Unified Web App) and ADR-047 (Raptag React Native):
┌─────────────────────────────────────────────────────────────────────────┐
│ NEXUS PLATFORM — 2 PRODUCTS │
│ │
│ ┌──────────────────────────────────────────────┐ ┌────────────────┐ │
│ │ NEXUS POS — UNIFIED WEB APP │ │ SEPARATE APP │ │
│ │ (ADR-052, ADR-048) │ │ (ADR-047) │ │
│ │ │ │ │ │
│ │ React / TypeScript / Vite │ │ ┌────────────┐│ │
│ │ Single SPA in the browser │ │ │NEXUS ││ │
│ │ │ │ │RAPTAG ││ │
│ │ Role-based access: │ │ │(React ││ │
│ │ CASHIER — sales terminal screens │ │ │ Native) ││ │
│ │ MANAGER — reports, inventory, setup │ │ │ ││ │
│ │ OWNER — full config, integrations │ │ │ RFID ││ │
│ │ BUYER — catalog, purchasing │ │ │ counting ││ │
│ │ AUDITOR — counts, audit log │ │ │ ~8 screens ││ │
│ │ │ │ │ + SQLite ││ │
│ │ 107 screens + SQLite WASM fallback │ │ │ offline ││ │
│ │ │ │ └────────────┘│ │
│ └──────────────────────────────────────────────┘ └────────────────┘ │
│ │
│ SHARED: PostgreSQL 16 + Redis 7.x + Socket.io (ADR-049) │
│ STATE: React Query (server) + Zustand (client) (ADR-051) │
│ OFFLINE: 3-state monitor, 2-table SQLite WASM fallback (ADR-048) │
└─────────────────────────────────────────────────────────────────────────┘
Product Summary
| Product | Tech | Deployment | Primary Users | Screens | Offline |
|---|---|---|---|---|---|
| Nexus POS | React/TS + Vite | Web browser (any device) | All roles (CASHIER through OWNER) | 107 + 3 cross-cutting | Yes (ADR-048, SQLite WASM) |
| Nexus Raptag | React Native + Expo | Mobile (iOS/Android) | Auditor, Manager | 8 | Yes (ADR-047) |
Key Insight (ADR-052): Nexus POS is a single React web application. All roles access the same app — the difference is what screens are visible based on permissions. A cashier at a register sees transaction screens; a manager sees reports and configuration. Role-based routing controls screen access (CASHIER+, MANAGER+, OWNER, BUYER+, AUDITOR).
Screen Distribution by Module
| Module | All Roles | CASHIER+ | MANAGER+ | OWNER | BUYER+ | Raptag | Total |
|---|---|---|---|---|---|---|---|
| 1. Sales | — | 19 | 3 | — | — | — | 22 |
| 2. Customers | 4 | — | 6 | — | — | — | 10 |
| 3. Catalog | 3 | — | 10 | 1 | 4 | — | 18 |
| 4. Inventory | 5 | 1 | 14 | 1 | 2 | — | 23 |
| 5. Setup & Config | 1 | 1 | 2 | 18 | — | — | 22 |
| 6. Integrations | — | — | — | 12 | — | — | 12 |
| Raptag Mobile | — | — | — | — | — | 8 | 8 |
| Cross-Cutting | 3 | — | — | — | — | — | 3 |
| Total | 16 | 21 | 35 | 32 | 6 | 8 | 118 |
G.3 Screen Inventory Summary
Complete master table of all 118 screens. See sections G.4-G.12 for full specifications.
Module 1: Sales (22 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M01-01 | Sales Terminal / Item Entry | 1.1 | CASHIER+ | CASHIER, MANAGER | Yes |
| SCR-M01-02 | Cart Panel & Line Items | 1.1 | CASHIER+ | CASHIER, MANAGER | Yes |
| SCR-M01-03 | Discount Modal | 1.2 | CASHIER+ | MANAGER, OWNER | Yes |
| SCR-M01-04 | Payment / Checkout | 1.3 | CASHIER+ | CASHIER, MANAGER | Degraded |
| SCR-M01-05 | Layaway Payment | 1.3 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-06 | Affirm Financing Flow | 1.3 | CASHIER+ | CASHIER | No |
| SCR-M01-07 | Parked Sales List | 1.1.1 | CASHIER+ | CASHIER, MANAGER | Yes |
| SCR-M01-08 | Gift Card Operations | 1.5 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-09 | Receipt Print / Reprint | 1.4 | CASHIER+ | CASHIER, MANAGER | Yes |
| SCR-M01-10 | Cash Drawer Management | 1.12 | CASHIER+ | CASHIER, MANAGER | Yes |
| SCR-M01-11 | Price Check Mode | 1.13 | CASHIER+ | CASHIER | Yes |
| SCR-M01-12 | Coupon Entry | 1.14 | CASHIER+ | CASHIER | No |
| SCR-M01-13 | Loyalty Display | 1.15 | CASHIER+ | CASHIER | No |
| SCR-M01-14 | Return Processing | 1.4 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-15 | Exchange Processing | 1.4 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-16 | Special Order Create | 1.6 | CASHIER+ | MANAGER | No |
| SCR-M01-17 | Multi-Store Inventory Lookup | 1.7 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-18 | Hold for Pickup (BOPIS) | 1.11 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M01-19 | Ship-to-Customer | 1.7 | CASHIER+ | MANAGER | No |
| SCR-M01-20 | Sales Reports Dashboard | 1.8, 1.19 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M01-21 | Commission Management | 1.8 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M01-22 | Manager Discrepancy Review | 1.16 | MANAGER+ | MANAGER, OWNER | No |
Module 2: Customers (10 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M02-01 | Customer List / Directory | 2.1 | All Roles | CASHIER, MANAGER | No |
| SCR-M02-02 | Customer Profile / Detail | 2.1 | All Roles | CASHIER, MANAGER | No |
| SCR-M02-03 | Customer Create / Edit | 2.1 | All Roles | CASHIER, MANAGER | No |
| SCR-M02-04 | Customer Group / Tier Mgmt | 2.2 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M02-05 | Customer Notes & Preferences | 2.3 | All Roles | CASHIER, MANAGER | No |
| SCR-M02-06 | Communication Preferences | 2.4 | MANAGER+ | MANAGER | No |
| SCR-M02-07 | Customer Merge / Dedup | 2.5 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M02-08 | Customer Deletion / Anonymize | 2.5 | MANAGER+ | OWNER | No |
| SCR-M02-09 | Loyalty Admin Dashboard | 2.6, 5.17 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M02-10 | Communication Log | 2.4 | MANAGER+ | MANAGER | No |
Module 3: Catalog (18 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M03-01 | Product List / Search | 3.1 | All Roles | ALL | Yes |
| SCR-M03-02 | Product Detail / Edit | 3.1 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-03 | Variant Matrix Editor | 3.1 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-04 | Pricing Engine / Price Books | 3.3 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M03-05 | Promotions Setup | 3.3 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M03-06 | Markdown Workflow | 3.3 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M03-07 | Coupon Management | 3.3 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M03-08 | Category Hierarchy | 3.5 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-09 | Tags & Collections | 3.5 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-10 | Seasons / Buying Calendar | 3.5 | BUYER+ | BUYER | No |
| SCR-M03-11 | Barcode Management | 3.4 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-12 | Multi-Channel Allocation | 3.6 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M03-13 | Vendor Management | 3.8 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M03-14 | Label Printing | 3.10 | All Roles | CASHIER, MANAGER | No |
| SCR-M03-15 | Product Media Manager | 3.11 | BUYER+ | BUYER | No |
| SCR-M03-16 | Custom Fields Config | 3.12, 5.12 | OWNER | OWNER | No |
| SCR-M03-17 | Product Search & Discovery | 3.9 | All Roles | ALL | Yes |
| SCR-M03-18 | Product Analytics | 3.14 | MANAGER+ | MANAGER, OWNER | No |
Module 4: Inventory (23 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M04-01 | Inventory Dashboard | 4.17 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M04-02 | Inventory List (per location) | 4.1 | All Roles | ALL | No |
| SCR-M04-03 | Low Stock Alerts | 4.16 | MANAGER+ | MANAGER, BUYER | No |
| SCR-M04-04 | PO Create / Edit | 4.3 | BUYER+ | BUYER, MANAGER | No |
| SCR-M04-05 | PO Approval / Track | 4.3 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M04-06 | PO Templates | 4.3 | BUYER+ | BUYER | No |
| SCR-M04-07 | Receiving & Inspection | 4.4 | All Roles | CASHIER, MANAGER | No |
| SCR-M04-08 | Receiving Variance | 4.4 | All Roles | MANAGER | No |
| SCR-M04-09 | Stock Count Session | 4.6 | All Roles | AUDITOR, MANAGER | No |
| SCR-M04-10 | Count Freeze Manager | 4.6 | MANAGER+ | MANAGER | No |
| SCR-M04-11 | Scanner Count Entry | 4.6 | CASHIER+ | AUDITOR, CASHIER | No |
| SCR-M04-12 | RFID Count Manager | 4.6.8 | MANAGER+ | MANAGER | No |
| SCR-M04-13 | Count Results Review | 4.6 | MANAGER+ | MANAGER, AUDITOR | No |
| SCR-M04-14 | Count Approval | 4.6 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M04-15 | Adjustment Request | 4.7 | All Roles | CASHIER, MANAGER | No |
| SCR-M04-16 | Adjustment Approval | 4.7 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M04-17 | Transfer Create / Track | 4.8 | MANAGER+ | MANAGER | No |
| SCR-M04-18 | Reorder Configuration | 4.5 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M04-19 | Reason Codes Management | 4.7 | OWNER | OWNER | No |
| SCR-M04-20 | Serial Number Tracking | 4.10 | All Roles | MANAGER | No |
| SCR-M04-21 | Vendor RMA | 4.9 | MANAGER+ | BUYER, MANAGER | No |
| SCR-M04-22 | Inventory Reports | 4.17 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M04-23 | Reservation Management | 4.13 | MANAGER+ | MANAGER | No |
Module 5: Setup & Configuration (22 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M05-01 | Onboarding Wizard (13 steps) | 5.20 | OWNER | OWNER | No |
| SCR-M05-02 | System Settings / Branding | 5.2 | OWNER | OWNER | No |
| SCR-M05-03 | Location Management | 5.4 | OWNER | OWNER | No |
| SCR-M05-04 | User Management | 5.5 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M05-05 | Role & Permission Config | 5.5 | OWNER | OWNER | No |
| SCR-M05-06 | Login / Authentication | 5.5 | All Roles | ALL | Yes |
| SCR-M05-07 | Clock-In / Clock-Out | 5.6 | CASHIER+ | CASHIER, MANAGER | No |
| SCR-M05-08 | Register Management | 5.7 | OWNER | MANAGER, OWNER | No |
| SCR-M05-09 | Register Profiles | 5.7 | OWNER | MANAGER, OWNER | No |
| SCR-M05-10 | Register Retirement | 5.7 | OWNER | OWNER | No |
| SCR-M05-11 | Payment Terminal Config | 5.11 | OWNER | OWNER | No |
| SCR-M05-12 | Payment Methods Setup | 5.11 | OWNER | OWNER | No |
| SCR-M05-13 | Tax Jurisdiction Config | 5.9 | OWNER | OWNER | No |
| SCR-M05-14 | Tax Exemption Management | 5.9 | MANAGER+ | MANAGER, OWNER | No |
| SCR-M05-15 | UOM Setup | 5.10 | OWNER | OWNER | No |
| SCR-M05-16 | Approval Workflows | 5.13 | OWNER | OWNER | No |
| SCR-M05-17 | Printer Configuration | 5.8 | OWNER | MANAGER, OWNER | No |
| SCR-M05-18 | Receipt Builder | 5.14 | OWNER | OWNER | No |
| SCR-M05-19 | Email Config & Templates | 5.15 | OWNER | OWNER | No |
| SCR-M05-20 | RFID Configuration | 5.16 | OWNER | OWNER | No |
| SCR-M05-21 | Audit Log Viewer | 5.18 | OWNER | OWNER | No |
| SCR-M05-22 | Business Rules (YAML Editor) | 5.19 | OWNER | OWNER | No |
Module 6: Integrations (12 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-M06-01 | Integration Hub Dashboard | 6.11 | OWNER | MANAGER, OWNER | No |
| SCR-M06-02 | Shopify Setup & Connection | 6.3 | OWNER | OWNER | No |
| SCR-M06-03 | Shopify Inventory Sync Config | 6.3, 6.7 | OWNER | MANAGER, OWNER | No |
| SCR-M06-04 | Shopify Order Fulfillment | 6.3 | OWNER | MANAGER | No |
| SCR-M06-05 | Amazon Setup & Connection | 6.4 | OWNER | OWNER | No |
| SCR-M06-06 | Amazon Catalog / Listings | 6.4 | OWNER | MANAGER | No |
| SCR-M06-07 | Google Merchant Setup | 6.5 | OWNER | OWNER | No |
| SCR-M06-08 | Google Validation Dashboard | 6.5 | OWNER | MANAGER | No |
| SCR-M06-09 | Cross-Platform Validation | 6.6 | OWNER | MANAGER, OWNER | No |
| SCR-M06-10 | Inventory Sync Dashboard | 6.7 | OWNER | MANAGER | No |
| SCR-M06-11 | Integration Health Monitor | 6.11 | OWNER | MANAGER, OWNER | No |
| SCR-M06-12 | Error DLQ Management | 6.2 | OWNER | MANAGER, OWNER | No |
Raptag Mobile (8 Screens)
| ID | Screen | BRD | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-R01 | Login | 5.5 | Raptag | ALL | Yes |
| SCR-R02 | Home Dashboard | 4.6.8 | Raptag | AUDITOR, MANAGER | Yes |
| SCR-R03 | Join / Start Session | 4.6.8 | Raptag | AUDITOR, MANAGER | No |
| SCR-R04 | Scanning Interface | 4.6.8 | Raptag | AUDITOR | Yes |
| SCR-R05 | Section Assignment & Progress | 4.6.8 | Raptag | AUDITOR | Yes |
| SCR-R06 | Session Summary | 4.6.8 | Raptag | AUDITOR, MANAGER | Yes |
| SCR-R07 | Chunked Upload / Sync | 4.6.8 | Raptag | AUDITOR | Degraded |
| SCR-R08 | Device Pairing | 5.16 | Raptag | MANAGER | No |
Cross-Cutting (3 Screens)
| ID | Screen | Ref | Product | Roles | Offline |
|---|---|---|---|---|---|
| SCR-X01 | Login / Authentication | 5.5, ADR-004 | All Roles | ALL | Yes |
| SCR-X02 | Error Pages (403/404/500) | — | All Roles | ALL | Yes |
| SCR-X03 | Offline Mode Indicator | 1.16, ADR-048 | All Roles | ALL | Yes |
G.4 Module 1: Sales Screens (22 Screens)
BRD Sections: 1.1-1.20 | Appendix F: §F.4 (37 services) | Pattern: Full CQRS + Event Sourcing
Cross-Reference: See Ch 05, Sections 1.1-1.20 for business rules. See Ch 08, Domain 4 (Sales & Payments) for table schemas. See Appendix F, §F.4 for service breakdown.
G.4.1 Sales Terminal / Item Entry
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-01 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.1 |
| Database Tables | orders (R/W), order_items (R/W), products (R), variants (R), inventory_levels (R), customers (R) |
| State Machine(s) | 7.1 Order States, 7.2 Parked Sale States |
| Appendix F Services | sale.cart.command.service, sale.cart.query.service, sale.park.command.service, sale.park.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (degraded – cached products from product_cache only, no real-time price updates, stale pricing warning displayed) |
| Route | /pos/sales/terminal |
Purpose
The Sales Terminal is the primary point-of-sale screen where staff ring up customer purchases. It supports barcode scanning, manual SKU entry, and bulk RFID tag lookup (max 50 tags per request). Staff can toggle between Sale, Return, and Exchange modes, attach customers for loyalty/pricing, and park sales for later retrieval (max 5 per terminal, 4-hour TTL).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Barcode/SKU Input | Input | Scan barcode or type SKU to add items; auto-focuses after each entry | BRD 1.1: GET /product/{sku} or POST /products/bulk-lookup (max 50 tags) |
| Mode Toggle | Button Group | Switch between Sale / Return / Exchange modes | BRD 1.1: Mode determines stock validation behavior |
| Cart Panel | Panel | Displays current line items with qty, price, discount, line total | BRD 1.1: Real-time subtotal calculation |
| Customer Attach | Button + Search | Search/attach customer by name, phone, email, or loyalty number | BRD 1.1: Recalculates prices if Price Tier applies; recalculates tax if exempt |
| Running Totals | Panel | Subtotal, discounts, tax breakdown (State/County/City), grand total | BRD 1.2.1: Strict discount calculation order (7 steps) |
| Stock Warning | Badge/Alert | Warns when stock is low or zero for sale items | BRD 1.1: Check Stock > 0 before adding to cart |
| Park Sale | Button | Serialize cart state and release locks; max 5 per terminal | BRD 1.1.1: 4-hour TTL, soft-reserve inventory |
| Retrieve Sale | Button | List and restore parked sales for this terminal | BRD 1.1.1: Parked items visible to other terminals with warning |
| Upsell Alert | Toast/Banner | Triggered by promo engine when cart meets criteria | BRD 1.1: “Buy 1 more for 10% off” style suggestions |
| Proceed to Payment | Button | Transition order from DRAFT to PENDING state | BRD 1.3: Navigates to Payment/Checkout |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXUS POS [Sale ▼] [Price Check] [Park Sale] [Retrieve] [User] [?] │
├────────────────────┬────────────────────────────────┬───────────────────────┤
│ ITEM ENTRY │ CART │ CUSTOMER & TOTALS │
│ │ │ │
│ ┌──────────────┐ │ # SKU Name Qty $ │ ┌─────────────────┐ │
│ │ Scan / SKU │ │ 1 NXJ-1001 Jacket 1 89 │ │ [Attach Cust.] │ │
│ └──────────────┘ │ 2 NXJ-2045 Tee 2 50 │ │ │ │
│ │ 3 NXJ-3012 Pants 1 65 │ │ Jane Doe │ │
│ ┌──────────────┐ │ │ │ Gold Tier │ │
│ │ Search Prod │ │ │ │ 450 pts │ │
│ │ ─────────── │ │ │ │ Tax Exempt: No │ │
│ │ Recent Items │ │ │ └─────────────────┘ │
│ │ NXJ-1001 │ │ │ │
│ │ NXJ-2045 │ │ │ Subtotal: $254.00 │
│ │ NXJ-3012 │ │ │ Discount: - $25.40 │
│ └──────────────┘ │ │ State Tax: + $9.83 │
│ │ │ Local Tax: + $2.29 │
│ ⚠ Low Stock: │ ├────────────────────────────┤ ───────────────────── │
│ NXJ-3012 (2 left) │ │ [Remove] [Discount] [Qty] │ TOTAL: $240.72 │
│ │ │ │
│ [Upsell: Buy 1 │ │ ┌─────────────────┐ │
│ more Tee, 10% │ │ │ PAY ($240.72)│ │
│ off all Tees] │ │ └─────────────────┘ │
└────────────────────┴────────────────────────────────┴───────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Pay” | SCR-M01-04 Payment / Checkout | CASHIER+ |
| Click “Park Sale” | Stays on terminal (cart cleared, sale serialized) | CASHIER+ |
| Click “Retrieve” | SCR-M01-07 Parked Sales List | CASHIER+ |
| Click “Price Check” | SCR-M01-11 Price Check Mode | CASHIER+ |
| Click line item discount | SCR-M01-03 Discount Modal | CASHIER+ (Manager PIN for override) |
| Click “Attach Customer” | SCR-M02-01 Customer List (search overlay) | CASHIER+ |
| Toggle to Return mode | SCR-M01-14 Return Processing | CASHIER+ |
| Toggle to Exchange mode | SCR-M01-15 Exchange Processing | CASHIER+ |
G.4.2 Cart Panel & Line Items
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-02 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.1 |
| Database Tables | orders (R/W), order_items (R/W), variants (R), products (R), pricing_rules (R) |
| State Machine(s) | 7.1 Order States |
| Appendix F Services | sale.cart.command.service, sale.cart.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (degraded – uses product_cache prices, stale pricing warning) |
| Route | /pos/sales/terminal (embedded panel within SCR-M01-01) |
Purpose
The Cart Panel is the central line-item display embedded within the Sales Terminal. It shows all items in the current transaction with quantity controls, per-line discount indicators, tax amounts, and line totals. Staff can modify quantities, remove items, apply line discounts, and see real-time running totals as the cart changes.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Line Item Row | Table Row | SKU, product name, quantity, unit price, discount, tax, line total | BRD 1.1: Snapshot product data at time of add |
| Quantity Stepper | Input | Increment/decrement quantity; validates against stock | BRD 1.1: Stock > 0 validation for sale mode |
| Remove Item | Button | Remove line item from cart | BRD 1.1: Releases any soft-reserve |
| Line Discount Indicator | Badge | Shows applied discount type and percentage | BRD 1.2.1: Discount calculation order applies |
| Non-Discountable Flag | Icon | Indicates items excluded from global discounts | BRD 1.2.1: Gift cards, deposits, is_discountable=false |
| Running Subtotal | Text | Live-calculated subtotal across all lines | BRD 1.1: Updated on every cart change |
| Serial Number Badge | Badge | Shows serial number for tracked items | BRD 1.10: Serial required flag on product |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click line item discount button | SCR-M01-03 Discount Modal | CASHIER+ |
| Click remove item | Stays on cart (item removed) | CASHIER+ |
| Adjust quantity to 0 | Stays on cart (item removed) | CASHIER+ |
| Click serial number prompt | Serial entry modal (inline) | CASHIER+ |
G.4.3 Discount Modal
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-03 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.2 |
| Database Tables | orders (R/W), order_items (R/W), pricing_rules (R) |
| State Machine(s) | – |
| Appendix F Services | sale.discount.command.service, sale.promotion.engine.service, sale.price-override.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (degraded – promotions may be stale from product_cache) |
| Route | /pos/sales/terminal (modal overlay) |
Purpose
The Discount Modal allows staff to apply line-item discounts, global order discounts, or manual price overrides. It enforces the strict 7-step discount calculation order: Price Tier, Line Discounts, Auto Promos, Global %, Coupons, Tax, Loyalty Redemption. Manual price overrides require a manager PIN and a mandatory reason code.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Discount Type Selector | Radio Group | Choose: percentage off, fixed amount off, or price override | BRD 1.2: Three discount application types |
| Percentage Input | Input | Enter discount percentage (1-100%) | BRD 1.2: Applied to line item or entire order |
| Fixed Amount Input | Input | Enter dollar amount to deduct | BRD 1.2: Cannot exceed line/order total |
| Reason Code Dropdown | Select | Required for all discounts: Damaged, Employee, Price Match, Manager Discretion | BRD 1.2: Reason code mandatory for audit |
| Manager PIN Prompt | Input | PIN entry for price overrides and discounts exceeding threshold | BRD 1.2: Manager Auth required for overrides |
| Discount Preview | Panel | Shows before/after price comparison and savings amount | BRD 1.2.1: Calculation order preview |
| Non-Discountable Warning | Alert | Blocks discount on gift cards, deposits, flagged items | BRD 1.2.1: is_discountable = false exclusion |
| Apply / Cancel | Button Pair | Confirm or cancel the discount | BRD 1.2: Discount added to order |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Apply discount | Returns to SCR-M01-01 Sales Terminal (discount applied) | CASHIER+ |
| Apply price override | Returns to SCR-M01-01 (requires MANAGER PIN) | MANAGER+ |
| Cancel | Returns to SCR-M01-01 (no change) | CASHIER+ |
G.4.4 Payment / Checkout
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-04 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.3, 1.18 |
| Database Tables | orders (R/W), order_items (R), payments (W), payment_attempts (W), gift_cards (R/W), gift_card_transactions (W), store_credits (R/W), customers (R/W), loyalty_accounts (R/W), cash_movements (W) |
| State Machine(s) | 7.1 Order States (PENDING -> PAID -> COMPLETED) |
| Appendix F Services | sale.payment.command.service, sale.payment.card.service, sale.payment.cash.service, sale.payment.giftcard.service, sale.payment.storecredit.service, sale.payment.affirm.service, sale.finalize.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Partial (cash only – card payments, gift cards, store credit, and Affirm blocked offline per BRD 1.16.2) |
| Route | /pos/sales/checkout |
Purpose
The Payment/Checkout screen processes split-tender payments across multiple methods: cash, credit/debit card (SAQ-A semi-integrated), gift card, store credit (on-account), layaway deposit, and Affirm third-party financing. It calculates change due for cash, manages remaining balance across multiple tenders, and finalizes the order once fully paid.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Amount Due | Panel | Displays remaining balance to be paid | BRD 1.3: Decrements with each tender applied |
| Cash Tender | Button + Input | Enter amount received; auto-calculates change due | BRD 1.3: Change = Tendered - Remaining |
| Card Payment | Button | Initiates SAQ-A terminal flow; displays “Insert/Tap Card” | BRD 1.18: Card data never touches system; token + auth code stored |
| Gift Card | Button + Input | Scan/enter gift card number; shows balance; apply partial or full | BRD 1.5: Validates balance & expiry; partial allowed |
| Store Credit (On-Account) | Button | Check credit limit: Credit Limit - (Debt + Pending Layaways + Cart) | BRD 1.3.3: Block if exceeds available credit |
| Layaway | Button | Select layaway mode; validate minimum deposit percentage | BRD 1.3.2: Status -> LAYAWAY |
| Affirm Financing | Button | Create Affirm checkout session; display QR code for customer | BRD 1.3: Webhook: loan approved + charge_id |
| Tender List | Table | Shows all applied payment methods with amounts | BRD 1.3: Multiple tenders allowed |
| Change Due Display | Panel | Shows change to return for cash overpayment | BRD 1.3: “Collect: $X.XX” or “Change: $X.XX” |
| Finalize Order | Button | Write order record, decrement inventory, award loyalty, record commission | BRD 1.3: POST /orders/finalize |
| Receipt Options | Button Group | Select receipt format: Thermal, A4 Invoice, Gift Receipt, Email | BRD 1.4: Template selection |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ PAYMENT [Back] [Cancel] │
├─────────────────────────────────┬───────────────────────────────────────────┤
│ ORDER SUMMARY │ PAYMENT METHODS │
│ │ │
│ Items: 3 │ ┌──────────┐ ┌──────────┐ │
│ Subtotal: $254.00 │ │ CASH │ │ CARD │ │
│ Discount: - $25.40 │ └──────────┘ └──────────┘ │
│ Tax: + $12.12 │ ┌──────────┐ ┌──────────┐ │
│ ───────────────────── │ │ GIFT CARD│ │ STORE CR │ │
│ TOTAL: $240.72 │ └──────────┘ └──────────┘ │
│ │ ┌──────────┐ ┌──────────┐ │
│ TENDERS APPLIED: │ │ LAYAWAY │ │ AFFIRM │ │
│ ┌────────────────────────┐ │ └──────────┘ └──────────┘ │
│ │ Visa ****4521 $200.00 │ │ │
│ │ [Remove] │ │ CASH ENTRY: │
│ └────────────────────────┘ │ ┌────────────────────────────┐ │
│ │ │ Amount Received: $50.00 │ │
│ REMAINING: $40.72 │ └────────────────────────────┘ │
│ │ │
│ │ Change Due: $9.28 │
│ │ │
│ │ ┌────────────────────────────────────┐ │
│ │ │ FINALIZE & PRINT │ │
│ │ └────────────────────────────────────┘ │
│ │ [Thermal] [A4 Invoice] [Gift] [Email] │
└─────────────────────────────────┴───────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Finalize order | SCR-M01-09 Receipt Print / Reprint | CASHIER+ |
| Select Layaway | SCR-M01-05 Layaway Payment | CASHIER+ |
| Select Affirm | SCR-M01-06 Affirm Financing Flow | CASHIER+ |
| Click Back | SCR-M01-01 Sales Terminal (cart preserved) | CASHIER+ |
| Cancel transaction | SCR-M01-01 Sales Terminal (cart cleared) | MANAGER+ |
G.4.5 Layaway Payment
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-05 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.3, 1.3.2 |
| Database Tables | orders (R/W), payments (W), customers (R), inventory_levels (R/W) |
| State Machine(s) | 7.4 Layaway States (DEPOSIT_PAID -> RESERVED -> PAID_IN_FULL -> COMPLETED) |
| Appendix F Services | sale.layaway.command.service, sale.payment.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires real-time balance verification and inventory reservation) |
| Route | /pos/sales/layaway |
Purpose
The Layaway Payment screen handles creating new layaways (with minimum deposit validation) and processing subsequent payments on existing layaways. When the final payment is received, inventory is released from reserved to sold status. Cancelled layaways trigger deposit refunds according to the configured policy.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Customer Display | Panel | Shows attached customer (required for layaway) | BRD 1.3.2: Customer attachment mandatory |
| Minimum Deposit Calculator | Panel | Shows minimum deposit percentage and dollar amount | BRD 1.3.2: Min deposit % configurable; default 50% |
| Deposit Amount Input | Input | Enter deposit amount; validates against minimum | BRD 1.3.2: Must meet or exceed minimum deposit |
| Payment History | Table | Shows all prior payments on this layaway with dates | BRD 1.3.2: RESERVED state allows additional payments |
| Remaining Balance | Panel | Calculated: Total - Sum(all payments) | BRD 1.3.2: Final payment transitions to PAID_IN_FULL |
| Payment Deadline | Date Display | Shows configured payment deadline | BRD 1.3.2: FORFEITED if deadline missed |
| Cancel Layaway | Button | Cancel with refund processing | BRD 1.3.2: CANCELLED_REFUND state; refund deposit |
| Complete Layaway | Button | Process final payment and release inventory | BRD 1.3.2: COMPLETED state; items released |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Process deposit | SCR-M01-04 Payment / Checkout (deposit payment) | CASHIER+ |
| Complete layaway (final payment) | SCR-M01-09 Receipt Print / Reprint | CASHIER+ |
| Cancel layaway | Confirmation modal -> SCR-M01-04 (refund) | MANAGER+ |
| Back to terminal | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.6 Affirm Financing Flow
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-06 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.3 |
| Database Tables | orders (R/W), payment_attempts (W) |
| State Machine(s) | 7.1 Order States |
| Appendix F Services | sale.payment.affirm.service, sale.finalize.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires active network connection for Affirm API and webhook) |
| Route | /pos/sales/checkout/affirm |
Purpose
The Affirm Financing Flow presents a QR code or redirect URL for customers to complete a third-party financing application on their personal device. The screen waits for an Affirm webhook confirming loan approval, then stores the charge_id and loan_id. The full purchase amount is received from Affirm; the customer pays Affirm directly over time.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Order Summary | Panel | Shows items, total, and financing amount | BRD 1.3: Full amount financed through Affirm |
| QR Code Display | Image | Generated QR code for customer to scan and complete Affirm application | BRD 1.3: POST /payments/affirm/initiate |
| Status Indicator | Badge | Shows: Waiting for Customer / Application Submitted / Approved / Declined | BRD 1.3: Webhook-driven status updates |
| Timer | Display | Shows elapsed waiting time; timeout at configurable threshold | BRD 1.3: Staff can cancel if customer abandons |
| Approval Confirmation | Alert | Shows Affirm charge_id and approval status | BRD 1.3: charge_id, loan_id, status stored |
| Cancel Affirm | Button | Cancel financing attempt and return to payment selection | BRD 1.3: Tender list updated |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Affirm approved | SCR-M01-04 Payment / Checkout (Affirm tender applied) | CASHIER+ |
| Affirm declined | SCR-M01-04 Payment / Checkout (try another method) | CASHIER+ |
| Cancel | SCR-M01-04 Payment / Checkout | CASHIER+ |
G.4.7 Parked Sales List
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-07 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.1, 1.1.1 |
| Database Tables | orders (R/W), order_items (R) |
| State Machine(s) | 7.2 Parked Sale States (ACTIVE -> PARKED -> ACTIVE or EXPIRED) |
| Appendix F Services | sale.park.command.service, sale.park.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (parked sales stored locally; retrieve from local state) |
| Route | /pos/sales/parked |
Purpose
The Parked Sales List displays all currently parked (held) sales for the active terminal or location. Each entry shows the customer name (if attached), item count, total, and time remaining before the 4-hour TTL expires. Staff can retrieve any parked sale to resume checkout or let expired sales auto-release their soft-reserved inventory.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Parked Sales Table | Table | Lists all parked sales: customer, item count, subtotal, parked time | BRD 1.1.1: Max 5 parked sales per terminal |
| Time Remaining | Badge | Countdown showing TTL remaining per parked sale | BRD 1.1.1: 4-hour TTL; expired sales auto-release inventory |
| Expired Indicator | Badge | Red badge for sales that have exceeded TTL | BRD 1.1.1: EXPIRED state; inventory released |
| Retrieve Button | Button | Restore parked sale to active cart | BRD 1.1.1: PARKED -> ACTIVE transition |
| Delete Expired | Button | Remove expired parked sales from list | BRD 1.1.1: Clean up expired entries |
| Soft-Reserve Warning | Alert | Shows if parked items are being viewed/held by another terminal | BRD 1.1: Soft-reserved while parked; visible with warning |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Retrieve parked sale | SCR-M01-01 Sales Terminal (cart restored) | CASHIER+ |
| Delete expired sale | Stays on list (sale removed) | CASHIER+ |
| Back | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.8 Gift Card Operations
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-08 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.5 |
| Database Tables | gift_cards (R/W), gift_card_transactions (R/W), orders (R/W), order_items (W) |
| State Machine(s) | 7.3 Gift Card States (INACTIVE -> ACTIVE -> DEPLETED/EXPIRED/CASHED_OUT) |
| Appendix F Services | sale.giftcard.command.service, sale.giftcard.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (gift card activation, reload, balance check, and cash-out all blocked offline per BRD 1.16.2) |
| Route | /pos/sales/gift-cards |
Purpose
The Gift Card Operations screen handles all gift card lifecycle actions: selling/activating new cards, checking balances, reloading existing cards, and processing cash-outs where jurisdiction rules permit (e.g., California law requires cash-out for balances under $10). The screen enforces jurisdiction-specific compliance rules based on the store location.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Card Number Input | Input | Scan or manually enter gift card number | BRD 1.5: Unique card number lookup |
| Action Selector | Tab Group | Sell New / Check Balance / Reload / Cash Out | BRD 1.5: Four distinct operations |
| Load Amount Input | Input | Dollar amount for new card or reload | BRD 1.5: POST /giftcards/activate |
| Balance Display | Panel | Current balance, expiry date, last activity | BRD 1.5: GET /giftcards/{number}/balance |
| Cash-Out Eligibility | Alert | Shows whether balance qualifies for cash-out under jurisdiction rules | BRD 1.5.2: California $10 threshold; Virginia no cash-out |
| Jurisdiction Rules | Info Panel | Displays applicable expiry, fee, and cash-out rules for store location | BRD 1.5.2: Strictest-rule-wins default |
| Transaction History | Table | Shows activation, reloads, redemptions, and cash-outs | BRD 1.5: Full audit trail |
| Add to Cart | Button | Adds gift card sale or reload as a cart line item | BRD 1.5: Non-discountable item |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Sell new gift card (add to cart) | SCR-M01-01 Sales Terminal (gift card in cart) | CASHIER+ |
| Reload existing card (add to cart) | SCR-M01-01 Sales Terminal (reload in cart) | CASHIER+ |
| Process cash-out | Cash dispensed from drawer | CASHIER+ |
| Back | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.9 Receipt Print / Reprint
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-09 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.4 |
| Database Tables | orders (R), order_items (R), payments (R), customers (R) |
| State Machine(s) | – |
| Appendix F Services | sale.receipt.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Partial (can reprint from local cache for recently completed sales; email requires network) |
| Route | /pos/sales/receipt |
Purpose
The Receipt Print/Reprint screen generates and delivers receipts in multiple formats. After completing a sale, it auto-shows with template selection. Staff can also access this screen from Sales History to reprint or email receipts for past transactions. Receipts include a scannable barcode for return/exchange validation.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Receipt Preview | Panel | Full receipt preview with line items, taxes, payments, barcode | BRD 1.4: Receipt data for verification |
| Template Selector | Button Group | Thermal (standard), A4 Invoice (detailed), Gift Receipt (no prices) | BRD 1.4: Three template formats |
| Email Input | Input | Override email address for sending digital receipt | BRD 1.4: POST /orders/{id}/email-receipt |
| Print Button | Button | Send to configured receipt printer | BRD 1.4: Thermal printer integration |
| Receipt Barcode | Image | Scannable barcode encoding order ID for return validation | BRD 1.4: POST /receipts/validate for returns |
| Offline Watermark | Badge | Shows “OFFLINE” watermark on receipts printed during offline mode | BRD 1.16: Offline receipt indicator |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Print receipt | Stays (prints to thermal/A4 printer) | CASHIER+ |
| Email receipt | Stays (sends email) | CASHIER+ |
| Done / New Sale | SCR-M01-01 Sales Terminal (fresh cart) | CASHIER+ |
G.4.10 Cash Drawer Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-10 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.12 |
| Database Tables | cash_drawers (R/W), cash_movements (R/W), cash_counts (R/W), cash_drops (R/W), cash_pickups (R), shifts (R/W) |
| State Machine(s) | 7.9 Cash Drawer States (CLOSED -> OPENING -> OPEN -> COUNTING -> BALANCED -> CLOSED) |
| Appendix F Services | sale.cashdrawer.command.service, sale.cashdrawer.count.service, sale.cashdrawer.pickup.service, sale.shift.command.service |
| User Roles | CASHIER (X-Report), MANAGER (Open/Close/Variance approval) |
| Offline Capable | Yes (cash operations are local; Z-Report synced on reconnection) |
| Route | /pos/cash-drawer |
Purpose
The Cash Drawer Management screen handles the full cash accountability lifecycle: opening with a float, mid-shift X-Reports, cash drops to safe, and end-of-shift Z-Report closure with blind denomination counting. It calculates expected cash based on all cash transactions and flags variances for manager review when outside tolerance.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Drawer Status | Badge | Shows: Closed / Open / Counting / Variance Detected | BRD 1.12.1: Cash Drawer State Machine |
| Opening Float Input | Input | Enter starting cash amount when opening drawer | BRD 1.12: POST /cash-drawer/open |
| Denomination Grid | Input Grid | Count each denomination: pennies through hundreds, rolled coins | BRD 1.12: Blind count (expected not shown) |
| X-Report Button | Button | Generate mid-shift cash snapshot without closing drawer | BRD 1.12.2: Unlimited per shift; does not close drawer |
| Z-Report Button | Button | Generate end-of-shift report; closes drawer and resets counters | BRD 1.12.2: Once per shift; closes drawer |
| Variance Display | Panel | Shows: Expected, Counted, Variance (+/-) | BRD 1.12: Variance = Counted - Expected |
| Manager Approval | Input | Manager PIN + reason code for out-of-tolerance variance | BRD 1.12: MANAGER_REVIEW state |
| Cash Drop | Button | Record cash drop to safe with amount | BRD 1.12: Cash drops reduce drawer balance |
| Paid In / Paid Out | Button Pair | Record non-sale cash movements (petty cash, vendor payments) | BRD 1.12: Logged in cash_movements |
| Cash Movement Log | Table | Chronological list of all cash in/out events for current shift | BRD 1.12.3: Immutable audit trail |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ CASH DRAWER MANAGEMENT Drawer: OPEN [X-Report] [?] │
├─────────────────────────────────────┬───────────────────────────────────────┤
│ DENOMINATION COUNT (BLIND) │ SHIFT SUMMARY │
│ │ │
│ COINS Qty Amount │ Opening Float: $200.00 │
│ Pennies ($0.01) [ ] $0.00 │ Cash Sales: $1,245.00 │
│ Nickels ($0.05) [ ] $0.00 │ Cash Refunds: - $85.00 │
│ Dimes ($0.10) [ ] $0.00 │ Paid Out: - $50.00 │
│ Quarters($0.25) [ ] $0.00 │ Cash Drops: - $500.00 │
│ Half $ ($0.50) [ ] $0.00 │ ────────────────────────────── │
│ Dollar ($1.00) [ ] $0.00 │ Expected Cash: $810.00 │
│ │ Counted Cash: $805.00 │
│ BILLS Qty Amount │ ────────────────────────────── │
│ $1 [ ] $0.00 │ VARIANCE: -$5.00 ⚠ │
│ $5 [ ] $0.00 │ │
│ $10 [ ] $0.00 │ ┌──────────────────────────────┐ │
│ $20 [ ] $0.00 │ │ Manager PIN: [____] │ │
│ $50 [ ] $0.00 │ │ Reason: [Counting error ▼] │ │
│ $100 [ ] $0.00 │ └──────────────────────────────┘ │
│ Rolled Coins [____] $0.00 │ │
│ │ Transactions: 47 │
│ COUNTED TOTAL: $805.00 │ Shift Duration: 8h 15m │
│ │ │
│ [Paid In] [Paid Out] [Cash Drop] │ [Close Drawer & Print Z-Report] │
└─────────────────────────────────────┴───────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Open drawer (enter float) | Stays (drawer status -> OPEN) | MANAGER+ |
| Print X-Report | Stays (report generated, drawer stays open) | CASHIER+ |
| Close drawer (Z-Report) | Prints Z-Report; drawer status -> CLOSED | CASHIER+ (MANAGER for variance approval) |
| Cash drop | Stays (cash_drops record created) | CASHIER+ |
| Paid In / Paid Out | Stays (cash_movements record created) | MANAGER+ |
G.4.11 Price Check Mode
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-11 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.13 |
| Database Tables | products (R), variants (R), pricing_rules (R), inventory_levels (R) |
| State Machine(s) | – |
| Appendix F Services | sale.price-check.query.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (degraded – uses product_cache; stale pricing warning shown) |
| Route | /pos/sales/price-check |
Purpose
Price Check Mode allows staff to quickly scan an item and display its current selling price, stock level, and any active promotions without adding the item to the cart. This is used for customer inquiries (“How much is this?”). The display auto-returns to the normal sale mode after a configurable timeout or keypress.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Barcode Input | Input | Scan or enter item barcode/SKU | BRD 1.13: GET /products/{sku} |
| Large Price Display | Panel | Product name, SKU, and price displayed in large font | BRD 1.13: Customer-visible price display |
| Stock Level | Badge | Current stock count at this location | BRD 1.13: Show stock level |
| Promotion Alert | Banner | Shows active sale/promotion: “Was $50, Now $39.99” | BRD 1.13: Active promotion display |
| Auto-Timeout | Timer | Returns to sale mode after configurable timeout | BRD 1.13: Press any key or timeout |
| Stale Price Warning | Alert | Warning when operating from cached prices (offline) | BRD 1.16: product_cache may be stale |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Press any key / Timeout | SCR-M01-01 Sales Terminal | CASHIER+ |
| Scan another item | Stays (new product displayed) | CASHIER+ |
G.4.12 Coupon Entry
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-12 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.14 |
| Database Tables | orders (R/W), order_items (R/W), pricing_rules (R) |
| State Machine(s) | 7.10 Coupon States (AVAILABLE -> APPLIED -> REDEEMED) |
| Appendix F Services | sale.promotion.engine.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Yes (degraded – coupon validation from cached rules; may accept expired coupons) |
| Route | /pos/sales/terminal (modal overlay) |
Purpose
The Coupon Entry modal allows staff to scan or manually enter coupon codes. The system validates single-use coupons (checking if already redeemed) and multi-use coupons (checking usage limits and expiry). Valid coupons are applied to the cart according to the discount calculation order (step 5 of 7). Coupons are marked REDEEMED upon order finalization.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Coupon Code Input | Input | Scan barcode or type coupon code | BRD 1.14: POST /coupons/validate |
| Validation Status | Alert | Shows: Valid, Already Redeemed, Expired, Limit Reached | BRD 1.14: Single-use vs multi-use validation |
| Discount Preview | Panel | Shows discount amount and type (% off, $ off, BOGO) | BRD 1.14.1: APPLIED state preview |
| Stacking Warning | Alert | Warns if coupon cannot stack with other active discounts | BRD 1.2.1: Calculation order step 5 |
| Savings Display | Panel | Shows total savings from applied coupon | BRD 1.14: Display savings |
| Apply / Cancel | Button Pair | Confirm coupon application or cancel | BRD 1.14: Coupon linked to order at finalize |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Apply coupon | Returns to SCR-M01-01 Sales Terminal (coupon applied) | CASHIER+ |
| Cancel | Returns to SCR-M01-01 (no change) | CASHIER+ |
G.4.13 Loyalty Display
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-13 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.15 |
| Database Tables | customers (R), loyalty_accounts (R/W), loyalty_transactions (R/W) |
| State Machine(s) | 7.11 Customer Tier States (BRONZE -> SILVER -> GOLD) |
| Appendix F Services | sale.loyalty.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (loyalty point balance requires real-time server data for accuracy) |
| Route | /pos/sales/terminal (embedded panel or overlay) |
Purpose
The Loyalty Display shows the attached customer’s current loyalty status during a sale: tier (Bronze/Silver/Gold), points balance, points to be earned from this transaction, and available redemption options. It supports three program types: points-per-dollar, punch cards, and spend thresholds. Points are awarded upon order finalization; redemptions are applied after tax calculation (step 7 of discount order).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Tier Badge | Badge | Current tier: Bronze (1x), Silver (1.5x + 5%), Gold (2x + 10%) | BRD 1.15.1: Tier state machine |
| Points Balance | Panel | Current redeemable points and dollar equivalent | BRD 1.15: 500 points = $5 off |
| Points to Earn | Panel | Points that will be awarded from this transaction | BRD 1.15: 1 point per $1 (modified by tier multiplier) |
| Redeem Points | Button | Apply points redemption to current sale | BRD 1.2.1: Step 7 – after tax calculation |
| Punch Card Progress | Progress Bar | Shows punch count for qualifying items (e.g., “3 of 10”) | BRD 1.15: Buy 10 Get 1 Free |
| Spend Threshold | Progress Bar | Shows progress toward next reward threshold | BRD 1.15: Spend $100, Get $10 Off |
| Tier Upgrade Alert | Toast | Notification when sale triggers tier upgrade | BRD 1.15.1: Automatic tier upgrades |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Redeem points | Stays (redemption applied to cart totals) | CASHIER+ |
| View loyalty history | SCR-M02-02 Customer Profile (loyalty tab) | CASHIER+ |
G.4.14 Return Processing
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-14 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.4, 1.9 |
| Database Tables | orders (R), order_items (R), returns (R/W), return_items (R/W), store_credits (W), customers (R/W), loyalty_accounts (R/W), cash_movements (W), inventory_levels (R/W) |
| State Machine(s) | 7.1 Order States (COMPLETED -> PARTIALLY_RETURNED / FULLY_RETURNED) |
| Appendix F Services | sale.return.command.service, sale.return-policy.engine.service, sale.receipt.validate.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | Partial (return with receipt allowed offline; queued for sync; no-receipt returns blocked offline) |
| Route | /pos/sales/return |
Purpose
Return Processing is a two-phase flow: first, locate the original sale via receipt barcode scan or order number lookup; second, select items to return and choose the refund method. The return policy engine evaluates eligibility based on receipt presence, time window (0-30 days: full refund; 31-90 days: store credit; 90+ days: manager override), and item-specific rules (final sale blocked, restocking fees for electronics).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Receipt Barcode Scanner | Input | Scan receipt barcode to validate and load original sale | BRD 1.4: POST /receipts/validate {barcode_data} |
| Order Number Search | Input | Manual order number entry as fallback | BRD 1.4: GET /orders/{id} |
| Original Sale Display | Panel | Shows all items from original order with quantities and prices | BRD 1.4: Original sale data loaded |
| Item Selection Checkboxes | Checkbox Group | Select which items to return with quantity controls | BRD 1.4: Partial returns supported |
| Policy Evaluation Result | Alert | Shows: Full Refund / Store Credit Only / Manager Override Required / Blocked | BRD 1.9: Time-based policy tiers |
| Refund Method Selector | Radio Group | Original Payment (Manual on terminal or Auto via token) / Cash / Store Credit | BRD 1.4.1: Card refund method selection |
| Restocking Fee | Panel | Shows applicable restocking fee (e.g., 15% for electronics) | BRD 1.9: Item-specific rules |
| Final Sale Block | Alert | Blocks return for items flagged as final sale | BRD 1.9: No returns on final sale |
| Manager PIN | Input | Required for policy exceptions and no-receipt returns | BRD 1.9: Manager override with reason code |
| Commission Reversal Preview | Panel | Shows proportional commission reversal calculation | BRD 1.8.1: Proportional reversal on return |
| Return Reason | Select | Required reason code for the return | BRD 1.4: Reason code for audit |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ RETURN PROCESSING [Cancel] [Help] │
├─────────────────────────────────────────────────────────────────────────────┤
│ PHASE 1: LOCATE ORIGINAL SALE │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Scan Receipt Barcode: [________________________] [Search by Order#] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ✓ Receipt Valid — Order #LOC-20260215-0042 — Feb 15, 2026 (14 days ago) │
│ Policy: WITHIN 30 DAYS — Full Refund Eligible │
├─────────────────────────────────────────────────────────────────────────────┤
│ PHASE 2: SELECT ITEMS TO RETURN │
│ │
│ [✓] NXJ-1001 Blue Jacket 1 of 1 $89.00 Reason: [Wrong Size ▼] │
│ [ ] NXJ-2045 Cotton Tee 0 of 2 $25.00 │
│ [✓] NXJ-3012 Slim Pants 1 of 1 $65.00 Reason: [Defective ▼] │
│ │
│ Refund Method: (●) Original Payment ( ) Cash ( ) Store Credit │
│ [●] Auto via token [ ] Manual on terminal │
│ │
│ Subtotal: $154.00 Commission Reversal: $5.13 │
│ Tax Refund: + $8.16 (66.7% of original $7.70) │
│ Restocking Fee: $0.00 │
│ ────────────────────────── │
│ REFUND TOTAL: $162.16 │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PROCESS RETURN │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Process return | SCR-M01-09 Receipt Print / Reprint (return receipt) | CASHIER+ |
| Manager override (no receipt / expired) | Manager PIN modal -> continues return flow | MANAGER+ |
| Cancel return | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.15 Exchange Processing
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-15 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.4 |
| Database Tables | orders (R/W), order_items (R/W), returns (R/W), return_items (R/W), products (R), variants (R), inventory_levels (R/W) |
| State Machine(s) | 7.1 Order States |
| Appendix F Services | sale.exchange.command.service, sale.return-policy.engine.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires real-time inventory and pricing for new items) |
| Route | /pos/sales/exchange |
Purpose
Exchange Processing is a dedicated flow for swapping items (e.g., different size or color). Staff load the original sale, select items to exchange OUT, scan/add new items IN, and the system calculates the price difference. If the customer owes money, payment is collected; if the store owes a refund, it is processed. Even exchanges require no payment.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Original Sale Loader | Panel | Load original order to select items for exchange out | BRD 1.4: GET /orders/{id} |
| Items Out Selection | Checkbox Group | Select items being returned in the exchange | BRD 1.4: Items being exchanged out |
| Items In Scanner | Input | Scan or search for new replacement items | BRD 1.4: Items being exchanged in |
| Price Difference Calculator | Panel | Shows: Items Out value vs Items In value, net difference | BRD 1.4: Calculate price difference |
| Collect Payment | Button | Process additional payment if customer owes money | BRD 1.4: “Collect: $15.00 difference” |
| Issue Refund | Button | Process refund if store owes customer | BRD 1.4: “Refund: $10.00 to customer” |
| Even Exchange | Badge | Indicates no payment required | BRD 1.4: No payment required |
| Commission Adjustment | Panel | Shows commission adjustment based on price difference | BRD 1.8.1: Adjust commission if price difference |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Process exchange (collect payment) | SCR-M01-04 Payment / Checkout | CASHIER+ |
| Process exchange (issue refund) | Refund processed -> SCR-M01-09 Receipt | CASHIER+ |
| Process even exchange | SCR-M01-09 Receipt Print / Reprint | CASHIER+ |
| Cancel | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.16 Special Order Create
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-16 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.6 |
| Database Tables | orders (R/W), order_items (R/W), products (R), variants (R), customers (R/W), payments (W) |
| State Machine(s) | 7.5 Special Order States (CREATED -> DEPOSIT_PAID -> ORDERED -> RECEIVED -> READY_FOR_PICKUP -> COMPLETED) |
| Appendix F Services | sale.specialorder.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires real-time product lookup and customer verification) |
| Route | /pos/sales/special-order |
Purpose
The Special Order screen allows staff to create orders for out-of-stock items with customer deposits. A customer must be attached, and a minimum deposit (50% or full) is collected. The order progresses through vendor ordering, receiving, staging, and customer pickup. Notifications are sent when items arrive. Abandoned orders (30+ days without pickup) are flagged.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Product Search | Input | Search for out-of-stock products to special order | BRD 1.6: “Available for Special Order” |
| Customer Attachment | Panel | Required customer profile for special orders | BRD 1.6: Attach Customer (Required) |
| Quantity Input | Input | Enter quantity needed | BRD 1.6: Quantity for vendor ordering |
| Deposit Calculator | Panel | Shows minimum deposit (50% or full) and amount due | BRD 1.6: Min 50% or Full deposit required |
| Deposit Payment | Button | Process deposit payment through standard payment flow | BRD 1.6: POST /special-orders/create |
| Order Status Display | Badge | Shows current state in special order lifecycle | BRD 1.6.1: 8-state machine |
| Expected Delivery Date | Date Display | Estimated arrival date from vendor | BRD 1.6: Purchasing team notification |
| Special Order Receipt | Button | Print receipt with order number (SO-XXXXX) | BRD 1.6: Print Special Order Receipt |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Process deposit | SCR-M01-04 Payment / Checkout (deposit amount) | CASHIER+ |
| Cancel special order | Stays (order cancelled, no deposit) | MANAGER+ |
| Print receipt | SCR-M01-09 Receipt Print / Reprint | CASHIER+ |
| Back | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.17 Multi-Store Inventory Lookup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-17 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.7 |
| Database Tables | inventory_levels (R), products (R), variants (R), locations (R), transfer_orders (R/W), transfer_order_items (R/W) |
| State Machine(s) | 7.6 Transfer States, 7.7 Reservation States |
| Appendix F Services | sale.cart.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires network for cross-store inventory query, max 5 min stale) |
| Route | /pos/sales/multi-store |
Purpose
Multi-Store Inventory Lookup displays real-time (eventually consistent, max 5-minute stale) stock levels across all store locations for a given product. When the current store is out of stock, staff can initiate a transfer request (ship to this store), a reservation (customer picks up at other store), or a ship-to-customer order. All options require full payment before processing.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Product Display | Panel | Shows product name, SKU, image for the searched item | BRD 1.7: GET /inventory/multi-store/{sku} |
| Stock Level Grid | Table | Stock count at each location: Store A: 5, Store B: 2, etc. | BRD 1.7: Eventually consistent (max 5 min stale) |
| Request Transfer | Button | Create transfer from source store to current store | BRD 1.7: Full payment required; POST /transfers/request |
| Reserve at Store | Button | Reserve item for customer pickup at source store | BRD 1.7: Full payment required; POST /reservations/create |
| Ship to Customer | Button | Ship directly from source store to customer address | BRD 1.7.3: Carrier shipping cost calculation |
| Staleness Indicator | Badge | Shows time since last inventory sync per location | BRD 1.7: Max 5 min stale data |
| Transfer Receipt | Button | Print transfer receipt or pickup voucher for customer | BRD 1.7: Transfer #TRF-789 receipt |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Request transfer | SCR-M01-04 Payment / Checkout (full payment required) | CASHIER+ |
| Reserve at store | SCR-M01-04 Payment / Checkout (full payment required) | CASHIER+ |
| Ship to customer | SCR-M01-19 Ship-to-Customer | CASHIER+ |
| Back | SCR-M01-01 Sales Terminal | CASHIER+ |
G.4.18 Hold for Pickup (BOPIS)
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-18 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.11 |
| Database Tables | orders (R/W), order_items (R), customers (R), inventory_levels (R/W) |
| State Machine(s) | 7.1 Order States (HOLD_FOR_PICKUP -> READY_FOR_PICKUP -> COMPLETED / HOLD_EXPIRED) |
| Appendix F Services | sale.hold-for-pickup.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires real-time inventory reservation and customer verification) |
| Route | /pos/sales/hold-pickup |
Purpose
Hold for Pickup manages fully paid items held for later customer pickup at the current store. This includes both in-store holds (customer pays now, picks up later) and BOPIS orders (buy online, pick up in store). The screen shows pickup deadline (default 7 days), handles staging items as “ready,” and manages expired holds with customer contact workflows.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Held Orders List | Table | All active hold-for-pickup orders with status and deadline | BRD 1.11: HOLD_FOR_PICKUP and READY_FOR_PICKUP states |
| Pickup Deadline | Date Display | Configurable deadline (default 7 days) per order | BRD 1.11: Set Pickup Deadline |
| Stage Items | Button | Mark items as staged and ready for customer pickup | BRD 1.11.1: HOLD_FOR_PICKUP -> READY_FOR_PICKUP |
| Release to Customer | Button | Verify customer ID and release items | BRD 1.11: PATCH /orders/{id}/pickup-complete |
| Expired Holds | Alert | Flagged orders past deadline with customer contact required | BRD 1.11.1: HOLD_EXPIRED -> CONTACT_CUSTOMER |
| Extend Deadline | Button | Extend pickup deadline for a held order | BRD 1.11.1: CONTACT_CUSTOMER -> READY_FOR_PICKUP |
| Refund Expired | Button | Process refund for expired hold if customer wants money back | BRD 1.11.1: CONTACT_CUSTOMER -> REFUNDED |
| BOPIS Indicator | Badge | Identifies orders originating from online purchase | BRD 1.11: In-store POS or Online order (BOPIS) |
| Print Pickup Slip | Button | Print pickup slip with order details | BRD 1.11: Print Pickup Slip |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Create hold (from checkout) | SCR-M01-04 Payment / Checkout (full payment) | CASHIER+ |
| Release to customer | Order COMPLETED; SCR-M01-09 Receipt | CASHIER+ |
| Refund expired hold | SCR-M01-14 Return Processing | MANAGER+ |
| Extend deadline | Stays (deadline updated) | MANAGER+ |
G.4.19 Ship-to-Customer
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-19 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 1.7, 1.7.3, 1.7.4 |
| Database Tables | orders (R/W), order_items (R/W), customers (R), inventory_levels (R), locations (R) |
| State Machine(s) | 7.8 Ship-to-Customer States (REQUESTED -> PAID -> PICKING -> PACKED -> SHIPPED -> DELIVERED) |
| Appendix F Services | sale.cart.command.service, sale.finalize.command.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires carrier API for real-time shipping cost calculation) |
| Route | /pos/sales/ship-to-customer |
Purpose
Ship-to-Customer enables direct shipping from a source store to the customer’s address. Staff enter the customer’s shipping address, the system queries carrier APIs for real-time shipping rates (Standard 3-5 days, Express 1-2 days), and the customer pays the full item price plus shipping cost. The source store packs and ships the item with tracking.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Source Store Selector | Select | Choose which store has the item in stock | BRD 1.7.3: Source store with inventory |
| Shipping Address Form | Form | Customer address entry (or pre-fill from customer profile) | BRD 1.7.3: Enter Address Details |
| Shipping Rate Display | Table | Shows carrier options with prices and delivery estimates | BRD 1.7.3: Standard ($8.99) vs Express ($15.99) |
| Total Calculator | Panel | Item price + selected shipping cost | BRD 1.7.3: Total = Item Price + Shipping Cost |
| Tracking Info | Panel | Shows tracking number and carrier once shipped | BRD 1.7.4: Tracking number from carrier label |
| Shipment Status | Badge | REQUESTED / PAID / PICKING / PACKED / SHIPPED / DELIVERED | BRD 1.7.4: Ship-to-Customer State Machine |
| Customer Notifications | Info | Email sent at SHIPPED (tracking) and DELIVERED (confirmation) | BRD 1.7: TMPL-SHIPMENT-TRACKING, TMPL-DELIVERY-CONFIRMATION |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Confirm shipping option | SCR-M01-04 Payment / Checkout (item + shipping) | CASHIER+ |
| Cancel | SCR-M01-17 Multi-Store Inventory Lookup | CASHIER+ |
G.4.20 Sales Reports Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-20 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 1.8, 1.19, 1.1.2, 1.3.4, 1.4.2, 1.5.3 |
| Database Tables | orders (R), order_items (R), payments (R), returns (R), return_items (R), shifts (R), cash_drawers (R), cash_movements (R), gift_cards (R), gift_card_transactions (R), reports (R/W) |
| State Machine(s) | – |
| Appendix F Services | sale.history.query.service, sale.daily-summary.projection.service |
| User Roles | MANAGER, OWNER, AUDITOR |
| Offline Capable | No (requires server-side aggregation of sales data) |
| Route | /admin/reports/sales |
Purpose
The Sales Reports Dashboard provides comprehensive sales analytics across all report categories defined in Module 1: daily sales summaries, hourly heatmaps, payment method breakdowns, discount usage, return summaries, gift card liability, variance history, and more. Managers can filter by date range, location, employee, and status, and export results to CSV.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Report Selector | Tab Group / Dropdown | Choose from 20+ report types across all sales subsections | BRD 1.1.2-1.18.4: All report types |
| Date Range Picker | Input | Filter by date range (today, this week, this month, custom) | BRD 1.19: Date-based filtering |
| Location Filter | Select | Filter by store location or “All Locations” | BRD 1.8: Per-location reporting |
| Employee Filter | Select | Filter by employee for commission and performance reports | BRD 1.8: Employee-specific data |
| Summary Cards | Panel Grid | Key metrics: Total Sales, Avg Transaction, Return Rate, etc. | BRD 1.1.2: Daily Sales Summary |
| Data Table | Table | Detailed tabular data for selected report | BRD 1.19: Paginated results |
| Chart Visualization | Chart | Bar charts, line charts, heatmaps for visual analysis | BRD 1.1.2: Hourly Sales Heatmap |
| Export CSV | Button | Export current report data to CSV file | BRD 1.19: Data export |
| Save Report | Button | Save report configuration for quick access | Reports table: Saved configurations |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Select report type | Stays (data reloads for selected report) | MANAGER+ |
| Export CSV | Download file (stays on page) | MANAGER+ |
| Click order row | SCR-M01-09 Receipt Print / Reprint (order detail) | MANAGER+ |
| Commission reports tab | SCR-M01-21 Commission Management | MANAGER+ |
G.4.21 Commission Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-21 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 1.8 |
| Database Tables | orders (R), order_items (R), returns (R), customers (R) |
| State Machine(s) | – |
| Appendix F Services | sale.commission.command.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (requires server-side aggregation of commission data) |
| Route | /admin/reports/commissions |
Purpose
The Commission Management screen provides detailed commission tracking per employee. It shows total sales, commission earned, returns impact (proportional reversal), and net commission for a configurable period. Managers can drill into individual transactions to see commission calculation details and review reversal adjustments from voids and returns.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Employee Selector | Select | Choose employee or view all employees | BRD 1.8: Per-employee commission tracking |
| Period Selector | Date Range | Select commission period (weekly, biweekly, monthly) | BRD 1.8.2: Period overview |
| Commission Summary | Table | Employee, Sales Count, Sales Total, Commission Earned, Returns Impact, Net | BRD 1.8.2: Commission Summary report |
| Commission Rate Display | Panel | Shows employee’s commission rate and tier | BRD 1.8.1: percentage_of_sale base method |
| Reversal Log | Table | Shows commission adjustments from voids (100%) and returns (proportional) | BRD 1.8.1: Void=full reversal, Return=proportional |
| Proportional Calc Detail | Panel | Original Commission x (Returned Value / Original Sale Value) | BRD 1.8.1: Proportional calculation formula |
| Export | Button | Export commission data for payroll processing | BRD 1.8.2: Commission by Employee report |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click employee row | Drill-down to employee transaction detail | MANAGER+ |
| Click order in reversal log | SCR-M01-09 Receipt Print / Reprint (order detail) | MANAGER+ |
| Export | Download CSV (stays on page) | MANAGER+ |
G.4.22 Manager Discrepancy Review
| Attribute | Value |
|---|---|
| Screen ID | SCR-M01-22 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 1.16, 1.16.3 |
| Database Tables | orders (R/W), order_items (R), shifts (R), cash_counts (R), cash_movements (R) |
| State Machine(s) | 7.12 Connectivity States |
| Appendix F Services | sale.offline.sync.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (this screen reviews discrepancies detected during online sync) |
| Route | /admin/discrepancy-review |
Purpose
The Manager Discrepancy Review screen displays all flagged discrepancies from offline-to-online sync operations and cash drawer variances. When the POS reconnects after offline operation, the server applies current prices (server-authoritative) and logs any differences. Managers review each flagged transaction, decide whether customer credits are warranted for price changes, and acknowledge or resolve each discrepancy.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Discrepancy Queue | Table | List of all flagged items: type, order, cached price vs server price, status | BRD 1.16.3: Server is authoritative |
| Discrepancy Type Filter | Select | Filter by: Price Changed, Out of Stock, Promotion Expired, Customer Deleted | BRD 1.16.3: Six discrepancy types |
| Price Difference Detail | Panel | Shows cached price at time of sale vs current server price, delta | BRD 1.16.3: Flag-on-sync price discrepancy |
| Customer Credit Action | Button | Issue store credit for price difference if warranted | BRD 1.16.3: Manager decides if credit warranted |
| Acknowledge | Button | Mark discrepancy as reviewed (no action needed) | BRD 1.16.3: Review and approve |
| Resolve | Button | Mark as resolved with notes | BRD 1.16.3: Resolution tracking |
| Cash Variance Section | Panel | Cash drawer variances from shift close operations | BRD 1.12.3: Variance History Report |
| Offline Sale Count | Badge | Total number of sales processed while offline | BRD 1.16: Queue count from sync |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Issue customer credit | Store credit created -> stays on page | MANAGER+ |
| Acknowledge discrepancy | Stays (item marked as reviewed) | MANAGER+ |
| Click order detail | SCR-M01-09 Receipt Print / Reprint | MANAGER+ |
| Resolve all | Stays (batch resolution) | OWNER+ |
G.5 Module 2: Customer Screens (10 Screens)
BRD Sections: 2.1-2.8 | Appendix F: §F.5 (7 services) | Pattern: Standard CRUD
Cross-Reference: See Ch 05, Sections 2.1-2.8 for business rules. See Ch 08, Domain 5 (Customer Loyalty & Gift Cards) for table schemas. See Appendix F, §F.5 for service breakdown.
G.5.1 Customer List / Directory
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-01 |
| Product(s) | All Roles |
| BRD Section(s) | 2.1 |
| Database Tables | customers (R), loyalty_accounts (R) |
| State Machine(s) | – |
| Appendix F Services | customer.search.query.service |
| User Roles | CASHIER, MANAGER, OWNER |
| Offline Capable | No (customer search requires real-time uniqueness and server query) |
| Route | /customers (MANAGER+), /pos/customers (CASHIER+ overlay) |
Purpose
The Customer List is the primary directory for searching and browsing customer profiles. It supports search by name, phone, email, or loyalty number with real-time filtering. On the sales terminal, it appears as a search overlay for attaching customers to sales; for managers, it provides full list management with sorting, filtering by group/tier, and bulk export capabilities.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Search Bar | Input | Search by name, phone, email, or loyalty number | BRD 2.1: GET /customers/search |
| Customer Table | Table | Name, phone, email, tier, group, last visit, total spent | BRD 2.1: Paginated customer list |
| Tier Filter | Select | Filter by tier: All, Bronze, Silver, Gold | BRD 2.2: Customer tiers |
| Group Filter | Select | Filter by group: All, Retail, Wholesale, VIP, Staff | BRD 2.2: Customer groups |
| Sort Options | Select | Sort by: Name, Last Visit, Total Spent, Loyalty Points | BRD 2.1: Customer directory sorting |
| Create New | Button | Open customer creation form | BRD 2.1: POST /customers/create |
| Export CSV | Button | Export filtered customer list (max 1,000 rows) | BRD 2.5: Limit 1000 rows per batch |
| Quick Attach | Button | Attach selected customer to current POS sale (POS mode only) | BRD 1.1: Attach Customer to sale |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click customer row | SCR-M02-02 Customer Profile / Detail | CASHIER+ |
| Click “Create New” | SCR-M02-03 Customer Create / Edit | CASHIER+ |
| Click “Export CSV” | Download file (stays on page) | MANAGER+ |
| Quick Attach (POS) | Returns to SCR-M01-01 Sales Terminal (customer attached) | CASHIER+ |
G.5.2 Customer Profile / Detail
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-02 |
| Product(s) | All Roles |
| BRD Section(s) | 2.1, 2.3, 2.4 |
| Database Tables | customers (R), loyalty_accounts (R), loyalty_transactions (R), orders (R), returns (R), store_credits (R), gift_cards (R) |
| State Machine(s) | 7.11 Customer Tier States |
| Appendix F Services | customer.profile.crud.service, customer.search.query.service |
| User Roles | CASHIER, MANAGER, OWNER |
| Offline Capable | No (requires real-time customer data including balances and history) |
| Route | /customers/:id |
Purpose
The Customer Profile is a dense information screen showing all aspects of a customer’s relationship with the business: personal details, contact information, loyalty tier and points, purchase history, store credit balances, notes and preferences, and communication settings. It serves as the central hub from which staff navigate to edit, merge, delete, or manage customer-specific actions.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Customer Header | Panel | Name, customer number, tier badge, group badge, last visit | BRD 2.1: Customer overview |
| Contact Info | Panel | Phone, email, shipping address, billing address | BRD 2.1: Distinct shipping and billing addresses |
| Loyalty Summary | Panel | Tier, points balance, lifetime points, point multiplier | BRD 1.15: Tier benefits display |
| Purchase History | Table | Recent orders with date, total, status, location | BRD 2.1: Sales history linked to customer |
| Store Credit Balance | Panel | Active store credits with amounts and expiry dates | Store credits table: remaining_amount, expires_at |
| Account Balance | Panel | On-account debt, credit limit, available credit | BRD 1.3.3: Credit Limit calculation |
| Notes & Preferences | Panel | Clothing size, shoe size, color preferences, free-form notes | BRD 2.3: Structured and free-form notes |
| Communication Prefs | Panel | Email opt-in, SMS opt-in, preferred contact, Do Not Contact flag | BRD 2.4: Marketing consent status |
| Tags | Badge Group | Customer tags (e.g., “High Value”, “Returns Often”) | Customers table: tags[] array |
| Tax Status | Badge | Tax exempt status and exemption certificate ID | BRD 2.1: Custom tax rates |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ CUSTOMER PROFILE [Edit] [Merge] [Delete] [?]│
├─────────────────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────┬─────────────────────────────────────┐│
│ │ JANE DOE #C-00142 │ LOYALTY ││
│ │ ★ Gold Tier VIP Group │ Tier: GOLD (2x points, 10% off) ││
│ │ │ Points: 2,450 ($24.50 available) ││
│ │ Phone: (804) 555-1234 │ Lifetime: 12,800 pts ││
│ │ Email: jane@example.com │ Next Downgrade: $5,000/yr spend ││
│ │ Last Visit: Feb 28, 2026 │ Annual Spend: $6,200 ││
│ │ Total Spent: $18,450 │ ││
│ │ Visits: 84 │ ACCOUNT ││
│ │ │ Credit Limit: $500 ││
│ │ Shipping: 123 Main St, Suite 4 │ Current Debt: $0.00 ││
│ │ Richmond, VA 23220 │ Available: $500.00 ││
│ │ Billing: Same as shipping │ Store Credits: $25.00 ││
│ │ │ ││
│ │ Tax: Standard (not exempt) │ Tags: [High Value] [VIP] ││
│ └───────────────────────────────────┴─────────────────────────────────────┘│
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ NOTES & PREFERENCES [Edit] │ │
│ │ Size: M | Shoe: 8.5 | Colors: Navy, Charcoal │ │
│ │ Brands: Levi's, Carhartt │ │
│ │ Note: "Prefers classic styles, allergic to wool" │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ COMMUNICATION [Edit] │ │
│ │ Email Marketing: ON | SMS Marketing: OFF │ │
│ │ Preferred Contact: Email | Do Not Contact: No │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ RECENT PURCHASES │ │
│ │ Date │ Order# │ Total │ Status │ │
│ │ 2026-02-28 │ LOC-20260228-012 │ $240.72 │ Completed │ │
│ │ 2026-02-15 │ LOC-20260215-042 │ $154.00 │ Partially Ret. │ │
│ │ 2026-01-30 │ LOC-20260130-008 │ $89.00 │ Completed │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Edit” | SCR-M02-03 Customer Create / Edit | CASHIER+ |
| Click “Merge” | SCR-M02-07 Customer Merge / Dedup | MANAGER+ |
| Click “Delete” | SCR-M02-08 Customer Deletion / Anonymize | MANAGER+ |
| Click order row | SCR-M01-09 Receipt Print / Reprint (order detail) | CASHIER+ |
| Click “Notes & Preferences Edit” | SCR-M02-05 Customer Notes & Preferences | CASHIER+ |
| Click “Communication Edit” | SCR-M02-06 Communication Preferences | MANAGER+ |
G.5.3 Customer Create / Edit
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-03 |
| Product(s) | All Roles |
| BRD Section(s) | 2.1 |
| Database Tables | customers (R/W), loyalty_accounts (W) |
| State Machine(s) | – |
| Appendix F Services | customer.profile.crud.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (customer creation requires uniqueness check against server; blocked offline per BRD 1.16.2) |
| Route | /customers/new, /customers/:id/edit |
Purpose
The Customer Create/Edit form handles profile creation and modification. It collects personal details (name, phone, email), separate shipping and billing addresses, customer group assignment (which determines price tier), tax exemption status, and initial communication preferences. Email and phone uniqueness is validated server-side to prevent duplicates.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| First Name / Last Name | Input | Required fields | BRD 2.1: first_name, last_name NOT NULL |
| Phone | Input | Phone number with format validation | BRD 2.1: Unique per tenant (partial index) |
| Input | Email with format validation | BRD 2.1: Unique per tenant (partial index) | |
| Shipping Address | Form Group | Street, city, state, ZIP, country for physical delivery | BRD 2.1: Enter Physical Address (Shipping) |
| Billing Address | Form Group | Same as shipping checkbox, or separate billing address | BRD 2.1: Enter Billing Address (if different) |
| Customer Group | Select | Retail, Wholesale, VIP, Staff | BRD 2.2: Group determines Price Tier |
| Tax Exemption | Toggle + Input | Tax exempt status and exemption certificate ID | BRD 2.1: Custom Tax Rate override |
| Communication Prefs | Toggle Group | Email opt-in, SMS opt-in, preferred contact method | BRD 2.4: Initial consent settings |
| Date of Birth | Date Input | Birthday for loyalty program benefits | Customer model: date_of_birth |
| Company | Input | Company name for business customers | Customer model: company field |
| Save / Cancel | Button Pair | Submit form or discard changes | BRD 2.1: POST /customers/create or PATCH |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save (create) | SCR-M02-02 Customer Profile (new customer) | CASHIER+ |
| Save (edit) | SCR-M02-02 Customer Profile (updated) | CASHIER+ |
| Cancel | SCR-M02-01 Customer List or SCR-M02-02 Profile | CASHIER+ |
G.5.4 Customer Group / Tier Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-04 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.2 |
| Database Tables | customers (R/W), loyalty_accounts (R/W) |
| State Machine(s) | 7.11 Customer Tier States (BRONZE -> SILVER -> GOLD) |
| Appendix F Services | customer.group.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (requires server-side tier calculation and group management) |
| Route | /admin/customers/groups |
Purpose
Customer Group/Tier Management allows administrators to configure customer groups (Retail, Wholesale, VIP, Staff), define automatic tier upgrade thresholds (Bronze at $0, Silver at $1,000/yr, Gold at $5,000/yr), and configure tier benefits (point multipliers, automatic discounts, early access). Managers can also manually assign or override customer groups and tiers.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Group List | Table | All customer groups with member count and pricing tier | BRD 2.2: Retail, Wholesale, VIP, Staff |
| Tier Thresholds | Form | Annual spend thresholds for each tier | BRD 1.15.1: $1,000 Silver, $5,000 Gold |
| Tier Benefits | Form | Point multiplier, discount %, early access per tier | BRD 1.15.1: Bronze 1x, Silver 1.5x+5%, Gold 2x+10% |
| Customer Assignment | Panel | Bulk assign/reassign customers to groups | BRD 2.2: Manual Group Assignment |
| Tier Distribution Chart | Chart | Visual breakdown of customers by tier | BRD 2.6.1: Tier Distribution Report |
| Auto-Tier Toggle | Toggle | Enable/disable automatic tier upgrades based on spend | BRD 2.2: Automatic Tier Upgrades (Background Job) |
| Price Tier Mapping | Table | Maps each group to its pricing rules | BRD 2.2: Group determines Price Tier |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Edit group settings | Stays (inline edit) | MANAGER+ |
| Click customer in group | SCR-M02-02 Customer Profile | MANAGER+ |
| Save tier thresholds | Stays (configuration saved) | OWNER+ |
G.5.5 Customer Notes & Preferences
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-05 |
| Product(s) | All Roles |
| BRD Section(s) | 2.3 |
| Database Tables | customers (R/W) |
| State Machine(s) | – |
| Appendix F Services | customer.notes.crud.service |
| User Roles | CASHIER, MANAGER |
| Offline Capable | No (requires server to persist preference updates) |
| Route | /customers/:id/notes |
Purpose
The Customer Notes & Preferences screen provides both structured preference fields and free-form notes for customer profiles. Structured fields capture clothing size, shoe size, color preferences, and brand preferences. Free-form notes allow staff to record any additional observations. These notes are displayed at the POS when the customer is attached to a sale, enabling personalized service.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Clothing Size | Select | S, M, L, XL, XXL | BRD 2.3: Structured preferences |
| Shoe Size | Input | Numeric shoe size | BRD 2.3: Structured preferences |
| Color Preferences | Multi-Select | Preferred colors from configurable list | BRD 2.3: Color Preferences |
| Brand Preferences | Multi-Select | Preferred brands from configurable list | BRD 2.3: Brand Preferences |
| Free-Form Notes | Textarea | Open text for staff observations | BRD 2.3: “Prefers classic styles, allergic to wool” |
| Note History | Table | Chronological list of note additions with author and timestamp | BRD 2.3: Audit trail of note changes |
| POS Display Preview | Panel | Preview of how notes appear during POS sale attachment | BRD 2.3: Notes Display at POS |
| Save | Button | Persist all preference changes | BRD 2.3: PATCH /customers/{id}/preferences |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save | SCR-M02-02 Customer Profile (preferences updated) | CASHIER+ |
| Cancel | SCR-M02-02 Customer Profile (no change) | CASHIER+ |
G.5.6 Communication Preferences
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-06 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.4 |
| Database Tables | customers (R/W) |
| State Machine(s) | – |
| Appendix F Services | customer.communication.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (consent changes must be logged server-side immediately for compliance) |
| Route | /customers/:id/communication |
Purpose
The Communication Preferences screen manages marketing consent and contact preferences for a customer. It controls email and SMS marketing opt-in/opt-out, preferred contact method, and the “Do Not Contact” flag that blocks all marketing outreach while preserving transactional notifications. All consent changes are logged with timestamps for privacy compliance audit trails.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Email Marketing Toggle | Toggle | Opt-in/opt-out for email marketing | BRD 2.4: Email Marketing [ON/OFF] |
| SMS Marketing Toggle | Toggle | Opt-in/opt-out for SMS marketing | BRD 2.4: SMS Marketing [ON/OFF] |
| Preferred Contact Method | Radio Group | Email, Phone, SMS | BRD 2.4: Preferred Contact selection |
| Do Not Contact | Toggle | Master block on all marketing outreach | BRD 2.4: Blocks all outreach |
| Consent Change Log | Table | Timestamp, field changed, old value, new value, changed by | BRD 2.4: Audit Trail for compliance |
| Privacy Notice | Info Panel | Displays consent terms and privacy policy reference | BRD 2.4: Privacy Compliance |
| Save | Button | Persist changes and log consent update | BRD 2.4: PATCH /customers/{id}/communication-prefs |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save | SCR-M02-02 Customer Profile (prefs updated, consent logged) | MANAGER+ |
| Cancel | SCR-M02-02 Customer Profile (no change) | MANAGER+ |
G.5.7 Customer Merge / Dedup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-07 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.5 |
| Database Tables | customers (R/W), loyalty_accounts (R/W), loyalty_transactions (R/W), orders (R/W), returns (R/W), store_credits (R/W) |
| State Machine(s) | – |
| Appendix F Services | customer.merge.command.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (requires server-side data transfer and uniqueness validation) |
| Route | /admin/customers/merge |
Purpose
Customer Merge/Dedup enables managers to consolidate duplicate customer profiles. Staff select a “Source” (duplicate) and “Target” (primary) profile. The system transfers all sales history, loyalty points, account balances, and notes to the target. The higher tier is preserved. The source profile is soft-deleted (archived) with a merge audit trail.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Source Customer Selector | Search + Select | Select the duplicate profile to merge FROM | BRD 2.5: Select “Source” (Duplicate) |
| Target Customer Selector | Search + Select | Select the primary profile to merge INTO | BRD 2.5: Select “Target” (Primary) |
| Side-by-Side Comparison | Panel | Compare both profiles: name, email, phone, points, tier, spend | BRD 2.5: Visual comparison before merge |
| Data Transfer Preview | Table | Shows what will be transferred: history count, points, balance, notes | BRD 2.5: Move History, Loyalty, Balance to Target |
| Tier Resolution | Display | Shows which tier will be kept (higher of the two) | BRD 2.5: Keep Higher Tier |
| Notes Merge Preview | Panel | Shows how notes will be appended (source appended to target) | BRD 2.5: Merge Notes (Append Source to Target) |
| Confirm Merge | Button | Execute merge with confirmation dialog | BRD 2.5: POST /customers/merge |
| Cancel | Button | Cancel merge operation | BRD 2.5: No data changed |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Confirm merge | SCR-M02-02 Customer Profile (merged target profile) | MANAGER+ |
| Cancel | SCR-M02-01 Customer List | MANAGER+ |
G.5.8 Customer Deletion / Anonymize
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-08 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.5 |
| Database Tables | customers (R/W), orders (R), store_credits (R), loyalty_accounts (R) |
| State Machine(s) | – |
| Appendix F Services | customer.privacy.command.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (requires server-side balance verification and anonymization) |
| Route | /admin/customers/:id/delete |
Purpose
Customer Deletion/Anonymize provides safe deletion with guard rails. The system blocks deletion if the customer has outstanding on-account debt or open layaways. When deletion proceeds, personal data is anonymized (replaced with “Anonymous”) while sales history is retained for accounting integrity. This screen also handles GDPR/privacy data subject requests for export, deletion, or restriction.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Balance Check Display | Alert | Shows outstanding debt and open layaways (if any) | BRD 2.5: Cannot delete with outstanding balance |
| Block Alert | Alert | “Cannot Delete - Outstanding Balance: $X.XX” | BRD 2.5: Error if debt or open layaway |
| Anonymization Preview | Panel | Shows which fields will be scrubbed vs retained | BRD 2.5: Anonymize Personal Data; retain sales history |
| Confirm Delete | Button | Type-to-confirm deletion with customer name | BRD 2.5: DELETE /customers/{id} |
| Privacy Request Type | Radio Group | Export / Delete / Restrict processing | BRD 2.5: Data Subject Request types |
| Request ID | Display | Generated request tracking ID | BRD 2.5: Must complete within 30 days |
| Completion Deadline | Date Display | 30-day deadline for privacy request completion | BRD 2.5: 30-day compliance requirement |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Confirm deletion | SCR-M02-01 Customer List (customer removed) | MANAGER+ |
| Submit privacy request | Stays (request logged with tracking ID) | MANAGER+ |
| Cancel | SCR-M02-02 Customer Profile | MANAGER+ |
| Settle balance first | SCR-M02-02 Customer Profile (account tab) | MANAGER+ |
G.5.9 Loyalty Admin Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-09 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.6, 5.17 |
| Database Tables | loyalty_accounts (R/W), loyalty_transactions (R/W), customers (R) |
| State Machine(s) | 7.11 Customer Tier States |
| Appendix F Services | sale.loyalty.command.service, customer.group.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (requires server-side aggregation of loyalty data) |
| Route | /admin/loyalty |
Purpose
The Loyalty Admin Dashboard provides a comprehensive view of the loyalty program: total points economy (issued, redeemed, expired, outstanding), tier distribution, program configuration, and individual customer point adjustments. Managers can manually adjust points (with mandatory reason notes), configure point earning rates, and view expiry forecasts. This is the management counterpart to the sales terminal loyalty display (SCR-M01-13).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Points Economy Summary | Panel | Total issued, redeemed, expired, outstanding balance | BRD 1.15.2: Loyalty Points Summary report |
| Tier Distribution | Chart | Pie/bar chart of customers by tier (Bronze/Silver/Gold) | BRD 2.6.1: Tier Distribution Report |
| Points Expiry Forecast | Table | Customers with points expiring soon, amounts, dates | BRD 1.15.2: Points Expiry Forecast |
| Manual Points Adjustment | Form | Select customer, enter points (+/-), mandatory reason note | BRD 2.7 (Story 2.B.3): Manual adjustment with reason |
| Program Configuration | Form | Point earning rate, redemption ratio, tier thresholds, expiry rules | BRD 5.17: Loyalty program settings |
| Punch Card Activity | Table | Active punch cards, completion rates, average time to complete | BRD 1.15.2: Punch Card Activity report |
| ROI Analysis | Panel | Points cost vs additional revenue from loyalty customers | BRD 1.15.2: Loyalty ROI Analysis |
| Export | Button | Export loyalty data for analysis | BRD 2.6.1: Reports export |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Adjust points | Stays (points updated, reason logged) | MANAGER+ |
| Click customer row | SCR-M02-02 Customer Profile | MANAGER+ |
| Save configuration | Stays (program settings saved) | OWNER+ |
| Export | Download CSV (stays on page) | MANAGER+ |
G.5.10 Communication Log
| Attribute | Value |
|---|---|
| Screen ID | SCR-M02-10 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 2.4 |
| Database Tables | customers (R) |
| State Machine(s) | – |
| Appendix F Services | customer.communication.crud.service |
| User Roles | MANAGER, OWNER, AUDITOR |
| Offline Capable | No (requires server-side audit log data) |
| Route | /admin/customers/communication-log |
Purpose
The Communication Log provides a complete audit trail of all marketing consent changes, automated notifications sent (welcome emails, tier upgrades, shipment tracking, refund confirmations), and privacy-related communications. This screen supports regulatory compliance by documenting every consent change with timestamp, source, and the user who made the change. Auditors use this for compliance verification.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Customer Filter | Search | Filter log by specific customer | BRD 2.4: Per-customer audit trail |
| Date Range Filter | Date Range | Filter by date range | BRD 2.4: Time-based filtering |
| Event Type Filter | Select | Filter by: Consent Change, Email Sent, SMS Sent, Privacy Request | BRD 2.4: All communication events |
| Log Table | Table | Timestamp, customer, event type, details, changed by, old/new value | BRD 2.4: All consent changes logged with timestamp |
| Email Template Log | Table | Emails sent: template ID, recipient, trigger event, delivery status | BRD 2.6.1: TMPL-WELCOME, TMPL-TIER-UPGRADE, etc. |
| Privacy Request Tracker | Table | Open privacy requests with type, status, deadline | BRD 2.5: 30-day compliance tracking |
| Export Audit Trail | Button | Export full communication audit log | BRD 2.4: Compliance export |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click customer name | SCR-M02-02 Customer Profile | MANAGER+ |
| Click privacy request | SCR-M02-08 Customer Deletion / Anonymize | MANAGER+ |
| Export | Download CSV (stays on page) | AUDITOR+ |
G.6 Module 3: Catalog Screens (18 Screens)
BRD Sections: 3.1-3.15 | Appendix F: §F.6 (20 services) | Pattern: CRUD + Redis Cache
Cross-Reference: See Ch 05, Sections 3.1-3.15 for business rules. See Ch 08, Domain 1 (Product Catalog) for table schemas. See Appendix F, §F.6 for service breakdown.
G.6.1 Product List / Search
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-01 |
| Product(s) | All Roles |
| BRD Section(s) | 3.1, 3.9 |
| Database Tables | products (R), variants (R), categories (R), brands (R), inventory_levels (R), tags (R), product_tag (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.product.query.service, catalog.search.service |
| User Roles | ALL |
| Offline Capable | Yes (sales terminal: cached product list from product_cache SQLite WASM table; management screens: No — requires server) |
| Route | /catalog/products |
Purpose
Provides a searchable, filterable list of all products in the tenant’s catalog. Staff use this screen to find products by SKU, barcode, name, brand, or tag, with results returned in under 200ms. It serves as the primary navigation hub for all catalog management operations.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Search bar | Input | Full-text search with auto-complete (2+ chars, 150ms debounce, top 8 suggestions). Supports fuzzy matching (Levenshtein distance <= 2) | BRD 3.9.1 |
| Product table | Table | Columns: Image, SKU, Name, Brand, Category, Price, Stock (sum across locations), Status. Sortable by any column | BRD 3.1.3 |
| Filter sidebar | Panel | Filters: Category (hierarchy tree), Brand, Status (Draft/Active/Discontinued/Archived), Tags, Price range, Stock level (In Stock/Low/Out) | BRD 3.9.3 |
| Bulk actions toolbar | Button group | Select multiple rows → Bulk Edit, Bulk Channel Toggle, Bulk Delete, Bulk Category Move, Export CSV | BRD 3.6.2 |
| Recent searches | Panel | Last 10 searches per user in dropdown when search field focused | BRD 3.9.1 |
| Status badge | Badge | Color-coded lifecycle status: Draft (grey), Active (green), Discontinued (amber), Archived (red) | BRD 3.2.1 |
| Pagination controls | Panel | Page size (25/50/100), page navigation, total result count | BRD 3.1 |
| + New Product button | Button | Opens product creation form (SCR-M03-02) | BRD 3.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click product row | SCR-M03-02 Product Detail / Edit | ALL |
| Click “+ New Product” | SCR-M03-02 Product Detail / Edit (create mode) | MANAGER+ |
| Click “Bulk Channel Toggle” | SCR-M03-12 Multi-Channel Allocation (modal) | MANAGER+ |
| Click “Print Labels” (bulk) | SCR-M03-14 Label Printing | ALL |
| Click category in filter | Filters product list to selected category | ALL |
G.6.2 Product Detail / Edit
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-02 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.1, 3.2, 3.4, 3.11, 3.12 |
| Database Tables | products (RW), variants (RW), categories (R), brands (R), tags (R), product_tag (RW), product_collection (RW), pricing_rules (R), collections (R) |
| State Machine(s) | Product Lifecycle (DRAFT → ACTIVE → DISCONTINUED → ARCHIVED) |
| Appendix F Services | catalog.product.crud.service, catalog.variant.crud.service, catalog.product.lifecycle.service, catalog.media.crud.service, catalog.barcode.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No — requires server for save operations |
| Route | /catalog/products/:id |
Purpose
The primary form for creating and editing products. Displays all product attributes organized into tabbed sections: Identity, Pricing, Variants, Media, Channels, Inventory, and Custom Fields. Manages the product lifecycle state machine and enforces validation rules before publishing.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Product type selector | Input | Standard / Variant Parent / Composite / Service — sets form mode | BRD 3.1.1 |
| Identity section | Panel | SKU (auto-generated or manual), Name, Description, Brand (dropdown), Category (hierarchy picker), Product Group, Gender, Origin, Fabric, Season | BRD 3.1.3 |
| Pricing section | Panel | Base price, Cost price, Compare-at price, Tax code, Selling UoM, Purchasing UoM, UoM conversion factor | BRD 3.1.3, 3.3.1 |
| Variant matrix tab | Panel | Grid editor for size/color combinations — links to SCR-M03-03 | BRD 3.1.6 |
| Media tab | Panel | Image upload/reorder, primary image selection, video URLs | BRD 3.11.1 |
| Channel visibility tab | Panel | Toggle per channel (IN_STORE, ONLINE, WHOLESALE) with scheduled date windows | BRD 3.6.2 |
| Barcode section | Panel | Primary barcode (UPC-A/EAN-13/Internal), alternate barcodes list, auto-generate button | BRD 3.4.1, 3.4.2 |
| Tags & Collections | Panel | Freeform tag entry, collection assignment multi-select | BRD 3.5.2 |
| Custom fields section | Panel | Tenant-defined custom attributes (TEXT/NUMBER/LIST/BOOLEAN) | BRD 3.1.4 |
| Lifecycle status bar | Panel | Current status badge + action buttons: Publish (Draft→Active), Discontinue, Archive. Preconditions displayed | BRD 3.2.1, 3.2.2 |
| Shipping section | Panel | Shippable toggle, weight, dimensions, package type. Mandatory when shippable=true | BRD 3.1.3 |
| Clone button | Button | Creates a new DRAFT product with new auto-generated SKU, copies all fields | BRD 3.1.5 |
| Audit trail | Panel | Created by, updated by, timestamps, lifecycle transitions log | BRD 3.13.4 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────┐
│ PRODUCT DETAIL: Classic Oxford Shirt (NXJ1078) [Active ●] [Clone] │
├──────────┬──────────────────────────────────────────────────────────────┤
│ Tabs: │ [Identity] [Pricing] [Variants] [Media] [Channels] [Custom]│
├──────────┴──────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────┐ ┌────────────────────────────────┐ │
│ │ SKU: NXJ1078 │ │ Primary Image [Upload] [▲▼] │ │
│ │ Name: Classic Oxford Shirt │ │ ┌──────────┐ ┌──────────┐ │ │
│ │ Brand: [Nexus ▼] │ │ │ │ │ │ │ │
│ │ Category: [Men > Tops ▼] │ │ │ img-1 │ │ img-2 │ │ │
│ │ Product Group: [Tops ▼] │ │ │ ★primary │ │ │ │ │
│ │ Gender: [Men ▼] │ │ └──────────┘ └──────────┘ │ │
│ │ Season: [Spring 2026 ▼] │ └────────────────────────────────┘ │
│ │ Origin: [Imported ▼] │ │
│ │ Fabric: [100% Cotton ] │ ┌────────────────────────────────┐ │
│ └─────────────────────────────┘ │ PRICING │ │
│ │ Base Price: [$29.00 ] │ │
│ ┌─────────────────────────────┐ │ Cost Price: [$12.00 ] │ │
│ │ BARCODE │ │ Compare At: [$35.00 ] │ │
│ │ Primary: [012345678901 ] │ │ Tax Code: [clothing ▼] │ │
│ │ Type: UPC-A │ │ Selling UoM: [EACH ▼] │ │
│ │ Alternates: + Add │ └────────────────────────────────┘ │
│ │ · VENDOR-SKU-X99 │ │
│ └─────────────────────────────┘ ┌────────────────────────────────┐ │
│ │ TAGS & COLLECTIONS │ │
│ ┌─────────────────────────────┐ │ Tags: [bestseller] [oxford] + │ │
│ │ SHIPPING │ │ Collections: [✓ New Arrivals] │ │
│ │ [✓] Shippable │ │ [✓ Staff Picks] │ │
│ │ Weight: [0.35] [lb ▼] │ └────────────────────────────────┘ │
│ │ Dims: [12x8x2] [in ▼] │ │
│ │ Package: [Box ▼] │ │
│ └─────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ [Save Draft] [Publish ▶] [Discontinue] [Delete] [Cancel] │
└─────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Variants” tab | SCR-M03-03 Variant Matrix Editor (inline) | MANAGER+ |
| Click “Publish” | Stays on page; status changes to ACTIVE. Validates: name, SKU, price, category set | MANAGER+ |
| Click “Discontinue” | Stays on page; status changes to DISCONTINUED. Blocks new POs | MANAGER+ |
| Click “Archive” | Stays on page; requires stock=0 at all locations | OWNER |
| Click “Clone” | SCR-M03-02 (new product, pre-filled, DRAFT status, new SKU) | MANAGER+ |
| Click “Print Label” | SCR-M03-14 Label Printing (pre-filled with this product) | ALL |
| Click “View Analytics” | SCR-M03-18 Product Analytics (filtered to this product) | MANAGER+ |
G.6.3 Variant Matrix Editor
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-03 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.1.6 |
| Database Tables | products (R), variants (RW) |
| State Machine(s) | — |
| Appendix F Services | catalog.variant.crud.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /catalog/products/:id/variants |
Purpose
A spreadsheet-like grid interface for managing variant products efficiently. Rows represent Dimension 1 values (e.g., sizes), columns represent Dimension 2 values (e.g., colors). Each cell shows price, cost, and stock level, enabling rapid inline editing and bulk variant creation.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Dimension config | Input | Up to 3 dimension names (e.g., Size, Color, Material) with ordered values | BRD 3.1.6 |
| Matrix grid | Table | Rows=Dim1, Cols=Dim2. Each cell: price / cost / stock. Click to edit inline. Tab navigates cells | BRD 3.1.6 |
| Add dimension value | Button | Add new size or color value — creates new variant row/column | BRD 3.1.6 |
| Bulk price update | Input | Set price/cost for entire row (size) or column (color) at once | BRD 3.1.6 |
| Changed cells highlight | Badge | Yellow highlight on modified cells until saved | BRD 3.1.6 |
| Save All Changes | Button | Persists all edits in one batch operation | BRD 3.1.6 |
| Generate barcodes | Button | Auto-generate internal barcodes for variants missing barcodes | BRD 3.4.2 |
| Variant active toggle | Input | Per-cell checkbox to activate/deactivate individual variants | BRD 3.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Save All Changes” | Stays on page; batch save all modified variants | MANAGER+ |
| Click variant SKU link | SCR-M03-02 Product Detail (variant tab focused) | MANAGER+ |
| Click “Generate Barcodes” | Stays on page; auto-generates internal barcodes for variants without barcodes | MANAGER+ |
G.6.4 Pricing Engine / Price Books
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-04 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.3.1, 3.3.2 |
| Database Tables | pricing_rules (RW), products (R), categories (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.pricing.crud.service, catalog.pricing.calculation.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/pricing |
Purpose
Manages the 5-level price hierarchy (Manual Override > Promotion > Price Book > Channel > Base) and named price books. Staff create, edit, and schedule price books for specific customer groups, channels, or date ranges. Includes CSV bulk import of price book entries and conflict preview.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Price hierarchy diagram | Panel | Visual display of 5-level pricing cascade with current active rules count at each level | BRD 3.3.1 |
| Price books list | Table | Name, Customer Group, Channel, Start/End Date, Priority, Status (Active/Inactive), Entry Count | BRD 3.3.2 |
| Price book detail form | Modal | Name, description, customer_group_id, channel, start_date, end_date, priority, is_active | BRD 3.3.2 |
| Price book entries table | Table | SKU, Product Name, Override Price, Override Cost. Inline editable | BRD 3.3.2 |
| CSV import | Button | Upload CSV (sku, override_price, override_cost). Validates SKU existence and numeric formats | BRD 3.3.2 |
| Conflict checker | Panel | Preview which products have overlapping price books for same group/channel | BRD 3.3.5 |
| Price audit trail | Panel | Log of all price book activations, deactivations, and entry modifications | BRD 3.3.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Price Book” | Price book detail modal (create mode) | MANAGER+ |
| Click price book row | Price book detail modal (edit mode) with entries table | MANAGER+ |
| Click “Import CSV” | File upload dialog; validates and applies entries | MANAGER+ |
| Click “View Conflicts” | Conflict checker panel expands | MANAGER+ |
G.6.5 Promotions Setup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-05 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.3.3 |
| Database Tables | pricing_rules (RW), products (R), categories (R) |
| State Machine(s) | Promotion Lifecycle (DRAFT → SCHEDULED → ACTIVE → EXPIRED/CANCELLED) |
| Appendix F Services | catalog.pricing.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/promotions |
Purpose
Creates and manages the four promotion types: Basic Discount, Tiered/Volume, BOGO/Cross-Item, and Scheduled/Automatic. Each promotion follows a lifecycle from DRAFT through ACTIVE to EXPIRED. Staff configure product scope, stacking rules, exclusivity, and date windows.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Promotions list | Table | Name, Type, Status, Start Date, End Date, Redemptions, Revenue Impact | BRD 3.3.3 |
| Promotion type selector | Input | BASIC, TIERED, BOGO, SCHEDULED — changes form fields dynamically | BRD 3.3.3 |
| Product scope selector | Input | ALL / CATEGORY / PRODUCT_LIST with category picker or product multi-select | BRD 3.3.3 |
| Tier editor | Panel | For TIERED type: min_qty, max_qty, price_per_unit rows. Add/remove tiers | BRD 3.3.3 |
| BOGO config | Panel | For BOGO: buy_product, buy_qty, get_product, get_qty, get_discount_type, get_discount_value | BRD 3.3.3 |
| Schedule config | Panel | For SCHEDULED: schedule_type (DATE_RANGE/RECURRING), recurrence_pattern | BRD 3.3.3 |
| Stacking rules | Input | stackable (boolean), exclusive (boolean). Combined discount cap = max_discount_percent (default 75%) | BRD 3.3.5 |
| Status badge | Badge | DRAFT (grey), SCHEDULED (blue), ACTIVE (green), EXPIRED (red), CANCELLED (dark) | BRD 3.3.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Promotion” | Promotion detail form (create mode) | MANAGER+ |
| Click “Schedule” on DRAFT | Stays on page; status → SCHEDULED, fields locked | MANAGER+ |
| Click “Cancel” on ACTIVE/SCHEDULED | Stays on page; status → CANCELLED (terminal) | MANAGER+ |
| Click “Clone” on EXPIRED | New promotion form pre-filled from source (DRAFT status) | MANAGER+ |
G.6.6 Markdown Workflow
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-06 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.3.4 |
| Database Tables | pricing_rules (RW), products (RW), inventory_levels (R) |
| State Machine(s) | Markdown Request (PENDING → APPROVED/REJECTED) |
| Appendix F Services | catalog.markdown.command.service, catalog.pricing.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/markdowns |
Purpose
Manages formal price reductions with approval workflows. Staff submit markdown requests specifying the product, new price, effective date, and reason. Managers review margin impact before approving. Also configures automatic markdown rules for slow-moving and aging inventory, and handles clearance tracking and write-offs.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Pending requests list | Table | Product, Current Price, Proposed Price, Margin Impact (%), Reason, Requested By, Date | BRD 3.3.4 |
| New markdown request form | Modal | Product picker, new_price, effective_date, reason (dropdown: Damaged, Price Match, Manager Discretion, Competitive Adjustment, Cost Change, Seasonal Reduction, Error Correction) | BRD 3.3.4 |
| Margin impact preview | Panel | Current margin vs. proposed margin, volume at each price point, total margin dollars change | BRD 3.3.4 |
| Auto-markdown rules | Table | Rule name, condition (Slow Mover/Aging/Season End), threshold, action (flag for review / apply immediately), status | BRD 3.3.4 |
| Clearance tracker | Table | Products flagged as clearance: product, original price, clearance price, days on clearance, units remaining, sell-through % | BRD 3.3.4 |
| Write-off form | Modal | Product, quantity, write_off_value (calculated), reason (DAMAGED/EXPIRED/RECALLED/SHRINKAGE/OBSOLETE), approved_by, location | BRD 3.3.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Approve” on pending request | Stays on page; price change scheduled, pushed to POS terminals | MANAGER+ |
| Click “Reject” on pending request | Stays on page; rejection reason required, requester notified | MANAGER+ |
| Click “+ New Markdown Request” | New markdown request modal | CASHIER+ |
| Click “Configure Auto Rules” | Auto-markdown rules editor panel | OWNER |
G.6.7 Coupon Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-07 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.3 |
| Database Tables | pricing_rules (RW), products (R), categories (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.pricing.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/coupons |
Purpose
Creates and manages code-based discount coupons that cashiers enter at POS during checkout. Coupons are a special subset of promotions requiring manual code entry rather than automatic application. Staff configure discount type, usage limits, valid date ranges, and applicable products.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Coupon list | Table | Code, Name, Discount Type (% / $), Value, Max Uses, Current Uses, Status, Valid Dates | BRD 3.3 |
| Coupon detail form | Modal | Code (auto-generated or manual), name, discount_type (PERCENT/AMOUNT), discount_value, product_scope, max_uses, start_date, end_date | BRD 3.3 |
| Usage tracker | Panel | Redemption count, total discount given, revenue with coupon vs. without | BRD 3.3 |
| Product scope | Input | ALL / CATEGORY / PRODUCT_LIST — determines which products the coupon applies to | BRD 3.3 |
| Bulk generate | Button | Generate batch of unique single-use codes (e.g., 500 codes for campaign) | BRD 3.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Coupon” | Coupon detail modal | MANAGER+ |
| Click coupon row | Coupon detail modal (edit mode) | MANAGER+ |
| Click “Deactivate” | Stays on page; coupon disabled immediately | MANAGER+ |
| Click “Bulk Generate” | Bulk code generation modal | OWNER |
G.6.8 Category Hierarchy
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-08 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.5.1, 3.5.3 |
| Database Tables | categories (RW), products (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.category.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/categories |
Purpose
Manages the 4-level product category hierarchy (Department > Category > Subcategory > Sub-subcategory). Staff create, reorder, and nest categories using drag-and-drop. Each category can set default tax code, commission rate, and display image. Supports bulk product moves between categories.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Category tree | Panel | Expandable/collapsible 4-level tree with drag-and-drop reordering. Shows product count per node | BRD 3.5.1 |
| Category detail form | Panel | Name, parent category (dropdown), description, display_order, is_active, image/icon upload | BRD 3.5.1 |
| Default tax code | Input | Category-level default tax code inherited by products unless overridden | BRD 3.5.3 |
| Default commission rate | Input | Category-level commission % for sales commission calculation | BRD 3.5.3 |
| Product count | Badge | Number of products assigned to each category node | BRD 3.5.1 |
| Bulk move | Button | Select multiple products from list → move to different category | BRD 3.5.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Category” | Category detail form (create mode, parent pre-selected) | MANAGER+ |
| Click category node | Category detail form (edit mode) + product list filtered to category | MANAGER+ |
| Drag-and-drop category | Stays on page; reorders or re-nests category | MANAGER+ |
| Click “View Products” | SCR-M03-01 Product List (filtered to this category) | ALL |
G.6.9 Tags & Collections
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-09 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.5.2 |
| Database Tables | tags (RW), product_tag (RW), collections (RW), product_collection (RW), products (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.tag.crud.service, catalog.collection.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/collections |
Purpose
Manages freeform product tags and named collections (manual and rule-based). Tags provide flexible filtering; collections group products for marketing purposes (e.g., “Summer Essentials”, “New Arrivals”). Rule-based collections auto-populate based on configurable conditions like published_at date or category membership.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Tags list | Table | Tag name, color hex, product count. Inline create/edit/delete | BRD 3.5.2 |
| Tag color picker | Input | Hex color for UI display (validated #RRGGBB format) | Ch 08 tags table |
| Collections list | Table | Name, Type (Manual/Rule-Based), Product Count, Start Date, End Date, Status | BRD 3.5.2 |
| Collection detail form | Modal | Name, description, image, is_active, start_date, end_date | BRD 3.5.2 |
| Manual product assignment | Panel | Add/remove products via search or drag-and-drop with display_order | BRD 3.5.2 |
| Rule builder | Panel | For rule-based collections: IF conditions (category=X, published_at within last N days, tag=Y) THEN auto-include | BRD 3.5.2 |
| Auto-tagging rules | Table | Condition → Tag mappings (e.g., IF category=“Outerwear” AND created_at within 14 days THEN tag “new-outerwear”) | BRD 3.5.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Collection” | Collection detail modal | MANAGER+ |
| Click collection row | Collection detail modal (edit mode) with product list | MANAGER+ |
| Click “+ New Tag” | Inline tag creation row | MANAGER+ |
| Click “View Products” | SCR-M03-01 Product List (filtered by tag or collection) | ALL |
G.6.10 Seasons / Buying Calendar
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-10 |
| Product(s) | BUYER+ |
| BRD Section(s) | 3.5.5, 3.5.6 |
| Database Tables | products (R), collections (R), pricing_rules (R) |
| State Machine(s) | Season Lifecycle (PLANNING → ACTIVE → CLEARANCE → CLOSED) |
| Appendix F Services | catalog.collection.crud.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /catalog/seasons |
Purpose
Manages formal buying seasons with lifecycle dates that track merchandise from buy planning through active selling, clearance, and close-out. Provides a calendar view of all seasons and enables sell-through tracking, carryover identification, and season-over-season comparison reporting.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Season calendar | Panel | Visual timeline showing all seasons with overlapping date ranges, color-coded by status | BRD 3.5.5 |
| Season list | Table | Name, Start Date, End Date, Status (PLANNING/ACTIVE/CLEARANCE/CLOSED), Product Count, Sell-Through % | BRD 3.5.5 |
| Season detail form | Modal | Name, start_date, end_date, status transition buttons | BRD 3.5.5 |
| Sell-through metrics | Panel | Units received, units sold, sell-through %, remaining inventory value, carryover count | BRD 3.5.6 |
| Season product list | Table | Products assigned to this season with sales performance per product | BRD 3.5.5 |
| Reporting dimensions | Panel | Department, Category, Brand, Season, Location multi-dimensional analysis | BRD 3.5.6 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Season” | Season detail modal | MANAGER+ |
| Click season row | Season detail modal with product list and metrics | MANAGER+ |
| Click “Move to Clearance” | Stays on page; status → CLEARANCE, triggers clearance pricing rules | MANAGER+ |
| Click “Close Season” | Stays on page; status → CLOSED (terminal), remaining inventory flagged as carryover | OWNER |
G.6.11 Barcode Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-11 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.4 |
| Database Tables | products (RW), variants (RW) |
| State Machine(s) | — |
| Appendix F Services | catalog.barcode.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/barcodes |
Purpose
Centralized barcode management for the entire catalog. Provides tools for bulk barcode import, auto-generation of internal barcodes, barcode coverage auditing, and scan failure log review. Supports UPC-A, EAN-13, and configurable internal barcode formats.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Barcode coverage summary | Panel | Total products, products with primary barcode, products with internal-only, products without barcode, coverage % | BRD 3.4.4 |
| Products without barcodes | Table | Products missing primary barcode — with bulk auto-generate option | BRD 3.4.2 |
| Bulk CSV import | Button | Upload CSV mapping barcodes to existing SKUs. Validates uniqueness, reports conflicts | BRD 3.4.2 |
| Internal barcode config | Panel | Prefix configuration (e.g., “INT-”), next sequence number preview | BRD 3.4.1 |
| Scan failure log | Table | Scanned value, timestamp, terminal, resolution (created new / manual lookup / abandoned) | BRD 3.4.4 |
| Duplicate barcode audit | Table | Barcode value, conflicting products, resolution status | BRD 3.4.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Auto-Generate” on missing barcodes list | Stays on page; generates internal barcodes for selected products | MANAGER+ |
| Click “Import CSV” | File upload dialog with validation results | MANAGER+ |
| Click product row | SCR-M03-02 Product Detail (barcode section focused) | MANAGER+ |
G.6.12 Multi-Channel Allocation
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-12 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.6 |
| Database Tables | products (R), variants (R), inventory_levels (R), locations (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.product.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/channels |
Purpose
Controls product visibility and inventory allocation across sales channels (IN_STORE, ONLINE, WHOLESALE). Supports two allocation modes: Shared Pool (all channels sell from same stock) and Dedicated Allocation (reserved quantities per channel). Enables bulk channel toggles and scheduled visibility windows.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Channel list | Table | Channel name, type (PHYSICAL/DIGITAL/B2B), is_default, product count, system flag | BRD 3.6.1 |
| Allocation mode selector | Input | Shared Pool (default) or Dedicated Allocation — tenant-wide setting | BRD 3.6.3 |
| Product-channel matrix | Table | Products as rows, channels as columns, toggles for visibility with optional date windows | BRD 3.6.2 |
| Dedicated allocation grid | Table | (Only in Dedicated mode) Product × Location × Channel: allocated_qty, sold_qty, available_qty | BRD 3.6.3 |
| Channel pricing overrides | Panel | Per-product per-channel price overrides and compare-at prices | BRD 3.6.4 |
| Bulk channel toggle | Button | Select multiple products → toggle channel visibility in batch | BRD 3.6.2 |
| Stockout warnings | Badge | “Channel Stockout” flag when a channel’s allocated qty reaches 0 | BRD 3.6.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Toggle channel visibility | Stays on page; updates product-channel record | MANAGER+ |
| Click “Reallocate” | Modal to move allocated qty between channels | MANAGER+ |
| Click “Channel Reports” | SCR-M03-18 Product Analytics (channel tab) | MANAGER+ |
G.6.13 Vendor Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-13 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.8 |
| Database Tables | brands (RW), products (R), purchase_orders (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.vendor.crud.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /catalog/vendors |
Purpose
Manages supplier/vendor records including contact information, payment terms, lead times, and the many-to-many vendor-product relationship. Each product can be sourced from multiple vendors with vendor-specific cost, SKU, and lead time. Designates primary vendors for automatic PO generation.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Vendor list | Table | Name, Code, Status (Active/Inactive), Product Count, Payment Terms, Lead Time, Last PO Date | BRD 3.8.1 |
| Vendor detail form | Panel | Name, code, tax_id, contact (email, phone, address, contact_person), payment_terms, currency, minimum_order, lead_time_days, preferred_carrier | BRD 3.8.1 |
| Vendor-product table | Table | Product SKU, Vendor SKU, Vendor Cost, Is Primary, Lead Time Override, Min Order Qty, Last Ordered | BRD 3.8.2 |
| Primary vendor toggle | Input | Designate primary vendor per product (one per product enforced) | BRD 3.8.2 |
| Performance metrics | Panel | PO count, on-time %, avg lead time, fill rate, defect rate, cost variance | BRD 3.8.4 |
| Cost comparison | Table | Compare vendor pricing for same product across multiple vendors | BRD 3.8.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Vendor” | Vendor detail form (create mode) | MANAGER+ |
| Click vendor row | Vendor detail form (edit mode) with product list | MANAGER+ |
| Click “Create PO” | SCR-M04-04 PO Create (vendor pre-selected) | BUYER+ |
| Click “View RMAs” | SCR-M04-21 Vendor RMA (filtered to this vendor) | MANAGER+ |
G.6.14 Label Printing
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-14 |
| Product(s) | All Roles |
| BRD Section(s) | 3.10 |
| Database Tables | products (R), variants (R), pricing_rules (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.label.print.service |
| User Roles | ALL |
| Offline Capable | No — requires printer connection |
| Route | /catalog/labels |
Purpose
Generates and prints barcode labels, price tags, and shelf labels for products. Supports multiple label types (price tag, shelf label, barcode-only) with configurable templates. Handles batch printing for new receiving, re-pricing, and routine label replacement.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Label type selector | Input | Price Tag (barcode + price + name), Shelf Label (category + price), Barcode Only (barcode + SKU) | BRD 3.10.1 |
| Template picker | Input | Predefined label dimensions: Avery 5160, Avery 5163, Dymo 30252, custom | BRD 3.10.2 |
| Product queue | Table | Products to print: SKU, Name, Variant, Price, Quantity of labels. Add via search, scan, or bulk select | BRD 3.10.3 |
| Label preview | Panel | Visual preview of label layout before printing | BRD 3.10.2 |
| Printer selector | Input | Available label printers (Zebra, Dymo, Brother) | BRD 3.10.3 |
| Print trigger indicators | Badge | Shows auto-print triggers: price change, new receiving, markdown applied | BRD 3.10.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Print” | Sends print job to selected printer | ALL |
| Click “Add Products” | Search/scan dialog to add products to queue | ALL |
| Click “Print All Received” | Adds all products from most recent PO receive to queue | CASHIER+ |
G.6.15 Product Media Manager
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-15 |
| Product(s) | BUYER+ |
| BRD Section(s) | 3.11 |
| Database Tables | products (RW), variants (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.media.crud.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/media |
Purpose
Centralized media management for product images and videos. Provides bulk upload, drag-and-drop reordering, primary image designation, and variant-specific image assignment. Supports image optimization, CDN sync, and batch operations across multiple products.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Media gallery grid | Panel | Thumbnail grid of all product images with product name overlay. Drag-and-drop reorder | BRD 3.11.1 |
| Upload zone | Panel | Drag-and-drop or file picker for bulk image upload. Supports JPG, PNG, WebP | BRD 3.11.1 |
| Primary image selector | Button | Star icon to designate primary display image per product | BRD 3.11.1 |
| Variant image assignment | Panel | Assign specific images to specific variants (e.g., blue shirt photo → Blue variant) | BRD 3.11.1 |
| Video URLs | Input | External video URLs (YouTube, Vimeo) linked to products | BRD 3.11.2 |
| Sync status | Badge | CDN sync status per image: synced, pending, error | BRD 3.11.3 |
| Products without images | Table | Products missing primary image — filter for quick remediation | BRD 3.11.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click product thumbnail | SCR-M03-02 Product Detail (media tab) | MANAGER+ |
| Drag-and-drop images | Stays on page; reorders image display priority | MANAGER+ |
| Click “Bulk Upload” | File picker for multi-file upload | MANAGER+ |
G.6.16 Custom Fields Config
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-16 |
| Product(s) | OWNER |
| BRD Section(s) | 3.12, 5.12 |
| Database Tables | products (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.notes.crud.service, setup.customfield.crud.service |
| User Roles | OWNER |
| Offline Capable | No |
| Route | /catalog/custom-fields |
Purpose
Configures tenant-defined custom attribute definitions that extend the standard product data model. Allows creating TEXT, NUMBER, LIST, and BOOLEAN fields with validation rules. Custom fields appear on the product edit form and can be made searchable, filterable, and required.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Custom fields list | Table | Name, Type (TEXT/NUMBER/LIST/BOOLEAN), Required, Searchable, Filterable, Display Order, Usage Count | BRD 3.1.4 |
| Field definition form | Modal | name, type, list_values[] (for LIST type), required, searchable, filterable, display_order | BRD 3.1.4 |
| List values editor | Panel | For LIST type: ordered list of allowed values with add/remove | BRD 3.1.4 |
| Field limit indicator | Badge | “X / 50 custom fields defined” — shows limit enforcement | BRD 3.1.4 (max 50 per tenant) |
| Usage preview | Panel | Shows where field appears: product form position, search index status, filter sidebar inclusion | BRD 3.1.4 |
| Structured notes config | Panel | Note types (INTERNAL, VENDOR, COMPLIANCE) and file attachment settings (max size, allowed types) | BRD 3.12.1, 3.12.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Field” | Field definition modal | OWNER |
| Click field row | Field definition modal (edit mode) | OWNER |
| Click “Archive” on field | Stays on page; field hidden from form but preserved in historical data | OWNER |
G.6.17 Product Search & Discovery
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-17 |
| Product(s) | All Roles |
| BRD Section(s) | 3.9 |
| Database Tables | products (R), variants (R), categories (R), inventory_levels (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.search.service |
| User Roles | ALL |
| Offline Capable | Yes (POS: searches product_cache SQLite table with degraded fuzzy matching) |
| Route | /pos/search (POS context) |
Purpose
POS-optimized product search and discovery interface. Provides category browsing tiles, quick-add favorites grid, barcode scanner integration, and product substitution suggestions. Designed for cashier speed with large touch targets and instant results (<200ms).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Search bar | Input | Full-text search with auto-complete, fuzzy matching, recent searches. Barcode scanner auto-submit | BRD 3.9.1 |
| Category browse tiles | Panel | Top-level categories as large touch tiles. Tap to drill into subcategories | BRD 3.9.2 |
| Quick-add favorites | Panel | Configurable grid of frequently sold products per staff member. One-tap to add to cart | BRD 3.9.4 |
| Search results grid | Panel | Product cards: image, name, price, stock level, variant indicator. Tap to add or view variants | BRD 3.9.1 |
| Substitution suggestions | Panel | When product is out of stock, shows similar alternatives with stock availability | BRD 3.9.5 |
| Advanced filters | Panel | Category, Brand, Price Range, In-Stock Only, Tags. Collapsible sidebar | BRD 3.9.3 |
| Recent searches | Panel | Last 10 searches with result count, re-execute on tap | BRD 3.9.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap product card | Adds product to cart (sales terminal) or navigates to detail (management view) | ALL |
| Tap variant indicator | Variant selector modal (size/color picker) | ALL |
| Tap category tile | Drills into subcategory product list | ALL |
| Tap substitution | Adds substitute product to cart | ALL |
| Tap “Manage Favorites” | Quick-add favorites editor (personal per user) | ALL |
G.6.18 Product Analytics
| Attribute | Value |
|---|---|
| Screen ID | SCR-M03-18 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 3.14 |
| Database Tables | products (R), variants (R), inventory_levels (R), inventory_transactions (R), pricing_rules (R) |
| State Machine(s) | — |
| Appendix F Services | catalog.analytics.query.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /catalog/analytics |
Purpose
Comprehensive product performance analytics dashboard. Displays embedded product metrics (velocity, margin, sell-through), ABC classification analysis, and catalog-wide KPIs. Enables data-driven decisions on pricing, restocking, and product lifecycle management.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| KPI summary cards | Panel | Total SKUs, Active Products, Avg Margin %, Top Seller (7d), Worst Performer (7d), New Products (30d) | BRD 3.14.3 |
| ABC classification chart | Panel | Pareto chart showing A (top 20% revenue), B (next 30%), C (bottom 50%) products. Interactive click-through | BRD 3.14.2 |
| Sales velocity heatmap | Panel | Product × Location matrix with color-coded sales velocity (hot/warm/cold) | BRD 3.14.1 |
| Margin analysis table | Table | Product, Sell Price, WAC, Gross Margin $, Gross Margin %, Units Sold, Total Margin. Sortable | BRD 3.14.1 |
| Pricing report | Table | Price book usage, promotion performance, markdown history, conflict resolution log | BRD 3.3.6 |
| Category sales breakdown | Panel | Revenue by category hierarchy with drill-down | BRD 3.5.4 |
| Channel comparison | Panel | Channel revenue, units, margin side-by-side comparison chart | BRD 3.6.5 |
| Date range selector | Input | 7d, 30d, 90d, custom range for all metrics | BRD 3.14 |
| Export | Button | CSV and PDF export of all analytics data | BRD 3.14.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click product in ABC chart | SCR-M03-02 Product Detail | MANAGER+ |
| Click category in breakdown | SCR-M03-01 Product List (filtered to category) | MANAGER+ |
| Click “Export Report” | Downloads CSV or PDF | MANAGER+ |
| Click velocity cell | SCR-M04-02 Inventory List (filtered to product + location) | MANAGER+ |
G.7 Module 4: Inventory Screens (23 Screens)
BRD Sections: 4.1-4.19 | Appendix F: §F.7 (23 services) | Pattern: Materialized + ES Audit Trail
Cross-Reference: See Ch 05, Sections 4.1-4.19 for business rules. See Ch 08, Domain 3 (Inventory) for table schemas. See Appendix F, §F.7 for service breakdown.
G.7.1 Inventory Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-01 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.17 |
| Database Tables | inventory_levels (R), inventory_transactions (R), purchase_orders (R), transfer_orders (R), locations (R), products (R), variants (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.dashboard.projection.service, inventory.report.query.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/dashboard |
Purpose
At-a-glance inventory health overview with eight KPI cards, trend indicators, and drill-down links to detailed reports. Provides multi-location summary with filters for location, category, brand, and date range. Serves as the primary inventory management landing page.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Total Inventory Value (WAC) | Panel | Sum of (available_qty x WAC) across all locations. 30-day trend arrow + % change | BRD 4.17.1 |
| Low Stock Items | Panel | Count of products where available_qty <= reorder_point. Delta from prior week | BRD 4.17.1 |
| Pending PO Count | Panel | Count of POs in OPEN/PARTIAL status with total pending PO value | BRD 4.17.1 |
| Open Transfers | Panel | Count of transfers in REQUESTED/APPROVED/PICKING/SHIPPED status with in-transit items count | BRD 4.17.1 |
| Upcoming Counts | Panel | Count of scheduled counts in next 7 days with next count date and type | BRD 4.17.1 |
| Shrinkage % | Panel | (Total variance value / Total inventory value) x 100 for last 30 days vs. prior period | BRD 4.17.1 |
| Dead Stock Count | Panel | Products with zero sales velocity in last 90 days. Delta from prior month | BRD 4.17.1 |
| Avg Days of Supply | Panel | Average days_of_supply across active products. Top 3 categories with lowest supply | BRD 4.17.1 |
| Dashboard filters | Input | Location (All/specific), Category, Date Range, Brand | BRD 4.17.1 |
| Active alerts feed | Panel | Recent LOW_STOCK, OVERSTOCK, SHRINKAGE, AGING_INVENTORY, PO_OVERDUE alerts | BRD 4.16.1 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────┐
│ INVENTORY DASHBOARD [All Locations ▼] [30 Days ▼] │
├─────────────────────────────────────────────────────────────────────────┤
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌──────────┐ │
│ │ TOTAL VALUE │ │ LOW STOCK │ │ PENDING POs │ │ OPEN │ │
│ │ $847,230 ▲2% │ │ 23 items ▼3 │ │ 8 POs ($12.4K) │ │ XFERS: 5 │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ └──────────┘ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ ┌──────────┐ │
│ │ UPCOMING COUNTS │ │ SHRINKAGE % │ │ DEAD STOCK │ │ AVG DAYS │ │
│ │ 2 (next: Mon) │ │ 1.2% ▼0.3% │ │ 15 items ▲2 │ │ SUPPLY │ │
│ │ │ │ │ │ │ │ 42 days │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ ACTIVE ALERTS [View All →] │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ ● CRITICAL Shrinkage 8.2% on CNT-2026-00031 at Store A │ │
│ │ ▲ WARNING Low Stock: 23 items below reorder point at Store B │ │
│ │ ▲ WARNING PO #PO-2026-00042 overdue by 3 days (Vendor: Nike) │ │
│ │ ○ INFO Overstock: 12 items >90 days supply at HQ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ INVENTORY BY LOCATION │
│ ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │
│ │ Store GM │ Store HM │ Store LM │ Store NM │ HQ (WH) │ │
│ │ $182K │ $165K │ $158K │ $147K │ $195K │ │
│ │ 1,245 SKU│ 1,180 SKU│ 1,090 SKU│ 1,050 SKU│ 1,420 SKU│ │
│ │ 3 low ▲ │ 5 low ▼ │ 8 low ▲ │ 4 low ─ │ 3 low ▼ │ │
│ └──────────┴──────────┴──────────┴──────────┴──────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Low Stock Items” KPI | SCR-M04-03 Low Stock Alerts | MANAGER+ |
| Click “Pending POs” KPI | SCR-M04-05 PO Approval / Track | BUYER+ |
| Click “Open Transfers” KPI | SCR-M04-17 Transfer Create / Track | MANAGER+ |
| Click “Upcoming Counts” KPI | SCR-M04-09 Stock Count Session | MANAGER+ |
| Click location card | SCR-M04-02 Inventory List (filtered to location) | MANAGER+ |
| Click alert row | Navigates to relevant detail screen based on alert type | MANAGER+ |
| Click “View All Reports” | SCR-M04-22 Inventory Reports | MANAGER+ |
G.7.2 Inventory List (per location)
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-02 |
| Product(s) | All Roles |
| BRD Section(s) | 4.1, 4.2 |
| Database Tables | inventory_levels (R), products (R), variants (R), locations (R) |
| State Machine(s) | Inventory Status (AVAILABLE, QUARANTINE, DAMAGED, PENDING_INSPECTION, RESERVED, IN_TRANSIT) |
| Appendix F Services | inventory.level.query.service, inventory.status-model.service |
| User Roles | ALL |
| Offline Capable | Yes (POS: reads from product_cache SQLite table; shows cached on_hand qty) |
| Route | /inventory/levels |
Purpose
Displays current stock quantities for all products at a selected location, broken down by inventory status (Available, Reserved, In-Transit, Quarantine, Damaged). Shows the computed available quantity (on_hand - committed - reserved) and visual indicators for low stock and reorder point thresholds.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Location selector | Input | Dropdown to select location. Default: user’s assigned location | BRD 4.1 |
| Inventory table | Table | SKU, Product Name, Variant, Available, Reserved, In-Transit, Quarantine, Damaged, On-Hand (total), Reorder Point, WAC | BRD 4.1.5 |
| Status breakdown | Panel | Per-row expandable showing qty by each status with last_status_change_at | BRD 4.2.1 |
| Low stock indicator | Badge | Red/amber badge when available_qty <= reorder_point | BRD 4.5 |
| Min display qty warning | Badge | Advisory badge when available_qty < min_display_qty. Suggests transfer from location with highest stock | BRD 4.2.4 |
| Filters | Panel | Category, Brand, Stock Status (All/Low/Out/Overstock), Search by SKU/Name | BRD 4.1 |
| Balance equation | Panel | Header showing: Available = On-Hand - Reserved - In-Transit - Quarantine - Damaged | BRD 4.1.5 |
| Export | Button | CSV export of current inventory snapshot | BRD 4.17.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click product row | Product inventory detail (movement history for this product+location) | ALL |
| Click “New Adjustment” | SCR-M04-15 Adjustment Request (product pre-selected) | CASHIER+ |
| Click “Request Transfer” | SCR-M04-17 Transfer Create (destination pre-selected) | MANAGER+ |
| Click “Create Count” | SCR-M04-09 Stock Count Session (location pre-selected) | MANAGER+ |
G.7.3 Low Stock Alerts
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-03 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.16 |
| Database Tables | inventory_levels (R), products (R), variants (R), locations (R), purchase_orders (R) |
| State Machine(s) | Alert Lifecycle (TRIGGERED → ACKNOWLEDGED → RESOLVED) |
| Appendix F Services | inventory.alert.service, inventory.reorder.engine.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/alerts |
Purpose
Displays all active inventory alerts across five types: Low Stock, Overstock, Shrinkage Threshold, Aging Inventory, and PO Overdue. Provides acknowledge/resolve workflow, auto-resolution tracking, and one-click actions to create POs or transfers from alert context.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Alert tabs | Panel | Tabs: All, Low Stock, Overstock, Shrinkage, Aging, PO Overdue. Count badges per tab | BRD 4.16.1 |
| Alert list | Table | Alert Type, Severity (CRITICAL/WARNING/INFO), Product, Location, Message, Triggered At, Status, Acknowledged By | BRD 4.16.2 |
| Severity indicators | Badge | Red=CRITICAL, Amber=WARNING, Blue=INFO | BRD 4.16.1 |
| Data snapshot | Panel | Expandable per alert showing key metrics at alert time (e.g., available_qty, reorder_point) | BRD 4.16.2 |
| One-click actions | Button | “Create PO” (for low stock), “Create Transfer” (for overstock/imbalance), “View Count” (for shrinkage) | BRD 4.16.1 |
| Acknowledge button | Button | Mark alert as acknowledged — staff is aware and working on it | BRD 4.16.3 |
| Auto-resolved indicator | Badge | Shows when alert was auto-resolved (e.g., stock received, sale occurred) | BRD 4.16.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Create PO” on low stock alert | SCR-M04-04 PO Create (product + vendor pre-filled) | BUYER+ |
| Click “Create Transfer” | SCR-M04-17 Transfer Create (product pre-filled) | MANAGER+ |
| Click “Acknowledge” | Stays on page; alert status → ACKNOWLEDGED | MANAGER+ |
| Click “View Count” on shrinkage alert | SCR-M04-13 Count Results Review (count session linked) | MANAGER+ |
G.7.4 PO Create / Edit
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-04 |
| Product(s) | BUYER+ |
| BRD Section(s) | 4.3 |
| Database Tables | purchase_orders (RW), purchase_order_items (RW), products (R), variants (R), brands (R), locations (R) |
| State Machine(s) | PO Lifecycle (DRAFT → PENDING_APPROVAL → SUBMITTED → PARTIALLY_RECEIVED → FULLY_RECEIVED → CLOSED / CANCELLED) |
| Appendix F Services | inventory.po.command.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/purchase-orders/new, /inventory/purchase-orders/:id/edit |
Purpose
Creates and edits purchase orders for inventory replenishment. Staff select a vendor, add line items with quantities and costs, set expected delivery dates, and submit for approval or directly to the vendor. Supports auto-generation from reorder alerts and PO templates.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| PO header | Panel | PO number (auto: PO-YYYY-NNNNN), Vendor selector, Destination Location, Expected Date, Notes | BRD 4.3.4 |
| Vendor product picker | Panel | Shows vendor’s product catalog with vendor SKU, vendor cost, lead time. Search + filter | BRD 4.3.3 |
| Line items table | Table | Product, Variant, Vendor SKU, Qty Ordered, Unit Cost, Line Total. Inline editable | BRD 4.3.5 |
| PO summary | Panel | Line count, subtotal, tax_amount, shipping_cost, total_amount | BRD 4.3.4 |
| Approval indicator | Badge | Shows if PO total exceeds auto-approve threshold ($2,000 default). “Requires Manager Approval” warning | BRD 4.3.2 |
| Auto-generated flag | Badge | “Auto-Generated” badge if created by reorder engine | BRD 4.5.2 |
| Status bar | Panel | Current status badge + available actions based on state | BRD 4.3.1 |
| Landed cost section | Panel | Freight, duties, customs, handling fields. Allocation method selector (BY_UNIT/BY_VALUE/BY_WEIGHT) | BRD 4.11.1 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────┐
│ PURCHASE ORDER: PO-2026-00042 [DRAFT ○] [Save] │
├─────────────────────────────────────────────────────────────────────────┤
│ Vendor: [Nike USA ▼] Destination: [HQ Warehouse ▼] │
│ Expected: [2026-03-15 📅] Notes: [Spring restock order ] │
│ ⚠ Total exceeds $2,000 — Manager approval required on submit │
├─────────────────────────────────────────────────────────────────────────┤
│ LINE ITEMS [+ Add Product] │
│ ┌─────┬──────────────────────┬────────┬─────┬──────────┬─────────────┐ │
│ │ # │ Product / Variant │ V-SKU │ Qty │ Unit Cost│ Line Total │ │
│ ├─────┼──────────────────────┼────────┼─────┼──────────┼─────────────┤ │
│ │ 1 │ Oxford Shirt - S/Blu │ NK-4401│ 20 │ $12.00 │ $240.00 │ │
│ │ 2 │ Oxford Shirt - M/Blu │ NK-4402│ 30 │ $12.00 │ $360.00 │ │
│ │ 3 │ Oxford Shirt - L/Blu │ NK-4403│ 25 │ $12.00 │ $300.00 │ │
│ │ 4 │ Oxford Shirt - XL/Bl │ NK-4404│ 15 │ $13.00 │ $195.00 │ │
│ │ 5 │ Classic Polo - M/Wht │ NK-5501│ 40 │ $10.00 │ $400.00 │ │
│ │ 6 │ Classic Polo - L/Wht │ NK-5502│ 35 │ $10.00 │ $350.00 │ │
│ └─────┴──────────────────────┴────────┴─────┴──────────┴─────────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ COST ALLOCATION │ SUMMARY │
│ Freight: [$500.00 ] │ Subtotal: $1,845.00 │
│ Duties: [$300.00 ] │ Tax: $0.00 │
│ Customs: [$100.00 ] │ Shipping: $500.00 │
│ Handling: [$80.00 ] │ ──────────────────────── │
│ Method: [BY_UNIT ▼] │ Total: $2,345.00 │
├─────────────────────────────────────────────────────────────────────────┤
│ [Save Draft] [Submit to Vendor ▶] [Cancel] │
└─────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Save Draft” | Stays on page; PO saved as DRAFT | BUYER+ |
| Click “Submit to Vendor” | If total <= threshold: status → SUBMITTED. If > threshold: status → PENDING_APPROVAL | BUYER+ |
| Click “Cancel PO” | Stays on page; status → CANCELLED | MANAGER+ |
| Click vendor name | SCR-M03-13 Vendor Management (vendor detail) | MANAGER+ |
| Click “Use Template” | SCR-M04-06 PO Templates (select and apply) | BUYER+ |
G.7.5 PO Approval / Track
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-05 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.3 |
| Database Tables | purchase_orders (RW), purchase_order_items (R), brands (R), locations (R) |
| State Machine(s) | PO Lifecycle (DRAFT → PENDING_APPROVAL → SUBMITTED → PARTIALLY_RECEIVED → FULLY_RECEIVED → CLOSED / CANCELLED) |
| Appendix F Services | inventory.po.command.service, inventory.po.query.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/purchase-orders |
Purpose
Lists all purchase orders with status tracking, filtering, and approval workflow. Managers review and approve/reject POs exceeding the auto-approve threshold. Buyers track PO status from submission through receiving. Shows overdue PO alerts and expected delivery timelines.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| PO list | Table | PO Number, Vendor, Status, Location, Order Date, Expected Date, Total Value, Received %, Overdue flag | BRD 4.3 |
| Status filter tabs | Panel | All, Draft, Pending Approval, Submitted, Partially Received, Fully Received, Closed, Cancelled | BRD 4.3.1 |
| Approval queue | Panel | POs in PENDING_APPROVAL status with Approve/Reject buttons. Shows PO total vs. threshold | BRD 4.3.2 |
| Overdue indicator | Badge | Red badge when expected_date passed and status is SUBMITTED/PARTIALLY_RECEIVED | BRD 4.3.6 |
| PO detail drawer | Panel | Slide-out panel showing full PO header + line items + receiving history | BRD 4.3 |
| Timeline view | Panel | Visual timeline of PO state transitions with timestamps and actors | BRD 4.3.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Approve” on pending PO | Stays on page; PO status → SUBMITTED | MANAGER+ |
| Click “Reject” on pending PO | Rejection reason modal; PO status → REJECTED → DRAFT (for revision) | MANAGER+ |
| Click PO row | SCR-M04-04 PO Create/Edit (read-only if not DRAFT) | BUYER+ |
| Click “Receive” | SCR-M04-07 Receiving & Inspection (PO pre-selected) | CASHIER+ |
| Click “+ New PO” | SCR-M04-04 PO Create | BUYER+ |
G.7.6 PO Templates
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-06 |
| Product(s) | BUYER+ |
| BRD Section(s) | 4.3 |
| Database Tables | purchase_orders (R), purchase_order_items (R), brands (R), products (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.po.command.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/purchase-orders/templates |
Purpose
Manages reusable purchase order templates for recurring vendor orders. Templates store pre-configured vendor, product line items, and default quantities, allowing staff to create new POs from templates with one click. Reduces repetitive data entry for routine restocking.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Template list | Table | Template Name, Vendor, Product Count, Last Used, Created By | BRD 4.3 |
| Template detail form | Panel | Name, Vendor (locked), line items (product, default qty, unit cost) | BRD 4.3 |
| “Use Template” button | Button | Creates a new DRAFT PO pre-filled from template. Staff can modify before submitting | BRD 4.3 |
| Save as template | Button | Save current PO as reusable template (strips PO-specific dates and numbers) | BRD 4.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Use Template” | SCR-M04-04 PO Create (pre-filled from template) | BUYER+ |
| Click template row | Template detail form (edit mode) | MANAGER+ |
| Click “+ New Template” | Template detail form (create mode) | MANAGER+ |
G.7.7 Receiving & Inspection
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-07 |
| Product(s) | All Roles |
| BRD Section(s) | 4.4 |
| Database Tables | purchase_orders (RW), purchase_order_items (RW), inventory_levels (RW), inventory_transactions (RW), products (R), variants (R) |
| State Machine(s) | PO Lifecycle (SUBMITTED → PARTIALLY_RECEIVED → FULLY_RECEIVED) |
| Appendix F Services | inventory.receiving.command.service |
| User Roles | CASHIER, MANAGER, OWNER |
| Offline Capable | No — inventory updates require server |
| Route | /inventory/receiving |
Purpose
Processes inbound inventory from four source types: PO shipments, inter-store transfers, customer returns (return-to-stock), and vendor RMA replacements. Staff scan or enter received quantities, inspect items, flag discrepancies, and confirm receipt. Inventory is incremented and WAC is recalculated on confirmation.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Source type selector | Input | PO Receive / Transfer Receive / Return-to-Stock / RMA Replacement | BRD 4.4.1 |
| PO/Transfer picker | Input | Select open PO or in-transit transfer to receive against | BRD 4.4 |
| Line items table | Table | Product, Variant, Ordered Qty, Previously Received, Receiving Now, Discrepancy. Scanner-primary input | BRD 4.4.3 |
| Scanner mode | Panel | Barcode scanner auto-increments received qty per scan. Manual entry fallback | BRD 4.4.9 |
| Inspection checklist | Panel | Per-line inspection: condition (GOOD/DAMAGED/WRONG_ITEM), notes | BRD 4.4 |
| Non-PO receive | Panel | Receive without PO: mandatory reason code (sample, gift, found, correction) | BRD 4.4.6 |
| Open receive mode | Panel | Allows receiving items not on the PO — flags as unexpected and requires reason | BRD 4.4.4 |
| WAC recalculation preview | Panel | Shows current WAC, new landed cost, and projected new WAC after receive | BRD 4.11.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Confirm Receive” | Stays on page; inventory incremented, WAC recalculated, PO status updated | CASHIER+ |
| Click “Report Discrepancy” | SCR-M04-08 Receiving Variance (pre-filled) | CASHIER+ |
| Click “Print Labels” | SCR-M03-14 Label Printing (received products queued) | ALL |
| Click “View PO” | SCR-M04-04 PO Create/Edit (read-only) | BUYER+ |
G.7.8 Receiving Variance
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-08 |
| Product(s) | All Roles |
| BRD Section(s) | 4.4.5, 4.4.7 |
| Database Tables | purchase_orders (R), purchase_order_items (R), inventory_transactions (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.receiving.command.service |
| User Roles | CASHIER, MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/receiving/:id/variance |
Purpose
Handles discrepancies between ordered/expected quantities and actually received quantities. Supports the triple approach: log a note (minor variance), auto-draft an RMA (defective items), or quarantine items for further inspection. Also manages over-shipment decisions (keep, return, or quarantine).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Variance summary | Table | Product, Ordered Qty, Received Qty, Variance (+/-), Variance %, Resolution | BRD 4.4.5 |
| Resolution actions | Input | Per-line: Note Only / Draft RMA / Quarantine. Default based on variance type | BRD 4.4.5 |
| Over-shipment handler | Panel | For positive variances: Accept & Add to Inventory / Return to Vendor / Quarantine for Review | BRD 4.4.7 |
| Condition notes | Input | Free-text notes per variance line explaining the discrepancy | BRD 4.4.5 |
| Auto-RMA draft | Button | Creates draft RMA for defective items detected during receiving | BRD 4.4.5 |
| Variance history | Table | Historical receiving variances by vendor for performance tracking | BRD 4.17.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Create RMA” for defective variance | SCR-M04-21 Vendor RMA (pre-filled from variance data) | MANAGER+ |
| Click “Accept Over-Shipment” | Stays on page; inventory incremented for excess qty | MANAGER+ |
| Click “Quarantine” | Stays on page; items moved to QUARANTINE status | MANAGER+ |
| Click “Save & Close” | Returns to SCR-M04-07 Receiving | CASHIER+ |
G.7.9 Stock Count Session
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-09 |
| Product(s) | All Roles |
| BRD Section(s) | 4.6 |
| Database Tables | inventory_levels (R), inventory_transactions (R), products (R), variants (R), locations (R) |
| State Machine(s) | Count Lifecycle (CREATED → IN_PROGRESS → REVIEW → APPROVED / CANCELLED) |
| Appendix F Services | inventory.count.command.service, inventory.count.query.service |
| User Roles | MANAGER, OWNER (create); CASHIER+ (count) |
| Offline Capable | Partial (POS: scanner counting can work offline; sync on reconnect) |
| Route | /inventory/counts/:id |
Purpose
Primary interface for conducting physical inventory counts. Managers create count sessions specifying type, location, scope, and mode (Freeze/Snapshot). Staff perform scanner-primary counting or manual entry. The screen tracks progress, shows expected vs. counted quantities, and handles the complete count lifecycle.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Count header | Panel | Count number (CNT-YYYY-NNNNN), type (Full/Cycle/Scanner/Monthly/On-Demand), location, mode (FREEZE/SNAPSHOT), scope, status, assigned_to | BRD 4.6.2 |
| Mode indicator | Badge | FREEZE: “Sales Suspended at This Location” warning. SNAPSHOT: “Sales continue — reconciled at review” | BRD 4.6.4 |
| Count entry table | Table | Product, Variant, Expected Qty, Counted Qty, Variance, Variance %, Count Method (Scanner/Manual), Notes | BRD 4.6.3 |
| Scanner input | Panel | Active barcode scanner listener. Each scan increments counted_qty by 1. Beep + product name confirmation | BRD 4.6.5 |
| Manual entry toggle | Button | Switch to manual qty entry for items with damaged/missing barcodes | BRD 4.6.5 |
| Section progress | Panel | For scoped counts: progress bar showing sections/categories completed vs. remaining | BRD 4.6.1 |
| Unrecognized barcode handler | Modal | “Unknown barcode — search product manually?” with product search | BRD 4.6.5 |
| Out-of-scope warning | Badge | Products scanned that are not in count scope — option to add | BRD 4.6.5 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────┐
│ STOCK COUNT: CNT-2026-00031 [IN_PROGRESS ●] [FREEZE MODE ⚠] │
│ Location: Store A | Type: Cycle Count | Scope: Men's Tops │
│ Assigned: Sarah M. | Started: 2026-03-01 08:15 │
├─────────────────────────────────────────────────────────────────────────┤
│ ⚠ FREEZE MODE ACTIVE — Sales suspended at Store A │
├─────────────────────────────────────────────────────────────────────────┤
│ SCANNER INPUT: [Scan barcode or type SKU... ] [Manual Mode] │
│ Last scan: "Classic Oxford Shirt - M/Blue" → Count: 23 │
├─────────────────────────────────────────────────────────────────────────┤
│ SECTION PROGRESS: Men's Tops [████████░░░░] 67% │
│ ┌──────────────────────┬──────┬─────────┬──────────┬──────┬─────────┐ │
│ │ Product / Variant │ Exp. │ Counted │ Variance │ Var% │ Method │ │
│ ├──────────────────────┼──────┼─────────┼──────────┼──────┼─────────┤ │
│ │ Oxford Shirt - S/Blu │ 15 │ 14 │ -1 │ -6.7%│ Scanner │ │
│ │ Oxford Shirt - M/Blu │ 22 │ 23 │ +1 │ +4.5%│ Scanner │ │
│ │ Oxford Shirt - L/Blu │ 18 │ 18 │ 0 │ 0.0%│ Scanner │ │
│ │ Oxford Shirt - XL/Bl │ 8 │ 6 │ -2 │ -25%│ Scanner │ │
│ │ Classic Polo - S/Wht │ 12 │ -- │ -- │ -- │ Pending │ │
│ │ Classic Polo - M/Wht │ 20 │ -- │ -- │ -- │ Pending │ │
│ │ Classic Polo - L/Wht │ 15 │ -- │ -- │ -- │ Pending │ │
│ └──────────────────────┴──────┴─────────┴──────────┴──────┴─────────┘ │
├─────────────────────────────────────────────────────────────────────────┤
│ Counted: 4/7 items | Total scans: 61 | High variance: 1 item │
├─────────────────────────────────────────────────────────────────────────┤
│ [Save Progress] [Submit for Review ▶] [Cancel] │
└─────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Submit for Review” | SCR-M04-13 Count Results Review (auto-navigates) | CASHIER+ |
| Click “Save Progress” | Stays on page; progress saved, count remains IN_PROGRESS | CASHIER+ |
| Click “Cancel” | Stays on page; confirmation dialog. Status → CANCELLED. If FREEZE: sales resume | MANAGER+ |
| Scan barcode | Stays on page; increments counted_qty for matched product | CASHIER+ |
G.7.10 Count Freeze Manager
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-10 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.6.4 |
| Database Tables | inventory_levels (R), locations (R), products (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.count.command.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/counts/freeze |
Purpose
Manages the FREEZE mode for inventory counts, controlling which locations have sales suspended during counting. Shows active freezes across all locations, provides ability to release freeze early, and manages queued transfers that are held until count completion.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Active freezes list | Table | Location, Count ID, Count Type, Start Time, Duration, Status, Queued Transfers count | BRD 4.6.4 |
| Freeze impact preview | Panel | Before starting freeze: estimated duration, expected revenue impact, affected terminals count | BRD 4.6.4 |
| Queued transfers | Table | Transfers held during freeze — to/from the frozen location | BRD 4.6.4 |
| POS terminal status | Panel | Per-terminal display at frozen location: “Count Mode — Sales Suspended” message active | BRD 4.6.4 |
| Emergency release | Button | Release freeze early — requires reason. Queued transfers process immediately | BRD 4.6.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Release Freeze” | Stays on page; sales resume at location, queued transfers process | MANAGER+ |
| Click count row | SCR-M04-09 Stock Count Session | MANAGER+ |
| Click queued transfer | SCR-M04-17 Transfer Create/Track (transfer detail) | MANAGER+ |
G.7.11 Scanner Count Entry
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-11 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 4.6.5 |
| Database Tables | inventory_levels (R), products (R), variants (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.count.command.service |
| User Roles | ALL |
| Offline Capable | Yes (counts stored locally; synced when online) |
| Route | /pos/count/:id |
Purpose
POS-optimized barcode scanning interface for inventory counting. Designed for handheld scanner use with large text, audio feedback, and minimal interaction. Each scan increments the count by 1. Supports fallback to manual entry for items with damaged barcodes.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Scanner listener | Panel | Active barcode input field. Auto-focus. Each scan: beep + product name + running count | BRD 4.6.5 |
| Running count display | Panel | Large font: “Product XYZ: 23 counted” with +1 animation on each scan | BRD 4.6.5 |
| Count progress | Panel | Items counted vs. total expected items. Progress bar | BRD 4.6.5 |
| Manual entry mode | Button | Switch to keyboard qty input for specific product | BRD 4.6.5 |
| Unknown barcode modal | Modal | Product search by SKU/name when barcode not recognized | BRD 4.6.5 |
| Count summary | Panel | Total scans, unique products counted, estimated completion % | BRD 4.6.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Submit for Review” | SCR-M04-13 Count Results Review | CASHIER+ |
| Click “Save & Exit” | Stays on count list; progress saved | CASHIER+ |
| Scan barcode | Stays on page; increments count for matched product | ALL |
G.7.12 RFID Count Manager
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-12 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.6.8 |
| Database Tables | rfid_scan_sessions (RW), rfid_scan_events (R), session_operators (RW), rfid_tags (R), inventory_levels (R), locations (R) |
| State Machine(s) | RFID Count Session (same as stock count lifecycle) |
| Appendix F Services | inventory.count.command.service, inventory.count.query.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No (management side; Raptag mobile handles offline scanning) |
| Route | /inventory/counts/rfid |
Purpose
Management-side interface for RFID-assisted counting sessions conducted via the Raptag mobile application. Managers create RFID count sessions, assign sections to operators (up to 10), monitor real-time upload progress, review chunked sync status, and manage server-side deduplication of overlapping reads.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| RFID session list | Table | Session ID, Location, Type (full_inventory/cycle_count/spot_check), Status, Operator Count, Tag Reads, Sync % | BRD 4.6.8 |
| Operator assignment | Panel | Add operators (up to 10), assign sections (e.g., “Sarah: Men’s Tops”), track join/leave status | BRD 4.6.8 |
| Chunked upload monitor | Panel | Per-operator: chunks uploaded, chunks pending, sync errors. 5,000 events per chunk | BRD 4.6.8 |
| Dedup results | Panel | Tags scanned by multiple operators with RSSI comparison. Shows which read was kept | BRD 4.6.8 |
| Session completion check | Badge | “All operators submitted” or “Waiting for: James (2 chunks pending)” | BRD 4.6.8 |
| EPC-to-product mapping | Table | EPC reads matched to products/variants via rfid_tag_mappings | BRD 4.6.8 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New RFID Session” | Session creation form (location, type, operator assignment) | MANAGER+ |
| Click “View Results” | SCR-M04-13 Count Results Review (RFID session data) | MANAGER+ |
| Click “Remove Operator” | Stays on page; operator removed from session (data preserved) | MANAGER+ |
| Click operator row | Operator detail: sections assigned, chunks uploaded, tags read | MANAGER+ |
G.7.13 Count Results Review
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-13 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.6 |
| Database Tables | inventory_levels (R), inventory_transactions (R), products (R), variants (R) |
| State Machine(s) | Count Lifecycle (REVIEW stage) |
| Appendix F Services | inventory.count.query.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/counts/:id/review |
Purpose
Presents the variance report from a completed stock count for manager review before adjustments are applied. Shows expected vs. counted quantities for every product in scope, highlights high-variance items, and provides per-line accept/reject controls. For SNAPSHOT mode, displays reconciliation adjustments for sales and transfers that occurred during the count.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Variance summary | Panel | Total items counted, items with variance, total positive variance, total negative variance, net variance, variance value at cost | BRD 4.6.3 |
| Variance table | Table | Product, Variant, Expected Qty, Counted Qty, Variance, Variance %, Count Method (Scanner/Manual/RFID), Accept/Reject toggle, Notes | BRD 4.6.3 |
| High-variance highlight | Badge | Red highlight on lines where variance % exceeds shrinkage_threshold_pct (default 5%) | BRD 4.16.1 |
| SNAPSHOT reconciliation | Panel | For SNAPSHOT mode: shows sales_during_count, receives_during_count, adjusted_expected_qty | BRD 4.6.4 |
| Cost impact preview | Panel | Total cost impact of accepting all variances (positive and negative) | BRD 4.7.2 |
| Count method breakdown | Panel | Percentage of items counted via Scanner vs. Manual vs. RFID | BRD 4.6.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Approve All Adjustments” | SCR-M04-14 Count Approval (confirmation) | MANAGER+ |
| Click “Reject” on individual line | Stays on page; line excluded from adjustment | MANAGER+ |
| Click “Request Recount” | SCR-M04-09 Stock Count Session (new count for specific items) | MANAGER+ |
| Click “Export Variance Report” | Downloads CSV/PDF of variance data | MANAGER+ |
G.7.14 Count Approval
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-14 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.6 |
| Database Tables | inventory_levels (RW), inventory_transactions (RW), products (R), variants (R) |
| State Machine(s) | Count Lifecycle (REVIEW → APPROVED) |
| Appendix F Services | inventory.count.command.service, inventory.level.adjustment.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/counts/:id/approve |
Purpose
Final confirmation step for applying count variance adjustments to inventory. Displays a summary of all accepted adjustments, the total cost impact, and requires manager confirmation. On approval, inventory quantities are updated, COUNT_ADJUST movements are logged, and if FREEZE mode was active, sales resume at the location.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Adjustment summary | Panel | Total lines to adjust, net quantity change, total cost impact (at WAC), approved_by field | BRD 4.6 |
| Accepted adjustments list | Table | Product, Variant, Old Qty, New Qty, Change, Cost Impact — only accepted lines shown | BRD 4.6.3 |
| Freeze release indicator | Badge | “Approving will release FREEZE and resume sales at [Location]” (if FREEZE mode) | BRD 4.6.4 |
| Confirmation checkbox | Input | “I confirm these inventory adjustments are accurate” — required before approve button enables | BRD 4.6 |
| Movement log preview | Panel | Preview of COUNT_ADJUST movement records that will be created | BRD 4.12.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Approve & Apply” | Stays on page; inventory updated, movements logged, count status → APPROVED. If FREEZE: sales resume | MANAGER+ |
| Click “Back to Review” | SCR-M04-13 Count Results Review | MANAGER+ |
| Click “Cancel Count” | Stays on page; count status → CANCELLED, no inventory changes. If FREEZE: sales resume | MANAGER+ |
G.7.15 Adjustment Request
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-15 |
| Product(s) | All Roles |
| BRD Section(s) | 4.7 |
| Database Tables | inventory_levels (R), inventory_transactions (R), products (R), variants (R), locations (R) |
| State Machine(s) | Adjustment Workflow (PENDING → APPROVED / REJECTED) |
| Appendix F Services | inventory.level.adjustment.service |
| User Roles | ALL (create); MANAGER+ (approve) |
| Offline Capable | No — requires server for submission |
| Route | /inventory/adjustments/new |
Purpose
Staff submit manual inventory adjustment requests when they discover discrepancies between system quantities and physical reality outside of a formal stock count. All adjustments require manager approval before inventory changes. Supports positive adjustments (found stock) and negative adjustments (shrinkage, damage, theft).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Product selector | Input | Search by SKU, barcode, or name. Shows current available qty at selected location | BRD 4.7.2 |
| Location selector | Input | Location where adjustment applies | BRD 4.7.2 |
| Qty change | Input | Positive (found stock) or negative (shrinkage/damage). Must not be 0 | BRD 4.7.2 |
| Reason code selector | Input | Standard codes: DAMAGED, THEFT, COUNT_CORRECTION, SAMPLE, WRITE_OFF, FOUND_STOCK, RETURN_TO_STOCK, OTHER + tenant custom codes | BRD 4.7.3 |
| Notes | Input | Free-text explanation. Mandatory when reason code = OTHER or custom code has requires_notes=true | BRD 4.7.2 |
| Cost impact preview | Panel | Calculated: qty_change x weighted_avg_cost. Shows dollar impact before submission | BRD 4.7.2 |
| Pending adjustments | Table | List of user’s pending adjustments with status tracking | BRD 4.7.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Submit for Approval” | Stays on page; adjustment created as PENDING. Notification sent to managers | ALL |
| Click pending adjustment row | Adjustment detail view (read-only until approved/rejected) | ALL |
| Click “View History” | SCR-M04-22 Inventory Reports (adjustment history filter) | MANAGER+ |
G.7.16 Adjustment Approval
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-16 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.7 |
| Database Tables | inventory_levels (RW), inventory_transactions (RW), products (R), variants (R) |
| State Machine(s) | Adjustment Workflow (PENDING → APPROVED / REJECTED) |
| Appendix F Services | inventory.level.adjustment.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/adjustments/approve |
Purpose
Manager queue for reviewing and approving/rejecting pending inventory adjustment requests. Shows adjustment details including product, location, quantity change, reason code, cost impact, and requester information. Approved adjustments immediately update inventory quantities and create movement records.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Pending queue | Table | Adjustment #, Product, Location, Qty Change, Reason Code, Requested By, Date, Cost Impact, Days Pending | BRD 4.7.1 |
| Adjustment detail | Panel | Full detail: current qty, proposed new qty, reason, notes, cost impact, requester info | BRD 4.7.2 |
| Approve button | Button | Applies adjustment to inventory, creates ADJUSTMENT_UP or ADJUSTMENT_DOWN movement | BRD 4.7.1 |
| Reject button | Button | Requires rejection reason. Inventory unchanged. Requester notified | BRD 4.7.1 |
| Concurrent adjustment warning | Badge | Warning when multiple pending adjustments exist for same product+location | BRD 4.7.4 |
| Approval history | Table | Recently approved/rejected adjustments for audit trail | BRD 4.7.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Approve” | Stays on page; inventory updated, movement logged, requester notified | MANAGER+ |
| Click “Reject” | Rejection reason modal; inventory unchanged, requester notified | MANAGER+ |
| Click product link | SCR-M04-02 Inventory List (filtered to product+location) | MANAGER+ |
| Click “View All History” | SCR-M04-22 Inventory Reports (adjustment history) | MANAGER+ |
G.7.17 Transfer Create / Track
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-17 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.8 |
| Database Tables | transfer_orders (RW), transfer_order_items (RW), inventory_levels (RW), inventory_transactions (RW), locations (R), products (R), variants (R) |
| State Machine(s) | Transfer Lifecycle (REQUESTED → APPROVED → PICKING → SHIPPED → IN_TRANSIT → RECEIVED → COMPLETED / REJECTED / CANCELLED) |
| Appendix F Services | inventory.transfer.command.service, inventory.transfer.query.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/transfers |
Purpose
Creates and tracks inter-store inventory transfers. Supports both Pull model (destination requests from source) and Push model (HQ pushes to stores). Full lifecycle from request through pick, ship, transit, receive, and verification. Includes auto-suggest transfer recommendations based on sales velocity imbalances.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Transfer list | Table | Transfer #, Source, Destination, Direction (PULL/PUSH), Status, Priority, Shipped Date, Items Count, Tracking # | BRD 4.8.3 |
| Status filter tabs | Panel | All, Requested, Approved, Picking, Shipped, In-Transit, Received, Completed, Cancelled | BRD 4.8.1 |
| Create transfer form | Modal | Source location, Destination location, Direction (PULL/PUSH), Priority (Normal/Urgent/Customer Request), line items (product, qty_requested) | BRD 4.8.3 |
| Auto-suggest panel | Panel | System recommendations: product, source (highest stock), destination (lowest stock), suggested qty, days-of-supply comparison | BRD 4.8.7 |
| Pick list | Panel | For PICKING status: items to pick with scan verification | BRD 4.8.1 |
| Ship confirmation | Panel | Enter qty_shipped per line, tracking_number, carrier. Source inventory decremented | BRD 4.8.4 |
| Receive confirmation | Panel | Enter qty_received per line, condition (GOOD/DAMAGED/WRONG_ITEM), variance notes | BRD 4.8.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Transfer” | Transfer creation modal | MANAGER+ |
| Click “Approve” on requested transfer | Stays on page; status → APPROVED, pick list generated | MANAGER+ (source location) |
| Click “Ship” | Stays on page; inventory decremented at source, status → SHIPPED | MANAGER+ |
| Click “Receive” | Receive confirmation panel; inventory incremented at destination | MANAGER+ |
| Click “View Suggestions” | Auto-suggest panel with rebalancing recommendations | MANAGER+ |
G.7.18 Reorder Configuration
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-18 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.5 |
| Database Tables | inventory_levels (RW), products (R), variants (R), locations (R), purchase_orders (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.reorder.engine.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/reorder |
Purpose
Configures automatic reorder point calculation and draft PO generation. Displays velocity-based reorder points per product per location, allows static overrides by managers, monitors dead stock detection, and shows auto-generated draft POs for review. Controls the reorder engine’s background job schedule and parameters.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Reorder points table | Table | Product, Location, Current Qty, Calculated Reorder Point, Override Value (if set), Days of Supply, Velocity (90d), Override Active (Y/N) | BRD 4.5.1 |
| Static override form | Modal | Product, Location, override_reorder_point, override_reason (mandatory). Visual indicator shows calculated vs. override | BRD 4.5.3 |
| Auto-PO queue | Table | Auto-generated draft POs awaiting review. Badge: “N draft POs auto-generated” | BRD 4.5.2 |
| Dead stock list | Table | Products with zero velocity for 90+ days. Actions: Markdown, Transfer, Write-Off, Dismiss | BRD 4.5.4 |
| Override audit | Table | Active overrides: product, location, override value, calculated value, reason, set by, set date, days active | BRD 4.5.5 |
| Reorder engine config | Panel | Safety stock calculation (1.65 sigma), velocity window (90d), check frequency, account_for_open_pos toggle | BRD 4.5.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Set Override” | Override form modal | MANAGER+ |
| Click “Remove Override” | Stays on page; returns to dynamic calculation | MANAGER+ |
| Click auto-PO row | SCR-M04-04 PO Create (auto-generated PO, editable) | BUYER+ |
| Click dead stock “Transfer” | SCR-M04-17 Transfer Create (product pre-filled) | MANAGER+ |
| Click dead stock “Markdown” | SCR-M03-06 Markdown Workflow (product pre-filled) | MANAGER+ |
G.7.19 Reason Codes Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-19 |
| Product(s) | OWNER |
| BRD Section(s) | 4.7.3 |
| Database Tables | inventory_transactions (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.level.adjustment.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/reason-codes |
Purpose
Manages tenant-defined custom reason codes for inventory adjustments, extending the standard set (DAMAGED, THEFT, COUNT_CORRECTION, etc.). Custom codes appear alongside standard codes in all adjustment workflows and reports. Supports direction constraints, mandatory notes, and deactivation (soft delete).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Standard codes table | Table | Code, Display Name, Direction, Description — read-only, system-defined | BRD 4.7.3 |
| Custom codes table | Table | Code, Display Name, Direction (POSITIVE/NEGATIVE/BOTH), Requires Notes, Sort Order, Status (Active/Inactive), Usage Count | BRD 4.7.3 |
| Custom code form | Modal | code (uppercase, alphanumeric + underscore), display_name, description, direction, requires_notes, sort_order | BRD 4.7.3 |
| Usage analytics | Panel | Per-code: adjustment count, total qty impact, total cost impact, last used date | BRD 4.7.5 |
| Code validation | Badge | Prevents duplicate codes and conflicts with standard codes | BRD 4.7.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New Reason Code” | Custom code form modal | MANAGER+ |
| Click custom code row | Custom code form modal (edit mode) | MANAGER+ |
| Click “Deactivate” | Stays on page; code hidden from dropdown but preserved in historical records | MANAGER+ |
G.7.20 Serial Number Tracking
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-20 |
| Product(s) | All Roles |
| BRD Section(s) | 4.10 |
| Database Tables | products (R), variants (R), inventory_levels (R), inventory_transactions (R), locations (R) |
| State Machine(s) | Serial Status (IN_STOCK → SOLD → RETURNED → IN_STOCK / RMA / WRITE_OFF) |
| Appendix F Services | inventory.serial-lot.command.service |
| User Roles | CASHIER, MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/serials |
Purpose
Manages serial number and lot/batch tracking for products that require individual unit traceability. Provides serial number lookup, status history, customer association, and recall support. Enforces serial capture at receiving and sale for serial-tracked products.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Serial search | Input | Search by serial number, product SKU, or customer name. Returns full serial history | BRD 4.10.1 |
| Serial detail | Panel | Serial number, product, current status (IN_STOCK/SOLD/RETURNED/RMA/WRITE_OFF), current location, received_at, received_via, sold_at, sold_to customer, sale_order_id | BRD 4.10.1 |
| Serial status timeline | Panel | Visual timeline of serial status changes with timestamps, actors, and source documents | BRD 4.10.1 |
| Lot inventory table | Table | Lot number, product, location, qty_received, qty_on_hand, qty_sold, received_date, expiry_date, age (days), source PO | BRD 4.10.2 |
| Recall lookup | Panel | Enter lot number → shows all units: in stock (by location), sold (customer, date, order), returned | BRD 4.10.2 |
| Warranty lookup | Panel | Enter serial → shows customer, purchase date, location, order number for warranty claims | BRD 4.10.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click serial in results | Serial detail panel with full history | ALL |
| Click customer link | Customer detail screen (from sales module) | MANAGER+ |
| Click order link | Order detail screen (from sales module) | MANAGER+ |
| Click “Recall Report” | Downloads lot trace report (CSV/PDF) | MANAGER+ |
G.7.21 Vendor RMA
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-21 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.9 |
| Database Tables | purchase_orders (R), inventory_levels (RW), inventory_transactions (RW), products (R), variants (R), brands (R) |
| State Machine(s) | RMA Lifecycle (DRAFT → SUBMITTED → VENDOR_APPROVED/VENDOR_REJECTED → SHIPPED_BACK → CREDIT_RECEIVED/REPLACEMENT_RECEIVED → CLOSED) |
| Appendix F Services | inventory.rma.command.service |
| User Roles | MANAGER, OWNER, BUYER |
| Offline Capable | No |
| Route | /inventory/rma |
Purpose
Manages vendor return merchandise authorizations for both defective returns and overstock returns. Tracks the full RMA lifecycle from draft through vendor response, shipping, and credit/replacement resolution. Inventory is decremented only when items are physically shipped back to the vendor.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| RMA list | Table | RMA #, Vendor, Type (DEFECTIVE_RETURN/OVERSTOCK_RETURN), Status, Source Location, Total Value, Credit Amount, Days Open | BRD 4.9.1 |
| RMA type selector | Input | Defective Return (inspection required) or Overstock Return (vendor agreement required) | BRD 4.9.6 |
| RMA detail form | Panel | RMA header: vendor, source_location, reason, notes. For overstock: vendor_agreement_ref, restocking_fee_pct | BRD 4.9.2 |
| Line items table | Table | Product, Variant, Qty, Unit Cost, Line Total, Condition Notes, Inspection Result | BRD 4.9.2 |
| Inspection result | Input | Per-line: CONFIRMED_DEFECTIVE, COSMETIC_DAMAGE, NOT_AS_DESCRIBED, NOT_INSPECTED (overstock only) | BRD 4.9.2 |
| Credit calculator | Panel | For overstock: Gross Credit, Restocking Fee (%), Restocking Fee Amount, Net Credit | BRD 4.9.6 |
| Status timeline | Panel | Visual progression through RMA states with timestamps | BRD 4.9.1 |
| Ship back form | Panel | Tracking number, ship date. Inventory decremented on ship | BRD 4.9.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “+ New RMA” | RMA detail form (create mode) | MANAGER+ |
| Click “Submit to Vendor” | Stays on page; status → SUBMITTED, line items locked | MANAGER+ |
| Click “Mark Vendor Approved” | Stays on page; status → VENDOR_APPROVED | MANAGER+ |
| Click “Ship Back” | Ship back form; inventory decremented at source location | MANAGER+ |
| Click “Record Credit” | Credit amount entry; status → CREDIT_RECEIVED | BUYER+ |
| Click “Receive Replacement” | Creates linked PO for replacement items; status → REPLACEMENT_RECEIVED | BUYER+ |
G.7.22 Inventory Reports
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-22 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.17.2 |
| Database Tables | inventory_levels (R), inventory_transactions (R), purchase_orders (R), transfer_orders (R), products (R), variants (R), locations (R), brands (R) |
| State Machine(s) | — |
| Appendix F Services | inventory.report.query.service, inventory.movement.query.service |
| User Roles | MANAGER, OWNER, BUYER (scoped by role) |
| Offline Capable | No |
| Route | /inventory/reports |
Purpose
Centralized inventory reporting hub providing access to all 33 inventory reports defined in the master report suite. Supports filtering by location, category, brand, vendor, date range, and custom grouping keys. All reports exportable to CSV and PDF with role-based access control.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Report catalog | Panel | 33 reports organized by category: Stock Levels, Purchase Orders, Receiving, Counting, Adjustments, Transfers, RMA, Costing, Serial/Lot, Alerts | BRD 4.17.2 |
| Report builder | Panel | Select report → configure filters (location, category, brand, date range) → generate | BRD 4.17.2 |
| Common filters | Input | Location (All/specific), Category, Brand, Vendor, Date Range, Status | BRD 4.17.2 |
| Grouping keys | Input | Per-report grouping options (e.g., by Location, by Category, by Vendor) | BRD 4.17.2 |
| Report output | Table | Tabular results with sortable columns and inline drill-down | BRD 4.17.2 |
| Export options | Button | CSV, PDF. Schedule recurring reports (email delivery) | BRD 4.17.2 |
| Access control indicator | Badge | Shows which reports are available to current user role | BRD 4.17.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click report in catalog | Report builder with report-specific filter options | MANAGER+ (scoped) |
| Click “Export CSV” | Downloads report data as CSV | MANAGER+ |
| Click “Export PDF” | Downloads formatted PDF report | MANAGER+ |
| Click “Schedule Report” | Report scheduling modal (frequency, recipients) | MANAGER+ |
| Click product/SKU in results | SCR-M04-02 Inventory List (filtered) or SCR-M03-02 Product Detail | MANAGER+ |
G.7.23 Reservation Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M04-23 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 4.13 |
| Database Tables | inventory_levels (RW), products (R), variants (R), locations (R) |
| State Machine(s) | Reservation Lifecycle (ACTIVE → COMMITTED / RELEASED / EXPIRED) |
| Appendix F Services | inventory.reservation.command.service |
| User Roles | MANAGER, OWNER |
| Offline Capable | No |
| Route | /inventory/reservations |
Purpose
Manages and monitors all active inventory reservations across five types: Sale Cart, Parked Transaction, Transfer, Online Order, and Hold-for-Pickup. Provides visibility into reserved quantities, allows manual release of stuck reservations, and monitors hold-for-pickup expirations with countdown timers.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Reservation type tabs | Panel | All, Sale Cart, Parked Transaction, Transfer, Online Order, Hold-for-Pickup. Count badges per tab | BRD 4.2.2 |
| Active reservations table | Table | Product, Variant, Location, Qty Reserved, Type, Status, Source Document, Reserved By, Reserved At, Expires At | BRD 4.2.2 |
| Hold-for-pickup countdown | Panel | Orders with pickup holds showing countdown timers. Reminder sent at 2 days before expiry | BRD 4.13.4 |
| Expired reservations | Table | Recently expired reservations: auto-released with reason. Stock returned to AVAILABLE | BRD 4.2.2 |
| Manual release | Button | Release stuck reservation (e.g., abandoned cart, system error). Requires reason | BRD 4.2.2 |
| Reservation impact summary | Panel | Total reserved qty across all types, breakdown by location, impact on available stock | BRD 4.1.5 |
| Parked sale warnings | Panel | Products with parked sale reservations showing terminal and sale reference | BRD 4.13.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click “Release” on reservation | Stays on page; reservation released, stock returns to AVAILABLE. Requires reason | MANAGER+ |
| Click source document link | Navigates to source: sale detail, parked sale, transfer, or order | MANAGER+ |
| Click “Extend Hold” on pickup | Stays on page; extends hold expiry (max 30 days total) | MANAGER+ |
| Click “View Expired” | Switches to expired reservations table | MANAGER+ |
G.8 Module 5: Setup & Configuration Screens (22 Screens)
BRD Sections: 5.1-5.21 | Appendix F: §F.8 (21 services) | Pattern: Standard CRUD
Cross-Reference: See Ch 05, Sections 5.1-5.21 for business rules. See Ch 08, Domains 8-16 for table schemas. See Appendix F, §F.8 for service breakdown.
G.8.1 Onboarding Wizard (13 Steps)
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-01 |
| Product(s) | OWNER |
| BRD Section(s) | 5.20 |
| Database Tables | tenants (R/W), locations (R/W), users (R/W), registers (R/W), tax_jurisdictions (R/W), tax_rates (R/W), tenant_settings (R/W), roles (R/W), role_permissions (R/W), devices (R/W), payment_terminals (R/W) |
| State Machine(s) | 5.20.4 Onboarding State Tracking |
| Appendix F Services | setup.onboarding.wizard.service, setup.settings.crud.service, setup.location.crud.service, setup.user.crud.service, setup.register.crud.service, setup.tax.crud.service |
| User Roles | OWNER |
| Offline Capable | No |
| Route | /admin/onboarding |
Purpose
The Onboarding Wizard is a 13-step guided setup workflow that provisions a new tenant from initial registration through operational go-live readiness. It ensures every required configuration area is addressed before the tenant begins processing transactions. Steps 1-5 and 7 and 9 are mandatory for go-live; remaining steps are recommended but deferrable.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Step indicator bar | Panel | Horizontal progress bar showing all 13 steps with completion status (green = complete, grey = pending, yellow = skipped) | BRD 5.20.1 |
| Current step content | Panel | Dynamic content area rendering the form fields for the active step | BRD 5.20.2 |
| Step navigation buttons | Button | “Back” and “Next” buttons to move between steps; “Skip” for optional steps | BRD 5.20.2 |
| Go-Live Checklist panel | Panel | Step 13: automated validation of 9 mandatory checks and 7 advisory checks with pass/fail indicators | BRD 5.20.3 |
| Mandatory check indicators | Badge | Red/green badges for each of the 9 mandatory checks (locations, registers, users, tax, payment, email, currency, timezone) | BRD 5.20.3 |
| Advisory warning list | Panel | Yellow warnings for recommended but non-blocking checks (Shopify, label printers, receipt customisation) | BRD 5.20.3 |
| “Go Live” button | Button | Enabled only when all 9 mandatory checks pass; activates tenant for live operations | BRD 5.20.3 |
| Progress persistence | Badge | Displays “Step X of 13” with auto-save; wizard state restored on return | BRD 5.20.4 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXUS ADMIN — Onboarding Wizard [Will] [?] [X] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ● ● ● ● ● ○ ○ ○ ○ ○ ○ ○ ○ Step 5 of 13: Users & Roles │
│ 1 2 3 4 5 6 7 8 9 10 11 12 13 │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CREATE USERS │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Display Name [________________________] │ │
│ │ Email [________________________] │ │
│ │ PIN (4-6 digit) [______] │ │
│ │ Role [OWNER ▼] │ │
│ │ Location [Garden Mall ▼] ☑ Primary │ │
│ │ [+ Add User] │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ CREATED USERS │
│ ┌──────────────────┬──────────┬─────────┬──────────────┐ │
│ │ Name │ Role │ PIN │ Location │ │
│ ├──────────────────┼──────────┼─────────┼──────────────┤ │
│ │ Will Johnson │ OWNER │ **** │ Garden Mall │ │
│ │ Sarah Adams │ MANAGER │ **** │ Heritage Mall│ │
│ └──────────────────┴──────────┴─────────┴──────────────┘ │
│ │
│ [◄ Back] [Skip] [Next ►] │
└─────────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Complete Step 13 + Go Live | SCR-M05-02 System Settings | OWNER |
| Skip optional step | Next wizard step | OWNER |
| Click “Back” | Previous wizard step | OWNER |
| Abandon wizard (close) | Nexus POS Dashboard (wizard progress saved) | OWNER |
G.8.2 System Settings / Branding
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-02 |
| Product(s) | OWNER |
| BRD Section(s) | 5.2 |
| Database Tables | tenant_settings (R/W), tenants (R) |
| State Machine(s) | None |
| Appendix F Services | setup.settings.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/general |
Purpose
The System Settings screen provides tenant-level identity, operational defaults, and visual branding configuration. Administrators configure the company name, logo, timezone, date format, session policies, and customer-facing visual identity. Settings are organised into three tabs: Core, Operational, and Branding.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Tenant name input | Input | Trading name displayed in headers and reports (max 100 chars) | BRD 5.2.1 |
| Legal entity name input | Input | Registered business name for invoices (max 200 chars) | BRD 5.2.1 |
| Company logo upload | Input | PNG/SVG upload with preview (max 2MB, min 200x200px) | BRD 5.2.1 |
| Default timezone selector | Input | IANA timezone dropdown; applies to all locations unless overridden | BRD 5.2.1 |
| Base currency display | Badge | Read-only after first transaction; shows ISO 4217 code (e.g., USD) | BRD 5.2.1 — immutable after first transaction |
| Auto-logout timeout | Input | Minutes of inactivity before POS session logout (5-120 range) | BRD 5.2.2 |
| Failed login lockout | Input | Max consecutive failed attempts before lockout (default: 5) | BRD 5.2.2 |
| Primary/Accent colour pickers | Input | Hex colour selectors for brand_primary_color and brand_accent_color | BRD 5.2.4 |
| Login background image upload | Input | JPG/PNG (max 5MB, 1920x1080 recommended) | BRD 5.2.4 |
| Business hours table | Table | Per-location, per-day-of-week open/close times with is_closed toggle | BRD 5.2.3 |
| Holiday calendar | Table | Date, name, applies_to, modified hours, recurring flag | BRD 5.2.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save Settings | Same screen (confirmation toast) | ADMIN+ |
| Upload logo | Same screen (preview updates) | ADMIN+ |
| Manage Business Hours | Inline editor expands | ADMIN+ |
| Manage Holiday Calendar | Modal dialog | ADMIN+ |
G.8.3 Location Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-03 |
| Product(s) | OWNER |
| BRD Section(s) | 5.4 |
| Database Tables | locations (R/W), tax_jurisdictions (R), tenant_settings (R) |
| State Machine(s) | None |
| Appendix F Services | setup.location.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/locations |
Purpose
The Location Management screen defines the physical topology of the tenant’s retail operation. Administrators create, edit, and deactivate store locations and warehouse facilities. Every inventory balance, register, user assignment, and transaction is scoped to a location, making this a foundational configuration screen.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Location list table | Table | Sortable table of all locations with code, name, type, city, status | BRD 5.4 |
| Add Location button | Button | Opens modal to create new STORE or WAREHOUSE location | BRD 5.4.1 |
| Location type selector | Input | Dropdown: STORE or WAREHOUSE; warehouses cannot have registers | BRD 5.4.1 |
| Address fields | Input | Street, city, state, ZIP, country (ISO 3166-1 alpha-2) | BRD 5.4.2 |
| Tax jurisdiction assignment | Input | Dropdown linking location to a tax_jurisdiction record | BRD 5.4.2 — tax_jurisdiction_id required |
| Timezone override | Input | IANA timezone; overrides tenant default for this location | BRD 5.4.2 |
| Franchise flag | Input | Checkbox for is_franchise (different reporting/fee rules) | BRD 5.4.2 |
| Sort order | Input | Integer for display ordering in dropdowns and reports | BRD 5.4.2 |
| Deactivate toggle | Button | Sets is_active=false; prevents new transactions at location | BRD 5.4.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Location | Modal form (stays on screen) | ADMIN+ |
| Edit Location | Inline edit / modal | ADMIN+ |
| View Location Registers | SCR-M05-08 Register Management (filtered) | ADMIN+ |
| Deactivate Location | Confirmation dialog | OWNER |
G.8.4 User Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-04 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 5.5 |
| Database Tables | tenant_users (R/W), users (R/W), roles (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.user.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/users |
Purpose
The User Management screen provides creation, editing, and deactivation of user accounts across the tenant. Each user represents a staff member who interacts with the POS system. Administrators assign roles, set PINs, configure location assignments, and manage commission rates from this screen.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| User list table | Table | All users with display name, email, role, primary location, status, last login | BRD 5.5.1 |
| Add User button | Button | Opens form to create new user with email, PIN, role, and location | BRD 5.5.1 |
| Email input | Input | Unique per tenant; used for standard login | BRD 5.5.1 |
| PIN input | Input | 4-6 digit numeric PIN for POS login; unique per tenant; stored as bcrypt hash | BRD 5.5.1 |
| Role selector | Input | Dropdown: OWNER, ADMIN, MANAGER, STAFF, BUYER | BRD 5.5.3 |
| Location assignment panel | Panel | Multi-select locations with primary location toggle | BRD 5.5.2 |
| Commission rate input | Input | Decimal percentage (e.g., 5.00 for 5%); null = no commission | BRD 5.5.1 |
| Deactivate/Reactivate toggle | Button | Sets is_active; deactivation invalidates all active sessions | BRD 5.5.1 |
| Failed login count badge | Badge | Shows current failed_login_count and locked_until status | BRD 5.5.1 |
| Reset lockout button | Button | Clears failed_login_count and locked_until for locked users | BRD 5.5.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add User | Modal form (stays on screen) | ADMIN+ |
| Edit User | Inline edit / detail panel | ADMIN+ |
| View User Activity | SCR-M05-21 Audit Log Viewer (filtered by user) | ADMIN+ |
| Manage Roles | SCR-M05-05 Role & Permission Config | ADMIN+ |
G.8.5 Role & Permission Config
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-05 |
| Product(s) | OWNER |
| BRD Section(s) | 5.5 |
| Database Tables | roles (R/W), role_permissions (R/W), tenant_users (R) |
| State Machine(s) | None |
| Appendix F Services | setup.role.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/roles |
Purpose
The Role & Permission Config screen provides granular control over which capabilities each role has access to. Administrators view and edit the feature toggle matrix for all five predefined roles (OWNER, ADMIN, MANAGER, STAFF, BUYER) and can create custom roles by duplicating and modifying an existing role’s permissions.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Role list sidebar | Panel | List of all roles with user count badge per role | BRD 5.5.3 |
| Feature toggle matrix | Table | Grid of feature_code rows x role columns with toggle switches | BRD 5.5.4 |
| Permission description | Panel | Shows description of selected feature code on hover/click | BRD 5.5.4 |
| OWNER lock indicator | Badge | Shows locked toggles for OWNER role (manage_settings, manage_users always true) | BRD 5.5.4 — OWNER cannot lose manage_settings/manage_users |
| Duplicate role button | Button | Creates custom role from existing role’s toggles (is_system=false) | BRD 5.5.4 |
| User count per role | Badge | Number of active users assigned to each role | BRD 5.5.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Toggle feature permission | Same screen (real-time save) | ADMIN+ |
| Create custom role | Same screen (new role appears in sidebar) | ADMIN+ |
| Delete custom role | Confirmation dialog (only if no users assigned) | OWNER |
| View users with role | SCR-M05-04 User Management (filtered by role) | ADMIN+ |
G.8.6 Login / Authentication
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-06 |
| Product(s) | All Roles |
| BRD Section(s) | 5.5 |
| Database Tables | users (R), tenant_users (R), roles (R), role_permissions (R), tenant_settings (R) |
| State Machine(s) | 7.12 Connectivity States |
| Appendix F Services | crosscutting.auth.service |
| User Roles | All (unauthenticated) |
| Offline Capable | Yes (POS terminal only) — Cached credentials allow PIN login when API is unreachable; standard email/password login requires connectivity |
| Route | /login |
Purpose
The Login screen provides two authentication flows: email/password for standard access and PIN-based quick login for POS terminal cashiers. The POS PIN login supports offline operation using cached credential hashes stored in the local SQLite WASM database, enabling staff to continue working during network outages.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Company logo | Panel | Displays tenant’s company_logo_url with login_tagline below | BRD 5.2.4 |
| Branded background | Panel | Custom login_bg_image_url or system default | BRD 5.2.4 |
| Email input | Input | Email address for standard login | BRD 5.5.5 |
| Password input | Input | Password validated against Argon2id hash | BRD 5.5.1 |
| PIN input (POS) | Input | 4-6 digit numeric PIN pad with large touch targets | BRD 5.5.5 |
| Lockout warning | Badge | Displays remaining lockout time when account is locked | BRD 5.2.2 — lockout_duration_minutes |
| Offline indicator (POS) | Badge | Shows “Offline Mode” banner when API is unreachable | BRD 5.5.5 / ADR-048 |
| Forgot password link | Button | Triggers TMPL-PASSWORD-RESET email | BRD 5.15.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Successful standard login | Dashboard (role-appropriate home) | Any authenticated |
| Successful POS PIN login | POS Terminal Home / SCR-M05-07 Clock-In | Any authenticated |
| Failed login (max attempts) | Same screen with lockout message | N/A |
| Forgot Password | Same screen (email sent confirmation) | N/A |
G.8.7 Clock-In / Clock-Out
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-07 |
| Product(s) | CASHIER+ |
| BRD Section(s) | 5.6 |
| Database Tables | clock_records (R/W), tenant_users (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.timetracking.command.service |
| User Roles | All POS users |
| Offline Capable | Yes — Clock events queued locally and synced when connectivity restores |
| Route | /pos/clock |
Purpose
The Clock-In/Clock-Out screen records staff work time for basic payroll reporting. Staff clock in via PIN after POS login and clock out at the end of their work period. Managers can view current clock-in status for all staff at the location and manually edit missed clock-out entries with audit notes.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Clock-In button | Button | Records clock_in timestamp for authenticated user at current location | BRD 5.6.1 |
| Clock-Out button | Button | Records clock_out timestamp; required before end-of-day close | BRD 5.6.1 |
| Current status indicator | Badge | Shows “Clocked In since HH:MM” or “Not Clocked In” | BRD 5.6.1 |
| Duration timer | Panel | Running timer showing elapsed time since clock-in | BRD 5.6.1 |
| Manager override panel | Panel | Allows MANAGER+ to manually set clock-out time with required notes field | BRD 5.6.1 |
| Staff clock status list | Table | (Manager view) All users at location with current clock-in/out status | BRD 5.6.1 |
| 16-hour alert indicator | Badge | Warning when clock-in exceeds 16 hours without clock-out | BRD 5.6.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Clock In | POS Terminal Home | All POS users |
| Clock Out | POS Login screen | All POS users |
| Manager: Edit missed clock-out | Same screen (inline edit with notes) | MANAGER+ |
| View clock history | Same screen (expandable history panel) | MANAGER+ |
G.8.8 Register Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-08 |
| Product(s) | OWNER |
| BRD Section(s) | 5.7 |
| Database Tables | registers (R/W), register_ip_changes (R/W), devices (R/W), locations (R), register_profiles (R) |
| State Machine(s) | 5.7.2 Register State Machine (ACTIVE / MAINTENANCE / RETIRED) |
| Appendix F Services | setup.register.crud.service, setup.device.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/registers |
Purpose
The Register Management screen maintains the register registry across all tenant locations. Administrators create registers, assign profiles (Full POS or Mobile), pair physical devices, manage IP addresses, and control register status through the ACTIVE/MAINTENANCE/RETIRED lifecycle. This screen enforces the IP change limit (max 2 per 365 days) and OWNER-only retirement with type-to-confirm safety.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Register list table | Table | Registers grouped by location with number, name, profile, status, IP, last seen | BRD 5.7.1 |
| Location filter | Input | Dropdown to filter registers by location | BRD 5.7.1 |
| Add Register button | Button | Create register with number, name, profile, and location | BRD 5.7.1 |
| Status badge | Badge | Colour-coded: green=ACTIVE, yellow=MAINTENANCE, red=RETIRED | BRD 5.7.2 |
| IP address field | Input | IPv4/IPv6 address; shows remaining IP changes count for the year | BRD 5.7.1 — ERR-5071: max 2 changes per 365 days |
| Take Offline button | Button | Transitions ACTIVE to MAINTENANCE | BRD 5.7.2 |
| Bring Online button | Button | Transitions MAINTENANCE to ACTIVE | BRD 5.7.2 |
| Retire Register button | Button | OWNER-only; type-to-confirm “RETIRE” dialog | BRD 5.7.2 — ERR-5072 |
| Device pairing panel | Panel | Shows paired devices with hardware_id, type, is_primary, last_seen_at | BRD 5.7.3 |
| Peripheral assignments | Table | Receipt printer, label printer, scanner, payment terminal, cash drawer per register | BRD 5.7.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Register | Modal form (stays on screen) | ADMIN+ |
| Change IP Address | Confirmation dialog showing remaining changes | ADMIN+ |
| Retire Register | Type-to-confirm “RETIRE” modal | OWNER only |
| Pair Device | SCR-M05-08 device pairing sub-panel | ADMIN+ |
| View Register Profile | SCR-M05-09 Register Profiles | ADMIN+ |
G.8.9 Register Profiles
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-09 |
| Product(s) | OWNER |
| BRD Section(s) | 5.7 |
| Database Tables | register_profiles (R), registers (R) |
| State Machine(s) | None |
| Appendix F Services | setup.register.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/registers/profiles |
Purpose
The Register Profiles screen displays the two system-defined register profiles (Full POS and Mobile Checkout) and their function availability matrix. This is a reference screen showing which POS functions are available on each terminal type. Custom profiles are not supported to prevent untested UI configurations.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Profile comparison table | Table | Side-by-side matrix of FULL_POS vs MOBILE with function availability (Y/N) | BRD 5.7.4 |
| Profile description | Panel | Description of each profile’s intended use case | BRD 5.7.4 |
| Function list | Table | All 15 POS functions (sale, return, exchange, layaway, etc.) with descriptions | BRD 5.7.4 |
| Registers using profile | Badge | Count of registers assigned to each profile | BRD 5.7.4 |
| Required peripherals indicator | Panel | Shows minimum required peripherals per profile (Full POS: printer+scanner+terminal+drawer; Mobile: scanner+terminal) | BRD 5.7.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| View registers using profile | SCR-M05-08 Register Management (filtered by profile) | ADMIN+ |
| Back to Register Management | SCR-M05-08 Register Management | ADMIN+ |
G.8.10 Register Retirement
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-10 |
| Product(s) | OWNER |
| BRD Section(s) | 5.7 |
| Database Tables | registers (R/W), devices (R/W) |
| State Machine(s) | 5.7.2 Register State Machine |
| Appendix F Services | setup.register.crud.service |
| User Roles | OWNER |
| Offline Capable | No |
| Route | /admin/settings/registers/:id/retire (modal) |
Purpose
The Register Retirement screen is a confirmation modal that enforces the OWNER-only, type-to-confirm safety protocol for permanently decommissioning a register. Retired registers cannot be reactivated, and all transaction history is preserved. This screen displays the full impact warning and requires exact-match text entry of “RETIRE”.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Register details summary | Panel | Shows register number, name, location, profile, and transaction count | BRD 5.7.2 |
| Warning message | Panel | Full text: “This action permanently retires this register. Retired registers cannot be reactivated…” | BRD 5.7.2 |
| Confirmation text input | Input | Text field requiring exact entry of “RETIRE” (case-sensitive) | BRD 5.7.2 — ERR-5072 |
| Confirm Retire button | Button | Disabled until confirmation text matches exactly | BRD 5.7.2 |
| Cancel button | Button | Returns to Register Management without changes | BRD 5.7.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Confirm retirement | SCR-M05-08 Register Management (register now shows RETIRED status) | OWNER only |
| Cancel | SCR-M05-08 Register Management | OWNER |
G.8.11 Payment Terminal Config
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-11 |
| Product(s) | OWNER |
| BRD Section(s) | 5.11, 6.8 |
| Database Tables | payment_terminals (R/W), register_peripherals (R/W), locations (R), registers (R) |
| State Machine(s) | 6.8 Terminal State Machine (active / offline / maintenance / disabled) |
| Appendix F Services | setup.payment-method.crud.service, integration.payment-processor.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/payments/terminals |
Purpose
The Payment Terminal Config screen manages the registration and pairing of physical payment processing devices (card readers, NFC terminals) to registers and locations. Administrators configure processor credentials (via Integration Hub), register terminal hardware, and assign terminals to specific registers. The SAQ-A semi-integrated architecture ensures no card data touches the POS system.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Terminal list table | Table | All payment terminals with terminal_id, name, location, processor, type, status | BRD 5.11.3 / 6.8.2 |
| Add Terminal button | Button | Register new terminal with ID, name, location, processor, and type | BRD 6.8.2 |
| Terminal type selector | Input | integrated, standalone, virtual, mobile | BRD 6.8.2 |
| Processor selector | Input | Dropdown of configured payment processors (Stripe, Square, Adyen) | BRD 6.8.3 |
| Capability badges | Badge | Contactless, EMV chip, swipe support indicators per terminal | Ch 08 Domain 14 |
| Register assignment | Input | Link terminal to a specific register via register_peripherals | BRD 5.7.5 |
| Health status indicator | Badge | Last transaction, last batch, current status | BRD 6.8.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Terminal | Modal form (stays on screen) | ADMIN+ |
| Assign to Register | SCR-M05-08 Register Management | ADMIN+ |
| Test Connection | Same screen (connection test result) | ADMIN+ |
| View Payment Batches | Inline expandable panel | ADMIN+ |
G.8.12 Payment Methods Setup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-12 |
| Product(s) | OWNER |
| BRD Section(s) | 5.11 |
| Database Tables | payment_methods (R/W), location_payment_methods (R/W), locations (R), tenant_settings (R/W) |
| State Machine(s) | None |
| Appendix F Services | setup.payment-method.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/payments/methods |
Purpose
The Payment Methods Setup screen configures which payment methods are accepted across the tenant and per location. Administrators enable/disable CASH, CREDIT_CARD, GIFT_CARD, STORE_CREDIT, LAYAWAY_PAYMENT, and FINANCING methods. Cash rounding rules (nearest cent, nickel, or dime) are also configured here.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Payment method list | Table | All 6 payment types with tenant-level enabled toggle, requires processor flag, split capability, offline support | BRD 5.11.1 |
| Per-location matrix | Table | Grid of locations x payment methods with per-location enable/disable toggles | BRD 5.11.2 |
| CASH lock indicator | Badge | CASH is always enabled and cannot be disabled at any location | BRD 5.11.2 |
| Cash rounding rule selector | Input | Dropdown: NEAREST_CENT, NEAREST_NICKEL, NEAREST_DIME | BRD 5.11.4 |
| Method display name | Input | Customisable name per method (e.g., “Visa/MC/Amex” instead of “Credit/Debit Card”) | BRD 5.11.1 |
| Offline capability indicator | Badge | Shows which methods work offline (CASH only) | BRD 5.11.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Toggle method tenant-wide | Same screen (cascades to all locations) | ADMIN+ |
| Toggle method per location | Same screen | ADMIN+ |
| Configure payment terminals | SCR-M05-11 Payment Terminal Config | ADMIN+ |
| Save rounding rules | Same screen (confirmation toast) | ADMIN+ |
G.8.13 Tax Jurisdiction Config
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-13 |
| Product(s) | OWNER |
| BRD Section(s) | 5.9 |
| Database Tables | tax_jurisdictions (R/W), tax_rates (R/W), location_tax_jurisdictions (R/W), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.tax.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/tax |
Purpose
The Tax Jurisdiction Config screen manages the 3-level compound tax system (State/County/City). Administrators create tax jurisdictions, define rate levels with effective dates, and assign jurisdictions to store locations. Future tax rate changes can be scheduled by setting an effective date in the future. The screen also displays the calculated compound rate for each location.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Jurisdiction list table | Table | All jurisdictions with code, name, state_name, active rates summary, location count | BRD 5.9.1 |
| Add Jurisdiction button | Button | Create jurisdiction with code (e.g., “VA-NFK”) and name | BRD 5.9.1 |
| Rate levels panel | Panel | Up to 3 rate rows per jurisdiction: STATE, COUNTY, CITY with rate_percent and effective_date | BRD 5.9.1 |
| Compound rate calculator | Badge | Shows summed effective rate (e.g., “State 4.3% + County 0.7% + City 1.0% = 6.0%”) | BRD 5.9.1 |
| Future rate indicator | Badge | Scheduled rates highlighted with effective_date and countdown | BRD 5.9.1 |
| Location-jurisdiction assignment | Table | Which jurisdiction is assigned to each store location | BRD 5.4.2 |
| Rate history | Table | Expandable historical rates per jurisdiction level for audit | BRD 5.9.1 |
| Tax reporting period selector | Input | MONTHLY or QUARTERLY dropdown | BRD 5.9.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Jurisdiction | Modal form (stays on screen) | ADMIN+ |
| Add/Edit rate level | Inline edit within jurisdiction panel | ADMIN+ |
| Schedule future rate | Same screen (rate appears with future badge) | ADMIN+ |
| Assign to location | Same screen (location-jurisdiction matrix) | ADMIN+ |
G.8.14 Tax Exemption Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-14 |
| Product(s) | MANAGER+ |
| BRD Section(s) | 5.9 |
| Database Tables | customers (R/W), products (R), tax_jurisdictions (R) |
| State Machine(s) | None |
| Appendix F Services | setup.tax.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/tax/exemptions |
Purpose
The Tax Exemption Management screen provides a consolidated view of all tax-exempt customers and tax-exempt products across the tenant. Administrators can review exemption certificates, check expiry dates, and manage product-level tax exemption flags. Expired certificates are highlighted for follow-up action before the next sale.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Exempt customers table | Table | Customers with tax_exempt=true, certificate number, expiry date, status | BRD 5.9.3 |
| Expired certificate warning | Badge | Red highlight for certificates past expiry_date | BRD 5.9.3 — “Tax exemption certificate expired” warning at POS |
| Certificate number input | Input | State or federal exemption certificate number (max 50 chars) | BRD 5.9.3 |
| Expiry date input | Input | Certificate expiration date; system validates at time of sale | BRD 5.9.3 |
| Exempt products list | Table | Products with tax_exempt=true flag, grouped by category | BRD 5.9.3 |
| Tax calculation priority diagram | Panel | Visual display of priority: Product exempt > Customer exempt > Jurisdiction rate | BRD 5.9.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Edit customer exemption | Customer detail modal | ADMIN+ |
| Toggle product tax exemption | Same screen (inline toggle) | ADMIN+ |
| Export exempt customer list | CSV/PDF download | ADMIN+ |
| View tax calculation priority | Same screen (expandable diagram) | ADMIN+ |
G.8.15 UOM Setup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-15 |
| Product(s) | OWNER |
| BRD Section(s) | 5.10 |
| Database Tables | uom (R/W), uom_conversions (R/W), tenant_settings (R) |
| State Machine(s) | None |
| Appendix F Services | setup.uom.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/uom |
Purpose
The UOM Setup screen manages predefined and tenant-customizable units of measure used for selling, purchasing, and inventory tracking. Administrators review the 12 system-predefined UoMs, create custom UoMs with conversion factors, and manage the conversion table between related units. This enables scenarios where products are purchased in bulk (cases, dozens) and sold individually (each, pair).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| System UoM list | Table | 12 predefined UoMs (EACH, PAIR, PACK, BOX, DOZEN, CASE, YARD, METER, FOOT, KG, LB, OZ) — read-only | BRD 5.10.1 |
| Custom UoM list | Table | Tenant-created UoMs with code, name, category, conversion factor, base UoM | BRD 5.10.2 |
| Add Custom UoM button | Button | Create UoM with code, name, category (QUANTITY/LENGTH/WEIGHT), and conversion factor to base | BRD 5.10.2 |
| Conversion table | Table | From-UoM to To-UoM with factor; auto-generates inverse conversion | BRD 5.10.3 |
| Category filter | Input | Filter UoMs by QUANTITY, LENGTH, or WEIGHT | BRD 5.10.1 |
| Deactivate UoM | Button | Soft-delete custom UoMs (system UoMs cannot be deactivated) | BRD 5.10.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Custom UoM | Modal form (stays on screen) | ADMIN+ |
| Edit Custom UoM | Inline edit | ADMIN+ |
| View products using UoM | Product list filtered by selling_uom or purchasing_uom | ADMIN+ |
G.8.16 Approval Workflows
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-16 |
| Product(s) | OWNER |
| BRD Section(s) | 5.13 |
| Database Tables | approval_rules (R/W), approval_requests (R/W), tenant_users (R), roles (R) |
| State Machine(s) | 5.13.3 Approval Request Lifecycle (PENDING / APPROVED / REJECTED / ESCALATED / AUTO_REJECTED) |
| Appendix F Services | setup.approval-workflow.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/approvals |
Purpose
The Approval Workflows screen configures approval rules that gate sensitive business actions behind manager or administrator review. Each of the 9 approvable actions (PO creation, inventory adjustments, refunds, voids, transfers, price markdowns, discount overrides, vendor RMAs) has its own rule with threshold, approver role, notification method, and escalation timeout.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Approval rules table | Table | 9 approvable actions with enabled toggle, threshold value, threshold type, approver role | BRD 5.13.1 |
| Threshold value input | Input | Dollar amount, unit count, or percentage depending on action | BRD 5.13.2 |
| Threshold type selector | Input | AMOUNT, UNITS, PERCENT, ALWAYS | BRD 5.13.2 |
| Approver role selector | Input | MANAGER, ADMIN, or OWNER minimum role to approve | BRD 5.13.2 |
| Notification method | Input | IN_APP, EMAIL, or BOTH | BRD 5.13.5 |
| Escalation timeout | Input | Hours before escalation to next-higher role (default 24) | BRD 5.13.4 |
| Auto-reject toggle | Input | Whether timed-out requests are auto-rejected | BRD 5.13.2 |
| Pending approvals queue | Table | Current PENDING and ESCALATED requests with action, requester, value, timestamp | BRD 5.13.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Edit approval rule | Inline edit (stays on screen) | ADMIN+ |
| Approve/Reject request | Same screen (request status updated) | MANAGER+ (per rule) |
| View escalation chain | Inline expansion: MANAGER -> ADMIN -> OWNER | ADMIN+ |
G.8.17 Printer Configuration
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-17 |
| Product(s) | OWNER |
| BRD Section(s) | 5.8 |
| Database Tables | printers (R/W), register_printers (R/W), registers (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.printer.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/printers |
Purpose
The Printer Configuration screen manages the central registry of all receipt and label printers across all tenant locations. Administrators register printers, configure connection settings, link printers to registers in specific roles (primary receipt, label, secondary receipt), and monitor printer health status via automated network pings.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Printer list table | Table | All printers with name, type (RECEIPT/LABEL), location, connection, model, health status | BRD 5.8.1 |
| Add Printer button | Button | Manual registration with name, type, connection_type, address, paper_width | BRD 5.8.1 |
| Discover Printers button | Button | Network subnet scan on ports 9100 (RAW) and 631 (IPP); returns candidates | BRD 5.8.5 |
| Health status badge | Badge | ONLINE (green), OFFLINE (red), ERROR (orange), UNKNOWN (grey) | BRD 5.8.6 |
| Connection type selector | Input | USB, NETWORK_IP, BLUETOOTH | BRD 5.8.1 |
| Paper width selector | Input | Receipt: 58MM/80MM; Label: 25x50MM, 50x25MM, 50x75MM, CUSTOM | BRD 5.8.1 |
| Register-printer links | Table | Which registers use this printer and in what role (PRIMARY_RECEIPT, LABEL, SECONDARY_RECEIPT) | BRD 5.8.4 |
| Shared printer toggle | Input | Whether multiple registers can use this printer (network printers only) | BRD 5.8.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Add Printer (manual) | Modal form (stays on screen) | ADMIN+ |
| Discover Printers (network scan) | Discovery results panel on same screen | ADMIN+ |
| Link to Register | Same screen (register-printer assignment panel) | ADMIN+ |
| Test Print | Same screen (sends test page to selected printer) | ADMIN+ |
G.8.18 Receipt Builder
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-18 |
| Product(s) | OWNER |
| BRD Section(s) | 5.14 |
| Database Tables | receipt_config (R/W), email_receipt_templates (R/W), tenant_settings (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.receipt.crud.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/receipts |
Purpose
The Receipt Builder provides full customisation of receipt layout, content, and formatting for both printed thermal receipts and email receipts. Administrators toggle field visibility, reorder sections via drag-and-drop, configure header/footer text and logos, select paper width and font size, and preview changes in real-time with a simulated thermal receipt rendering.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Field toggle list | Panel | All 17 receipt fields with show/hide toggles and drag-and-drop reorder | BRD 5.14.1 |
| Live receipt preview | Panel | Real-time simulated receipt with sample data, monospace font, configured paper width | BRD 5.14.7 |
| Header configuration | Input | Three header lines (max 100 chars each) + logo upload (PNG/BMP, max 300px wide) | BRD 5.14.3 |
| Footer configuration | Input | Three footer lines (max 200 chars each); blank lines omitted | BRD 5.14.4 |
| Paper width selector | Input | 58MM (~32 chars/line) or 80MM (~48 chars/line) | BRD 5.14.2 |
| Font size selector | Input | SMALL, MEDIUM, LARGE | BRD 5.14.2 |
| Line separator style | Input | DASH, EQUALS, BLANK, STAR | BRD 5.14.2 |
| Location override toggle | Input | Per-location receipt config vs tenant-wide default | BRD 5.14.5 |
| Email receipt tab | Panel | HTML template editor with merge fields, subject line, and “Send Test Email” button | BRD 5.14.6 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save receipt config | Same screen (confirmation toast, preview updates) | ADMIN+ |
| Send Test Email | Same screen (sends sample email receipt to admin’s email) | ADMIN+ |
| Create location override | Same screen (new location-specific config tab) | ADMIN+ |
| Reset to defaults | Confirmation dialog, then same screen | ADMIN+ |
G.8.19 Email Config & Templates
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-19 |
| Product(s) | OWNER |
| BRD Section(s) | 5.15, 6.9 |
| Database Tables | email_templates (R/W), integration_providers (R/W), integration_credentials (R/W), tenant_settings (R) |
| State Machine(s) | 7.14 Integration Connection States |
| Appendix F Services | setup.email-template.crud.service, integration.email.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/email |
Purpose
The Email Config & Templates screen manages the email provider connection (SMTP, SendGrid, or Mailgun) and the complete template registry for all automated communications. Administrators configure provider credentials, send test emails, and enable/disable individual templates from a catalog of 13 pre-seeded templates covering sales receipts, refund confirmations, order notifications, inventory alerts, and system emails.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Provider config panel | Panel | Provider type (SMTP/SENDGRID/MAILGUN), host, port, credentials, from address | BRD 6.9.1 |
| Connection test button | Button | Sends test email to verify provider credentials | BRD 6.9.1 |
| Template catalog table | Table | 13 templates with code, name, trigger event, recipient type, enabled toggle | BRD 5.15.2 |
| Template editor | Panel | Subject and body HTML editor with merge field insertion toolbar | BRD 5.15.3 |
| Merge fields reference | Panel | Expandable list of all available merge fields by category (common, transaction, inventory) | BRD 5.15.4 |
| Send preview button | Button | Sends preview email with sample data to admin’s email | BRD 5.15.3 |
| Delivery monitoring | Panel | Bounce rate, consecutive failures per address, suppression list | BRD 6.9.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save provider config | Same screen (connection test recommended) | ADMIN+ |
| Test connection | Same screen (success/failure indicator) | ADMIN+ |
| Edit template | Inline editor on same screen | ADMIN+ |
| Toggle template enabled | Same screen (immediate effect) | ADMIN+ |
G.8.20 RFID Configuration
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-20 |
| Product(s) | OWNER |
| BRD Section(s) | 5.16 |
| Database Tables | rfid_config (R/W), rfid_readers (R/W), rfid_printers (R/W), rfid_tags (R), rfid_scan_sessions (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.integrations-hub.config.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/rfid |
Purpose
The RFID Configuration screen manages all RFID hardware, EPC encoding parameters, tag printing settings, and scan session configuration for the dedicated inventory counting subsystem. Administrators register readers via claim codes, configure SGTIN-96 encoding parameters, manage RFID-enabled label printers, and set variance thresholds for count sessions. RFID is counting-only and does not participate in sales, receiving, or transfers.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| EPC encoding panel | Panel | Company prefix, partition, filter, indicator, format regex; set during onboarding | BRD 5.16.2 |
| Reader list table | Table | Registered readers with name, model, serial, location, status, last_seen_at | BRD 5.16.1 |
| Generate claim code button | Button | Creates 6-character alphanumeric code valid for 24 hours (one-time use) | BRD 5.16.1 |
| Reader status badges | Badge | active (green), offline (yellow/alert after 15min), maintenance, retired | BRD 5.16.1 |
| RFID printer list | Table | Registered RFID printers with model, location, DPI, label size, rfid_position | BRD 5.16.3 |
| Variance threshold config | Panel | Auto-approve (0%), review (1-2%), manager review (3-5%), mandatory recount (>5%) | BRD 5.16.4 |
| Session parameters | Panel | Timeout (480min), auto-save interval (30s), chunk upload size (5000), RSSI threshold (-70dBm) | BRD 5.16.4 |
| Tag stats summary | Badge | Total active tags, tags per location, void/lost counts | BRD 5.16.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Generate claim code | Same screen (displays code with 24h expiry countdown) | ADMIN+ |
| Register printer | Modal form (stays on screen) | ADMIN+ |
| Edit EPC config | Same screen (inline edit, requires confirmation for changes) | OWNER |
| View scan sessions | Separate RFID session history page | ADMIN+ |
G.8.21 Audit Log Viewer
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-21 |
| Product(s) | OWNER |
| BRD Section(s) | 5.18 |
| Database Tables | audit_log (R), audit_config (R/W), tenant_users (R), locations (R) |
| State Machine(s) | None |
| Appendix F Services | setup.audit.config.service, crosscutting.audit-log.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/audit |
Purpose
The Audit Log Viewer provides a searchable, filterable view of the tamper-evident audit trail for every significant action in the system. Administrators can filter by category (LOGIN, SALE, VOID, etc.), date range, user, location, and entity type. The screen also manages audit configuration including category toggles, retention policies, and export settings.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Audit log table | Table | Paginated list with category, action, actor, role, location, entity, timestamp, details | BRD 5.18.4 |
| Category filter | Input | Multi-select from 12 audit categories (LOGIN, SALE, RETURN, VOID, etc.) | BRD 5.18.1 |
| Date range picker | Input | Start/end date with max range of 365 days per query | BRD 5.18.3 |
| User filter | Input | Dropdown of all tenant users to filter by actor | BRD 5.18.4 |
| Location filter | Input | Dropdown to filter by location | BRD 5.18.4 |
| Details expansion | Panel | Click-through to JSON details showing before/after values for changes | BRD 5.18.4 |
| Export button | Button | Export filtered results as CSV, JSON, or PDF (max 10,000 rows per export) | BRD 5.18.3 |
| Retention config panel | Panel | Retention days (min 90), archive toggle, archive format, purge after days | BRD 5.18.2 |
| Category toggle panel | Panel | Enable/disable audit logging per category | BRD 5.18.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Filter audit log | Same screen (results update) | ADMIN+ |
| Export results | CSV/JSON/PDF download | ADMIN+ |
| View entry details | Same screen (expanded detail panel) | ADMIN+ |
| Edit retention config | Same screen (settings panel) | OWNER |
G.8.22 Business Rules (YAML Editor)
| Attribute | Value |
|---|---|
| Screen ID | SCR-M05-22 |
| Product(s) | OWNER |
| BRD Section(s) | 5.19 |
| Database Tables | tenant_settings (R/W) |
| State Machine(s) | None |
| Appendix F Services | setup.rules.engine.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/settings/business-rules |
Purpose
The Business Rules screen provides a structured editor for all configurable business rules across the POS system. Rather than raw YAML editing, the screen presents rules as organised form fields grouped by module (Sales, Customers, Catalog, Inventory). Each field maps to a YAML key in the consolidated configuration with validation, defaults, and contextual help. Advanced users can switch to raw YAML view.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Module tabs | Panel | Tabs for Sales, Customers, Catalog, Inventory rule sections | BRD 5.19 |
| Return policy fields | Input | Full refund days (30), store credit days (90), restocking fee %, final sale categories | BRD 5.19.1 |
| Parked sales config | Input | Max per terminal (5), TTL hours (4), reservation type (soft/hard) | BRD 5.19.1 |
| Discount limits | Input | Max line discount % (20), max global discount % (15), require reason code toggle | BRD 5.19.1 |
| Cash drawer settings | Input | Variance tolerance ($5.00), blind count toggle, max opening float ($500) | BRD 5.19.1 |
| Offline mode config | Panel | Read-only display of allowed/blocked offline operations | BRD 5.19.1 |
| Inventory rules | Input | Low stock threshold, reorder point formula, count frequency | BRD 5.19.4 |
| Raw YAML toggle | Button | Switch between form view and raw YAML editor for advanced users | BRD 5.19 |
| Reset to defaults button | Button | Resets all rules to system defaults with confirmation | BRD 5.19 |
| Validation indicators | Badge | Green check or red X per field showing valid/invalid configuration | BRD 5.19 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save rules | Same screen (confirmation toast, validation runs) | ADMIN+ |
| Reset to defaults | Confirmation dialog, then same screen | OWNER |
| Switch to YAML view | Same screen (raw YAML editor) | ADMIN+ |
| View rule documentation | Contextual help panel expands | ADMIN+ |
G.9 Module 6: Integration Screens (12 Screens)
BRD Sections: 6.1-6.13 | Appendix F: §F.9 (20 services) | Pattern: CRUD + Audit ES
Cross-Reference: See Ch 05, Sections 6.1-6.13 for business rules. See Ch 08, Domain 14 (Integration) for table schemas. See Appendix F, §F.9 for service breakdown.
G.9.1 Integration Hub Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-01 |
| Product(s) | OWNER |
| BRD Section(s) | 6.11 |
| Database Tables | integration_providers (R), integration_sync_log (R), integration_credentials (R) |
| State Machine(s) | 7.14 Integration Connection States, 7.13 Integration Sync States |
| Appendix F Services | integration.provider.registry.service, integration.circuit-breaker.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations |
Purpose
The Integration Hub Dashboard is the central management console for all external system integrations. It provides a consolidated health view across all six integration types (Shopify, Amazon, Google Merchant, Payment Processor, Email Provider, Shipping Carrier) with real-time status indicators, sync latency metrics, rate limit usage, and error counts. Administrators can trigger manual syncs and drill into individual integration details.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Integration cards grid | Panel | Card per configured integration showing status dot, provider name, last sync time, error count | BRD 6.11.4 |
| Health status indicator | Badge | Green (CONNECTED), yellow (RATE_LIMITED), red (ERROR/DISCONNECTED) per integration | BRD 6.11.4 |
| Last sync timestamp | Badge | Time since last successful sync with colour coding (<30min green, 30min-2hr yellow, >2hr red) | BRD 6.11.4 |
| Error count (24h) | Badge | Rolling error count with severity colours (0=green, 1-5=yellow, >5=red) | BRD 6.11.4 |
| Sync latency gauge | Panel | Average sync latency in ms (<2000 green, 2000-5000 yellow, >5000 red) | BRD 6.11.4 |
| Rate limit bar | Panel | Visual bar showing rate limit consumption % per integration | BRD 6.11.4 |
| Sync Now button | Button | Manual sync trigger per integration (requires ADMIN role) | BRD 6.11.4 |
| Add Integration button | Button | Opens provider type selection and connection wizard | BRD 6.11.1 |
| Credential expiry banner | Panel | Warning banner when OAuth token expires within 30 days | BRD 6.11.2 |
Wireframe
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXUS ADMIN — Integration Hub [Will] [?] [X] │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INTEGRATION STATUS [+ Add Integration] │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ ● Shopify │ │ ○ Amazon SP-API │ │ ○ Google Merch. │ │
│ │ CONNECTED │ │ NOT CONFIGURED │ │ NOT CONFIGURED │ │
│ │ Last sync: 3 min ago │ │ -- │ │ -- │ │
│ │ Errors (24h): 0 │ │ │ │ │ │
│ │ Latency: 1,240ms │ │ [Configure] │ │ [Configure] │ │
│ │ Rate limit: ████░ 62%│ │ │ │ │ │
│ │ [Sync Now] [Details] │ │ │ │ │ │
│ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ ● Payment Processor │ │ ● Email (SendGrid) │ │ ○ Shipping │ │
│ │ CONNECTED │ │ CONNECTED │ │ NOT CONFIGURED │ │
│ │ Last sync: 1 min ago │ │ Last sync: 12 min ago│ │ Planned v2.0 │ │
│ │ Errors (24h): 0 │ │ Errors (24h): 1 │ │ │ │
│ │ Terminals: 4 active │ │ Bounce rate: 0.2% │ │ │ │
│ │ [Details] │ │ [Details] │ │ │ │
│ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │
│ │
│ RECENT SYNC ACTIVITY │
│ ┌────────┬──────────────────┬───────────┬──────────┬────────┬───────────┐ │
│ │ Time │ Integration │ Type │ Records │ Status │ Duration │ │
│ ├────────┼──────────────────┼───────────┼──────────┼────────┼───────────┤ │
│ │ 14:32 │ Shopify │ WEBHOOK_IN│ 1 │ ✓ │ 340ms │ │
│ │ 14:30 │ Shopify │ RECON │ 847 │ ✓ │ 4,200ms │ │
│ │ 14:28 │ Email │ MANUAL │ 1 │ ✗ │ 1,100ms │ │
│ └────────┴──────────────────┴───────────┴──────────┴────────┴───────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Click integration card | Individual integration detail screen (SCR-M06-02 through SCR-M06-08) | ADMIN+ |
| Sync Now | Same screen (sync status updates in real-time) | ADMIN+ |
| Add Integration | Integration type selection modal | ADMIN+ |
| View sync log | SCR-M06-11 Integration Health Monitor | ADMIN+ |
G.9.2 Shopify Setup & Connection
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-02 |
| Product(s) | OWNER |
| BRD Section(s) | 6.3 |
| Database Tables | integration_providers (R/W), integration_credentials (R/W), tenant_settings (R/W) |
| State Machine(s) | 7.14 Integration Connection States |
| Appendix F Services | integration.shopify.product-sync.service, integration.shopify.webhook-handler.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/shopify/setup |
Purpose
The Shopify Setup screen manages the OAuth connection to a Shopify store, configures sync mode (POS-master vs bidirectional), and sets up webhook subscriptions. Administrators enter the shop URL, initiate the OAuth flow, verify the connection, and configure the fundamental sync behaviour that determines how product, inventory, and order data flows between the POS and Shopify.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Shop URL input | Input | Shopify store URL (e.g., nexus-clothes.myshopify.com) | BRD 6.3 |
| Connect button | Button | Initiates OAuth 2.0 flow for offline access token | BRD 6.2.2 |
| Connection status | Badge | NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR | BRD 7.14 |
| Sync mode selector | Input | pos_master (default) or bidirectional | BRD 6.3.1 |
| API preference | Input | GraphQL (default) or REST | BRD 6.3.6 |
| Third-party POS toggle | Input | Enables Shopify third-party POS integration rules | BRD 6.3.12 |
| Webhook status panel | Panel | List of subscribed webhook topics with active/inactive status | BRD 6.3.11 |
| Verify connection button | Button | Tests credentials and API access | BRD 6.2.1 |
| Disconnect button | Button | Revokes OAuth token and marks integration inactive | BRD 6.2.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Connect to Shopify | Shopify OAuth redirect, then returns to same screen | ADMIN+ |
| Verify connection | Same screen (status badge updates) | ADMIN+ |
| Configure inventory sync | SCR-M06-03 Shopify Inventory Sync Config | ADMIN+ |
| Disconnect | Confirmation dialog, then same screen | OWNER |
G.9.3 Shopify Inventory Sync Config
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-03 |
| Product(s) | OWNER |
| BRD Section(s) | 6.3, 6.7 |
| Database Tables | integration_providers (R/W), tenant_settings (R/W), locations (R), integration_sync_log (R) |
| State Machine(s) | 7.13 Integration Sync States |
| Appendix F Services | integration.shopify.inventory-sync.service, integration.cross-platform.inventory-orchestrator.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/shopify/inventory |
Purpose
The Shopify Inventory Sync Config screen manages real-time inventory synchronisation between the POS and Shopify. Administrators configure location-to-Shopify-location mapping, safety buffer quantities, reconciliation intervals, and sync triggers. The screen displays sync status per location and provides controls for the safety buffer system that holds back inventory from online availability.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Location mapping table | Table | POS locations mapped to Shopify locations for inventory sync | BRD 6.3.1 |
| Safety buffer config | Input | Per-location quantity or percentage to hold back from Shopify availability | BRD 6.7.2 — Channel Available = POS Available - Safety Buffer |
| Reconciliation interval | Input | Minutes between full inventory reconciliation (default: 15) | BRD 6.3.14 |
| Sync trigger list | Panel | Events that trigger immediate inventory sync (sale, return, adjustment, transfer, receiving) | BRD 6.3.14 |
| Last reconciliation status | Badge | Timestamp and result of last full reconciliation | BRD 6.7.1 |
| Oversell prevention toggle | Input | Block sale if available_qty <= 0 | BRD 6.7.3 |
| Sync failure freeze setting | Input | Minutes to freeze marketplace quantity on sync failure (default: 120) | BRD 6.7.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Save sync config | Same screen (confirmation toast) | ADMIN+ |
| Force reconciliation | Same screen (reconciliation runs, status updates) | ADMIN+ |
| View sync log | SCR-M06-11 Integration Health Monitor (filtered to Shopify) | ADMIN+ |
G.9.4 Shopify Order Fulfillment
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-04 |
| Product(s) | OWNER |
| BRD Section(s) | 6.3 |
| Database Tables | integration_sync_log (R), orders (R), locations (R), integration_providers (R) |
| State Machine(s) | 7.13 Integration Sync States |
| Appendix F Services | integration.shopify.order-sync.service, integration.shopify.webhook-handler.service |
| User Roles | ADMIN, OWNER, MANAGER |
| Offline Capable | No |
| Route | /admin/integrations/shopify/orders |
Purpose
The Shopify Order Fulfillment screen displays incoming Shopify orders that require POS-side fulfillment. Orders appear within 60 seconds of placement via webhook, with inventory reserved immediately. Managers can view order details, assign fulfillment to a location, update fulfillment status, and push tracking information back to Shopify.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Pending orders table | Table | Incoming Shopify orders awaiting fulfillment with order number, items, total, timestamp | BRD 6.3 |
| Fulfillment location selector | Input | Assign order to a POS store location for fulfillment | BRD 6.3 |
| Order detail panel | Panel | Line items, customer info, shipping address, payment status | BRD 6.3 |
| Fulfillment status update | Button | Mark as picking, packed, shipped with tracking number entry | BRD 6.3 |
| BOPIS indicator | Badge | “Buy Online, Pick Up In Store” tag for applicable orders | BRD 6.3.1 |
| Sync status | Badge | Shows if fulfillment update has been pushed to Shopify | BRD 6.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Assign to location | Same screen (order assigned) | MANAGER+ |
| Mark as fulfilled | Same screen (status pushes to Shopify) | MANAGER+ |
| View order detail | Expandable panel on same screen | MANAGER+ |
| View all sync activity | SCR-M06-11 Integration Health Monitor | ADMIN+ |
G.9.5 Amazon Setup & Connection
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-05 |
| Product(s) | OWNER |
| BRD Section(s) | 6.4 |
| Database Tables | integration_providers (R/W), integration_credentials (R/W), tenant_settings (R/W) |
| State Machine(s) | 7.14 Integration Connection States |
| Appendix F Services | integration.amazon.catalog-sync.service, integration.amazon.order-sync.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/amazon/setup |
Purpose
The Amazon Setup screen manages the SP-API OAuth connection to Amazon Seller Central. Administrators configure marketplace, region, fulfillment mode (FBM/FBA), and notification delivery method (SQS or polling). The OAuth flow exchanges a Login with Amazon (LWA) refresh token for short-lived access tokens that are automatically refreshed.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Seller ID input | Input | Amazon Seller Central seller identifier | BRD 6.4.1 |
| OAuth connect button | Button | Initiates LWA OAuth 2.0 flow | BRD 6.4.1 |
| Connection status | Badge | NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR | BRD 7.14 |
| Marketplace selector | Input | US (ATVPDKIKX0DER), CA, UK, etc. | BRD 6.4.1 |
| Region selector | Input | NA, EU, FE | BRD 6.4.1 |
| Fulfillment mode | Input | FBM (Fulfilled by Merchant), FBA (Fulfilled by Amazon), or both | BRD 6.4.5 |
| Order poll interval | Input | Seconds between order polling (default: 120) | BRD 6.4.4 |
| Notification delivery | Input | SQS or polling | BRD 6.4.6 |
| Safety buffer config | Input | Quantity or percentage to hold back from Amazon availability | BRD 6.7.2 |
| Seller code compliance toggle | Input | Enforce Amazon seller code rules | BRD 6.4.8 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Connect to Amazon | LWA OAuth redirect, then returns to same screen | ADMIN+ |
| Verify connection | Same screen (status badge updates) | ADMIN+ |
| Configure listings | SCR-M06-06 Amazon Catalog / Listings | ADMIN+ |
| Disconnect | Confirmation dialog, then same screen | OWNER |
G.9.6 Amazon Catalog / Listings
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-06 |
| Product(s) | OWNER |
| BRD Section(s) | 6.4 |
| Database Tables | integration_providers (R), integration_sync_log (R), products (R), variants (R) |
| State Machine(s) | 7.16 Product Sync Validation States |
| Appendix F Services | integration.amazon.catalog-sync.service, integration.amazon.inventory-sync.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/amazon/listings |
Purpose
The Amazon Catalog / Listings screen shows the sync status of all POS products with their Amazon marketplace listings. Administrators can view validation status per product, identify items failing Amazon compliance rules (bullet point limits, search term byte limits, image requirements), and trigger manual sync operations for individual products or in bulk.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Product listing table | Table | POS products with Amazon sync status, ASIN, listing status, last sync, validation state | BRD 6.4.2 / 6.4.3 |
| Validation status column | Badge | DRAFT, VALID, INVALID, SYNCED, SYNC_FAILED, BLOCKED per product | BRD 7.16 |
| Compliance warnings | Panel | Products failing Amazon rules: max 5 bullet points, 1000 chars/bullet, 250 bytes search terms | BRD 6.4.8 |
| Bulk sync button | Button | Sync all valid products to Amazon via flat-file feed | BRD 6.4.3 |
| Individual sync button | Button | Sync single product to Amazon | BRD 6.4.3 |
| FBA/FBM status | Badge | Per-product fulfillment assignment | BRD 6.4.5 |
| Image validation | Badge | Shows if product images meet Amazon requirements (white background, min 1000px) | BRD 6.6.2 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Sync product | Same screen (sync status updates) | ADMIN+ |
| Bulk sync all | Same screen (bulk operation progress bar) | ADMIN+ |
| Fix validation error | Product edit screen in Catalog module | ADMIN+ |
| View sync log | SCR-M06-11 Integration Health Monitor (filtered to Amazon) | ADMIN+ |
G.9.7 Google Merchant Setup
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-07 |
| Product(s) | OWNER |
| BRD Section(s) | 6.5 |
| Database Tables | integration_providers (R/W), integration_credentials (R/W), tenant_settings (R/W) |
| State Machine(s) | 7.14 Integration Connection States |
| Appendix F Services | integration.google.product-sync.service, integration.google.inventory-sync.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/google/setup |
Purpose
The Google Merchant Setup screen manages the service account OAuth connection to Google Merchant Center. Administrators upload the service-account JSON key file, configure the Merchant ID, enable Local Inventory Ads (LIA), set product update frequency, and configure Google Business Profile integration for local listing enrichment.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Merchant ID input | Input | Google Merchant Center merchant identifier | BRD 6.5.1 |
| Service account key upload | Input | JSON key file upload (stored encrypted) | BRD 6.5.1 |
| Connection status | Badge | NOT_CONFIGURED, CONNECTING, CONNECTED, ERROR | BRD 7.14 |
| Local Inventory Ads toggle | Input | Enable/disable LIA for local inventory visibility in Google Shopping | BRD 6.5.3 |
| Product update frequency | Input | 2x_daily (default), daily, or hourly | BRD 6.5 |
| Image validation strict toggle | Input | Enforce Google image requirements (min 1000x1000, no watermarks, no text overlay) | BRD 6.5.7 |
| GTIN required toggle | Input | Require barcode (GTIN/UPC/EAN) for all products synced to Google | BRD 6.5.6 |
| Content API migration banner | Panel | Deadline reminder for Merchant API migration (2026-08-18) | BRD 6.5.10 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Upload service account key | Same screen (connection test runs) | ADMIN+ |
| Verify connection | Same screen (status badge updates) | ADMIN+ |
| Configure validation | SCR-M06-08 Google Validation Dashboard | ADMIN+ |
| Disconnect | Confirmation dialog, then same screen | OWNER |
G.9.8 Google Validation Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-08 |
| Product(s) | OWNER |
| BRD Section(s) | 6.5 |
| Database Tables | integration_providers (R), integration_sync_log (R), products (R), variants (R) |
| State Machine(s) | 7.16 Product Sync Validation States |
| Appendix F Services | integration.google.product-sync.service, integration.cross-platform.validation.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/google/validation |
Purpose
The Google Validation Dashboard shows product compliance status against Google Merchant Center requirements. Administrators can identify products with disapproval risks (missing GTIN, invalid images, incorrect pricing, missing product type taxonomy) and fix issues before they cause Google disapprovals. The disapproval prevention engine pre-validates products before pushing.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Validation summary cards | Panel | Total products, valid count, invalid count, blocked count, synced count | BRD 6.5.8 |
| Invalid products table | Table | Products failing Google rules with specific failure reasons | BRD 6.5.8 |
| Image compliance column | Badge | Pass/fail per product for image requirements (size, format, no watermarks) | BRD 6.5.7 |
| GTIN/barcode status | Badge | Missing barcode indicator per product | BRD 6.5.6 |
| Price match validation | Badge | Verifies POS price matches Google listing price | BRD 6.5 |
| Product type taxonomy | Badge | Google product category assignment status | BRD 6.5.6 |
| Disapproval prevention log | Table | Products caught by pre-validation before being pushed to Google | BRD 6.5.8 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Fix product issue | Product edit screen in Catalog module | ADMIN+ |
| Re-validate product | Same screen (validation re-runs) | ADMIN+ |
| Force sync valid products | Same screen (bulk push to Google) | ADMIN+ |
| View disapproval details | Expandable detail panel per product | ADMIN+ |
G.9.9 Cross-Platform Validation
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-09 |
| Product(s) | OWNER |
| BRD Section(s) | 6.6 |
| Database Tables | integration_providers (R), products (R), variants (R), integration_sync_log (R) |
| State Machine(s) | 7.16 Product Sync Validation States |
| Appendix F Services | integration.cross-platform.validation.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/validation |
Purpose
The Cross-Platform Validation screen applies the strictest-rule-wins validation engine across all connected channels (Shopify, Amazon, Google Merchant). It shows a unified view of product readiness per platform, identifies which platform’s rules are causing failures, and provides a consolidated validation matrix for titles, descriptions, images, barcodes, and required attributes.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Platform readiness matrix | Table | Products x Platforms grid showing pass/fail per channel | BRD 6.6 — strictest-rule-wins |
| Unified validation rules | Panel | Combined requirements: title max 150 chars, description max 5000 chars, image min 1000px, no watermarks | BRD 6.6.1 |
| Image requirements matrix | Table | Per-platform image requirements side-by-side comparison | BRD 6.6.2 |
| Failing products list | Table | Products that fail at least one platform’s validation with specific failure reasons per platform | BRD 6.6.3 |
| Pre-sync validation toggle | Input | Enable/disable automatic pre-sync validation before pushing to any channel | BRD 6.6.3 |
| Platform-specific attributes | Panel | Required fields per platform (Amazon bullet points, Google product type, Shopify tags) | BRD 6.6.4 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Fix product issue | Product edit screen in Catalog module | ADMIN+ |
| Re-validate all products | Same screen (bulk re-validation runs) | ADMIN+ |
| View platform detail | SCR-M06-06/08 (Amazon/Google validation screens) | ADMIN+ |
G.9.10 Inventory Sync Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-10 |
| Product(s) | OWNER |
| BRD Section(s) | 6.7 |
| Database Tables | integration_providers (R), integration_sync_log (R), inventory_levels (R), locations (R) |
| State Machine(s) | 7.13 Integration Sync States |
| Appendix F Services | integration.cross-platform.inventory-orchestrator.service, integration.shopify.inventory-sync.service, integration.amazon.inventory-sync.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/inventory |
Purpose
The Inventory Sync Dashboard provides a unified view of inventory levels across all connected channels (POS, Shopify, Amazon, Google). Administrators monitor safety buffer calculations, channel-available quantities, oversell prevention status, and sync failure freeze states. The screen shows real-time inventory allocation across platforms with the formula: Channel Available = POS Available - Safety Buffer.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Channel inventory table | Table | Product x Channel grid showing POS qty, safety buffer, channel-available qty per platform | BRD 6.7.2 |
| Safety buffer summary | Panel | Per-location and per-channel buffer configuration with current allocation | BRD 6.7.2 |
| Sync status per channel | Badge | Last sync time, success/failure, latency per channel | BRD 6.7.1 |
| Oversell prevention status | Badge | Active/inactive indicator with oversell event count | BRD 6.7.3 |
| Sync failure freeze indicator | Badge | Products currently frozen due to sync failure (120-min freeze window) | BRD 6.7.5 |
| Reconciliation schedule | Panel | Next scheduled reconciliation per channel (Shopify 15min, Amazon 30min, Google 6hr) | BRD 6.7.1 |
| Force reconciliation button | Button | Trigger immediate full inventory reconciliation across all channels | BRD 6.7.1 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Force reconciliation | Same screen (reconciliation runs) | ADMIN+ |
| Edit safety buffers | Same screen (inline edit per channel/location) | ADMIN+ |
| View sync failures | SCR-M06-12 Error DLQ Management | ADMIN+ |
| View channel detail | Platform-specific sync config (SCR-M06-03) | ADMIN+ |
G.9.11 Integration Health Monitor
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-11 |
| Product(s) | OWNER |
| BRD Section(s) | 6.11 |
| Database Tables | integration_sync_log (R), integration_providers (R) |
| State Machine(s) | 7.13 Integration Sync States, 7.14 Integration Connection States |
| Appendix F Services | integration.provider.registry.service, integration.circuit-breaker.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/health |
Purpose
The Integration Health Monitor provides a detailed sync log and operational metrics across all integrations. Administrators can filter by integration type, sync type (webhook, scheduled, manual, bulk), status, date range, and entity type. The screen shows circuit breaker states, retry attempts, and dead-letter queue entries for failed operations.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Sync log table | Table | Paginated log with integration, sync_type, direction, entity_type, status, records, duration, timestamp | BRD 6.11.3 |
| Integration type filter | Input | Multi-select: Shopify, Amazon, Google, Payment, Email, Shipping | BRD 6.11.3 |
| Sync type filter | Input | WEBHOOK_IN, WEBHOOK_OUT, SCHEDULED_PULL, SCHEDULED_PUSH, RECONCILIATION, MANUAL, BULK_OPERATION, NOTIFICATION | BRD 6.11.3 |
| Status filter | Input | SUCCESS, FAILED, PARTIAL, SKIPPED, RETRYING | BRD 6.11.3 |
| Circuit breaker status | Panel | Per-provider circuit state: CLOSED (green), HALF_OPEN (yellow), OPEN (red) | BRD 6.2.4 |
| Error detail expansion | Panel | Click-through to full error_details text for failed entries | BRD 6.11.3 |
| Retry status tracker | Badge | Shows retry attempt count and next retry time for RETRYING entries | BRD 6.2.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Filter sync log | Same screen (results update) | ADMIN+ |
| View error details | Same screen (expanded detail panel) | ADMIN+ |
| Retry failed operation | Same screen (retry initiated) | ADMIN+ |
| View DLQ entries | SCR-M06-12 Error DLQ Management | ADMIN+ |
G.9.12 Error DLQ Management
| Attribute | Value |
|---|---|
| Screen ID | SCR-M06-12 |
| Product(s) | OWNER |
| BRD Section(s) | 6.2 |
| Database Tables | integration_sync_log (R/W), integration_providers (R) |
| State Machine(s) | 7.13 Integration Sync States |
| Appendix F Services | integration.dead-letter.service, integration.idempotency.service |
| User Roles | ADMIN, OWNER |
| Offline Capable | No |
| Route | /admin/integrations/dlq |
Purpose
The Error DLQ (Dead-Letter Queue) Management screen displays integration messages that have failed all retry attempts (3 retries with exponential backoff: 5s, 15s, 45s) and been moved to the dead-letter queue for manual review. Administrators can inspect error details, retry individual messages, replay batches, or discard messages that are no longer relevant. The DLQ retains entries for 24 hours before auto-purge.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| DLQ entries table | Table | Failed messages with integration, entity_type, entity_id, external_id, error details, failed_at | BRD 6.2.3 |
| Error detail panel | Panel | Full error message, stack trace, request/response payloads | BRD 6.2.3 |
| Retry button | Button | Re-enqueue message for another round of retries (with new idempotency window) | BRD 6.2.5 |
| Batch retry button | Button | Retry all selected DLQ entries | BRD 6.2.3 |
| Discard button | Button | Remove message from DLQ (acknowledge as unrecoverable) | BRD 6.2.3 |
| Integration filter | Input | Filter by integration type | BRD 6.2.3 |
| Age indicator | Badge | Time since message entered DLQ; highlight entries nearing 24-hour auto-purge | BRD 6.2.3 |
| Retry count | Badge | Number of previous retry attempts per message | BRD 6.2.3 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Retry message | Same screen (message re-enqueued, status updates) | ADMIN+ |
| Batch retry | Same screen (selected messages re-enqueued) | ADMIN+ |
| Discard message | Confirmation dialog, then same screen | ADMIN+ |
| View source entity | Navigates to relevant entity screen (product, order, etc.) | ADMIN+ |
G.10 Nexus POS Terminal Context
This section documents the POS terminal experience — the screens and navigation flows specific to the sales register context within the Nexus POS web application (ADR-052). While individual screen specs are in G.4-G.9 by module, this section covers POS navigation, hardware integration, offline behavior, keyboard shortcuts, and screen transition flows.
G.10.1 POS Navigation Structure
The Nexus POS terminal uses a sidebar-primary layout optimized for fixed-position retail terminals. When a CASHIER logs in, the app opens directly to the Sales Terminal with a transaction-focused sidebar. MANAGER and OWNER roles see an expanded navigation with access to administrative sections (reports, inventory, setup, integrations) in addition to the POS sidebar.
Main Navigation Layout
The POS screen is divided into three zones:
| Zone | Position | Width | Content |
|---|---|---|---|
| Sidebar | Left | 64px (collapsed) / 240px (expanded) | Primary navigation icons/labels |
| Content Area | Center | Remaining | Active screen content |
| Status Bar | Top | Full width, 48px | Connection indicator, location name, clock, user avatar |
Sidebar Navigation Items (Top to Bottom)
| Icon | Label | Route | Description | Register Profile |
|---|---|---|---|---|
| Cart | Sales | /pos/sales | Active sale screen with product grid, cart panel, and payment | FULL_POS, MOBILE |
| Search | Lookup | /pos/lookup | Product search by barcode, SKU, or name — shows price, stock, location availability | FULL_POS, MOBILE |
| Users | Customers | /pos/customers | Customer search, attach to sale, view purchase history, loyalty balance | FULL_POS |
| Box | Inventory | /pos/inventory | Quick stock check, count participation, adjustment requests | FULL_POS |
| Clock | Time Clock | /pos/timeclock | Clock-in/clock-out for current user | FULL_POS, MOBILE |
| DollarSign | Drawer | /pos/drawer | Cash drawer operations: open, drop, X-Report, Z-Report | FULL_POS |
| FileText | History | /pos/history | Today’s transaction history for this register — reprint receipts, view details | FULL_POS |
| Settings | Settings | /pos/settings | Register-local settings (receipt format, display brightness) | FULL_POS |
The sidebar collapses to icon-only mode on smaller displays (< 1280px) and can be toggled via the hamburger menu icon at the top.
Role-Based Navigation Visibility
Since Nexus POS is a single web app (ADR-052), the navigation adapts based on the logged-in user’s role:
| Role | Default Screen on Login | Sidebar Sections Visible | Hardware Actions | Authentication |
|---|---|---|---|---|
| CASHIER | Sales Terminal | Sales, Lookup, Customers, Inventory (count only), Time Clock, Drawer, History | Print receipt, open drawer, scan barcode | PIN-based login (4-6 digit numeric PIN via POST /auth/pin-login) |
| MANAGER | Dashboard | All CASHIER sections + Sales Reports, Catalog, Inventory (full), Setup (partial) | Same as CASHIER (when at register) | Email + password or PIN |
| OWNER | Dashboard | All sections including Setup (full), Integrations | Same as CASHIER (when at register) | Email + password |
| BUYER | Catalog | Catalog, Inventory (POs, vendors, reorder) | None (back-office role) | Email + password |
| AUDITOR | Inventory | Inventory (counts), Audit Log | None (back-office role) | Email + password |
When a MANAGER or OWNER navigates to the Sales Terminal, the POS sidebar replaces the admin navigation, providing the same transaction-focused experience as a CASHIER.
Quick-Access Toolbar
At the top of the Sales screen, a persistent quick-access toolbar provides one-tap shortcuts for the most common mid-sale actions:
| Button | Action | Shortcut |
|---|---|---|
| New Sale | Clear cart and start a new transaction | F1 |
| Customer | Open customer search overlay, attach to current sale | F2 |
| Price Check | Open price check dialog (scan or type barcode) | F3 |
| Park Sale | Park current cart (save for later retrieval) | F4 |
| Retrieve | Retrieve a previously parked sale | F5 |
| Void | Void the current transaction (requires Manager role) | F8 |
G.10.2 Hardware Integration Points
The Nexus POS web application integrates with retail peripherals via web-compatible protocols (WebUSB, Star WebPRNT, Stripe Terminal JS SDK) and standard network interfaces. All hardware interactions are abstracted behind a HardwareService interface so that the React UI layer never directly communicates with physical devices.
Barcode Scanner Integration
| Attribute | Value |
|---|---|
| Protocol | USB HID (keyboard wedge mode) |
| Supported Devices | Any USB barcode scanner that emits keyboard events (Zebra DS2208, Honeywell Voyager, Symbol LS2208) |
| Connection | USB wired — plug and play, no driver required |
| Data Format | Barcode string terminated by Enter key (configurable: CR, LF, or CR+LF) |
| Integration Method | Global keyboard event listener in React — detects rapid keystroke sequences (< 50ms between characters) as scanner input vs. manual typing |
Focus Management: The barcode scanner input is captured globally regardless of which UI element has focus. When a scan is detected on the Sales screen, the system automatically performs a product lookup (API or product_cache) and adds the item to the cart. On non-Sales screens (e.g., Inventory Lookup), the scan triggers a product search instead.
Scan Detection Logic: The POS uses a timing-based heuristic to distinguish scanner input from keyboard typing:
- Characters arriving within 50ms of each other are buffered as a scan sequence
- The sequence is finalized when an Enter key is received or 200ms elapses after the last character
- Sequences shorter than 3 characters are ignored (likely accidental keypresses)
Receipt Printer
| Attribute | Value |
|---|---|
| Protocol | ESC/POS (Epson Standard Code for Point of Sale) |
| Supported Devices | Epson TM-T88VI, Star TSP143IV, Star mC-Print3 |
| Connection | USB or network (TCP/IP port 9100) |
| Paper Width | 80mm (default), 58mm (compact) |
| Integration Method | Star WebPRNT SDK (HTTP/HTTPS to network printers) or WebUSB API for USB-connected printers. ESC/POS byte stream generated in the browser via receipt-printer-encoder library. |
Receipt Content: Receipts are composed in the React layer as a structured receipt object (store name, items, totals, payment breakdown, barcode) and serialized to ESC/POS commands by the browser-side print service. Offline sales include an “OFFLINE” watermark below the store header.
Receipt Reprint: Receipts for today’s transactions can be reprinted from the History screen. The system stores the receipt data (not the rendered ESC/POS bytes) so reprints reflect the original sale data.
Cash Drawer
| Attribute | Value |
|---|---|
| Protocol | DK port trigger (RJ-12 cable from receipt printer) |
| Supported Devices | Any cash drawer with DK port (APG Vasario, Star CD3-1616) |
| Connection | Connected to receipt printer’s DK port — drawer opens when printer sends the open command |
| Integration Method | ESC/POS drawer kick pulse (0x1B 0x70 0x00) sent via the same Star WebPRNT / WebUSB channel used for receipt printing. Cash drawers connect to the printer’s DK port (kick-out cable). |
Business Rules: The cash drawer opens automatically at the end of a cash sale. Manual drawer open requires the cash_drawer_operations feature toggle (enabled for STAFF, MANAGER, OWNER by default). All drawer open events are logged in the audit trail with user ID, timestamp, and reason (sale_complete, manual_open, cash_drop, drawer_count).
Payment Terminal
| Attribute | Value |
|---|---|
| Protocol | Semi-integrated (SAQ-A compliant) |
| Supported Devices | Verifone P400, Ingenico Lane/3000, PAX A80 |
| Connection | Network (TCP/IP) — POS sends amount, terminal handles card interaction |
| PCI Scope | SAQ-A: No card data touches the POS system. The payment terminal handles all card reads, PIN entry, and encryption. POS receives only authorization codes and masked card numbers (last 4 digits). |
| Integration Method | Stripe Terminal JS SDK (or equivalent vendor SDK) sends amount to terminal via the browser. Terminal returns authorization result. |
Payment Flow: POS sends { amount, currency, transactionType } to the payment terminal. The terminal displays amount, processes card tap/insert/swipe, communicates with the payment processor, and returns { approved, authCode, maskedPan, cardBrand } to the POS. If the terminal is unreachable, the POS shows “Payment terminal offline — use cash or try again.”
Offline Limitation: Card payments are blocked offline (see G.10.3). Only cash payments are accepted during OFFLINE mode because the payment terminal requires network connectivity to reach the payment processor.
RFID Reader (Web POS)
| Attribute | Value |
|---|---|
| Protocol | USB HID (WebUSB API) or network-connected readers (HTTP/TCP) |
| Supported Devices | Zebra FX9600 (fixed reader, dock door, network), Zebra RFD40 (USB sled) |
| Connection | USB (WebUSB) or Ethernet (fixed readers via REST/LLRP gateway) |
| Use Case | Web POS RFID operations: tag lookup, tag void, tag status check. Bulk scanning is handled by the Raptag mobile app (G.11). |
| Integration Method | WebUSB API for USB-connected readers; HTTP REST calls for network-attached readers via RFID gateway service |
Scope: RFID readers connected to the web POS are used for individual tag operations only (e.g., looking up a tag during a count discrepancy investigation, voiding a damaged tag). Bulk inventory counting uses the Raptag mobile app with handheld readers.
G.10.3 Offline Behavior (ADR-048)
The Nexus POS operates in an online-first architecture (ADR-048). All reads and writes go through the Central API via React Query during normal operation. When connectivity is lost, the POS falls back to a thin 2-table SQLite WASM database (sql.js or wa-sqlite backed by OPFS) for sales continuity.
3-State Connection Monitor
The connection monitor uses three detection layers (Socket.io events, HTTP health ping every 5s, navigator.onLine API) to determine the current state:
| State | Trigger | UI Indicator | Data Reads | Data Writes |
|---|---|---|---|---|
| ONLINE | WebSocket connected + health ping OK | Green dot + “Connected” | React Query → Central API | POST → Central API |
| DEGRADED | WebSocket dropped, health ping intermittent | Yellow dot + “Unstable” | Try API (2s timeout) → fallback to SQLite product_cache | POST → API + local backup copy in sales_queue |
| OFFLINE | 3 consecutive health pings fail (~15s) | Red dot + banner: “Working offline. N sales queued. Prices may be outdated.” | SQLite product_cache only | SQLite sales_queue (append-only, FIFO) |
SYNCING (recovery sub-state): When connectivity restores, the monitor enters SYNCING — yellow dot with “Syncing 2/3…” progress text. The sales_queue is flushed oldest-first. On completion, the state transitions to ONLINE.
Operations Allowed Offline
| Operation | Details |
|---|---|
| New sale (cash only) | Product prices from product_cache. Sale written to sales_queue. Receipt printed with “OFFLINE” watermark. |
| Return with receipt | Original receipt data available locally. Return queued for sync. |
| Price check | From product_cache. Stale warning shown if cache > 60 minutes old. |
| Park sale | Cart state saved to local storage. |
| Retrieve parked sale | Parked carts retrieved from local storage. |
Operations Blocked Offline
| Operation | Reason |
|---|---|
| Card payment | Payment terminal requires network to reach processor |
| Customer create/lookup | Requires uniqueness check against Central API |
| Credit limit check / on-account payment | Risk of exceeding balance without real-time verification |
| Gift card activation / reload / redeem / balance | Risk of double-activation or stale balance |
| Multi-store inventory lookup | Requires network to query other locations |
| Transfer request / reservation create | Requires coordination with other locations |
| Inventory adjustment / receiving | Requires server-side validation and approval workflow |
When a user attempts a blocked operation offline, the POS shows a modal: “This feature requires an internet connection. Please try again when connected.” The blocked action button is visually disabled (grayed out) with a “requires connection” tooltip.
2-Table SQLite WASM Fallback
The fallback database runs in the browser via SQLite WASM (sql.js or wa-sqlite), with persistence to the Origin Private File System (OPFS) for durability across page reloads and browser restarts.
| Table | Purpose | Size Estimate |
|---|---|---|
| product_cache | Read-only product data (id, sku, barcode, name, price, cost, tax_code, variants). Pre-warmed on startup. Updated via WebSocket push events. | ~5MB for 10,000 SKUs |
| sales_queue | Append-only offline transactions. Each row contains full sale data as JSON (line items, payments, totals). UUID sale_id for idempotent sync. | ~1KB per sale |
The product_cache includes a last_refreshed timestamp. If the cache is older than 60 minutes during offline mode, the POS shows a subtle warning banner: “Product data may be outdated.”
Flag-on-Sync Price Discrepancy Handling
When the sales_queue flushes on reconnection, the Central API compares each line item’s unit_price (from the stale product_cache) against the current server price:
| Scenario | Server Action | Manager Review |
|---|---|---|
| Price match | Accept sale normally | None |
| Price changed | Accept sale at current server price; log discrepancy (sold_price, current_price, difference) | Review flagged transactions in Nexus POS (MANAGER+ dashboard); decide if customer credit is warranted |
| Item out of stock | Record sale; flag negative inventory | Approve negative balance or adjust |
| Customer deleted | Reassign to “Walk-in Customer” | Informational |
| Promotion expired | Apply current (non-promotional) price | Review flagged transactions |
Discrepancies appear in the Nexus POS manager dashboard under “Price Discrepancies” with [Issue Credit] and [Dismiss] actions per transaction.
G.10.4 POS Keyboard Shortcuts
The POS terminal supports function key shortcuts for rapid operation. These are active whenever the POS application has focus, regardless of which screen is displayed.
Function Key Assignments
| Key | Action | Context | Required Role |
|---|---|---|---|
| F1 | New Sale | Any screen — navigates to Sales, clears cart | ALL |
| F2 | Customer Lookup | Sales screen — opens customer search overlay | ALL |
| F3 | Price Check | Any screen — opens price check dialog (scan or type barcode) | ALL |
| F4 | Park Sale | Sales screen — saves current cart for later retrieval | ALL |
| F5 | Retrieve Parked Sale | Sales screen — opens list of parked sales | ALL |
| F6 | Inventory Lookup | Any screen — opens stock level search by barcode or SKU | ALL |
| F7 | Transaction History | Any screen — opens today’s register transaction list | ALL |
| F8 | Void Transaction | Sales screen — voids the current or last transaction | MANAGER, OWNER |
| F9 | Apply Discount | Sales screen — opens discount dialog (line-item or cart-level) | ALL (amount limits apply) |
| F10 | Open Cash Drawer | Sales screen — manual drawer open | ALL (requires cash_drawer_operations toggle) |
| F11 | Manager Override | Any screen — prompts for manager PIN to authorize restricted actions | MANAGER, OWNER |
| F12 | Lock Terminal | Any screen — locks the POS to the PIN entry screen (preserves current sale) | ALL |
Barcode Scan Handling
Barcode scans are intercepted globally via a keyboard event listener. The scan detection algorithm distinguishes rapid scanner input (< 50ms between keystrokes) from manual typing:
- Sales screen: Scan adds item to cart (or increments quantity if already in cart)
- Lookup screen: Scan populates the search field and triggers product detail display
- Inventory screen: Scan shows stock levels across all locations for the scanned product
- Any other screen: Scan opens a floating product info tooltip (price, stock, image)
Quick-Key Product Buttons
The Sales screen supports a configurable grid of quick-key buttons for high-frequency items that lack barcodes (e.g., gift wrapping, alterations, miscellaneous charges). Quick keys are configured under Settings > Registers > Quick Keys (OWNER role) and synced to the POS terminal via WebSocket.
| Attribute | Value |
|---|---|
| Grid size | 4x5 (20 buttons, configurable) |
| Button content | Product name + price, color-coded by category |
| Location scope | Per-register (different registers can have different quick-key layouts) |
Manager Override Shortcut
When a restricted action is attempted by a STAFF user (e.g., void, price override beyond discount limit), the system prompts for a manager PIN:
- Modal appears: “Manager authorization required for [action name]”
- Manager enters their PIN on the numeric keypad
- System validates PIN against users with MANAGER or OWNER role at this tenant
- If valid, the action proceeds under the manager’s authority (logged as
authorized_byin the audit trail) - If invalid, the action is denied with “Invalid manager PIN”
The F11 shortcut opens this same override prompt pre-emptively, allowing a manager to authorize before the restricted action is attempted.
G.10.5 POS Screen Transition Flows
Standard Sale Flow
Login → Terminal → Cart → Payment → Receipt
=========================================================
[PIN Entry]
│
▼
[Sales Screen] ◄──────────────────────────────────────┐
│ │
│ scan/search │
▼ │
[Product Added to Cart] │
│ │
│ (repeat for each item) │
│ │
▼ │
[Cart Review] │
│ ├── [Apply Discount] (F9) │
│ ├── [Attach Customer] (F2) │
│ └── [Park Sale] (F4) ──► [Parked Sales List] │
│ │
│ [Pay] button │
▼ │
[Payment Dialog] │
│ ├── Cash: enter tendered amount │
│ ├── Card: send to payment terminal │
│ ├── Gift Card: scan/enter card number │
│ ├── On Account: select customer account │
│ └── Split: combine multiple payment methods │
│ │
│ all payments applied │
▼ │
[Receipt Printed] │
│ ├── Print receipt (ESC/POS) │
│ ├── Email receipt (if customer attached) │
│ └── Cash drawer opens (if cash payment) │
│ │
└──────────────── [New Sale] (F1) ─────────────────┘
Return Flow
Terminal → Return Lookup → Item Selection → Refund → Receipt
=========================================================
[Sales Screen]
│
│ [Returns] button or navigate to History
▼
[Return Lookup]
│ ├── Scan receipt barcode
│ ├── Enter sale number manually
│ └── Search by date + register
│
│ original sale found
▼
[Original Sale Details]
│ ├── View all line items from original sale
│ ├── Check return eligibility per item
│ └── Items already returned are grayed out
│
│ select items to return
▼
[Return Item Selection]
│ ├── Select quantity per item (up to original qty)
│ ├── Select reason code (defective, wrong_size, etc.)
│ └── Select return action: refund or exchange
│
│ [Process Return] button
▼
[Refund Method]
│ ├── Original payment method (default)
│ ├── Store credit
│ └── Cash (if manager authorized)
│
│ refund applied
▼
[Return Receipt Printed]
│ ├── Print return receipt
│ ├── Inventory restocked (if return_to_stock)
│ └── Customer loyalty points adjusted
│
└──── back to [Sales Screen]
Cash Drawer Open/Close Flow
Open Drawer → X-Report → Z-Report → Close
=========================================================
[Start of Day]
│
│ Manager navigates to Drawer screen
▼
[Open Drawer]
│ ├── Enter starting cash amount (counted bills/coins)
│ ├── System records opening_balance
│ └── Drawer status: OPEN
│
│ (normal sales throughout the day)
│
▼
[Mid-Day: X-Report] (non-destructive read)
│ ├── View running totals: cash, card, gift card
│ ├── View transaction count by payment method
│ ├── Expected cash = opening_balance + cash_in - cash_out
│ └── Does NOT reset counters
│
│ (optional: cash drop)
▼
[Cash Drop] (if drawer has excess cash)
│ ├── Enter drop amount
│ ├── System records cash_drop event
│ ├── Expected cash reduced by drop amount
│ └── Manager signature/PIN required
│
│ (end of day)
▼
[Z-Report / Close Drawer]
│ ├── Enter counted cash amount (actual physical count)
│ ├── System calculates variance (counted - expected)
│ ├── Display variance: $0.00 = green, < $5 = yellow, > $5 = red
│ ├── Print Z-Report (end-of-day summary)
│ ├── Drawer status: CLOSED
│ └── Register locked until next opening
│
└──── [Drawer Closed — Register Locked]
G.11 Nexus Raptag Mobile Context (8 Screens)
BRD Section: 4.6.8 (RFID Counting Subsystem) + 5.16 (RFID Configuration) | Appendix F: Section F.10A (6 services) | Tech: React Native + Expo (ADR-047)
This section provides full screen specifications for the Nexus Raptag mobile RFID counting application. Unlike the Nexus POS web app, Raptag is a separate React Native app deployed to iOS and Android via Expo (ADR-047).
Cross-Reference: See Ch 05, Section 4.6.8 for RFID counting business rules. See Ch 08, Domain 16 (RFID) for table schemas. See Appendix F, Section F.10A for RFID service breakdown.
G.11.1 Login
| Attribute | Value |
|---|---|
| Screen ID | SCR-R01 |
| Product(s) | Raptag |
| BRD Section(s) | 5.5 |
| Database Tables | users (R), tenants (R), roles (R), user_locations (R) |
| State Machine(s) | – |
| Appendix F Services | auth.login.service |
| User Roles | ALL |
| Offline Capable | Yes (cached credentials for returning users) |
| Route | /login |
Purpose
The Login screen authenticates Raptag operators against the Central API using email and password credentials. Unlike the POS terminal (which uses numeric PIN entry for speed), Raptag uses standard email/password authentication because operators log in once per session rather than switching users frequently. Returning users can authenticate offline using cached credential hashes stored in the device’s secure storage.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Tenant Selector | Dropdown | Select tenant (shown only if user belongs to multiple tenants) | Pre-populated from last login; stored in AsyncStorage |
| Email Field | Text Input | User’s login email address | Validated against users.email with users.is_active = true |
| Password Field | Secure Input | Password with show/hide toggle | Verified against users.password_hash (bcrypt) |
| Remember Me | Toggle | Cache credentials for offline re-authentication | Stores bcrypt hash in device secure storage (Expo SecureStore) |
| Login Button | Primary Button | Submit credentials to POST /auth/login | Disabled until both fields non-empty; shows spinner during request |
| Server URL | Settings Link | Configure Central API endpoint (first-time setup only) | Stored in AsyncStorage; hidden after initial configuration |
| Offline Badge | Info Banner | “Offline mode — using cached credentials” shown when device is disconnected | Only appears if cached credentials exist and device has no connectivity |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Successful login (online) | SCR-R02 Home Dashboard | ALL |
| Successful login (offline, cached) | SCR-R02 Home Dashboard (limited features) | ALL |
| Failed login (wrong credentials) | Stay on SCR-R01 with error message | – |
| Account locked | Stay on SCR-R01 with “Account locked” message | – |
G.11.2 Home Dashboard
| Attribute | Value |
|---|---|
| Screen ID | SCR-R02 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8 |
| Database Tables | rfid_scan_sessions (R), session_operators (R), rfid_config (R), devices (R) |
| State Machine(s) | RFID Session (7.11) |
| Appendix F Services | rfid.scan.command.service, rfid.config.crud.service |
| User Roles | ALL |
| Offline Capable | Partial (shows locally cached session data; cannot fetch new sessions) |
| Route | /home |
Purpose
The Home Dashboard is the primary landing screen after login. It displays the operator’s active session (if any), a list of available sessions at their assigned location, recent session history, and device/reader status. It serves as the central hub from which operators join existing sessions or managers create new ones.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Active Session Card | Highlight Card | Shows current session if operator is participating (session name, type, section assignment, tag count, elapsed time) | Only one active session per operator at a time (enforced by session_operators unique constraint) |
| Available Sessions List | Scrollable List | Sessions at operator’s location with status in_progress that they haven’t joined yet | Filtered by rfid_scan_sessions.location_id matching operator’s assigned location |
| New Session Button | FAB (Floating Action Button) | Create a new counting session | Requires MANAGER or OWNER role; opens SCR-R03 |
| Reader Status Indicator | Status Badge | Shows connected RFID reader model and battery level | Green = connected, Yellow = low battery (< 20%), Red = disconnected |
| Recent Sessions | Card List | Last 5 completed sessions with variance summary | Tapping opens session summary (SCR-R06, read-only) |
| Pending Uploads | Warning Badge | Count of sessions with un-synced scan data | Shows “3 sessions pending upload” with upload button → SCR-R07 |
| Location Name | Header Text | Current location name (from user_locations.is_primary) | Operator cannot change location from Raptag; set in Nexus POS (OWNER > Setup > Locations) |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap “Active Session” card | SCR-R04 Scanning Interface (resume scanning) | ALL |
| Tap “New Session” FAB | SCR-R03 Join / Start Session | MANAGER, OWNER |
| Tap available session → “Join” | SCR-R05 Section Assignment | ALL |
| Tap “Pending Uploads” | SCR-R07 Chunked Upload / Sync | ALL |
| Tap recent session card | SCR-R06 Session Summary (read-only) | ALL |
| Tap reader status | SCR-R08 Device Pairing | ALL |
G.11.3 Join / Start Session
| Attribute | Value |
|---|---|
| Screen ID | SCR-R03 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8, 5.16 |
| Database Tables | rfid_scan_sessions (W), session_operators (W), locations (R), rfid_config (R) |
| State Machine(s) | RFID Session (7.11): [*] → in_progress |
| Appendix F Services | rfid.scan.command.service, rfid.config.crud.service |
| User Roles | MANAGER, OWNER (create); ALL (join) |
| Offline Capable | Yes (session created locally in SQLite, synced later) |
| Route | /session/new |
Purpose
This screen allows managers to create new RFID counting sessions and operators to join existing sessions. When creating a session, the manager selects the count type, location, and assigns sections to operators. When joining, the operator selects their assigned section and confirms their reader device.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Session Type Selector | Segmented Control | full_inventory, cycle_count, spot_check, find_item | See BRD Section 5.16.4 for type definitions |
| Location Display | Read-only Text | Pre-filled with operator’s primary location | Sessions are location-scoped; cannot count across locations |
| Section List | Editable List | Manager defines sections (e.g., “Men’s Tops”, “Women’s Bottoms”, “Accessories”) | Free-text section names; at least one section required |
| Operator Assignment | Multi-select | Assign operators to sections from a list of active users at the location | Max 10 operators per session; each operator assigned exactly one section |
| Session Name | Text Input | Optional friendly name (e.g., “Q1 Full Count - March 2026”) | Auto-generated as {session_type}-{location_code}-{date} if left blank |
| Expected Count | Number Input | Optional expected tag count for variance calculation | If provided, used to calculate variance % on SCR-R06 |
| Start Session Button | Primary Button | Creates session and transitions to scanning | Writes to rfid_scan_sessions (status: in_progress) and session_operators |
| Join Existing Panel | Card | Shows session details for joining (session name, type, available sections) | Operator selects their section and taps “Join” |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap “Start Session” (create mode) | SCR-R04 Scanning Interface | MANAGER, OWNER |
| Tap “Join Session” (join mode) | SCR-R05 Section Assignment → SCR-R04 | ALL |
| Cancel | SCR-R02 Home Dashboard | ALL |
G.11.4 Scanning Interface
| Attribute | Value |
|---|---|
| Screen ID | SCR-R04 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8, 5.16 |
| Database Tables | rfid_scan_events (W, local SQLite), rfid_tags (R, cached), rfid_tag_mappings (R, cached) |
| State Machine(s) | RFID Session (7.11): in_progress |
| Appendix F Services | rfid.scan.command.service, rfid.tag.crud.service |
| User Roles | ALL |
| Offline Capable | Yes (fully offline — all scan data stored in local SQLite) |
| Route | /session/:id/scan |
Purpose
The Scanning Interface is the primary operational screen where operators perform RFID tag reads using the connected Zebra reader. It displays real-time scan progress including tag count, RSSI signal strength, section progress, and a live-updating list of detected EPCs. All scan data is written to local SQLite with 30-second auto-save checkpoints for crash recovery.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Start/Stop Scan Toggle | Large Button | Toggle RFID reader on/off (green = scanning, red = stopped) | Reader controlled via Zebra RFID SDK native bridge |
| Tag Counter | Large Numeric Display | “347 / 500” format showing found tags vs expected count | Expected count from session creation (SCR-R03) or total active tags at location |
| RSSI Signal Indicator | Vertical Bar / Arc | Real-time signal strength of current reads (-30 dBm = strong, -70 dBm = threshold) | Tags below min_rssi_threshold (-70 dBm default) are filtered as phantom reads |
| Section Progress Bar | Horizontal Progress | Percentage of expected tags found in operator’s assigned section | Color-coded: green (> 95%), yellow (80-95%), red (< 80%) |
| EPC List | Scrollable List | Live-updating list of scanned EPCs with SKU name, variant, and read count | Sorted by most-recent-first; matched tags show product info from rfid_tag_mappings cache |
| Unknown Tags Badge | Warning Count | Count of EPCs not found in rfid_tag_mappings (unmatched) | Displayed as “12 unknown” badge; could indicate tags from other tenants or unregistered tags |
| Battery Indicator | Icon + Percentage | Device battery level and reader battery level | Below 15%: triggers auto-save and warning “Low battery — save your progress” |
| Auto-Save Indicator | Subtle Text | “Last saved: 12s ago” timestamp | 30-second SQLite checkpoint interval (configurable via rfid_config.auto_save_interval_seconds) |
| Elapsed Time | Timer | Session duration counter (HH:MM:SS) | Auto-expires after session_timeout_minutes (default: 480 min / 8 hours) |
Wireframe
┌─────────────────────────────────┐
│ ◄ Session Men's Tops ■ ■ │ <- Back, Section name, Battery icons
│ 12:34:21 │ <- Elapsed time
├─────────────────────────────────┤
│ │
│ ┌───────────┐ │
│ │ │ │
│ │ 347 │ │ <- Tag count (large)
│ │ ─── ─── │ │
│ │ 500 │ │ <- Expected count
│ │ │ │
│ └───────────┘ │
│ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░ 69% │ <- Section progress bar
│ │
│ RSSI: ████████░░ -42 dBm │ <- Signal strength
│ │
├─────────────────────────────────┤
│ Scanned Tags 12 unk │ <- Unknown tag count
├─────────────────────────────────┤
│ ▸ 3034F8A2C1E9B7D0 NXJ-1078 │
│ Blue Hoodie / M ×3 -38 │
│ ▸ 3034F8A2C1E9B7D1 NXJ-1078 │
│ Blue Hoodie / L ×2 -45 │
│ ▸ 3034F8A2C1E9B7D2 NXJ-1079 │
│ Black Tee / S ×1 -52 │
│ ▸ 3034F8A2C1E9B7D3 ??? unkn │
│ Unknown tag ×1 -61 │
│ ▸ 3034F8A2C1E9B7D4 NXJ-1080 │
│ White Polo / XL ×1 -44 │
│ │
│ (scrollable) │
├─────────────────────────────────┤
│ Auto-saved 12s ago │
│ │
│ ┌─────────────────────────────┐│
│ │ ■ STOP SCANNING ││ <- Large toggle button (red)
│ └─────────────────────────────┘│
└─────────────────────────────────┘
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap “Stop Scanning” → “Complete Section” | SCR-R05 Section Assignment & Progress | ALL |
| Tap “Stop Scanning” → “End Session” (manager) | SCR-R06 Session Summary | MANAGER, OWNER |
| App backgrounded / battery critical | Stay on SCR-R04 (auto-save triggered immediately) | ALL |
| App crash / force close | SCR-R04 with recovery dialog on relaunch (“Resume session?”) | ALL |
G.11.5 Section Assignment & Progress
| Attribute | Value |
|---|---|
| Screen ID | SCR-R05 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8 |
| Database Tables | session_operators (R/W), rfid_scan_sessions (R), rfid_scan_events (R, local) |
| State Machine(s) | RFID Session (7.11): in_progress |
| Appendix F Services | rfid.scan.command.service |
| User Roles | ALL (view); MANAGER, OWNER (reassign sections) |
| Offline Capable | Partial (shows local progress; section reassignment requires connectivity) |
| Route | /session/:id/sections |
Purpose
The Section Assignment & Progress screen shows the overall progress of a multi-operator counting session. Each operator’s assigned section is displayed with their individual tag count, progress percentage, and connection status. Managers can reassign sections or add/remove operators from this screen.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Session Header | Info Card | Session name, type, location, total tags found, elapsed time | Aggregates counts from all operators |
| Operator Section Cards | Card List | One card per operator showing: name, section, tag count, progress %, last activity timestamp | Progress % = operator’s tags / (expected / num_sections) |
| My Section Highlight | Visual Emphasis | Current user’s section card is highlighted with a colored border | Allows quick identification of own section |
| Operator Status | Badge per Card | “Scanning” (green pulse), “Paused” (yellow), “Completed” (checkmark), “Disconnected” (red) | Based on last activity timestamp; disconnected if no scan events > 5 minutes |
| Add Operator Button | Icon Button | Add a new operator to the session (manager only) | Max 10 operators per session |
| Remove Operator Button | Icon Button | Remove an operator from the session (manager only) | Already-uploaded data preserved; session_operators.left_at set |
| Reassign Section | Edit Button | Change an operator’s assigned section (manager only) | Updates session_operators.assigned_section |
| Resume Scanning Button | Primary Button | Return to scanning interface for current user’s section | Navigates to SCR-R04 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap “Resume Scanning” | SCR-R04 Scanning Interface | ALL |
| Tap “Complete Session” | SCR-R06 Session Summary | MANAGER, OWNER |
| Tap “Add Operator” | Operator selection modal (inline) | MANAGER, OWNER |
| Back button | SCR-R02 Home Dashboard | ALL |
G.11.6 Session Summary
| Attribute | Value |
|---|---|
| Screen ID | SCR-R06 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8 |
| Database Tables | rfid_scan_sessions (R/W), rfid_scan_events (R), session_operators (R), rfid_tags (R) |
| State Machine(s) | RFID Session (7.11): in_progress → completed or in_progress → cancelled |
| Appendix F Services | rfid.scan.command.service, rfid.inventory-reconciliation.service |
| User Roles | ALL (view); MANAGER, OWNER (approve/reject) |
| Offline Capable | Partial (shows local counts; variance calculation requires server data) |
| Route | /session/:id/summary |
Purpose
The Session Summary displays the final results of an RFID counting session, including total tags scanned, expected count, variance analysis, and per-section breakdowns. Managers use this screen to review results and decide whether to approve the count, request a recount, or cancel the session. Variance is color-coded by severity threshold.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Session Info Header | Info Card | Session number, type, location, start/end time, duration, operator count | Read from rfid_scan_sessions |
| Total Count Summary | Large Stats | Found: 4,832 / Expected: 5,000 / Variance: -168 (-3.36%) | Variance = found - expected; negative = missing tags |
| Variance Severity | Color-Coded Banner | Green (0%), Yellow (1-2%), Orange (3-5%), Red (>5%) | Thresholds from rfid_config variance settings (see BRD Section 5.16.4) |
| Per-Section Breakdown | Table/Cards | Each section with operator name, tags found, expected, variance, duration | Grouped by session_operators.assigned_section |
| Unknown Tags List | Expandable Section | EPCs not found in rfid_tags table — potential unregistered or foreign tags | Count shown as “23 unknown tags” with expandable detail list |
| Missing Tags List | Expandable Section | Expected tags (active in rfid_tags at this location) not found during scan | Helps identify potentially lost or misplaced inventory |
| Upload Status | Badge | “Pending upload” or “Uploaded” with timestamp | Shows whether scan data has been synced to server |
| Approve Button | Primary Button | Mark session as completed and accept the count | MANAGER/OWNER only; writes rfid_scan_sessions.status = 'completed', sets completed_by and completed_at |
| Recount Button | Secondary Button | Keep session open for additional scanning passes | Returns to SCR-R05 Section Assignment |
| Cancel Button | Destructive Button | Cancel the session entirely | MANAGER/OWNER only; sets rfid_scan_sessions.status = 'cancelled'; scan data preserved for audit |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap “Approve” | SCR-R07 Chunked Upload (if not already uploaded) → SCR-R02 Home | MANAGER, OWNER |
| Tap “Recount” | SCR-R05 Section Assignment (session stays in_progress) | MANAGER, OWNER |
| Tap “Cancel Session” | SCR-R02 Home Dashboard (after confirmation dialog) | MANAGER, OWNER |
| Back button | SCR-R02 Home Dashboard | ALL |
G.11.7 Chunked Upload / Sync
| Attribute | Value |
|---|---|
| Screen ID | SCR-R07 |
| Product(s) | Raptag |
| BRD Section(s) | 4.6.8 |
| Database Tables | rfid_scan_events (R, local SQLite → W, server PostgreSQL), rfid_scan_sessions (W) |
| State Machine(s) | RFID Session (7.11): in_progress → uploaded |
| Appendix F Services | rfid.scan.command.service, rfid.inventory-reconciliation.service |
| User Roles | ALL |
| Offline Capable | No (requires connectivity to upload) |
| Route | /session/:id/sync |
Purpose
The Chunked Upload screen manages the transfer of locally-stored RFID scan data from the mobile device to the Central API. Scan events are uploaded in chunks of 5,000 events each, with progress tracking, retry logic for failed chunks, and idempotency guarantees (UNIQUE constraint on session_id + epc prevents duplicate processing on retries).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Session Upload Card | Info Card | Session name, total events to upload, chunk count | Total events / 5,000 = number of chunks (rounded up) |
| Upload Progress Bar | Determinate Progress | “Chunk 3 of 7 — 15,000 / 35,000 events” | Each chunk = POST to /api/rfid/sessions/:id/upload with 5,000 events |
| Upload Speed | Stats Text | “~2,400 events/sec” throughput indicator | Calculated from chunk upload time |
| Chunk Status List | Scrollable List | Per-chunk status: pending, uploading, completed, failed | Failed chunks show error message and retry button |
| Retry Failed Button | Warning Button | Retry all failed chunks | Idempotent: server uses UNIQUE(session_id, epc) with UPSERT logic; safe to re-send |
| Connection Status | Indicator | Current connectivity state | Upload pauses automatically if connectivity drops; resumes on reconnection |
| Server Processing | Status Text | “Server merging operator data…” shown after all chunks uploaded | Server-side dedup (keeps highest RSSI per EPC) and variance calculation |
| Upload Complete Summary | Success Card | “35,000 events uploaded. 4,832 unique tags. Server processing complete.” | Shown after server acknowledges all chunks and completes merge |
| Cancel Upload Button | Text Button | Cancel remaining upload (already-uploaded chunks are preserved) | Partial uploads are safe; session stays in_progress for retry later |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Upload complete | SCR-R06 Session Summary (with server-calculated variance) | ALL |
| Cancel upload | SCR-R02 Home Dashboard (session marked as “pending upload”) | ALL |
| All sessions synced (from pending uploads) | SCR-R02 Home Dashboard with cleared “Pending Uploads” badge | ALL |
G.11.8 Device Pairing
| Attribute | Value |
|---|---|
| Screen ID | SCR-R08 |
| Product(s) | Raptag |
| BRD Section(s) | 5.16 |
| Database Tables | devices (R/W), rfid_config (R) |
| State Machine(s) | – |
| Appendix F Services | rfid.config.crud.service |
| User Roles | ALL (view); MANAGER, ADMIN, OWNER (pair/unpair) |
| Offline Capable | Partial (shows previously paired devices; new pairing requires connectivity) |
| Route | /devices |
Purpose
The Device Pairing screen manages the connection between the Raptag mobile app and Zebra RFID readers. Operators pair their phone with a reader using Bluetooth discovery or a 6-character claim code generated in Nexus POS (Settings > RFID > Devices). Once paired, the reader appears on the Home Dashboard status indicator and is available for scanning sessions.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Paired Device Card | Info Card | Currently paired reader: model (MC3390R / RFD40), serial number, connection type, battery level, firmware version | One reader per device at a time |
| Connection Status | Status Badge | “Connected” (green), “Disconnected” (red), “Searching…” (yellow pulse) | Based on Bluetooth/WiFi connection state to the reader |
| Claim Code Entry | Text Input | 6-character alphanumeric code for initial reader registration | Code generated in Nexus POS > Settings > RFID > Devices (OWNER); valid for 24 hours; one-time use |
| Bluetooth Discovery | Button + List | Scan for nearby Bluetooth RFID readers | Shows discoverable Zebra devices with model and signal strength |
| Reader Details | Expandable Section | Serial number, firmware version, last seen, assigned location, read range | From devices table joined with reader registration data |
| Unpair Button | Destructive Text Button | Disconnect and unpair the current reader | Requires MANAGER role; clears device association |
| Test Read Button | Secondary Button | Fire a single RFID read to verify reader is working | Reads one tag and displays EPC + RSSI; confirms hardware connection |
| Supported Models Info | Collapsible Section | List of supported reader models with specs (MC3390R, RFD40, FX9600) | Reference information; not interactive |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Successful pairing | SCR-R02 Home Dashboard (reader status updated) | MANAGER, ADMIN, OWNER |
| Tap “Test Read” | Stay on SCR-R08 (inline result display) | ALL |
| Unpair device | Stay on SCR-R08 (device card cleared) | MANAGER, ADMIN, OWNER |
| Back button | SCR-R02 Home Dashboard | ALL |
G.11.9 Offline Mode Indicator (Cross-Screen Component)
| Attribute | Value |
|---|---|
| Screen ID | SCR-X03 |
| Product(s) | All Roles |
| BRD Section(s) | 1.16 |
| Database Tables | – (connection state is client-side only) |
| State Machine(s) | Offline Mode (7.12): ONLINE / DEGRADED / OFFLINE |
| Appendix F Services | system.connection-state.service |
| User Roles | ALL |
| Offline Capable | N/A (this component IS the offline indicator) |
Purpose
The Offline Mode Indicator is a persistent banner component displayed at the top of all Nexus POS screens (all roles). It communicates the current connectivity state to the user using the 3-state model from ADR-048 (ONLINE / DEGRADED / OFFLINE). The banner is minimal when online, becomes more prominent during degraded connectivity, and displays critical information (queue count, staleness warning) when fully offline.
Wireframe
ONLINE (green — minimal, auto-hides after 3 seconds):
┌──────────────────────────────────────────────┐
│ ● Connected │
└──────────────────────────────────────────────┘
DEGRADED (yellow — persistent, subtle):
┌──────────────────────────────────────────────┐
│ ● Limited connectivity — some features │
│ unavailable │
└──────────────────────────────────────────────┘
OFFLINE (red — persistent, prominent):
┌──────────────────────────────────────────────┐
│ ● Offline mode — sales will sync when │
│ connected. 3 sales queued. │
│ Prices may be outdated (last sync: 47m) │
└──────────────────────────────────────────────┘
SYNCING (yellow — persistent, with progress):
┌──────────────────────────────────────────────┐
│ ● Syncing... 2/3 sales uploaded │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░ 67% │
└──────────────────────────────────────────────┘
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Status Dot | Colored Circle | Green (ONLINE), Yellow (DEGRADED/SYNCING), Red (OFFLINE) | Driven by ConnectionMonitor 3-state model |
| Status Text | Label | State-specific message (see wireframe above) | ONLINE message auto-hides after 3 seconds; DEGRADED and OFFLINE persist |
| Queue Count | Dynamic Text | “N sales queued” — count of pending entries in sales_queue | Only shown in OFFLINE state; updated as new offline sales are created |
| Staleness Warning | Subtle Text | “Prices may be outdated (last sync: Xm)” | Shown when product_cache.last_refreshed > 60 minutes old |
| Sync Progress | Progress Bar + Text | “Syncing… 2/3 sales uploaded” with determinate progress bar | Shown during SYNCING sub-state; transitions to ONLINE when queue is empty |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Tap banner (OFFLINE state) | Show detail modal: list of queued sales with timestamps | ALL |
| Tap banner (SYNCING state) | Show detail modal: per-sale sync progress with success/error status | ALL |
| Connection restores | Banner auto-transitions: OFFLINE → SYNCING → ONLINE | – |
G.12 Cross-Cutting Screens
These screens appear across all products and are not tied to a single BRD module.
G.12.1 Login / Authentication
| Attribute | Value |
|---|---|
| Screen ID | SCR-X01 |
| Product(s) | All Roles |
| BRD Section(s) | 5.5 |
| Database Tables | tenant_users (R), tenants (R), roles (R), role_permissions (R) |
| State Machine(s) | — |
| Appendix F Services | auth.login.service, auth.token.service |
| User Roles | ALL |
| Offline Capable | Yes (cached credentials for POS terminal PIN login and Raptag; standard email/password login requires online) |
| Route | /login |
Purpose
The login screen is the entry point for both products. Users authenticate with email/password (standard login) or PIN (POS quick-switch for cashiers). On POS and Raptag, cached credentials allow offline login for previously authenticated users. The screen adapts its layout based on the deployment context (web browser for Nexus POS, React Native mobile for Raptag).
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Email Field | Input | User email address | BRD 5.5 — required |
| Password Field | Input | Masked password entry | BRD 5.5 — Argon2id hashing |
| PIN Entry (POS only) | Input | 4-6 digit numeric PIN for quick-switch | BRD 5.5 — BCrypt hashing |
| Tenant Selector | Dropdown | Select tenant (multi-tenant users only) | BRD 5.5 — row-level isolation |
| Remember Me | Checkbox | Cache credentials for offline login | ADR-048 |
| Offline Indicator | Banner | Shows connection state if DEGRADED/OFFLINE | ADR-048 |
| Forgot Password | Link | Trigger password reset email | BRD 5.5 |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Login (POS) | SCR-M01-01 Sales Terminal | CASHIER+ |
| Login (standard) | Dashboard (role-appropriate home) | MANAGER+ |
| Login (Raptag) | SCR-R02 Home Dashboard | AUDITOR+ |
| Switch User (POS PIN) | SCR-M01-01 Sales Terminal (different user) | CASHIER+ |
| Forgot Password | Password reset email flow | ALL |
G.12.2 Error Pages (403 / 404 / 500)
| Attribute | Value |
|---|---|
| Screen ID | SCR-X02 |
| Product(s) | All |
| BRD Section(s) | — |
| Database Tables | — |
| State Machine(s) | — |
| Appendix F Services | — |
| User Roles | ALL |
| Offline Capable | Yes (static pages bundled with app) |
| Route | /error/403, /error/404, /error/500 |
Purpose
Standard error pages displayed when a user encounters an authorization failure (403), navigates to a non-existent route (404), or the server returns an unhandled error (500). These pages are statically bundled with each application and work offline. Each provides a clear error message, suggested actions, and a link to navigate back to a known-good state.
Key Elements
| Element | Type | Description | Business Rule |
|---|---|---|---|
| Error Code | Heading | Large display of 403, 404, or 500 | — |
| Error Message | Text | Human-readable explanation (e.g., “Page not found”) | — |
| Suggested Actions | Text | “Check the URL” / “Go back” / “Contact support” | — |
| Back to Home | Button | Navigate to dashboard | — |
| Report Issue | Link | Opens support/feedback form | — |
Actions & Transitions
| Action | Navigates To | Requires Role |
|---|---|---|
| Back to Home | Dashboard (role-appropriate home) | ALL |
| Go Back | Previous page (browser history) | ALL |
| Report Issue | Support form / feedback modal | ALL |
G.12.3 Offline Mode Indicator
Note: Full specification for SCR-X03 is provided in G.11 (Raptag section) as it was written by Agent A5 with the complete 3-state + SYNCING wireframe. See G.11 for the detailed specification including the ASCII wireframe showing all 4 states (ONLINE, DEGRADED, OFFLINE, SYNCING).
| Attribute | Value |
|---|---|
| Screen ID | SCR-X03 |
| Product(s) | All Roles |
| BRD Section(s) | 1.16, ADR-048 |
| Full Specification | See G.11 (Agent A5 output) |
G.13 Navigation Flow Maps
These diagrams show the primary user workflows across screens. Screen IDs reference the detailed specifications in G.4-G.12.
G.13.1 Standard Sale Flow (POS)
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCR-X01 │ │ SCR-M01-01 │ │ SCR-M01-02 │ │ SCR-M01-04 │ │ SCR-M01-09 │
│ Login │───►│ Sales │───►│ Cart Panel │───►│ Payment / │───►│ Receipt │
│ │ │ Terminal │ │ & Line Items │ │ Checkout │ │ Print │
└──────────┘ └──────┬───────┘ └──────────────┘ └──────┬───────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ SCR-M02-01 │ │ SCR-M01-03 │
│ Customer │ │ Discount │
│ Lookup │ │ Modal │
└──────────────┘ └──────────────┘
G.13.2 Return / Exchange Flow (POS)
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCR-M01 │ │ SCR-M01-14 │ │ Item │ │ Refund │ │ SCR-M01-09 │
│ -01 │───►│ Return │───►│ Selection │───►│ Method │───►│ Receipt │
│ Terminal │ │ Processing │ │ (within │ │ Selection │ │ Print │
│ │ │ (Receipt │ │ SCR-M01-14) │ │ (within │ │ │
│ │ │ Lookup) │ │ │ │ SCR-M01-14) │ │ │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ SCR-M01-15 │
│ Exchange │
│ (optional) │
└──────────────┘
G.13.3 Cash Drawer Lifecycle (POS)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCR-M01-10 │ │ Sales Day │ │ SCR-M01-10 │ │ SCR-M01-10 │
│ Cash Drawer │───►│ (Multiple │───►│ X-Report │───►│ Z-Report │
│ Open │ │ sales via │ │ (Mid-day │ │ (End-of-day │
│ (Starting $) │ │ SCR-M01-01) │ │ read-only) │ │ close out) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
G.13.4 Purchase Order Flow (BUYER+ / MANAGER+)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCR-M04-04 │ │ SCR-M04-05 │ │ SCR-M04-07 │ │ SCR-M04-08 │
│ PO Create / │───►│ PO Approval │───►│ Receiving & │───►│ Receiving │
│ Edit │ │ / Track │ │ Inspection │ │ Variance │
│ │ │ │ │ │ │ (if needed) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
▲
│
┌──────────────┐
│ SCR-M04-06 │
│ PO Templates │
│ (optional) │
└──────────────┘
G.13.5 Stock Count Flow (MANAGER+ / CASHIER+ / Raptag)
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ ┌──────────────┐
│ SCR-M04-09 │ │ SCR-M04-10 │ │ Count Entry: │ │ SCR-M04-13 │
│ Stock Count │───►│ Count Freeze │───►│ SCR-M04-11 (Scanner) │───►│ Count │
│ Session │ │ Manager │ │ SCR-M04-12 (RFID) │ │ Results │
│ (Create) │ │ │ │ SCR-R04 (Raptag) │ │ Review │
└──────────────┘ └──────────────┘ └──────────────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ SCR-M04-14 │
│ Count │
│ Approval │
└──────────────┘
G.13.6 RFID Count Flow (Raptag Mobile)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ SCR-R01 │ │ SCR-R02 │ │ SCR-R03 │ │ SCR-R04 │ │ SCR-R06 │ │ SCR-R07 │
│ Login │───►│ Home │───►│ Join / │───►│ Scanning │───►│ Session │───►│ Chunked │
│ │ │ Dashboard│ │ Start │ │ Interface│ │ Summary │ │ Upload │
│ │ │ │ │ Session │ │ │ │ │ │ / Sync │
└──────────┘ └──────────┘ └──────────┘ └────┬─────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐
│ SCR-R05 │
│ Section │
│ Progress │
└──────────┘
G.13.7 Onboarding Flow (OWNER — New Tenant)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ SCR-M05-01 Onboarding Wizard │
│ │
│ Step 1 Step 2 Step 3 Step 4 Step 5 Step 6 Step 7 │
│ Company → Locations → Tax → Payment → Receipt → Users → Registers │
│ Profile Setup Config Setup Template & Roles Setup │
│ │
│ Step 8 Step 9 Step 10 Step 11 Step 12 Step 13 │
│ Product → Inventory → Category → Integration → Printer → Go-Live │
│ Import Sync Setup Setup Setup Checklist │
└──────────────────────────────────────────────────────────────────────────────────┘
G.13.8 Integration Setup Flow (OWNER)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ SCR-M06-01 │ │ Provider │ │ SCR-M06-03 │ │ SCR-M06-10 │
│ Integration │───►│ Setup: │───►│ Sync Config │───►│ Inventory │
│ Hub │ │ SCR-M06-02 │ │ (rules, │ │ Sync │
│ Dashboard │ │ SCR-M06-05 │ │ buffers) │ │ Dashboard │
│ │ │ SCR-M06-07 │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ SCR-M06-11 │
│ Health │
│ Monitor │
└──────────────┘
G.14 Screen Statistics & Traceability Matrix
G.14.1 Screen Count Summary
| Category | Count |
|---|---|
| Module 1: Sales | 22 |
| Module 2: Customers | 10 |
| Module 3: Catalog | 18 |
| Module 4: Inventory | 23 |
| Module 5: Setup & Config | 22 |
| Module 6: Integrations | 12 |
| Raptag Mobile | 8 |
| Cross-Cutting | 3 |
| Total | 118 |
G.14.2 Product Distribution
| Product | Screens | Notes |
|---|---|---|
| Nexus POS (Web App) | 107 | All roles access the same web app; visibility controlled by role-based routing |
| Nexus Raptag (Mobile) | 8 | Separate React Native app (ADR-047) |
| Cross-Cutting (All Products) | 3 | Login, Error Pages, Offline Indicator |
| Total | 118 |
Role-Based Screen Access (Nexus POS)
| Minimum Role | Screen Count | Notes |
|---|---|---|
| All Roles | 16 | Customer list/profile, product search, inventory list, receiving, stock count, login, etc. |
| CASHIER+ | 21 | Sales terminal, cart, payment, receipts, drawer, clock-in, scanner count |
| MANAGER+ | 35 | Reports, catalog management, inventory management, user management |
| OWNER | 32 | Setup, integrations, configuration, onboarding, RFID config |
| BUYER+ | 6 | Seasons, media manager, PO create, PO templates |
G.14.3 Offline Capability
| Offline Status | Screen Count | Notes |
|---|---|---|
| Yes (full offline) | 14 | Includes Sales Terminal, Product Search, Receipt, Login, Raptag Scanning |
| Degraded | 3 | Payment (cash only), Raptag Upload (queued), SCR-X03 indicator |
| No (online required) | 101 | All management/config screens, most features beyond basic sales |
G.14.4 Role Access Matrix
| Role | Screens Accessible | Primary Modules |
|---|---|---|
| OWNER | 118 (all) | Setup (sole access to 8 screens), all others |
| MANAGER | ~95 | Sales, Inventory, Customers, Setup (partial), Integrations |
| CASHIER | ~25 | Sales (primary), basic Customer, basic Inventory |
| BUYER | ~20 | Catalog (primary), Inventory (POs, vendors, reorder) |
| AUDITOR | ~15 | Inventory (counts), Raptag (all), Audit Log |
G.14.5 BRD Module Coverage
| BRD Module | Total BRD Sections | Screens Mapped | Coverage |
|---|---|---|---|
| Module 1: Sales (1.1-1.20) | 20 | 22 | 100% |
| Module 2: Customers (2.1-2.8) | 8 | 10 | 100% |
| Module 3: Catalog (3.1-3.15) | 15 | 18 | 100% |
| Module 4: Inventory (4.1-4.19) | 19 | 23 | 100% |
| Module 5: Setup (5.1-5.21) | 21 | 22 | 100% |
| Module 6: Integrations (6.1-6.13) | 13 | 12 | 100% |
| Module 7: State Machines (7.1-7.16) | 16 | Referenced in screens | 100% |
G.14.6 State Machine Traceability
| State Machine | BRD Section | Referenced By Screens |
|---|---|---|
| 7.1 Order States | 7.1 | SCR-M01-01, SCR-M01-02, SCR-M01-04, SCR-M01-07 |
| 7.2 Payment States | 7.2 | SCR-M01-04, SCR-M01-05, SCR-M01-06 |
| 7.3 Layaway States | 7.3 | SCR-M01-05 |
| 7.4 Return States | 7.4 | SCR-M01-14, SCR-M01-15 |
| 7.5 PO States | 7.5 | SCR-M04-04, SCR-M04-05, SCR-M04-07 |
| 7.6 Transfer States | 7.6 | SCR-M04-17 |
| 7.7 Count Session States | 7.7 | SCR-M04-09, SCR-M04-10, SCR-M04-13, SCR-M04-14 |
| 7.8 Adjustment States | 7.8 | SCR-M04-15, SCR-M04-16 |
| 7.9 RMA States | 7.9 | SCR-M04-21 |
| 7.10 Reservation States | 7.10 | SCR-M04-23 |
| 7.11 Gift Card States | 7.11 | SCR-M01-08 |
| 7.12 Connectivity States | 7.12 | SCR-X03 |
| 7.13 Integration Sync States | 7.13 | SCR-M06-10, SCR-M06-11 |
| 7.14 Integration Connection States | 7.14 | SCR-M06-01, SCR-M06-02, SCR-M06-05, SCR-M06-07 |
| 7.15 Special Order States | 7.15 | SCR-M01-16 |
| 7.16 Onboarding States | 7.16 | SCR-M05-01 |
G.14.7 ASCII Wireframe Index
| # | Screen ID | Screen Name | Section |
|---|---|---|---|
| 1 | SCR-M01-01 | Sales Terminal / Item Entry | G.4.1 |
| 2 | SCR-M01-04 | Payment / Checkout | G.4.4 |
| 3 | SCR-M01-10 | Cash Drawer Z-Report | G.4.10 |
| 4 | SCR-M01-14 | Return Processing | G.4.14 |
| 5 | SCR-M02-02 | Customer Profile / Detail | G.5.2 |
| 6 | SCR-M03-02 | Product Detail / Edit | G.6.2 |
| 7 | SCR-M04-01 | Inventory Dashboard | G.7.1 |
| 8 | SCR-M04-04 | PO Create / Edit | G.7.4 |
| 9 | SCR-M04-09 | Stock Count Session | G.7.9 |
| 10 | SCR-M05-01 | Onboarding Wizard (13 Steps) | G.8.1 |
| 11 | SCR-M06-01 | Integration Hub Dashboard | G.9.1 |
| 12 | SCR-R04 | Scanning Interface | G.11.4 |
| 13 | SCR-X03 | Offline Mode Indicator | G.11.9 |
G.15 Document Information
| Attribute | Value |
|---|---|
| Version | 7.0.0 |
| Created | March 1, 2026 |
| Updated | March 2, 2026 |
| Author | Claude Code UI/UX Team |
| Status | Active |
| Appendix | G |
| Total Screens | 118 |
| ASCII Wireframes | 13 |
| BRD Version | 20.0 |
Change Log
| Version | Date | Changes |
|---|---|---|
| 7.0.0 | 2026-03-02 | Unified web app pivot: removed Tauri/desktop references, “Nexus Admin” product eliminated. Nexus POS is now a single React web app (ADR-052). Product(s) field changed from POS/Admin/Both to role-based values (All Roles, CASHIER+, MANAGER+, OWNER, BUYER+, Raptag). G.2 rewritten for 2 products. G.10 POS vs Admin navigation comparison replaced with role-based navigation visibility table. G.10.2 hardware integration updated: Tauri Rust commands replaced with Star WebPRNT, WebUSB, Stripe Terminal JS SDK. G.10.3 SQLite fallback updated to SQLite WASM (sql.js/wa-sqlite + OPFS). G.14 statistics rewritten with role-based screen distribution. All 118 screen entries updated. |
| 6.4.0 | 2026-03-01 | Initial release: 118 screens cataloged (7 modules + Raptag + cross-cutting). 13 ASCII wireframes. Full BRD traceability. |
This appendix is part of the POS Blueprint Book. All content is self-contained.