Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The POS Platform Blueprint

A Complete Guide to Building an Enterprise Multi-Tenant Point of Sale System

Version: 1.0.0 Created: December 29, 2025 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 visionPart I: Foundation
Design the systemPart II: Architecture
Build the databasePart III: Database
Write the backendPart IV: Backend
Create the UIPart V: Frontend
Start implementingPart VI: Implementation
Deploy to productionPart VII: Operations
Look up termsPart VIII: Reference

Claude Code Commands

Throughout this book, you’ll see commands like:

/dev-team implement tenant middleware

These are Claude Code multi-agent commands. See Chapter 20: Claude Code Reference for the complete command guide.


Table of Contents

Front Matter


Part I: Foundation

Understanding the “why” before the “how”


Part II: Architecture

System design and key decisions


Part III: Database

Complete data layer specification


Part IV: Backend

API and service layer implementation


Part V: Frontend

User interface specifications


Part VI: Implementation Guide

Step-by-step building instructions


Part VII: Operations

Deployment and ongoing maintenance


Part VIII: Reference

Quick lookup resources


Appendices


Book Statistics

MetricValue
Total Chapters37
Parts8
Appendices5
Database Tables51
API Endpoints75+
Domain Events35+
Target GradeA (Production-Ready)

How to Print This Book

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 \
  Part-IV-Backend/*.md \
  Part-V-Frontend/*.md \
  Part-VI-Implementation/*.md \
  Part-VII-Operations/*.md \
  Part-VIII-Reference/*.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

  1. Install “Markdown PDF” extension in VS Code
  2. Open each chapter
  3. Right-click → “Markdown PDF: Export (pdf)”
  4. 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-VIII
for part in Part-I-Foundation Part-II-Architecture Part-III-Database \
            Part-IV-Backend Part-V-Frontend Part-VI-Implementation \
            Part-VII-Operations Part-VIII-Reference 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

FormatPagesNotes
Full Book~400-500All chapters and appendices
Core (Parts I-IV)~200Architecture + Backend
Quick Reference~50Part VIII only

Version History

VersionDateChanges
1.0.02025-12-29Initial Blueprint Book

Contributors

RoleContributor
ArchitectClaude Code Architect Agent
AuthorClaude Code Editor Agent
ReviewerClaude Code Engineer Agent
ResearchClaude Code Researcher Agent
CoordinatorClaude Code Orchestrator

“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 51 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

ReaderFocus Areas
ArchitectsParts II, III, VIII (Architecture, Database, ADRs)
Backend DevelopersParts III, IV, VI (Database, Backend, Implementation)
Frontend DevelopersParts V, VI (Frontend, Implementation)
DevOps EngineersParts VII, VIII (Operations, Deployment)
Product ManagersParts I, VI (Foundation, Roadmap)
Business AnalystsParts I, V (Foundation, UI Specifications)

How to Use This Book

If Starting Fresh

Read sequentially from Part I through Part VI. This gives you the full context before implementation.

If Joining Mid-Project

  1. Read Part I (Foundation) for context
  2. Read the relevant Part for your work area
  3. Use Part VIII (Reference) for quick lookups

If Looking Up Specific Information

Jump directly to:

  • Glossary (Chapter 35) for term definitions
  • API Reference (Appendix A) for endpoint details
  • Checklists (Chapter 36) for procedures
  • Troubleshooting (Chapter 37) for problem-solving

The Technology Stack

This Blueprint specifies a complete technology stack:

┌─────────────────────────────────────────────────────────────────────────┐
│                         TECHNOLOGY STACK                                │
│                                                                         │
│   BACKEND                          FRONTEND                             │
│   ───────                          ────────                             │
│   ASP.NET Core 8                   Blazor Server (Admin Portal)        │
│   Entity Framework Core 8          .NET MAUI (POS Client)              │
│   PostgreSQL 16                    .NET MAUI (Raptag Mobile)           │
│   SignalR (Real-time)              SQLite (Offline Storage)            │
│                                                                         │
│   INFRASTRUCTURE                   INTEGRATIONS                         │
│   ──────────────                   ────────────                         │
│   Docker + Docker Compose          Shopify (E-commerce)                │
│   Prometheus + Grafana             Stripe/Square (Payments)            │
│   Redis (Caching - optional)       Zebra (RFID Printers)               │
│   Tailscale (VPN Mesh)                                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Conventions Used

Code Samples

// C# code appears in blocks like this
public class OrderService : IOrderService
{
    // 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

AttributeValue
Book TitleThe POS Platform Blueprint
Version1.0.0
CreatedDecember 29, 2025
Total Chapters37
Total Appendices5
Target Platform/volume1/docker/pos-platform/
Print CommandSee “How to Print This Book” in Index

Chapter 01: Vision and Goals

Overview

This chapter establishes the foundational vision for building an enterprise multi-tenant Point of Sale (POS) system. It outlines the three-phase development approach, explains the rationale for building custom software, and defines the strategic goals that will guide all technical decisions.


1.1 The Three-Phase Development Approach

The POS system development follows a deliberate three-phase methodology designed to minimize risk while maximizing learning and quality.

+------------------------------------------------------------------+
|                    THREE-PHASE APPROACH                           |
+------------------------------------------------------------------+
|                                                                   |
|   PHASE 1: LEARN              PHASE 2: DESIGN                    |
|   +-----------------+         +-----------------+                 |
|   |     STANLY      |         |    BLUEPRINT    |                |
|   |                 |         |                 |                 |
|   | - Bridge comms  | ------> | - Architecture  |                |
|   | - QB POS quirks |         | - Data models   |                |
|   | - Store reality |         | - API design    |                |
|   | - Sync patterns |         | - UI patterns   |                |
|   +-----------------+         +-----------------+                 |
|          |                           |                            |
|          | Learnings                 | Specifications             |
|          v                           v                            |
|                     +-----------------+                           |
|                     |    PHASE 3:     |                           |
|                     |     BUILD       |                           |
|                     |                 |                           |
|                     | - Clean codebase|                           |
|                     | - No QB legacy  |                           |
|                     | - Multi-tenant  |                           |
|                     | - Modern stack  |                           |
|                     +-----------------+                           |
|                                                                   |
+------------------------------------------------------------------+

Phase 1: LEARN (Stanly Project)

Timeline: Completed / Ongoing maintenance Purpose: Understand real-world retail operations through hands-on integration

The Stanly project serves as a learning laboratory. By building a bridge between Shopify and QuickBooks POS V19, we gain deep understanding of:

Learning AreaKnowledge Gained
Store OperationsHow 5 physical stores actually operate day-to-day
Inventory SyncChallenges of keeping inventory accurate across locations
POS QuirksQuickBooks POS V19 data structures and limitations
Network RealityTailscale VPN, offline scenarios, unreliable connections
User BehaviorHow store associates interact with POS systems
Data VolumeActual transaction volumes, inventory counts, sync frequency

Key Insight: Stanly exists to teach us what NOT to do in the new system. Every workaround, every hack, every limitation becomes a design requirement for the clean build.

Phase 2: DESIGN (Blueprint Book)

Timeline: Current phase Purpose: Create comprehensive specifications before writing code

This Blueprint Book captures all learnings and translates them into:

  • Architectural decisions and their rationale
  • Database schema with complete documentation
  • API specifications and contracts
  • UI/UX patterns and components
  • Deployment and operations procedures

Key Principle: No code is written until the design is complete. This prevents feature creep, ensures consistency, and enables parallel development once building begins.

Phase 3: BUILD (Clean Implementation)

Timeline: Future Purpose: Implement the designed system from scratch

The build phase follows strict guidelines:

  1. No code copying from Stanly - Fresh implementation only
  2. Multi-tenant from day one - Not retrofitted later
  3. Modern technology stack - No legacy constraints
  4. Test-driven development - Comprehensive coverage
  5. Documentation as code - Specs become tests

1.2 Why Build a Custom POS System

The Business Case

QuickBooks Point of Sale V19 is end-of-life software with no future development. Intuit has discontinued the product line, leaving thousands of retailers searching for alternatives.

+------------------------------------------------------------------+
|              QUICKBOOKS POS END-OF-LIFE TIMELINE                  |
+------------------------------------------------------------------+
|                                                                   |
| 2019        2020        2021        2022        2023        2024  |
|   |           |           |           |           |           |   |
|   v           v           v           v           v           v   |
|   +-----+     +-----+     +-----+     +-----+     +-----+         |
|   |V19  |     |Last |     |No   |     |Support|   |Support|       |
|   |Rel. |     |Patch|     |More |     |Ends   |   |Ended  |       |
|   +-----+     +-----+     +-----+     +-----+     +-----+         |
|                                                                   |
+------------------------------------------------------------------+

Current Pain Points

Pain PointImpactCost
No real-time inventory syncLost sales, overselling$50K+/year
Desktop-only softwareNo remote managementManagement overhead
Nightly batch sync (Store Exchange)Stale data all dayOperational chaos
No Shopify integrationManual order entry2 FTE hours/day
Single-tenant architectureCannot scaleBusiness limitation
No mobile capabilityFixed checkout onlyCustomer friction

Why Not Buy an Existing Solution

Existing POS solutions were evaluated and rejected:

SolutionRejection Reason
SquareLimited inventory management, no multi-store
Shopify POSRequires Shopify ecosystem lock-in
LightspeedCost prohibitive for 5+ stores
VendAcquired by Lightspeed, uncertain future
CloverHardware lock-in, limited customization

The custom-build decision is justified by:

  1. Exact Feature Match: Build precisely what the business needs
  2. Integration Control: Own the Shopify integration completely
  3. Multi-Tenant Revenue: Potential SaaS offering to other retailers
  4. Technology Investment: Skills transfer to future projects
  5. Long-Term Cost: No per-terminal licensing fees

1.3 The Clean Room Design Principle

Definition

Clean room design means building the new system without copying any code from the Stanly project. Only learnings, not implementations, transfer between projects.

+------------------------------------------------------------------+
|                    CLEAN ROOM PRINCIPLE                           |
+------------------------------------------------------------------+
|                                                                   |
|   STANLY (Learning Project)        NEW POS (Production System)    |
|   +----------------------+         +----------------------+       |
|   |                      |         |                      |       |
|   | BridgeCommand.cs     |   X     | Command.cs           |       |
|   | QBXMLBuilder.cs      | ----X   | TransactionAPI.cs    |       |
|   | InventoryService.cs  |    X    | InventoryService.cs  |       |
|   |                      |   X     |                      |       |
|   +----------------------+         +----------------------+       |
|              |                              ^                     |
|              | LEARNINGS ONLY               |                     |
|              |                              |                     |
|              v                              |                     |
|   +----------------------+                  |                     |
|   |  What We Learned:    |                  |                     |
|   |                      |------------------+                     |
|   | - Polling intervals  |   INFORM DESIGN                       |
|   | - Error patterns     |                                        |
|   | - Data structures    |                                        |
|   | - Sync strategies    |                                        |
|   +----------------------+                                        |
|                                                                   |
+------------------------------------------------------------------+

Why Clean Room

ReasonExplanation
No Technical DebtStanly has workarounds for QB POS that we do not need
Proper ArchitectureDesign for multi-tenant from day one
Modern PatternsUse current best practices, not legacy patterns
Legal ClarityNo licensing complications from Stanly code
DocumentationEverything in new system is documented fresh

What Transfers

Allowed to transfer:

  • Business rules and logic (documented, not coded)
  • Data structure insights (what fields are needed)
  • Operational workflows (how stores actually work)
  • Error handling strategies (what can go wrong)
  • Performance requirements (actual volumes and timing)

Not allowed to transfer:

  • Source code files
  • Database schema directly
  • API endpoint implementations
  • UI component code
  • Configuration patterns

1.4 Target System Vision

Multi-Tenant SaaS POS

The new system is designed from the ground up as a multi-tenant Software as a Service platform.

+------------------------------------------------------------------+
|                    MULTI-TENANT ARCHITECTURE                      |
+------------------------------------------------------------------+
|                                                                   |
|   +---------------+  +---------------+  +---------------+         |
|   |  TENANT A     |  |  TENANT B     |  |  TENANT C     |         |
|   | Nexus Clothing|  | Future Client |  | Future Client |         |
|   +---------------+  +---------------+  +---------------+         |
|          |                  |                  |                  |
|          v                  v                  v                  |
|   +----------------------------------------------------------+   |
|   |                    SHARED PLATFORM                        |   |
|   |                                                           |   |
|   |   +-------------+  +-------------+  +-------------+       |   |
|   |   | API Layer   |  | Web UI      |  | Mobile Apps |       |   |
|   |   +-------------+  +-------------+  +-------------+       |   |
|   |                                                           |   |
|   |   +-------------+  +-------------+  +-------------+       |   |
|   |   | Auth/Tenant |  | Inventory   |  | Reporting   |       |   |
|   |   +-------------+  +-------------+  +-------------+       |   |
|   |                                                           |   |
|   |   +--------------------------------------------------+   |   |
|   |   |            PostgreSQL with Row-Level Security     |   |   |
|   |   +--------------------------------------------------+   |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Core Capabilities

CapabilityDescriptionPriority
Point of SaleProcess sales transactions, returns, exchangesP0
Inventory ManagementTrack stock across all locations in real-timeP0
Multi-LocationSupport any number of stores per tenantP0
Shopify SyncTwo-way inventory and order synchronizationP0
Offline ModeContinue operations during network outagesP0
User ManagementRole-based access control per tenantP0
ReportingSales, inventory, and performance analyticsP1
Payment ProcessingPCI-compliant card processingP1
RFID IntegrationSupport for RFID inventory scanningP2
Customer ManagementLoyalty programs, purchase historyP2

1.5 Store Locations and Scale

Nexus Clothing Retail Chain

The initial deployment serves Nexus Clothing with 5 locations in Virginia.

+------------------------------------------------------------------+
|                    NEXUS CLOTHING LOCATIONS                       |
+------------------------------------------------------------------+
|                                                                   |
|   VIRGINIA MAP (Simplified)                                       |
|                                                                   |
|   +----------------------------------------------------------+   |
|   |                                                           |   |
|   |                     [NM]                                  |   |
|   |                  Newport News                             |   |
|   |                       |                                   |   |
|   |                       |                                   |   |
|   |   [HQ]               [HM]                                |   |
|   | Chesapeake        Hampton                                 |   |
|   |    Warehouse                                              |   |
|   |       |                                                   |   |
|   |       +---[GM]                                           |   |
|   |       | Greenbrier                                        |   |
|   |       |                                                   |   |
|   |       +---[LM]                                           |   |
|   |         Virginia Beach                                    |   |
|   |                                                           |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Location Details

CodeNameTypeCityFunction
HQHeadquartersWarehouseChesapeake, VACentral inventory, receiving, distribution
GMGreenbrier MallRetail StoreChesapeake, VAFull retail with high foot traffic
HMPeninsula Town CenterRetail StoreHampton, VAFull retail, regional hub
LMLynnhaven MallRetail StoreVirginia Beach, VAFull retail, tourist area
NMPatrick Henry MallRetail StoreNewport News, VAFull retail, military adjacent

Scale Parameters

MetricCurrent ValueTarget Capacity
Number of Stores550+ per tenant
SKU Count~30,000100,000+
Daily Transactions~50010,000+
Concurrent Users~20500+
Tenants1Unlimited

1.6 Integration Goals

Primary Integrations

+------------------------------------------------------------------+
|                    INTEGRATION ARCHITECTURE                       |
+------------------------------------------------------------------+
|                                                                   |
|   +-------------+         +--------------+         +------------+ |
|   |   SHOPIFY   | <-----> |   NEW POS    | <-----> |  PAYMENT   | |
|   |             |         |    SYSTEM    |         | PROCESSOR  | |
|   | - Products  |         |              |         |            | |
|   | - Orders    |         | Central Hub  |         | - Cards    | |
|   | - Inventory |         |              |         | - Refunds  | |
|   | - Customers |         |              |         |            | |
|   +-------------+         +--------------+         +------------+ |
|                                  ^                                |
|                                  |                                |
|                    +-------------+-------------+                  |
|                    |             |             |                  |
|               +----+----+  +----+----+  +----+----+              |
|               |  RFID   |  | RECEIPT |  |  SCALE  |              |
|               | SCANNER |  | PRINTER |  |  (opt)  |              |
|               +---------+  +---------+  +---------+              |
|                                                                   |
+------------------------------------------------------------------+

Shopify Integration

FeatureDirectionPriority
Product CatalogShopify -> POSP0
Inventory LevelsBidirectionalP0
Order FulfillmentShopify -> POSP0
Customer SyncBidirectionalP1
Price UpdatesShopify -> POSP1

Payment Processing

RequirementSpecification
Card TypesVisa, Mastercard, Amex, Discover
EMV SupportChip-and-PIN required
NFC SupportApple Pay, Google Pay, Tap-to-Pay
PCI ComplianceSAQ-D or P2PE solution
ProcessorStripe recommended (alternatives: Square, Adyen)

RFID Integration (Future Phase)

CapabilityDescription
Inventory CountingRapid bulk counting with handheld scanners
Item LookupScan to find product location
Loss PreventionExit gate detection
ReceivingAutomated PO verification

1.7 Technology Decisions

Selected Stack

LayerTechnologyRationale
Backend APIASP.NET Core 8.0Team expertise, performance, long-term support
Frontend WebBlazor ServerShared C# codebase, real-time updates
Mobile.NET MAUICross-platform, code sharing with backend
DatabasePostgreSQL 16Multi-tenant RLS, JSON support, proven scale
CacheRedisSession state, real-time inventory cache
QueueRabbitMQReliable async processing, offline support
ContainerDockerConsistent deployment across environments
OrchestrationDocker Compose (initial) -> Kubernetes (scale)

Architecture Style

+------------------------------------------------------------------+
|                    ARCHITECTURE OVERVIEW                          |
+------------------------------------------------------------------+
|                                                                   |
|   CLIENT LAYER                                                    |
|   +-------------+  +-------------+  +-------------+               |
|   | Web Browser |  | Mobile App  |  | POS Terminal|               |
|   | (Blazor)    |  | (.NET MAUI) |  | (Kiosk Mode)|               |
|   +------+------+  +------+------+  +------+------+               |
|          |                |                |                      |
|          +----------------+----------------+                      |
|                           |                                       |
|   API LAYER               v                                       |
|   +----------------------------------------------------------+   |
|   |                    API Gateway                            |   |
|   |              (Rate limiting, Auth, Routing)               |   |
|   +----------------------------------------------------------+   |
|                           |                                       |
|   SERVICE LAYER           v                                       |
|   +-------------+  +-------------+  +-------------+               |
|   | Transaction |  | Inventory   |  | Reporting   |               |
|   | Service     |  | Service     |  | Service     |               |
|   +------+------+  +------+------+  +------+------+               |
|          |                |                |                      |
|   DATA LAYER              v                v                      |
|   +----------------------------------------------------------+   |
|   |                    PostgreSQL                             |   |
|   |              (Row-Level Security per Tenant)              |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

1.8 Project Governance

Decision Framework

All technical decisions follow this evaluation framework:

CriterionWeightDescription
Multi-Tenant Impact25%Does this support or hinder multi-tenancy?
Scalability20%Will this work at 10x current load?
Maintainability20%Can future developers understand this?
Security20%Does this introduce vulnerabilities?
Performance15%Does this meet response time requirements?

Documentation Requirements

Every feature must have:

  1. Specification - What it does and why
  2. Data Model - Database schema changes
  3. API Contract - Endpoint definitions
  4. Test Plan - How to verify correctness
  5. Deployment Notes - How to release safely

1.9 Summary

Key Takeaways

  1. Three phases ensure quality: Learn, Design, then Build
  2. Clean room design prevents legacy debt: No code copying
  3. Multi-tenant from day one: Not retrofitted
  4. Business justification is clear: QB POS is dead
  5. Technology choices are deliberate: Each selection is documented

Next Steps

  • Chapter 02: Understand the business context and retail operations
  • Chapter 03: Review systems being replaced and their limitations
  • Chapter 04: Define measurable success criteria

Document Information

AttributeValue
Version1.0.0
Created2025-12-29
AuthorClaude-NAS
StatusDraft
PartI - Foundation
Chapter01 of 04

This document is part of the POS Blueprint Book. All content is self-contained and requires no external file references.

Chapter 02: Business Context

Overview

This chapter provides comprehensive background on Nexus Clothing, the retail chain that serves as the first tenant and primary use case for the POS system. Understanding the business context is essential for making correct technical decisions.


2.1 Company Profile

Nexus Clothing Overview

Nexus Clothing is a regional fashion retailer operating in the Hampton Roads metropolitan area of Virginia. The company specializes in urban fashion and streetwear, serving a diverse customer base across 5 physical locations plus an e-commerce presence through Shopify.

+------------------------------------------------------------------+
|                    NEXUS CLOTHING AT A GLANCE                     |
+------------------------------------------------------------------+
|                                                                   |
|   Founded:          2008                                          |
|   Headquarters:     Chesapeake, Virginia                          |
|   Retail Locations: 5 (Virginia)                                  |
|   E-commerce:       Shopify (nexuspremier.myshopify.com)          |
|   Employees:        ~35 (retail + corporate)                      |
|   Annual Revenue:   ~$3.5M                                        |
|   Inventory Value:  ~$1.2M (at cost)                              |
|   SKU Count:        ~30,000 active                                |
|                                                                   |
+------------------------------------------------------------------+

Business Model

AspectDescription
Customer BaseUrban fashion consumers, ages 16-45
Price PointMid-range ($20-$200 per item)
Product MixApparel (70%), Footwear (20%), Accessories (10%)
Sales ChannelsIn-store (75%), Online (25%)
SeasonalityPeak: Back-to-School, Holiday; Slow: Jan-Feb

2.2 Store Locations

Location Hierarchy

+------------------------------------------------------------------+
|                    LOCATION HIERARCHY                             |
+------------------------------------------------------------------+
|                                                                   |
|                          +--------+                               |
|                          |   HQ   |                               |
|                          |Warehouse|                              |
|                          +----+---+                               |
|                               |                                   |
|              +-------+--------+--------+-------+                  |
|              |       |        |        |       |                  |
|              v       v        v        v       v                  |
|           +----+  +----+   +----+   +----+  +----+                |
|           | GM |  | HM |   | LM |   | NM |  |Online              |
|           +----+  +----+   +----+   +----+  +----+                |
|                                                                   |
|   Legend:                                                         |
|   - HQ: Central warehouse, receives all vendor shipments         |
|   - GM/HM/LM/NM: Retail stores, receive transfers from HQ        |
|   - Online: Shopify orders, fulfilled from any location          |
|                                                                   |
+------------------------------------------------------------------+

Store Details

HQ - Headquarters Warehouse

AttributeValue
CodeHQ
TypeWarehouse / Distribution Center
AddressChesapeake, VA
Shopify Location ID71681179880
FunctionReceiving, storage, distribution to stores
POS Terminals1 (for internal transfers)
Public-FacingNo
Can Ship Online OrdersYes (primary fulfillment)
HoursMon-Fri 8am-5pm

HQ Responsibilities:

  • Receive all vendor shipments
  • Process and tag new inventory
  • Distribute stock to retail locations
  • Fulfill online orders
  • Store seasonal overflow inventory
  • Handle returns processing

GM - Greenbrier Mall

AttributeValue
CodeGM
TypeRetail Store
AddressGreenbrier Mall, Chesapeake, VA
Shopify Location ID19718045760
Square Footage~2,500 sq ft
POS Terminals2
Staff6-8 associates
HoursMall hours (10am-9pm daily)
Customer ProfileSuburban families, mall shoppers

GM Characteristics:

  • Highest foot traffic location
  • Strong family/youth demographic
  • Mall-driven seasonality
  • Anchor location for Chesapeake market

HM - Peninsula Town Center

AttributeValue
CodeHM
TypeRetail Store
AddressPeninsula Town Center, Hampton, VA
Shopify Location ID57145622693
Square Footage~2,000 sq ft
POS Terminals2
Staff5-7 associates
Hours10am-9pm daily
Customer ProfileRegional shoppers, diverse demographics

HM Characteristics:

  • Lifestyle center location
  • Strong evening traffic
  • Regional hub for Peninsula area
  • Growing market with new developments nearby

LM - Lynnhaven Mall

AttributeValue
CodeLM
TypeRetail Store
AddressLynnhaven Mall, Virginia Beach, VA
Shopify Location ID84809318632
Square Footage~2,200 sq ft
POS Terminals2
Staff6-8 associates
HoursMall hours (10am-9pm daily)
Customer ProfileBeach community, tourists, military families

LM Characteristics:

  • Tourist traffic in summer months
  • Strong military family customer base
  • Beach lifestyle merchandise performs well
  • Seasonal variance highest of all stores

NM - Patrick Henry Mall

AttributeValue
CodeNM
TypeRetail Store
AddressPatrick Henry Mall, Newport News, VA
Shopify Location ID53005287589
Square Footage~1,800 sq ft
POS Terminals2
Staff4-6 associates
HoursMall hours (10am-9pm daily)
Customer ProfileMilitary, college students, local residents

NM Characteristics:

  • Smaller footprint, curated selection
  • Strong military customer base (near bases)
  • College student traffic from nearby campuses
  • Most price-sensitive customer base

Store Comparison Matrix

StoreSq FtTerminalsStaffAvg Daily TransTop Category
HQN/A1420 (transfers)N/A
GM2,5002785Urban Apparel
HM2,0002665Sneakers
LM2,2002775Casual Wear
NM1,8002545Basics

2.3 Current Operations

Daily Workflow

+------------------------------------------------------------------+
|                    TYPICAL STORE DAY                              |
+------------------------------------------------------------------+
|                                                                   |
|   9:30 AM  - Manager opens store                                  |
|            - Count cash drawer                                    |
|            - Check overnight online orders                        |
|            - Review transfer requests                             |
|                                                                   |
|   10:00 AM - Store opens                                          |
|            - First customers arrive                               |
|            - Process any holds from previous day                  |
|                                                                   |
|   11:00 AM - HQ delivery arrives (if scheduled)                   |
|            - Receive transfer, check quantities                   |
|            - Enter into QB POS manually                           |
|                                                                   |
|   12:00 PM - Lunch rush begins                                    |
|            - Peak transaction time                                |
|                                                                   |
|   3:00 PM  - Afternoon lull                                       |
|            - Restock floor from backroom                          |
|            - Process any returns                                  |
|                                                                   |
|   5:00 PM  - After-work rush                                      |
|            - Second peak transaction time                         |
|                                                                   |
|   8:30 PM  - Last customers                                       |
|            - Begin closing procedures                             |
|                                                                   |
|   9:00 PM  - Store closes                                         |
|            - Final drawer count                                   |
|            - Z-out register                                       |
|            - QB POS Store Exchange runs (nightly sync)            |
|                                                                   |
|   10:00 PM - Manager leaves                                       |
|            - Store secured                                        |
|                                                                   |
+------------------------------------------------------------------+

Inventory Flow

+------------------------------------------------------------------+
|                    INVENTORY FLOW                                 |
+------------------------------------------------------------------+
|                                                                   |
|   VENDOR                                                          |
|      |                                                            |
|      | Purchase Order                                             |
|      v                                                            |
|   +-----+                                                         |
|   | HQ  | <-- All vendor shipments arrive here                    |
|   +--+--+                                                         |
|      |                                                            |
|      | Transfer Slips                                             |
|      |                                                            |
|      +------------+------------+------------+                     |
|      |            |            |            |                     |
|      v            v            v            v                     |
|   +----+       +----+       +----+       +----+                   |
|   | GM |       | HM |       | LM |       | NM |                   |
|   +----+       +----+       +----+       +----+                   |
|      |            |            |            |                     |
|      v            v            v            v                     |
|   SALES        SALES        SALES        SALES                    |
|      |            |            |            |                     |
|      +------------+------------+------------+                     |
|                   |                                               |
|                   v                                               |
|             SOLD TO CUSTOMER                                      |
|                                                                   |
|   Note: Inter-store transfers (GM <-> LM) also occur              |
|   but are less common than HQ -> Store transfers                  |
|                                                                   |
+------------------------------------------------------------------+

Current Technology Stack

FunctionCurrent SystemIssues
Point of SaleQuickBooks POS V19End of life, no updates
Inventory SyncStore ExchangeNightly batch only, unreliable
E-commerceShopifyNo real-time POS integration
AccountingQuickBooks DesktopManual reconciliation
Employee SchedulingPaper/ExcelNo integration
Customer LoyaltyNoneLost opportunity

2.4 Current Pain Points

Pain Point Analysis

+------------------------------------------------------------------+
|                    PAIN POINT SEVERITY MATRIX                     |
+------------------------------------------------------------------+
|                                                                   |
|   HIGH     |  Inventory      |  Shopify         |                 |
|   IMPACT   |  Accuracy       |  Integration     |                 |
|            |                 |                  |                 |
|   MEDIUM   |  Report         |  Store           |  Manual         |
|   IMPACT   |  Delays         |  Exchange        |  Transfers      |
|            |                 |  Reliability     |                 |
|   LOW      |  UI/UX          |                  |  Training       |
|   IMPACT   |  Dated          |                  |  Time           |
|            +--------+--------+--------+--------+--------+         |
|              HIGH      MEDIUM     LOW                             |
|                      FREQUENCY                                    |
|                                                                   |
+------------------------------------------------------------------+

Detailed Pain Points

1. No Real-Time Inventory Visibility

Problem: Store managers cannot see current inventory at other locations.

ScenarioCurrent BehaviorDesired Behavior
Customer asks “Do you have this in another store?”Manager calls other stores, waits on holdCheck app, see real-time stock
Online order arrives for itemMay already be sold in-storeReserved immediately
Transfer requestBased on yesterday’s dataBased on current data

Business Impact:

  • Lost sales: Estimated $50K/year
  • Customer frustration: Cannot answer simple questions
  • Overselling: Online orders for out-of-stock items

2. Shopify Integration Gap

Problem: No connection between Shopify and QuickBooks POS.

+------------------------------------------------------------------+
|                    CURRENT SHOPIFY WORKFLOW                       |
+------------------------------------------------------------------+
|                                                                   |
|   CUSTOMER PLACES ORDER (Shopify)                                 |
|                |                                                  |
|                v                                                  |
|   NOTIFICATION EMAIL TO STORE                                     |
|                |                                                  |
|                v                                                  |
|   MANAGER LOGS INTO SHOPIFY ADMIN     <-- Manual step             |
|                |                                                  |
|                v                                                  |
|   PRINTS ORDER DETAILS                <-- Manual step             |
|                |                                                  |
|                v                                                  |
|   PICKS ITEMS FROM STORE                                          |
|                |                                                  |
|                v                                                  |
|   MANUALLY ENTERS IN QB POS           <-- Manual step (ERROR PRONE)|
|                |                                                  |
|                v                                                  |
|   PACKS AND SHIPS ORDER                                           |
|                |                                                  |
|                v                                                  |
|   RETURNS TO SHOPIFY, MARKS FULFILLED <-- Manual step             |
|                                                                   |
+------------------------------------------------------------------+

Business Impact:

  • Labor cost: 2 FTE hours/day processing orders
  • Error rate: ~5% of orders have quantity discrepancies
  • Delay: Average 4-hour delay in fulfillment

3. Store Exchange Unreliability

Problem: Nightly sync frequently fails silently.

IssueFrequencyImpact
Sync fails overnight2-3 times/weekStale data for entire day
Partial sync (some stores miss)1-2 times/weekIncorrect transfer decisions
Corrupted dataMonthlyManual reconciliation required
No failure notificationAlwaysProblems discovered late

Business Impact:

  • Inventory accuracy: Only ~85% accurate at any time
  • Trust: Managers do not trust system data
  • Workarounds: Physical counts done weekly (waste of labor)

4. Manual Transfer Process

Problem: Inter-store transfers require extensive manual work.

Current Process:

  1. Store A identifies excess inventory (manual review)
  2. Store A calls/emails other stores to find need
  3. Paper transfer slip created (handwritten)
  4. Items boxed and labeled manually
  5. Delivery driver transports
  6. Receiving store counts items
  7. Both stores manually enter transfer in QB POS
  8. Discrepancies resolved by phone

Business Impact:

  • Time: 30-45 minutes per transfer (both stores)
  • Errors: ~10% of transfers have quantity mismatches
  • Delay: 2-3 days from identification to transfer

5. No Mobile Capability

Problem: Associates tied to fixed terminals.

ScenarioCurrentDesired
Customer checkoutWalk to registerCheckout on floor
Price checkWalk to registerScan with phone
Inventory lookupWalk to computerCheck on tablet
ReceivingPaper + later entryScan on handheld

Business Impact:

  • Customer friction: Wait in line for simple tasks
  • Labor inefficiency: Associates leave customers to check systems
  • Lost sales: Customers leave during busy periods

2.5 Business Requirements

Functional Requirements

Category: Sales Transaction Processing

IDRequirementPriority
BR-001Process cash, credit, debit paymentsP0
BR-002Process returns with original receipt lookupP0
BR-003Process exchanges as single transactionP0
BR-004Apply percentage and fixed discountsP0
BR-005Split tender across payment methodsP0
BR-006Suspend and recall transactionsP1
BR-007Process layaway depositsP2

Category: Inventory Management

IDRequirementPriority
BR-010Real-time inventory visibility across all locationsP0
BR-011Process vendor receipts with PO matchingP0
BR-012Create and process inter-store transfersP0
BR-013Perform physical inventory countsP0
BR-014Adjust inventory with reason codesP0
BR-015Set reorder points and generate alertsP1
BR-016Track inventory by bin/location within storeP2

Category: Shopify Integration

IDRequirementPriority
BR-020Sync inventory levels in real-time (bi-directional)P0
BR-021Import online orders for in-store fulfillmentP0
BR-022Update order status in Shopify when fulfilledP0
BR-023Sync product catalog from ShopifyP0
BR-024Sync customer records bi-directionallyP1

Category: Offline Capability

IDRequirementPriority
BR-030Process sales transactions during network outageP0
BR-031Queue all changes during offline periodP0
BR-032Automatically sync when connection restoredP0
BR-033Resolve conflicts with defined rulesP0
BR-034Alert users to offline status clearlyP0

Category: Multi-Location Support

IDRequirementPriority
BR-040Support any number of locations per tenantP0
BR-041Location-specific pricing rulesP1
BR-042Location-specific tax ratesP0
BR-043Location hierarchy (HQ, Region, Store)P1

Non-Functional Requirements

IDRequirementTargetPriority
NFR-001Transaction response time< 2 secondsP0
NFR-002System availability99.9% uptimeP0
NFR-003Concurrent users per tenant50+P0
NFR-004Data retention7 yearsP0
NFR-005Offline queue depth1,000 transactionsP0
NFR-006Sync latency< 30 secondsP1

2.6 User Roles and Personas

Role Definitions

+------------------------------------------------------------------+
|                    USER ROLE HIERARCHY                            |
+------------------------------------------------------------------+
|                                                                   |
|   TENANT LEVEL                                                    |
|   +----------------------------------------------------------+   |
|   |                      Tenant Owner                         |   |
|   |                    (Business Owner)                       |   |
|   +------------------------------+---------------------------+   |
|                                  |                               |
|   REGIONAL LEVEL                 v                               |
|   +----------------------------------------------------------+   |
|   |                    Regional Manager                       |   |
|   |                   (Multi-Store Oversight)                 |   |
|   +------------------------------+---------------------------+   |
|                                  |                               |
|   STORE LEVEL                    v                               |
|   +----------------------------------------------------------+   |
|   |                      Store Manager                        |   |
|   |                    (Single Store)                         |   |
|   +------------------------------+---------------------------+   |
|                                  |                               |
|   FLOOR LEVEL                    v                               |
|   +-------------+  +-------------+  +-------------+              |
|   |  Key Holder |  |  Cashier    |  |  Stocker    |              |
|   +-------------+  +-------------+  +-------------+              |
|                                                                   |
+------------------------------------------------------------------+

Persona Details

Persona: Store Manager (Maria)

AttributeValue
NameMaria Chen
RoleStore Manager at Greenbrier Mall
Experience5 years retail, 2 years as manager
Tech ComfortModerate - uses smartphone, learns new systems
Key TasksOpen/close, scheduling, inventory, customer issues
Pain PointsCannot trust inventory counts, time wasted on manual entry
GoalsAccurate data, happy customers, efficient operations

Typical Day:

  • Opens store, reviews sales targets
  • Handles customer escalations
  • Processes transfers and receipts
  • Coaches team on sales techniques
  • Reviews end-of-day reports

Persona: Cashier (Jaylen)

AttributeValue
NameJaylen Williams
RolePart-time Cashier
Experience1 year retail
Tech ComfortHigh - digital native
Key TasksRing up sales, process returns, answer questions
Pain PointsSlow system, cannot answer inventory questions
GoalsFast checkout, helping customers find what they need

Typical Day:

  • Arrives, logs into register
  • Processes transactions all shift
  • Answers customer questions
  • Restocks register supplies
  • Logs out at end of shift

Persona: Business Owner (David)

AttributeValue
NameDavid Nexus
RoleOwner/CEO of Nexus Clothing
Experience17 years in retail
Tech ComfortLow - prefers reports over dashboards
Key TasksStrategic decisions, financial review, vendor relations
Pain PointsLack of real-time visibility, inaccurate data
GoalsGrow business, improve margins, expand locations

Typical Day:

  • Reviews previous day sales (from emailed reports)
  • Meets with vendors
  • Visits stores periodically
  • Reviews monthly financial statements

2.7 Operational Constraints

Technical Constraints

ConstraintDetailsMitigation
Limited IT StaffNo dedicated IT departmentSystem must be low-maintenance
Varied Internet QualityMall WiFi varies, some stores have outagesRobust offline mode required
Hardware BudgetCannot replace all hardware immediatelySupport existing receipt printers
Training Capacity30 minutes max for cashier trainingIntuitive UI essential

Business Constraints

ConstraintDetailsMitigation
Operating HoursCannot close stores for migrationPhased rollout required
Peak SeasonsNo changes Nov-Dec or AugSchedule around blackout periods
Staff TurnoverHigh turnover in retailTraining must be quick
Vendor RelationshipsCannot change payment processor easilyPlan for multiple processors

Regulatory Constraints

ConstraintDetailsMitigation
PCI-DSSCard data handling requirementsUse P2PE or tokenization
Sales TaxMulti-jurisdiction in VirginiaTax engine integration
ADA ComplianceAccessible kiosk requirementsScreen reader support
Data RetentionFinancial records 7 yearsArchive strategy

2.8 Summary

Business Context Highlights

  1. 5 locations requiring real-time inventory visibility
  2. HQ is warehouse - all vendor receipts flow through HQ
  3. Shopify integration is critical for online sales
  4. Offline capability is non-negotiable
  5. Current pain points center on data accuracy and manual processes

Key Stakeholders

StakeholderInterestInfluence
Owner (David)ROI, growth enablementDecision maker
Store ManagersEfficiency, accuracyDaily users
CashiersSpeed, simplicityFrontline users
CustomersFast checkoutEnd beneficiaries

Next Steps

  • Chapter 03: Deep dive into systems being replaced
  • Chapter 04: Define measurable success criteria

Document Information

AttributeValue
Version1.0.0
Created2025-12-29
AuthorClaude-NAS
StatusDraft
PartI - Foundation
Chapter02 of 04

This document is part of the POS Blueprint Book. All content is self-contained and requires no external file references.

Chapter 03: Systems Being Replaced

Overview

This chapter provides detailed analysis of the systems currently in use that the new POS will replace. Understanding these systems - their capabilities, limitations, and failure modes - informs the design of the replacement system.


3.1 QuickBooks Point of Sale V19

Product Overview

QuickBooks Point of Sale (QB POS) was Intuit’s retail point-of-sale solution designed for small to medium businesses. Version 19 was the final release before Intuit discontinued the product line.

+------------------------------------------------------------------+
|                    QUICKBOOKS POS V19 PROFILE                     |
+------------------------------------------------------------------+
|                                                                   |
|   Vendor:              Intuit                                     |
|   Product:             QuickBooks Point of Sale Pro V19           |
|   Release Date:        2019                                       |
|   End of Sale:         2023                                       |
|   End of Support:      2024                                       |
|   Architecture:        Desktop application (Windows only)         |
|   Database:            Proprietary (Pervasive SQL)               |
|   Multi-Store:         Via Store Exchange (nightly sync)          |
|   Deployment:          On-premises per store                      |
|                                                                   |
+------------------------------------------------------------------+

System Architecture

+------------------------------------------------------------------+
|                    QB POS V19 ARCHITECTURE                        |
+------------------------------------------------------------------+
|                                                                   |
|   STORE 1 (HQ)              STORE 2 (GM)           STORE 3 (HM)   |
|   +---------------+         +---------------+      +------------+ |
|   | Windows PC    |         | Windows PC    |      | Windows PC | |
|   +---------------+         +---------------+      +------------+ |
|   | QB POS V19    |         | QB POS V19    |      | QB POS V19 | |
|   | (Server Mode) |         | (Client Mode) |      | (Client)   | |
|   +-------+-------+         +-------+-------+      +------+-----+ |
|           |                         |                     |       |
|           v                         v                     v       |
|   +---------------+         +---------------+      +------------+ |
|   | Local DB      |         | Local DB      |      | Local DB   | |
|   | (Pervasive)   |         | (Pervasive)   |      | (Pervasive)| |
|   +-------+-------+         +-------+-------+      +------+-----+ |
|           |                         |                     |       |
|           |    STORE EXCHANGE       |                     |       |
|           |    (Nightly Batch)      |                     |       |
|           +----------->-------------+---------------------+       |
|                    SYNC DIRECTION: HQ <-> ALL STORES              |
|                                                                   |
+------------------------------------------------------------------+

Features and Capabilities

What QB POS V19 Does Well

FeatureCapabilityRating
Transaction ProcessingReliable sales, returns, exchangesGood
Cash Drawer ManagementDrawer counts, Z-outs, blind countsGood
Basic InventoryItem tracking, quantities, costsGood
Receipt PrintingStandard receipts with customizationGood
QuickBooks IntegrationSyncs with QuickBooks DesktopGood
ReportsCanned reports for sales, inventoryAdequate
Hardware SupportReceipt printers, barcode scannersGood

What QB POS V19 Does Poorly

LimitationImpactSeverity
No real-time multi-storeStale data, oversellingCritical
Desktop-onlyNo remote managementHigh
Windows-onlyHardware constraintsMedium
No cloud backupData loss riskHigh
No mobile supportTied to registerHigh
Limited APICannot integrate easilyCritical
No Shopify integrationManual e-commerce processCritical
Outdated UITraining challengesMedium
No updatesSecurity concernsHigh

Data Model

QB POS V19 uses a proprietary database structure. Key entities learned from the Stanly project:

+------------------------------------------------------------------+
|                    QB POS DATA ENTITIES                           |
+------------------------------------------------------------------+
|                                                                   |
|   ITEMS                          SALES                            |
|   +----------------------+       +----------------------+         |
|   | ItemNumber (SKU/UPC) |       | SalesReceiptNumber   |         |
|   | ItemName             |       | TransactionDate      |         |
|   | Department           |       | Customer             |         |
|   | QuantityOnHand       |       | Total                |         |
|   | OnHandStore01        |       | TaxAmount            |         |
|   | OnHandStore02        |       | PaymentMethod        |         |
|   | ... (up to 10)       |       | Associate            |         |
|   | Cost                 |       +----------------------+         |
|   | Price                |                |                       |
|   | Vendor               |                v                       |
|   | LastReceived         |       +----------------------+         |
|   | LastSold             |       | SalesReceiptItems    |         |
|   +----------------------+       | - ItemNumber         |         |
|                                  | - Quantity           |         |
|                                  | - Price              |         |
|                                  | - Discount           |         |
|                                  +----------------------+         |
|                                                                   |
+------------------------------------------------------------------+

Query Interface (QBXML)

QB POS uses QBXML for programmatic access. This is the interface Stanly’s Bridge uses.

Sample Item Query:

<?xml version="1.0"?>
<?qbposxml version="3.0"?>
<QBPOSXML>
  <QBPOSXMLMsgsRq onError="continueOnError">
    <ItemInventoryQueryRq requestID="1">
      <MaxReturned>1000</MaxReturned>
      <OwnerID>0</OwnerID>
    </ItemInventoryQueryRq>
  </QBPOSXMLMsgsRq>
</QBPOSXML>

Lessons Learned from QBXML Integration:

LessonDetailDesign Implication
Pagination RequiredMust use MaxReturned + iteratorsAPI must support cursor pagination
No Store FilterReturns aggregate quantitiesPer-store data needs store prefix fields
Connection TimeoutsLong queries disconnectImplement chunked queries
Version SensitivityDifferent QBXML versionsAbstract API layer

Known Issues and Workarounds

Issue 1: Store Number Mapping

Problem: QuantityOnHand returns total across all stores, not per-store.

Current Data:

ItemNumber: NXP0323
QuantityOnHand: 58  (TOTAL - misleading)
OnHandStore01: 12   (HQ)
OnHandStore02: 8    (HM - Store 2)
OnHandStore04: 15   (LM - Store 4)
OnHandStore06: 10   (NM - Store 6)
OnHandStore07: 13   (GM - Store 7)

Workaround in Stanly: Parse OnHandStoreXX fields, map to store codes.

Design Implication: New system will have explicit per-location quantities.

Issue 2: SKU vs UPC Confusion

Problem: ItemNumber field contains UPC barcode, not SKU.

Example:

Returns:  ItemNumber = "0657381512532" (UPC)
Expected: SKU = "NXP0323", UPC = "0657381512532"

Root Cause: Original data entry used barcode scanner into ItemNumber field.

Design Implication: New system will have separate, enforced SKU and UPC fields.

Issue 3: Company File Connection

Problem: Connection string format is inconsistent.

Formats Found:
- "Company Data=NEXUS"
- "Computer Name=HQ-PC;Company Data=NEXUS;Version=19"
- "" (empty - uses currently open file)

Workaround in Stanly: Detect format and normalize.

Design Implication: New system uses standard connection configuration.


3.2 Store Exchange

Product Overview

Store Exchange is Intuit’s multi-store synchronization component for QB POS. It runs nightly to replicate data between locations.

+------------------------------------------------------------------+
|                    STORE EXCHANGE PROFILE                         |
+------------------------------------------------------------------+
|                                                                   |
|   Function:        Nightly data synchronization                   |
|   Schedule:        Typically 11:00 PM - 1:00 AM                   |
|   Direction:       Bi-directional (HQ as hub)                     |
|   Method:          Full data comparison and delta sync            |
|   Failure Mode:    Silent - no notifications on failure           |
|   Recovery:        Manual intervention required                   |
|   Documentation:   Minimal (Intuit discontinued)                  |
|                                                                   |
+------------------------------------------------------------------+

Synchronization Model

+------------------------------------------------------------------+
|                    STORE EXCHANGE DATA FLOW                       |
+------------------------------------------------------------------+
|                                                                   |
|   11:00 PM                                                        |
|   +-------+     +-------+     +-------+     +-------+             |
|   |  HQ   |     |  GM   |     |  HM   |     |  LM   |             |
|   +---+---+     +---+---+     +---+---+     +---+---+             |
|       |             |             |             |                 |
|       v             v             v             v                 |
|   [Collect Changes from Each Store]                               |
|       |             |             |             |                 |
|       +------+------+------+------+                               |
|              |                                                    |
|              v                                                    |
|   +--------------------+                                          |
|   |   HQ AGGREGATION   |                                          |
|   |   - Merge changes  |                                          |
|   |   - Resolve dupes  |                                          |
|   |   - Update totals  |                                          |
|   +--------------------+                                          |
|              |                                                    |
|              v                                                    |
|   [Push Merged Data to All Stores]                                |
|       |             |             |             |                 |
|       v             v             v             v                 |
|   +-------+     +-------+     +-------+     +-------+             |
|   |  HQ   |     |  GM   |     |  HM   |     |  LM   |             |
|   +-------+     +-------+     +-------+     +-------+             |
|                                                                   |
|   1:00 AM - Sync Complete (if successful)                         |
|                                                                   |
+------------------------------------------------------------------+

Known Problems

Problem 1: Silent Failures

Issue: Store Exchange fails without notification.

Failure ModeFrequencyDetection
Network timeout2x/weekNext day when data is stale
Database lock conflict1x/weekMorning sync mismatch
Partial sync2x/monthSome stores updated, others not
Corruption1x/monthData integrity errors

Impact: Staff do not know data is stale. Decisions made on wrong information.

Design Implication: New system will have:

  • Real-time sync status dashboard
  • Alert notifications on failure
  • Automatic retry with exponential backoff
  • Manual sync trigger available

Problem 2: Batch Window Constraints

Issue: Sync only runs once per day.

+------------------------------------------------------------------+
|                    DATA STALENESS TIMELINE                        |
+------------------------------------------------------------------+
|                                                                   |
|   10AM     12PM     2PM      4PM      6PM      8PM     10PM       |
|   |--------|--------|--------|--------|--------|--------|         |
|                                                                   |
|   STORE A SELLS ITEM X                                            |
|   |                                                               |
|   v                                                               |
|   [10:30 AM - Sale recorded locally]                              |
|                                                                   |
|   STORE B CHECKS INVENTORY                                        |
|                    |                                              |
|                    v                                              |
|   [2:00 PM - Still shows item X as available at Store A]          |
|                                                                   |
|   ONLINE ORDER PLACED                                             |
|                              |                                    |
|                              v                                    |
|   [5:00 PM - Order placed for item X, shows Store A has stock]    |
|                                                                   |
|   RESULT: Oversold - item was sold at 10:30 AM                    |
|                                                                   |
|   SYNC RUNS                                                       |
|                                                          |        |
|                                                          v        |
|   [11:00 PM - Finally updates inventory across stores]            |
|                                                                   |
|   DISCOVERY: Order cannot be fulfilled                            |
|                                                                   |
+------------------------------------------------------------------+

Business Impact:

  • 12-14 hours of potential data staleness
  • Overselling on high-velocity items
  • Customer disappointment on online orders

Design Implication: Real-time sync with < 30 second latency.

Problem 3: No Conflict Resolution

Issue: Same item edited at multiple stores creates conflicts.

ScenarioStore Exchange BehaviorCorrect Behavior
Both stores change priceLast sync wins (random)Flag for review
Both stores adjust qtyMerge adjustmentsSum the deltas
Item deleted at one storeSometimes cascades deleteProtect with confirmation

Design Implication: Implement proper conflict detection and resolution rules.


3.3 Manual Inventory Counting

Current Process

Without reliable real-time inventory, stores perform frequent manual counts.

+------------------------------------------------------------------+
|                    MANUAL COUNT PROCESS                           |
+------------------------------------------------------------------+
|                                                                   |
|   WEEKLY CYCLE COUNT                                              |
|   Duration: 2-3 hours per store                                   |
|   Staff Required: 2 people minimum                                |
|                                                                   |
|   Step 1: Print count sheets from QB POS                          |
|           (30 minutes)                                            |
|                                                                   |
|   Step 2: Physically count sections                               |
|           (60-90 minutes)                                         |
|           - One person counts                                     |
|           - One person records on paper                           |
|                                                                   |
|   Step 3: Enter counts into QB POS                                |
|           (30-45 minutes)                                         |
|           - Manual data entry                                     |
|           - High error rate                                       |
|                                                                   |
|   Step 4: Review variances                                        |
|           (15-30 minutes)                                         |
|           - Manager reviews discrepancies                         |
|           - Often just "approved" without investigation           |
|                                                                   |
|   Total Cost per Store per Week: ~3 hours x 2 staff = 6 hours    |
|   Total Cost per Month (5 stores): ~120 labor hours               |
|                                                                   |
+------------------------------------------------------------------+

Problems with Current Approach

ProblemDetailImpact
Labor Intensive6 hours per store per week$2,400/month in labor
Error ProneManual transcription errorsCreates new discrepancies
DisruptiveCounts done during business hoursCustomer service impact
IncompleteNever counts entire storePartial accuracy at best
No Audit TrailPaper records discardedCannot investigate losses
Delayed EntryCounts entered hours laterData already stale

Comparison to Target State

AspectCurrent ManualTarget with RFID
Time per count2-3 hours15-30 minutes
Staff required2 people1 person
Accuracy~95%~99.5%
Audit trailPaper (discarded)Digital (permanent)
FrequencyWeeklyDaily or continuous
CoverageSectionsEntire store

Design Implication: System must support both:

  • Traditional barcode scanning (immediate)
  • RFID bulk scanning (future phase)

3.4 Paper-Based Transfers

Current Transfer Process

Inter-store transfers use paper forms with manual data entry.

+------------------------------------------------------------------+
|                    PAPER TRANSFER PROCESS                         |
+------------------------------------------------------------------+
|                                                                   |
|   SENDING STORE (GM)                                              |
|   +----------------------------------------------------------+   |
|   | 1. Manager identifies excess inventory                    |   |
|   |    (Manual review of shelves and QB POS reports)          |   |
|   |                                                           |   |
|   | 2. Calls other stores to find need                        |   |
|   |    (Phone calls, often voicemail tag)                     |   |
|   |                                                           |   |
|   | 3. Creates handwritten transfer slip                      |   |
|   |    (Paper form, carbon copy)                              |   |
|   |    - Date                                                 |   |
|   |    - From/To stores                                       |   |
|   |    - List of items (SKU, description, qty)                |   |
|   |    - Manager signature                                    |   |
|   |                                                           |   |
|   | 4. Boxes items, attaches slip                             |   |
|   |    (Physical packaging)                                   |   |
|   |                                                           |   |
|   | 5. Enters transfer in QB POS (decrease qty)               |   |
|   |    (Manual data entry after items leave)                  |   |
|   +----------------------------------------------------------+   |
|                               |                                   |
|                               | DELIVERY                          |
|                               | (Next day usually)                |
|                               v                                   |
|   RECEIVING STORE (LM)                                            |
|   +----------------------------------------------------------+   |
|   | 1. Receives box, finds transfer slip                      |   |
|   |                                                           |   |
|   | 2. Counts items, compares to slip                         |   |
|   |    (Discrepancies are common)                             |   |
|   |                                                           |   |
|   | 3. Notes variances on slip                                |   |
|   |    (Handwritten corrections)                              |   |
|   |                                                           |   |
|   | 4. Enters transfer in QB POS (increase qty)               |   |
|   |    (Manual data entry - may not match sending store)      |   |
|   |                                                           |   |
|   | 5. Calls sending store about discrepancies                |   |
|   |    (Resolution often never happens)                       |   |
|   |                                                           |   |
|   | 6. Files paper slip                                       |   |
|   |    (Stored in filing cabinet, rarely referenced)          |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Transfer Problems

ProblemFrequencyImpact
Quantity Mismatch10% of transfersInventory discrepancy
Lost Paperwork5% of transfersNo audit trail
Entry Errors15% of entriesData corruption
Delayed EntryMost transfersStale data during transit
No ConfirmationAll transfersSender does not know receipt
No OptimizationAll transfersSub-optimal stock allocation

Cost Analysis

+------------------------------------------------------------------+
|                    TRANSFER COST BREAKDOWN                        |
+------------------------------------------------------------------+
|                                                                   |
|   Per Transfer (Average)                                          |
|   +----------------------------------------------------------+   |
|   | Sending Store                                             |   |
|   |   - Manager time to identify need: 15 min                 |   |
|   |   - Phone calls to find destination: 10 min               |   |
|   |   - Create transfer slip: 10 min                          |   |
|   |   - Box and label items: 10 min                           |   |
|   |   - Enter in QB POS: 10 min                               |   |
|   |   Subtotal: 55 minutes                                    |   |
|   +----------------------------------------------------------+   |
|   | Receiving Store                                           |   |
|   |   - Receive and unbox: 10 min                             |   |
|   |   - Count and verify: 15 min                              |   |
|   |   - Note discrepancies: 5 min                             |   |
|   |   - Enter in QB POS: 10 min                               |   |
|   |   - Resolve issues (calls): 10 min                        |   |
|   |   Subtotal: 50 minutes                                    |   |
|   +----------------------------------------------------------+   |
|   | Total Labor: 105 minutes per transfer                     |   |
|   | At $15/hour average: ~$26 labor cost per transfer         |   |
|   |                                                           |   |
|   | Average transfers per week: 15                            |   |
|   | Monthly transfer labor cost: ~$1,560                      |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Target Transfer Process

StepCurrentTarget
Identify NeedManual reviewSystem recommendation
Find DestinationPhone callsReal-time availability
Create TransferPaper formDigital request
Approve TransferManager signatureDigital approval
Track TransitNoneStatus tracking
Receive TransferManual countScan to confirm
Resolve DiscrepanciesPhone callsIn-app workflow
Total Time105 minutes20 minutes

3.5 Separate Shopify Administration

Current State

Shopify and QB POS operate as completely separate systems with no integration.

+------------------------------------------------------------------+
|                    CURRENT SHOPIFY WORKFLOW                       |
+------------------------------------------------------------------+
|                                                                   |
|   SHOPIFY ECOSYSTEM                    QB POS ECOSYSTEM           |
|   +------------------------+           +----------------------+   |
|   | Shopify Admin          |           | QuickBooks POS V19   |   |
|   |                        |           |                      |   |
|   | - Products             |   X   X   | - Items              |   |
|   | - Inventory            | X   X   X | - Inventory          |   |
|   | - Orders               |   X   X   | - Sales              |   |
|   | - Customers            | X   X   X | - Customers          |   |
|   | - Analytics            |   X   X   | - Reports            |   |
|   +------------------------+           +----------------------+   |
|           |                                     |                 |
|           |    NO INTEGRATION                   |                 |
|           |    (Manual Bridge Only)             |                 |
|           v                                     v                 |
|   +------------------------+           +----------------------+   |
|   | E-commerce Website     |           | 5 Physical Stores    |   |
|   | - Customer orders      |           | - Walk-in sales      |   |
|   | - Online payments      |           | - Cash/card payments |   |
|   +------------------------+           +----------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Manual Synchronization Tasks

TaskFrequencyTime RequiredError Rate
Update Shopify inventory from QBDaily2 hours5-10%
Process online orders in QBPer order15 min each3-5%
Add new products to both systemsPer product20 min each5%
Sync price changesWeekly1 hour10%
Reconcile discrepanciesWeekly2 hoursN/A

Problems

Problem 1: Inventory Mismatch

+------------------------------------------------------------------+
|                    INVENTORY DIVERGENCE EXAMPLE                   |
+------------------------------------------------------------------+
|                                                                   |
|   Item: Nike Air Max 90 - Size 10 (SKU: NAM90-10)                 |
|                                                                   |
|   Reality:                                                        |
|     GM: 2 units                                                   |
|     LM: 1 unit                                                    |
|     NM: 0 units                                                   |
|     HQ: 5 units                                                   |
|     ------------------                                            |
|     Total Available: 8 units                                      |
|                                                                   |
|   What Shopify Shows: 15 units                                    |
|   (Not updated since last manual sync 3 days ago)                 |
|                                                                   |
|   Result: Customer orders 10 units for wholesale order            |
|           Only 8 units available                                  |
|           Manual scramble to cancel/partial fill                  |
|                                                                   |
+------------------------------------------------------------------+

Problem 2: Order Fulfillment Chaos

IssueDescriptionFrequency
Duplicate OrdersSame order entered in QB twice2-3/week
Missed OrdersOrder not entered, never fulfilled1-2/week
Wrong StoreOrder fulfilled from wrong location3-4/week
Late FulfillmentOrder sits in queue for daysDaily

Problem 3: Customer Data Silos

+------------------------------------------------------------------+
|                    CUSTOMER DATA FRAGMENTATION                    |
+------------------------------------------------------------------+
|                                                                   |
|   Customer: John Smith                                            |
|                                                                   |
|   SHOPIFY RECORD                    QB POS RECORD                 |
|   +------------------------+        +----------------------+      |
|   | Email: john@email.com  |        | Name: John Smith     |      |
|   | Phone: 555-123-4567    |        | Phone: 555-123-4567  |      |
|   | Orders: 15 online      |        | Transactions: 8      |      |
|   | Total Spent: $2,340    |        | Total: $1,200        |      |
|   | Address: Current       |        | Address: Old (2022)  |      |
|   | Loyalty Points: N/A    |        | Loyalty: N/A         |      |
|   +------------------------+        +----------------------+      |
|                                                                   |
|   REALITY:                                                        |
|   - Same customer, two different profiles                         |
|   - Total relationship: $3,540 (unknown to either system)         |
|   - Cannot offer unified loyalty program                          |
|   - Cannot track true customer value                              |
|                                                                   |
+------------------------------------------------------------------+

Stanly Project Learnings

The Stanly project was built specifically to address Shopify integration. Key learnings:

LearningDetailDesign Implication
Webhooks vs PollingWebhooks for orders, polling for inventorySupport both patterns
Rate LimitsShopify limits API calls per minuteImplement rate limiter
Order StatesMultiple states (pending, paid, fulfilled)State machine design
Inventory LocationsShopify supports multi-locationMap locations correctly
Fulfillment FlowSeparate from order creationTwo-step process

3.6 What We Keep vs Replace

Decision Matrix

+------------------------------------------------------------------+
|                    KEEP vs REPLACE DECISION                       |
+------------------------------------------------------------------+
|                                                                   |
|   KEEP (Re-use)              REPLACE (New System)                 |
|   +-----------------------+  +-----------------------+            |
|   | Receipt Printers      |  | QuickBooks POS V19    |            |
|   | Barcode Scanners      |  | Store Exchange        |            |
|   | Cash Drawers          |  | Manual Counting       |            |
|   | Shopify Store         |  | Paper Transfers       |            |
|   | PostgreSQL Database   |  | Dual Admin Systems    |            |
|   | Tailscale VPN         |  | Offline = No Work     |            |
|   +-----------------------+  +-----------------------+            |
|                                                                   |
|   MIGRATE (Transform)        INTEGRATE (Connect)                  |
|   +-----------------------+  +-----------------------+            |
|   | Historical Sales Data |  | Payment Processor     |            |
|   | Customer Records      |  | Shopify API           |            |
|   | Inventory Baselines   |  | RFID Hardware         |            |
|   | Product Catalog       |  | Accounting System     |            |
|   +-----------------------+  +-----------------------+            |
|                                                                   |
+------------------------------------------------------------------+

Migration Requirements

Data TypeSourceVolumeComplexity
ProductsQB POS + Shopify~30,000 SKUsMedium
CustomersQB POS + Shopify~15,000 recordsHigh (dedup)
InventoryQB POS5 locations x 30K SKUsMedium
Sales HistoryQB POS3 yearsLow (import only)
VendorsQB POS~200 vendorsLow

Hardware Compatibility

HardwareCurrent ModelCompatibleNotes
Receipt PrinterEpson TM-T88VYesESC/POS standard
Barcode ScannerSymbol LS2208YesHID keyboard mode
Cash DrawerAPG VasarioYesPrinter-driven
POS TerminalDell OptiPlexYesWill run new software
Touch ScreenELO 15“YesStandard USB
Card ReaderVerifone MX915MaybeDepends on processor choice

3.7 Summary

Systems Being Replaced

SystemPrimary IssuesReplacement Strategy
QuickBooks POS V19End of life, no real-time, no APICustom POS application
Store ExchangeBatch sync, silent failuresReal-time sync engine
Manual CountingLabor intensive, error proneBarcode/RFID scanning
Paper TransfersSlow, no trackingDigital transfer workflow
Separate ShopifyManual sync, errorsNative integration

Key Learnings Applied

  1. From QB POS: Need proper SKU/UPC separation, per-location quantities
  2. From Store Exchange: Real-time sync with failure alerts
  3. From Manual Counting: Mobile-first scanning workflow
  4. From Paper Transfers: End-to-end digital tracking
  5. From Shopify Split: Single source of truth for inventory

Next Steps

  • Chapter 04: Define measurable success criteria to validate replacement

Document Information

AttributeValue
Version1.0.0
Created2025-12-29
AuthorClaude-NAS
StatusDraft
PartI - Foundation
Chapter03 of 04

This document is part of the POS Blueprint Book. All content is self-contained and requires no external file references.

Chapter 04: Success Criteria

Overview

This chapter defines the measurable criteria that will determine whether the POS system implementation is successful. Success criteria are organized into technical, business, compliance, and operational categories, each with specific Key Performance Indicators (KPIs) and target values.


4.1 Success Criteria Framework

Measurement Philosophy

Success is not binary. The system must be evaluated across multiple dimensions with clear thresholds.

+------------------------------------------------------------------+
|                    SUCCESS EVALUATION FRAMEWORK                   |
+------------------------------------------------------------------+
|                                                                   |
|   EVALUATION LEVELS                                               |
|   +----------------------------------------------------------+   |
|   |                                                           |   |
|   |   EXCEPTIONAL    All KPIs exceed target + 20%             |   |
|   |   ============   Ready for multi-tenant expansion         |   |
|   |                                                           |   |
|   |   SUCCESSFUL     All P0 KPIs meet target                  |   |
|   |   ==========     System is production-ready               |   |
|   |                                                           |   |
|   |   ACCEPTABLE     All P0 KPIs at minimum threshold         |   |
|   |   ==========     Requires improvement plan                |   |
|   |                                                           |   |
|   |   FAILED         Any P0 KPI below minimum                 |   |
|   |   ======         Cannot go to production                  |   |
|   |                                                           |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+

Criteria Categories

CategoryWeightDescription
Technical35%System performance, reliability, scalability
Business35%Business outcomes, user satisfaction, ROI
Compliance20%Security, regulatory, data protection
Operational10%Maintenance, support, documentation

4.2 Technical Success Criteria

T1: System Availability

Definition: Percentage of time the system is operational and accessible.

+------------------------------------------------------------------+
|                    AVAILABILITY CALCULATION                       |
+------------------------------------------------------------------+
|                                                                   |
|   Availability % = (Total Time - Downtime) / Total Time x 100    |
|                                                                   |
|   Monthly Time:  43,200 minutes (30 days x 24 hours x 60 min)     |
|                                                                   |
|   TARGETS:                                                        |
|   +----------------------------------------------------------+   |
|   | Level        | Availability | Max Downtime/Month          |   |
|   |--------------|--------------|----------------------------|   |
|   | Minimum      | 99.5%        | 216 minutes (3.6 hours)    |   |
|   | Target       | 99.9%        | 43 minutes                 |   |
|   | Exceptional  | 99.95%       | 21 minutes                 |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   EXCLUSIONS:                                                     |
|   - Planned maintenance (with 48hr notice): Not counted           |
|   - Third-party outages (Shopify, payment): Tracked separately    |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
T1.1 Uptime99.5%99.9%99.95%P0
T1.2 Planned Maintenance< 4 hours/month< 2 hours/month< 1 hour/monthP1
T1.3 MTTR (Mean Time To Recovery)< 60 min< 30 min< 15 minP0

T2: Transaction Performance

Definition: Speed and reliability of core transaction processing.

+------------------------------------------------------------------+
|                    TRANSACTION TIMING BREAKDOWN                   |
+------------------------------------------------------------------+
|                                                                   |
|   COMPLETE TRANSACTION TIMELINE                                   |
|                                                                   |
|   [Scan Item] --> [Add to Cart] --> [Payment] --> [Receipt]       |
|       |               |                |              |           |
|       v               v                v              v           |
|     50ms           100ms           1000ms          200ms          |
|                                                                   |
|   Total Target: < 2000ms (2 seconds)                              |
|                                                                   |
|   COMPONENT BREAKDOWN:                                            |
|   +----------------------------------------------------------+   |
|   | Component              | Target    | Maximum              |   |
|   |------------------------|-----------|----------------------|   |
|   | Item Lookup            | 50ms      | 200ms               |   |
|   | Cart Update            | 100ms     | 300ms               |   |
|   | Payment Processing     | 1000ms    | 3000ms              |   |
|   | Receipt Generation     | 200ms     | 500ms               |   |
|   | Inventory Update       | 100ms     | 300ms               |   |
|   | Total Transaction      | 1450ms    | 4300ms              |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
T2.1 Transaction Time< 4 sec< 2 sec< 1 secP0
T2.2 Item Lookup< 500ms< 100ms< 50msP0
T2.3 Payment Processing< 5 sec< 3 sec< 2 secP0
T2.4 Receipt Print< 2 sec< 500ms< 200msP1
T2.5 Transaction Success Rate99.5%99.9%99.99%P0

T3: Synchronization Performance

Definition: Speed and accuracy of data synchronization between stores and systems.

+------------------------------------------------------------------+
|                    SYNC LATENCY REQUIREMENTS                      |
+------------------------------------------------------------------+
|                                                                   |
|   EVENT: Sale at Store GM                                         |
|                                                                   |
|   T+0s     Sale completed at GM register                          |
|   T+5s     GM local database updated                              |
|   T+10s    Central server notified                                |
|   T+15s    Inventory broadcast to all stores                      |
|   T+20s    All store local caches updated                         |
|   T+30s    Shopify inventory updated                              |
|                                                                   |
|   MAXIMUM END-TO-END: 30 seconds                                  |
|   TARGET END-TO-END: 15 seconds                                   |
|                                                                   |
|   COMPARISON TO CURRENT (Store Exchange):                         |
|   Current: 12-14 hours (overnight batch)                          |
|   Target: 15-30 seconds                                           |
|   Improvement: 2,880x faster                                      |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
T3.1 Internal Sync Latency< 60 sec< 30 sec< 10 secP0
T3.2 Shopify Sync Latency< 5 min< 2 min< 30 secP0
T3.3 Sync Success Rate99%99.9%99.99%P0
T3.4 Conflict Resolution< 5 min< 1 minAutomaticP1

T4: Offline Capability

Definition: Ability to continue operations during network outages.

+------------------------------------------------------------------+
|                    OFFLINE MODE SPECIFICATIONS                    |
+------------------------------------------------------------------+
|                                                                   |
|   SUPPORTED OFFLINE OPERATIONS:                                   |
|   +----------------------------------------------------------+   |
|   | Operation              | Offline Support | Priority       |   |
|   |------------------------|-----------------|----------------|   |
|   | Process Sale           | FULL            | P0             |   |
|   | Process Return         | FULL            | P0             |   |
|   | Cash Payment           | FULL            | P0             |   |
|   | Card Payment           | LIMITED*        | P0             |   |
|   | View Local Inventory   | FULL            | P0             |   |
|   | View Other Store Inv   | CACHED**        | P1             |   |
|   | Create Transfer        | QUEUE           | P1             |   |
|   | Customer Lookup        | LOCAL ONLY      | P2             |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   * Card payments require store-and-forward capability            |
|   ** Other store inventory shows last synced values              |
|                                                                   |
|   OFFLINE QUEUE CAPACITY:                                         |
|   - Minimum: 500 transactions                                     |
|   - Target: 1,000 transactions                                    |
|   - Storage: ~50MB local SQLite database                          |
|                                                                   |
|   SYNC AFTER RECONNECTION:                                        |
|   - Automatic sync initiation: < 5 seconds                        |
|   - Full queue flush: < 5 minutes for 1,000 transactions          |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
T4.1 Offline Queue Depth500 trans1,000 trans5,000 transP0
T4.2 Offline Duration4 hours8 hours24 hoursP0
T4.3 Auto-Reconnect Time< 60 sec< 10 sec< 5 secP0
T4.4 Queue Sync Speed10 trans/sec50 trans/sec100 trans/secP1

T5: Scalability

Definition: System capacity and growth capability.

+------------------------------------------------------------------+
|                    SCALABILITY REQUIREMENTS                       |
+------------------------------------------------------------------+
|                                                                   |
|   CURRENT STATE (Nexus Clothing)                                  |
|   +----------------------------------------------------------+   |
|   | Metric              | Current   | Design Capacity         |   |
|   |---------------------|-----------|-------------------------|   |
|   | Stores              | 5         | 500+ per tenant         |   |
|   | SKUs                | 30,000    | 1,000,000               |   |
|   | Daily Transactions  | 500       | 100,000                 |   |
|   | Concurrent Users    | 20        | 5,000                   |   |
|   | Tenants             | 1         | Unlimited               |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   LOAD TESTING REQUIREMENTS:                                      |
|   - Sustain 2x peak load for 1 hour without degradation           |
|   - Handle 10x burst for 5 minutes with graceful degradation      |
|   - No data loss under any load condition                         |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
T5.1 Stores per Tenant50100500+P0
T5.2 SKUs per Tenant100,000500,0001,000,000P0
T5.3 Concurrent Users1005005,000P0
T5.4 Daily Transactions10,00050,000100,000P1
T5.5 Peak Burst Handling2x5x10xP1

4.3 Business Success Criteria

B1: Inventory Accuracy

Definition: Percentage match between system inventory and physical count.

+------------------------------------------------------------------+
|                    INVENTORY ACCURACY MEASUREMENT                 |
+------------------------------------------------------------------+
|                                                                   |
|   ACCURACY CALCULATION:                                           |
|                                                                   |
|   Accuracy % = (Items Matching / Total Items Counted) x 100       |
|                                                                   |
|   MEASUREMENT PROTOCOL:                                           |
|   1. Select 500 random SKUs across all locations                  |
|   2. Physically count each SKU at each location                   |
|   3. Compare to system quantity                                   |
|   4. Calculate match percentage                                   |
|                                                                   |
|   CURRENT STATE:                                                  |
|   With QB POS + Store Exchange: ~85% accuracy                     |
|   (15% of items have discrepancies at any time)                   |
|                                                                   |
|   TARGET STATE:                                                   |
|   With new POS + real-time sync: 98%+ accuracy                    |
|                                                                   |
|   VARIANCE THRESHOLDS:                                            |
|   +----------------------------------------------------------+   |
|   | Variance     | Classification | Action Required          |   |
|   |--------------|----------------|--------------------------|   |
|   | 0 units      | Exact Match    | None                     |   |
|   | +/- 1 unit   | Minor          | Auto-flag for review     |   |
|   | +/- 2-5      | Significant    | Manager investigation    |   |
|   | > 5 units    | Critical       | Immediate audit          |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
B1.1 Overall Accuracy95%98%99.5%P0
B1.2 High-Value Item Accuracy98%99.5%99.9%P0
B1.3 Time to Discrepancy Detection< 24 hours< 4 hours< 1 hourP1
B1.4 Discrepancy Resolution Rate90% in 48hr95% in 24hr99% in 24hrP1

B2: Shopify Integration Efficiency

Definition: Automation and accuracy of Shopify order processing.

+------------------------------------------------------------------+
|                    SHOPIFY INTEGRATION METRICS                    |
+------------------------------------------------------------------+
|                                                                   |
|   CURRENT STATE (Manual Process):                                 |
|   +----------------------------------------------------------+   |
|   | Metric                    | Current Value                 |   |
|   |---------------------------|-------------------------------|   |
|   | Order Processing Time     | 15-30 minutes per order       |   |
|   | Daily Labor Hours         | 2-4 hours                     |   |
|   | Error Rate                | 5-10%                         |   |
|   | Fulfillment Delay         | 4-8 hours average             |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   TARGET STATE (Automated):                                       |
|   +----------------------------------------------------------+   |
|   | Metric                    | Target Value                  |   |
|   |---------------------------|-------------------------------|   |
|   | Order Processing Time     | < 1 minute (automated)        |   |
|   | Daily Labor Hours         | < 15 minutes (exceptions)     |   |
|   | Error Rate                | < 0.5%                        |   |
|   | Fulfillment Delay         | < 30 minutes                  |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   AUTOMATION COVERAGE:                                            |
|   - Order import: 100% automated                                  |
|   - Inventory sync: 100% automated                                |
|   - Fulfillment update: 100% automated                            |
|   - Exception handling: Manual (flagged automatically)            |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
B2.1 Order Import Time< 5 min< 1 minReal-timeP0
B2.2 Inventory Sync Accuracy98%99.5%99.9%P0
B2.3 Fulfillment Update Delay< 1 hour< 15 min< 5 minP0
B2.4 Manual Intervention Rate< 10%< 5%< 1%P1
B2.5 Labor Hours Saved50%80%95%P1

B3: Operational Efficiency

Definition: Reduction in manual labor and process time.

+------------------------------------------------------------------+
|                    OPERATIONAL EFFICIENCY GAINS                   |
+------------------------------------------------------------------+
|                                                                   |
|   MONTHLY LABOR COMPARISON:                                       |
|                                                                   |
|   TASK                    CURRENT         TARGET          SAVINGS |
|   +----------------------------------------------------------+   |
|   | Manual Inventory      | 120 hours      | 20 hours     | 100h |
|   | Shopify Order Entry   | 80 hours       | 5 hours      | 75h  |
|   | Transfer Processing   | 60 hours       | 15 hours     | 45h  |
|   | Report Generation     | 20 hours       | 2 hours      | 18h  |
|   | Data Reconciliation   | 40 hours       | 5 hours      | 35h  |
|   +----------------------------------------------------------+   |
|   | TOTAL                 | 320 hours      | 47 hours     | 273h |
|   +----------------------------------------------------------+   |
|                                                                   |
|   COST SAVINGS:                                                   |
|   273 hours x $15/hour = $4,095/month = $49,140/year             |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
B3.1 Manual Process Reduction50%75%90%P0
B3.2 Transfer Time (end-to-end)< 60 min< 30 min< 15 minP1
B3.3 Receiving Time< 30 min< 15 min< 5 minP1
B3.4 Report GenerationAutomaticScheduledReal-timeP1

B4: User Satisfaction

Definition: System acceptance and satisfaction among users.

+------------------------------------------------------------------+
|                    USER SATISFACTION MEASUREMENT                  |
+------------------------------------------------------------------+
|                                                                   |
|   SURVEY METHODOLOGY:                                             |
|   - Quarterly surveys to all POS users                            |
|   - NPS (Net Promoter Score) calculation                          |
|   - Feature satisfaction ratings (1-5 scale)                      |
|   - Training adequacy assessment                                  |
|                                                                   |
|   NPS CALCULATION:                                                |
|   "How likely are you to recommend this system to a colleague?"   |
|   Scale: 0-10                                                     |
|   - Promoters (9-10): Count                                       |
|   - Passives (7-8): Ignore                                        |
|   - Detractors (0-6): Count                                       |
|   NPS = % Promoters - % Detractors                                |
|                                                                   |
|   TARGET SCORES:                                                  |
|   +----------------------------------------------------------+   |
|   | Metric           | Minimum | Target   | Exceptional       |   |
|   |------------------|---------|----------|-------------------|   |
|   | NPS Score        | +20     | +40      | +60               |   |
|   | Overall Rating   | 3.5/5   | 4.0/5    | 4.5/5             |   |
|   | Training Rating  | 3.5/5   | 4.0/5    | 4.5/5             |   |
|   | Support Rating   | 3.5/5   | 4.0/5    | 4.5/5             |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
B4.1 Net Promoter Score+20+40+60P1
B4.2 Overall Satisfaction3.5/54.0/54.5/5P1
B4.3 Training Completion Rate90%95%100%P0
B4.4 Time to Proficiency< 8 hours< 4 hours< 2 hoursP1

4.4 Compliance Success Criteria

C1: PCI-DSS Compliance

Definition: Adherence to Payment Card Industry Data Security Standards.

+------------------------------------------------------------------+
|                    PCI-DSS COMPLIANCE REQUIREMENTS                |
+------------------------------------------------------------------+
|                                                                   |
|   APPLICABLE STANDARD: PCI-DSS v4.0                               |
|   SAQ TYPE: SAQ D (if processing cards) or SAQ P2PE              |
|                                                                   |
|   APPROACH: Point-to-Point Encryption (P2PE)                      |
|   - Card data never touches our systems                           |
|   - Payment terminal encrypts at swipe/tap/dip                    |
|   - Decryption happens at processor                               |
|   - Reduces scope to SAQ P2PE-HW (minimal requirements)           |
|                                                                   |
|   KEY REQUIREMENTS:                                               |
|   +----------------------------------------------------------+   |
|   | Requirement    | Description                | Status      |   |
|   |----------------|----------------------------|-------------|   |
|   | Req 1          | Firewall configuration     | Required    |   |
|   | Req 2          | No vendor defaults         | Required    |   |
|   | Req 3          | Protect stored data        | N/A (P2PE)  |   |
|   | Req 4          | Encrypt transmission       | Required    |   |
|   | Req 5          | Anti-malware               | Required    |   |
|   | Req 6          | Secure development         | Required    |   |
|   | Req 7          | Restrict access            | Required    |   |
|   | Req 8          | Unique IDs                 | Required    |   |
|   | Req 9          | Restrict physical access   | Required    |   |
|   | Req 10         | Track and monitor          | Required    |   |
|   | Req 11         | Test security              | Required    |   |
|   | Req 12         | Security policy            | Required    |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
C1.1 SAQ Completion100%100%100%P0
C1.2 Vulnerability ScansQuarterlyMonthlyContinuousP0
C1.3 Penetration TestingAnnualSemi-annualQuarterlyP1
C1.4 Security TrainingAnnualSemi-annualQuarterlyP1

C2: Data Protection

Definition: Protection of personal and business data.

+------------------------------------------------------------------+
|                    DATA PROTECTION REQUIREMENTS                   |
+------------------------------------------------------------------+
|                                                                   |
|   DATA CLASSIFICATION:                                            |
|   +----------------------------------------------------------+   |
|   | Classification | Examples              | Protection       |   |
|   |----------------|----------------------|------------------|   |
|   | Public         | Store hours, address | None required    |   |
|   | Internal       | Sales reports        | Access control   |   |
|   | Confidential   | Customer PII         | Encryption       |   |
|   | Restricted     | Payment data         | P2PE + tokenize  |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   ENCRYPTION STANDARDS:                                           |
|   - At Rest: AES-256                                              |
|   - In Transit: TLS 1.3                                           |
|   - Key Management: Hardware Security Module (HSM) or equivalent  |
|                                                                   |
|   RETENTION POLICY:                                               |
|   +----------------------------------------------------------+   |
|   | Data Type              | Retention    | Disposal Method   |   |
|   |------------------------|--------------|-------------------|   |
|   | Transaction Records    | 7 years      | Secure delete     |   |
|   | Customer PII           | Active + 3yr | Anonymization     |   |
|   | Audit Logs             | 1 year       | Secure delete     |   |
|   | System Logs            | 90 days      | Overwrite         |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
C2.1 Data Encryption Coverage100% PII100% all sensitive100% all dataP0
C2.2 Backup EncryptionRequiredRequiredRequiredP0
C2.3 Access Audit Coverage100%100%100%P0
C2.4 Data Breach Response Time< 72 hours< 24 hours< 4 hoursP0

C3: Tax Compliance

Definition: Accurate calculation and reporting of sales tax.

+------------------------------------------------------------------+
|                    TAX COMPLIANCE REQUIREMENTS                    |
+------------------------------------------------------------------+
|                                                                   |
|   VIRGINIA TAX STRUCTURE:                                         |
|   +----------------------------------------------------------+   |
|   | Component                 | Rate        | Destination     |   |
|   |---------------------------|-------------|-----------------|   |
|   | State Sales Tax           | 4.3%        | Virginia        |   |
|   | Local Option Tax          | 1.0%        | Varies by city  |   |
|   | Additional Local (some)   | 0.7%        | Hampton Roads   |   |
|   +----------------------------------------------------------+   |
|   | Total (Hampton Roads)     | 6.0%        |                 |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   REQUIREMENTS:                                                   |
|   - Accurate tax calculation on every transaction                 |
|   - Support for tax-exempt sales (with documentation)             |
|   - Monthly sales tax reports by jurisdiction                     |
|   - Annual reconciliation support                                 |
|   - Audit trail for all tax-related transactions                  |
|                                                                   |
|   INTEGRATION OPTIONS:                                            |
|   - Built-in: Virginia-only, manual rate updates                  |
|   - Avalara: Automatic rates, multi-state, real-time              |
|   - TaxJar: Similar to Avalara, popular with Shopify              |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
C3.1 Tax Calculation Accuracy99.9%99.99%100%P0
C3.2 Exempt Sale Documentation100%100%100%P0
C3.3 Report Generation Time< 1 hour< 15 min< 5 minP1
C3.4 Rate Update Latency< 7 days< 24 hoursSame dayP1

4.5 Operational Success Criteria

O1: System Maintenance

Definition: Effort required to maintain and update the system.

+------------------------------------------------------------------+
|                    MAINTENANCE REQUIREMENTS                       |
+------------------------------------------------------------------+
|                                                                   |
|   MAINTENANCE WINDOWS:                                            |
|   - Preferred: Sunday 2:00 AM - 6:00 AM (lowest activity)         |
|   - Emergency: Any time with user notification                    |
|   - Maximum Duration: 4 hours                                     |
|                                                                   |
|   UPDATE CATEGORIES:                                              |
|   +----------------------------------------------------------+   |
|   | Category        | Frequency    | Downtime    | Notice     |   |
|   |-----------------|--------------|-------------|------------|   |
|   | Security Patch  | As needed    | < 15 min    | 4 hours    |   |
|   | Bug Fix         | Weekly       | < 30 min    | 24 hours   |   |
|   | Feature Update  | Monthly      | < 2 hours   | 7 days     |   |
|   | Major Version   | Quarterly    | < 4 hours   | 30 days    |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   ZERO-DOWNTIME DEPLOYMENT TARGET:                                |
|   - All updates except major versions should be zero-downtime     |
|   - Blue-green deployment strategy                                |
|   - Automatic rollback on failure                                 |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
O1.1 Planned Downtime< 4 hr/month< 2 hr/monthZeroP1
O1.2 Deployment Success Rate95%99%99.9%P0
O1.3 Rollback Time< 30 min< 10 min< 5 minP0
O1.4 Security Patch Time< 7 days< 48 hours< 24 hoursP0

O2: Support and Issue Resolution

Definition: Responsiveness to support requests and issues.

+------------------------------------------------------------------+
|                    SUPPORT LEVEL DEFINITIONS                      |
+------------------------------------------------------------------+
|                                                                   |
|   SEVERITY LEVELS:                                                |
|   +----------------------------------------------------------+   |
|   | Level  | Definition                | Example              |   |
|   |--------|---------------------------|----------------------|   |
|   | SEV-1  | System down, no workaround| All registers down   |   |
|   | SEV-2  | Major feature broken      | Cannot process cards |   |
|   | SEV-3  | Minor feature issue       | Report formatting    |   |
|   | SEV-4  | Enhancement request       | New report column    |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   RESPONSE TIME TARGETS:                                          |
|   +----------------------------------------------------------+   |
|   | Level  | Response    | Resolution | Escalation            |   |
|   |--------|-------------|------------|-----------------------|   |
|   | SEV-1  | 15 minutes  | 2 hours    | Immediate             |   |
|   | SEV-2  | 1 hour      | 8 hours    | After 4 hours         |   |
|   | SEV-3  | 4 hours     | 48 hours   | After 24 hours        |   |
|   | SEV-4  | 24 hours    | Next sprint| N/A                   |   |
|   +----------------------------------------------------------+   |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
O2.1 SEV-1 Response< 30 min< 15 min< 5 minP0
O2.2 SEV-1 Resolution< 4 hours< 2 hours< 1 hourP0
O2.3 First Contact Resolution50%70%85%P1
O2.4 User Satisfaction (Support)3.5/54.0/54.5/5P1

O3: Documentation Quality

Definition: Completeness and accuracy of system documentation.

+------------------------------------------------------------------+
|                    DOCUMENTATION REQUIREMENTS                     |
+------------------------------------------------------------------+
|                                                                   |
|   DOCUMENTATION CATEGORIES:                                       |
|   +----------------------------------------------------------+   |
|   | Category         | Audience         | Update Frequency    |   |
|   |------------------|------------------|---------------------|   |
|   | User Manual      | End users        | Each release        |   |
|   | Admin Guide      | System admins    | Each release        |   |
|   | API Reference    | Developers       | Each API change     |   |
|   | Training Guide   | Trainers         | Major releases      |   |
|   | Runbook          | Operations       | Monthly             |   |
|   | Architecture     | Developers       | Quarterly           |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   QUALITY STANDARDS:                                              |
|   - All features documented before release                        |
|   - Screenshots updated with each UI change                       |
|   - API examples tested and working                               |
|   - Search functionality available                                |
|   - Version history maintained                                    |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
O3.1 Feature Coverage90%100%100% + ExamplesP1
O3.2 Documentation Currency90 days30 daysReal-timeP1
O3.3 User Findability70%85%95%P2
O3.4 Self-Service Resolution30%50%70%P2

4.6 Multi-Tenant Readiness Criteria

M1: Tenant Isolation

Definition: Guarantee that tenants cannot access each other’s data.

+------------------------------------------------------------------+
|                    TENANT ISOLATION MODEL                         |
+------------------------------------------------------------------+
|                                                                   |
|   ISOLATION STRATEGY: Row-Level Security (RLS)                    |
|                                                                   |
|   +----------------------------------------------------------+   |
|   |                     SHARED DATABASE                       |   |
|   |                                                           |   |
|   |   +---------------+  +---------------+  +---------------+ |   |
|   |   | Tenant A      |  | Tenant B      |  | Tenant C      | |   |
|   |   | (tenant_id=1) |  | (tenant_id=2) |  | (tenant_id=3) | |   |
|   |   |               |  |               |  |               | |   |
|   |   | - Products    |  | - Products    |  | - Products    | |   |
|   |   | - Inventory   |  | - Inventory   |  | - Inventory   | |   |
|   |   | - Sales       |  | - Sales       |  | - Sales       | |   |
|   |   +---------------+  +---------------+  +---------------+ |   |
|   |                                                           |   |
|   |   RLS Policy enforces tenant_id filter on ALL queries     |   |
|   +----------------------------------------------------------+   |
|                                                                   |
|   ISOLATION TESTING:                                              |
|   - Automated tests verify cross-tenant access is blocked         |
|   - Penetration testing includes tenant boundary testing          |
|   - Audit logs track any access attempts                          |
|                                                                   |
+------------------------------------------------------------------+
KPIMinimumTargetExceptionalPriority
M1.1 Data Isolation100%100%100%P0
M1.2 Cross-Tenant Test Coverage90%100%100% + FuzzingP0
M1.3 Isolation Audit FrequencyMonthlyWeeklyContinuousP1

M2: Tenant Onboarding

Definition: Speed and ease of adding new tenants.

KPIMinimumTargetExceptionalPriority
M2.1 Tenant Setup Time< 4 hours< 1 hour< 15 minP1
M2.2 Self-Service OnboardingNoGuidedFully automatedP2
M2.3 Data Migration SupportManualSemi-autoFully automatedP2
M2.4 Go-Live ChecklistManualAutomatedSelf-verifiedP1

4.7 Success Dashboard

KPI Summary Dashboard

+------------------------------------------------------------------+
|                    SUCCESS CRITERIA DASHBOARD                     |
+------------------------------------------------------------------+
|                                                                   |
|   CATEGORY          P0 KPIS    PASSING    STATUS                  |
|   +----------------------------------------------------------+   |
|   | Technical       | 14       | --/14    | [         ]          |
|   | Business        | 8        | --/8     | [         ]          |
|   | Compliance      | 6        | --/6     | [         ]          |
|   | Operational     | 5        | --/5     | [         ]          |
|   | Multi-Tenant    | 3        | --/3     | [         ]          |
|   +----------------------------------------------------------+   |
|   | TOTAL           | 36       | --/36    | [         ]          |
|   +----------------------------------------------------------+   |
|                                                                   |
|   LEGEND:                                                         |
|   [ GREEN ] All P0 KPIs meeting TARGET                            |
|   [YELLOW ] All P0 KPIs at MINIMUM                                |
|   [  RED  ] Any P0 KPI below MINIMUM                              |
|                                                                   |
|   GO/NO-GO DECISION:                                              |
|   - Production: All P0 at MINIMUM + 80% P1 at MINIMUM             |
|   - Multi-Tenant: All P0 at TARGET + 90% P1 at MINIMUM            |
|                                                                   |
+------------------------------------------------------------------+

4.8 Measurement and Reporting

Measurement Schedule

KPI CategoryMeasurement FrequencyReport FrequencyOwner
Technical PerformanceReal-timeDailyDevOps
Business OutcomesDaily aggregationWeeklyProduct
Compliance StatusContinuousQuarterlySecurity
Operational MetricsReal-timeWeeklyOperations
User SatisfactionQuarterly surveyQuarterlyProduct

Reporting Hierarchy

+------------------------------------------------------------------+
|                    REPORTING STRUCTURE                            |
+------------------------------------------------------------------+
|                                                                   |
|   DAILY (Automated)                                               |
|   - System availability percentage                                |
|   - Transaction count and success rate                            |
|   - Sync latency and success                                      |
|   - Error rates by category                                       |
|                                                                   |
|   WEEKLY (Operations Review)                                      |
|   - KPI dashboard review                                          |
|   - Incident summary                                              |
|   - Capacity utilization                                          |
|   - Support ticket trends                                         |
|                                                                   |
|   MONTHLY (Leadership Report)                                     |
|   - Executive KPI summary                                         |
|   - Business outcome metrics                                      |
|   - Cost/benefit analysis                                         |
|   - Risk assessment                                               |
|                                                                   |
|   QUARTERLY (Board Report)                                        |
|   - Strategic goal alignment                                      |
|   - Multi-tenant readiness assessment                             |
|   - Compliance certification status                               |
|   - Investment return analysis                                    |
|                                                                   |
+------------------------------------------------------------------+

4.9 Summary

Critical Success Factors

#Success FactorMeasurementTarget
1System never prevents salesTransaction success rate99.9%
2Inventory is always accurateAccuracy audit98%
3Users find system easyNPS score+40
4Shopify sync is seamlessAutomation rate95%
5System is secureCompliance certification100%
6Multi-tenant readyIsolation testing100%

Success Validation Timeline

PhaseValidationDurationExit Criteria
AlphaInternal testing4 weeksAll P0 technical KPIs pass
BetaSingle store pilot4 weeksAll P0 KPIs at minimum
ProductionFull rollout8 weeksAll P0 KPIs at target
Multi-TenantExternal tenant12 weeksAll M1/M2 KPIs pass

Document Information

AttributeValue
Version1.0.0
Created2025-12-29
AuthorClaude-NAS
StatusDraft
PartI - Foundation
Chapter04 of 04

This document is part of the POS Blueprint Book. All content is self-contained and requires no external file references.

Chapter 05: High-Level Architecture

The Complete System Overview

This chapter presents the complete high-level architecture of the POS Platform. Every component, connection, and data flow is documented here as the authoritative reference for implementation.


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                                    |
|                    (ASP.NET Core 8.0 / Node.js)                           |
|                                                                            |
|  +------------------+  +------------------+  +------------------+          |
|  |  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  |  | tenant_nexus    |  | tenant_acme     |        |  |
|  |  |  (platform)     |  | (Nexus Clothing)|  | (Acme Retail)   |        |  |
|  |  +-----------------+  +-----------------+  +-----------------+        |  |
|  |                                                                       |  |
|  +---------------------------------------------------------------------+  |
|  +------------------+  +------------------+                               |
|  |     Redis        |  |  Event Store     |                               |
|  |  (Cache/Queue)   |  |  (Append-Only)   |                               |
|  +------------------+  +------------------+                               |
+===========================================================================+

+===========================================================================+
|                      CLIENT APPLICATIONS                                   |
|                                                                            |
|  +------------------+  +------------------+  +------------------+          |
|  |    POS Client    |  |   Admin Portal   |  |  Raptag Mobile   |          |
|  |   (Desktop App)  |  |   (React SPA)    |  |  (.NET MAUI)     |          |
|  |                  |  |                  |  |                  |          |
|  | - Sales Terminal |  | - Dashboard      |  | - RFID Scanning  |          |
|  | - Offline Mode   |  | - Reports        |  | - Inventory      |          |
|  | - Local SQLite   |  | - Configuration  |  | - Quick Counts   |          |
|  | - Receipt Print  |  | - User Mgmt      |  | - Transfers      |          |
|  +------------------+  +------------------+  +------------------+          |
|                                                                            |
+===========================================================================+

Three-Tier Architecture

The POS Platform follows a clean three-tier architecture with clear separation of concerns.

Tier 1: Cloud Layer (External Services)

External cloud services that the platform integrates with:

ServicePurposeProtocolData Flow
Shopify APIE-commerce syncREST/GraphQLBidirectional
Payment GatewayCard processingREST + WebhooksRequest/Response
Tax ServiceTax calculationRESTRequest/Response
Email ServiceNotificationsSMTP/APIOutbound only
SMS ServiceAlertsAPIOutbound 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)

The heart of the platform - all business logic resides here.

API Gateway

The gateway handles cross-cutting concerns before requests reach services:

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

ServiceResponsibilitiesKey Endpoints
Catalog ServiceProducts, categories, pricing, variants/api/v1/products/*
Sales ServiceTransactions, receipts, refunds, holds/api/v1/sales/*
Inventory ServiceStock levels, adjustments, transfers/api/v1/inventory/*
Customer ServiceProfiles, loyalty, purchase history/api/v1/customers/*
Employee ServiceUsers, roles, permissions, shifts/api/v1/employees/*
Sync ServiceOffline sync, conflict resolution/api/v1/sync/*

Tier 3: Data Layer (Persistence)

All data storage and caching systems:

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: tenant_xxx     |   {sku}    |                           |
|   +--------------+       +------------+                           |
|   | products     |                                                |
|   | sales        |                                                |
|   | inventory    |                                                |
|   | customers    |                                                |
|   +--------------+                                                |
|                                                                   |
+-------------------------------------------------------------------+

Client Applications

POS Client (Desktop)

The primary point-of-sale terminal application.

POS Client Architecture
=======================

+-------------------------------------------------------------------+
|                      POS CLIENT (Electron/Tauri)                   |
|                                                                    |
|  +-----------------------+      +---------------------------+     |
|  |      UI Layer         |      |     Local Storage         |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  |  | Sales Screen   |   |      |  |   SQLite Database  |   |     |
|  |  +----------------+   |      |  |                    |   |     |
|  |  | Product Grid   |   |      |  | - products_cache   |   |     |
|  |  +----------------+   |      |  | - pending_sales    |   |     |
|  |  | Cart Panel     |   |      |  | - sync_queue       |   |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  |  | Payment Dialog |   |      |                           |     |
|  |  +----------------+   |      +---------------------------+     |
|  +-----------------------+                                        |
|                                                                    |
|  +-----------------------+      +---------------------------+     |
|  |    Service Layer      |      |    Hardware Layer         |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  |  | SaleService    |   |      |  | Receipt Printer    |   |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  |  | SyncService    |   |      |  | Barcode Scanner    |   |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  |  | OfflineService |   |      |  | Cash Drawer        |   |     |
|  |  +----------------+   |      |  +--------------------+   |     |
|  +-----------------------+      |  | Card Reader        |   |     |
|                                 |  +--------------------+   |     |
|                                 +---------------------------+     |
+-------------------------------------------------------------------+

Key Features:

  • Offline-first with local SQLite database
  • Automatic sync when connectivity restored
  • Hardware integration (printers, scanners, drawers)
  • Multi-register support per location

Admin Portal (Web)

Management dashboard for administrators and managers.

Admin Portal Architecture
=========================

+-------------------------------------------------------------------+
|                    ADMIN PORTAL (React SPA)                        |
|                                                                    |
|  +------------------------+    +---------------------------+      |
|  |     Navigation         |    |      Main Content         |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Dashboard        |  |    |  | Dashboard View      |  |      |
|  |  +------------------+  |    |  |   - KPIs            |  |      |
|  |  | Products         |  |    |  |   - Charts          |  |      |
|  |  +------------------+  |    |  |   - Alerts          |  |      |
|  |  | Sales            |  |    |  +---------------------+  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Inventory        |  |    |  | Product Manager     |  |      |
|  |  +------------------+  |    |  |   - CRUD            |  |      |
|  |  | Customers        |  |    |  |   - Bulk Import     |  |      |
|  |  +------------------+  |    |  |   - Sync Status     |  |      |
|  |  | Employees        |  |    |  +---------------------+  |      |
|  |  +------------------+  |    |                           |      |
|  |  | Reports          |  |    |                           |      |
|  |  +------------------+  |    |                           |      |
|  |  | Settings         |  |    |                           |      |
|  |  +------------------+  |    |                           |      |
|  +------------------------+    +---------------------------+      |
|                                                                    |
|  State Management: React Query + Context                           |
|  Routing: React Router                                             |
|  UI Framework: TailwindCSS                                         |
+-------------------------------------------------------------------+

Raptag Mobile (RFID)

Mobile application for RFID inventory operations.

Raptag Mobile Architecture
==========================

+-------------------------------------------------------------------+
|                   RAPTAG MOBILE (.NET MAUI)                        |
|                                                                    |
|  +------------------------+    +---------------------------+      |
|  |      RFID Layer        |    |       UI Layer            |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Zebra SDK        |  |    |  | Scan Screen         |  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Tag Parser       |  |    |  | Inventory Count     |  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Batch Processor  |  |    |  | Transfer Screen     |  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  +------------------------+    +---------------------------+      |
|                                                                    |
|  +------------------------+    +---------------------------+      |
|  |    Local Storage       |    |     API Client            |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | SQLite           |  |    |  | HTTP Client         |  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  |  | Scan Buffer      |  |    |  | Offline Queue       |  |      |
|  |  +------------------+  |    |  +---------------------+  |      |
|  +------------------------+    +---------------------------+      |
+-------------------------------------------------------------------+

Data Flow Patterns

Pattern 1: Online Sale Flow

Online Sale Flow
================

[POS Client]                    [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
=================

[POS Client]                    [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   |
     |                               |------------------------------>|
     |                               |           (SignalR)           |

Service Boundaries

Each service owns its data and exposes it only through APIs.

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

LayerTechnologyJustification
API GatewayKong or NGINXProven, scalable, plugin ecosystem
Central APIASP.NET Core 8.0Performance, C# ecosystem, EF Core
DatabasePostgreSQL 16Schema-per-tenant, JSON support, reliability
CacheRedisSession storage, real-time features
Event StorePostgreSQL (append-only)Simplicity, same DB engine
POS ClientElectron or TauriCross-platform desktop, offline SQLite
Admin PortalReact + TypeScriptModern SPA, rich ecosystem
Mobile App.NET MAUIC# codebase, Zebra RFID SDK support
Real-timeSignalRInventory broadcasts, notifications

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       |
| +------------+ |   | +------------+ |   | +------------+ |
| |POS Client 1| |   | |POS Client 1| |   | |POS Client 1| |
| +------------+ |   | +------------+ |   | +------------+ |
| |POS Client 2| |   | +------------+ |   +----------------+
| +------------+ |   | |POS Client 2| |
+----------------+   | +------------+ |
                     +----------------+

Security Architecture

Security Layers
===============

+------------------------------------------------------------------+
|                        INTERNET                                   |
+---------------------------+--------------------------------------+
                            |
                            v
+---------------------------+--------------------------------------+
|                    TLS TERMINATION                                |
|                    (Let's Encrypt)                                |
+---------------------------+--------------------------------------+
                            |
                            v
+------------------------------------------------------------------+
|                    API GATEWAY                                    |
|  +-----------------------+  +-----------------------+             |
|  | Rate Limiting         |  | IP Whitelisting       |             |
|  | 100 req/min/client    |  | (Admin Portal 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    |             |
|  +-----------------------+  +-----------------------+             |
+------------------------------------------------------------------+

System Flow Overview: From Subscription to Operations

This section explains how the complete system works from a tenant’s perspective - from initial subscription through daily operations.

The Tenant Journey

TENANT JOURNEY: Subscription → Deployment → Operations
======================================================

STEP 1: SUBSCRIPTION
┌─────────────────────────────────────────────────────────────────────────────┐
│  Prospective tenant visits pos-platform.com                                  │
│                                                                              │
│  ┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐       │
│  │ Select Plan      │ → │ Create Account   │ → │ Payment Setup    │       │
│  │ - Starter $49    │    │ - Company info   │    │ - Stripe billing │       │
│  │ - Pro $149       │    │ - Admin user     │    │ - Trial period   │       │
│  │ - Enterprise $499│    │ - Store count    │    │ - Auto-provision │       │
│  └──────────────────┘    └──────────────────┘    └──────────────────┘       │
│                                                              │               │
│                                                              ▼               │
│  ┌────────────────────────────────────────────────────────────────────────┐ │
│  │                    AUTOMATIC PROVISIONING                               │ │
│  │  • Create tenant schema: CREATE SCHEMA tenant_{id}                      │ │
│  │  • Create admin user with temporary password                            │ │
│  │  • Apply plan limits (stores, registers, features)                      │ │
│  │  • Generate API keys (Enterprise tier only)                             │ │
│  │  • Send welcome email with login credentials                            │ │
│  └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
STEP 2: ADMIN PORTAL ACCESS
┌─────────────────────────────────────────────────────────────────────────────┐
│  Tenant admin logs into Admin Portal (React SPA)                             │
│  https://app.pos-platform.com                                                │
│                                                                              │
│  ┌────────────────────────────────────────────────────────────────────────┐ │
│  │  INITIAL SETUP WIZARD                                                   │ │
│  │                                                                          │ │
│  │  1. Configure Locations      →  Add store addresses, assign codes       │ │
│  │  2. Import Products          →  CSV upload or Shopify sync              │ │
│  │  3. Create Employee Accounts →  Set roles (Manager, Cashier)            │ │
│  │  4. Configure Tax Rates      →  By state/locality                       │ │
│  │  5. Set Up Payment           →  Connect Stripe/Square terminal          │ │
│  │  6. Download Client Apps     →  POS Client + Raptag Mobile              │ │
│  │                                                                          │ │
│  └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
STEP 3: CLIENT APPLICATION DEPLOYMENT
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│  ┌─────────────────────────────┐    ┌─────────────────────────────┐         │
│  │   POS CLIENT DOWNLOAD       │    │   RAPTAG MOBILE DOWNLOAD    │         │
│  │                             │    │                             │         │
│  │  From Admin Portal:         │    │  From Admin Portal:         │         │
│  │  Settings → Downloads       │    │  Settings → Downloads       │         │
│  │                             │    │                             │         │
│  │  ┌─────────────────────┐   │    │  ┌─────────────────────┐   │         │
│  │  │ Download for:       │   │    │  │ Download for:       │   │         │
│  │  │ • Windows (x64)     │   │    │  │ • Android (APK)     │   │         │
│  │  │ • macOS (Intel/ARM) │   │    │  │                     │   │         │
│  │  │ • Linux (AppImage)  │   │    │  │ Scan QR code or     │   │         │
│  │  └─────────────────────┘   │    │  │ email download link │   │         │
│  │                             │    │  └─────────────────────┘   │         │
│  │  Installer includes:        │    │                             │         │
│  │  • Pre-configured API URL   │    │  App auto-discovers:        │         │
│  │  • Tenant ID embedded       │    │  • Cloud API endpoint       │         │
│  │  • SQLite for offline       │    │  • Authenticates via QR     │         │
│  │                             │    │                             │         │
│  └─────────────────────────────┘    └─────────────────────────────┘         │
│                                                                              │
│  INSTALLATION (per store computer):                                          │
│  1. Run installer                                                            │
│  2. Log in with employee credentials                                         │
│  3. Select store location                                                    │
│  4. Initial sync downloads: products, customers, pricing                     │
│  5. Ready to ring sales!                                                     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
STEP 4: DAILY OPERATIONS
┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│  ┌────────────────────────────────────────────────────────────────────────┐ │
│  │                      ALL CLIENTS CONNECT TO CLOUD                       │ │
│  │                                                                          │ │
│  │        ┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐        │ │
│  │        │   POS   │    │   POS   │    │ Raptag  │    │  Admin  │        │ │
│  │        │ Store 1 │    │ Store 2 │    │ Mobile  │    │ Portal  │        │ │
│  │        └────┬────┘    └────┬────┘    └────┬────┘    └────┬────┘        │ │
│  │             │              │              │              │              │ │
│  │             │   WiFi/LAN   │    WiFi      │    Browser   │              │ │
│  │             │              │              │              │              │ │
│  │             └──────────────┴──────────────┴──────────────┘              │ │
│  │                                   │                                      │ │
│  │                                   ▼                                      │ │
│  │                    ┌──────────────────────────┐                          │ │
│  │                    │     CENTRAL CLOUD API    │                          │ │
│  │                    │   (Multi-Tenant SaaS)    │                          │ │
│  │                    │                          │                          │ │
│  │                    │  • Real-time sync        │                          │ │
│  │                    │  • Inventory updates     │                          │ │
│  │                    │  • Sales aggregation     │                          │ │
│  │                    │  • Report generation     │                          │ │
│  │                    └──────────────────────────┘                          │ │
│  │                                                                          │ │
│  └────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
│  KEY POINT: POS Client and Raptag are SIBLINGS, not parent-child            │
│  - Both connect directly to Cloud API                                        │
│  - Both can work offline (local SQLite cache)                                │
│  - Both sync independently when connectivity restored                        │
│  - Raptag does NOT connect to POS Client                                     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Component Relationship Diagram

Understanding how the three client applications relate:

                    ┌─────────────────────────────────────────┐
                    │           CENTRAL CLOUD API             │
                    │         (The Single Source of Truth)    │
                    │                                         │
                    │  ┌─────────────────────────────────┐   │
                    │  │  Multi-Tenant PostgreSQL         │   │
                    │  │  └── tenant_acme (schema)        │   │
                    │  │      ├── products                │   │
                    │  │      ├── inventory               │   │
                    │  │      ├── sales                   │   │
                    │  │      └── customers               │   │
                    │  └─────────────────────────────────┘   │
                    └─────────────────┬───────────────────────┘
                                      │
              ┌───────────────────────┼───────────────────────┐
              │                       │                       │
              ▼                       ▼                       ▼
    ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
    │   POS CLIENT    │     │  ADMIN PORTAL   │     │  RAPTAG MOBILE  │
    │  (Desktop App)  │     │   (Web App)     │     │  (Mobile App)   │
    ├─────────────────┤     ├─────────────────┤     ├─────────────────┤
    │                 │     │                 │     │                 │
    │ PURPOSE:        │     │ PURPOSE:        │     │ PURPOSE:        │
    │ Ring sales,     │     │ Manage business,│     │ RFID inventory, │
    │ process payments│     │ view reports,   │     │ quick counts,   │
    │                 │     │ configure system│     │ transfers       │
    │                 │     │                 │     │                 │
    │ OFFLINE: ✓      │     │ OFFLINE: ✗      │     │ OFFLINE: ✓      │
    │ (SQLite cache)  │     │ (Requires net)  │     │ (SQLite cache)  │
    │                 │     │                 │     │                 │
    │ USERS:          │     │ USERS:          │     │ USERS:          │
    │ Cashiers,       │     │ Owner, Admin,   │     │ Stock clerks,   │
    │ Managers        │     │ Managers        │     │ Managers        │
    │                 │     │                 │     │                 │
    └─────────────────┘     └─────────────────┘     └─────────────────┘

    NOTE: These are THREE INDEPENDENT applications
          Each connects DIRECTLY to Cloud API
          They do NOT connect to each other

Offline Mode Explained

When network connectivity is lost:

ONLINE MODE (Normal)                    OFFLINE MODE (Network Lost)
====================                    ============================

┌─────────────┐     ┌──────────┐       ┌─────────────┐     ┌──────────┐
│ POS Client  │────▶│ Cloud    │       │ POS Client  │  ✗  │ Cloud    │
│             │◀────│ API      │       │             │     │ API      │
└─────────────┘     └──────────┘       └──────┬──────┘     └──────────┘
                                              │
                                              ▼
                                       ┌──────────────┐
                                       │ LOCAL SQLite │
                                       │              │
                                       │ • Products   │ ← Pre-synced catalog
                                       │ • Prices     │ ← Pre-synced pricing
                                       │ • Customers  │ ← Pre-synced data
                                       │ • Pending    │ ← Queue of sales
                                       │   Sales      │   awaiting sync
                                       └──────────────┘

WHEN CONNECTIVITY RESTORED:
1. Sync service wakes up
2. Pending sales pushed to Cloud API
3. Cloud applies changes (with conflict resolution)
4. Fresh catalog/inventory pulled down
5. Local SQLite updated

Summary

This high-level architecture provides:

  1. Clear separation between cloud services, API layer, data layer, and clients
  2. Multi-tenant design with schema-per-tenant isolation
  3. Offline-first POS clients with sync queue
  4. Event-driven inventory and sales tracking
  5. Scalable stateless API servers behind load balancer
  6. Secure with TLS, JWT, RBAC, and audit logging

The following chapters dive deep into each architectural decision, starting with multi-tenancy in Chapter 06.


Next: Chapter 06: Multi-Tenancy Design

Chapter 06: Multi-Tenancy Design

Schema-Per-Tenant Architecture

This chapter details the multi-tenancy strategy for the POS Platform. We use schema-per-tenant isolation - the gold standard for enterprise SaaS applications requiring strong data isolation.


Multi-Tenancy Strategies Comparison

Before diving into our chosen approach, let’s understand the alternatives:

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 [CHOSEN]
+-----------------------------------------------------+
| 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)

Why Schema-Per-Tenant?

Decision Matrix

RequirementShared TablesSeparate DBsSchema-Per-Tenant
Data IsolationPoorExcellentExcellent
PerformanceGoodExcellentVery Good
Backup/RestoreComplexSimpleSimple
Connection OverheadLowHighLow
Query ComplexityHighLowLow
Compliance (SOC2)DifficultEasyEasy
Cost at ScaleLowHighMedium
Migration ComplexityLowLowMedium

Our Choice: Schema-Per-Tenant because:

  1. Strong isolation for compliance and trust
  2. Easy per-tenant backup and restore
  3. Single database connection per API server
  4. Clean data model without tenant_id on every table

Database Structure

The Platform Database

Database: pos_platform
======================

+-- Schema: shared (Platform-wide)
|   +-- tenants              (tenant registry)
|   +-- subscription_plans   (pricing plans)
|   +-- feature_flags        (feature toggles)
|   +-- platform_settings    (global config)
|   +-- api_keys             (external integrations)
|
+-- Schema: tenant_nexus (Nexus Clothing tenant)
|   +-- products
|   +-- product_variants
|   +-- categories
|   +-- sales
|   +-- sale_line_items
|   +-- payments
|   +-- inventory_items
|   +-- stock_levels
|   +-- customers
|   +-- employees
|   +-- locations
|   +-- (... all tenant tables)
|
+-- Schema: tenant_acme (Acme Retail tenant)
|   +-- (same structure as tenant_nexus)
|
+-- Schema: tenant_beta (Beta Store tenant)
    +-- (same structure as tenant_nexus)

Shared Schema Tables

The shared schema contains platform-level data accessible to all tenants:

-- Schema: shared

-- Tenant Registry
CREATE TABLE shared.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'
    schema_name     VARCHAR(100) NOT NULL,         -- 'tenant_nexus'
    plan_id         UUID REFERENCES shared.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
CREATE TABLE shared.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
CREATE TABLE shared.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()
);

-- Platform Settings
CREATE TABLE shared.platform_settings (
    key             VARCHAR(100) PRIMARY KEY,
    value           JSONB NOT NULL,
    description     TEXT,
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Insert default plans
INSERT INTO shared.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

Tenant Schema Template

Each tenant gets an identical schema structure:

-- Template for creating a new tenant schema
-- Replace {tenant_slug} with actual tenant slug (e.g., 'nexus')

CREATE SCHEMA tenant_{tenant_slug};

-- Set search path for this session
SET search_path TO tenant_{tenant_slug};

-- Core lookup tables
CREATE TABLE locations (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    code            VARCHAR(10) UNIQUE NOT NULL,
    name            VARCHAR(255) NOT NULL,
    address_line1   VARCHAR(255),
    address_line2   VARCHAR(255),
    city            VARCHAR(100),
    state           VARCHAR(50),
    postal_code     VARCHAR(20),
    country         VARCHAR(2) DEFAULT 'US',
    phone           VARCHAR(20),
    email           VARCHAR(255),
    timezone        VARCHAR(50) DEFAULT 'America/New_York',
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE employees (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    employee_number VARCHAR(20) UNIQUE,
    first_name      VARCHAR(100) NOT NULL,
    last_name       VARCHAR(100) NOT NULL,
    email           VARCHAR(255) UNIQUE,
    phone           VARCHAR(20),
    pin_hash        VARCHAR(255),                  -- Hashed PIN for clock-in
    role            VARCHAR(50) NOT NULL,          -- 'admin', 'manager', 'cashier'
    home_location_id UUID REFERENCES locations(id),
    hourly_rate     DECIMAL(10,2),
    is_active       BOOLEAN DEFAULT TRUE,
    last_login_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE categories (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    slug            VARCHAR(255) UNIQUE NOT NULL,
    parent_id       UUID REFERENCES categories(id),
    sort_order      INTEGER DEFAULT 0,
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sku             VARCHAR(50) UNIQUE NOT NULL,
    barcode         VARCHAR(50),
    name            VARCHAR(255) NOT NULL,
    description     TEXT,
    category_id     UUID REFERENCES categories(id),
    brand           VARCHAR(100),
    vendor          VARCHAR(100),
    cost            DECIMAL(10,2) DEFAULT 0,
    price           DECIMAL(10,2) NOT NULL,
    compare_at_price DECIMAL(10,2),
    tax_code        VARCHAR(20),
    is_taxable      BOOLEAN DEFAULT TRUE,
    track_inventory BOOLEAN DEFAULT TRUE,
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE product_variants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    product_id      UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    sku             VARCHAR(50) UNIQUE NOT NULL,
    barcode         VARCHAR(50),
    name            VARCHAR(255) NOT NULL,         -- e.g., 'Large / Blue'
    option1_name    VARCHAR(50),                   -- 'Size'
    option1_value   VARCHAR(100),                  -- 'Large'
    option2_name    VARCHAR(50),                   -- 'Color'
    option2_value   VARCHAR(100),                  -- 'Blue'
    option3_name    VARCHAR(50),
    option3_value   VARCHAR(100),
    cost            DECIMAL(10,2),
    price           DECIMAL(10,2),
    weight          DECIMAL(10,2),
    is_active       BOOLEAN DEFAULT TRUE,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE inventory_items (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    product_id      UUID REFERENCES products(id),
    variant_id      UUID REFERENCES product_variants(id),
    location_id     UUID NOT NULL REFERENCES locations(id),
    quantity_on_hand INTEGER DEFAULT 0,
    quantity_committed INTEGER DEFAULT 0,           -- Reserved for orders
    quantity_available INTEGER GENERATED ALWAYS AS (quantity_on_hand - quantity_committed) STORED,
    reorder_point   INTEGER DEFAULT 0,
    reorder_quantity INTEGER DEFAULT 0,
    last_counted_at TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (product_id, variant_id, location_id)
);

CREATE TABLE customers (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_number VARCHAR(20) UNIQUE,
    first_name      VARCHAR(100),
    last_name       VARCHAR(100),
    email           VARCHAR(255) UNIQUE,
    phone           VARCHAR(20),
    company         VARCHAR(255),
    tax_exempt      BOOLEAN DEFAULT FALSE,
    tax_exempt_id   VARCHAR(50),
    notes           TEXT,
    loyalty_points  INTEGER DEFAULT 0,
    total_spent     DECIMAL(12,2) DEFAULT 0,
    visit_count     INTEGER DEFAULT 0,
    last_visit_at   TIMESTAMPTZ,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE sales (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sale_number     VARCHAR(50) UNIQUE NOT NULL,
    location_id     UUID NOT NULL REFERENCES locations(id),
    register_id     VARCHAR(20),
    employee_id     UUID REFERENCES employees(id),
    customer_id     UUID REFERENCES customers(id),
    status          VARCHAR(20) DEFAULT 'completed', -- draft, completed, voided, refunded
    subtotal        DECIMAL(12,2) NOT NULL,
    discount_total  DECIMAL(12,2) DEFAULT 0,
    tax_total       DECIMAL(12,2) DEFAULT 0,
    total           DECIMAL(12,2) NOT NULL,
    payment_status  VARCHAR(20) DEFAULT 'paid',     -- pending, partial, paid, refunded
    notes           TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE sale_line_items (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sale_id         UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
    product_id      UUID REFERENCES products(id),
    variant_id      UUID REFERENCES product_variants(id),
    sku             VARCHAR(50) NOT NULL,
    name            VARCHAR(255) NOT NULL,
    quantity        INTEGER NOT NULL,
    unit_price      DECIMAL(10,2) NOT NULL,
    discount_amount DECIMAL(10,2) DEFAULT 0,
    tax_amount      DECIMAL(10,2) DEFAULT 0,
    total           DECIMAL(12,2) NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE payments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sale_id         UUID NOT NULL REFERENCES sales(id) ON DELETE CASCADE,
    payment_method  VARCHAR(50) NOT NULL,          -- cash, credit, debit, gift_card
    amount          DECIMAL(12,2) NOT NULL,
    reference       VARCHAR(100),                  -- Card last 4, check #, etc.
    status          VARCHAR(20) DEFAULT 'completed',
    processor_response JSONB,                      -- Payment gateway response
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- Additional tables: refunds, inventory_adjustments, etc.
-- (Full schema in Chapter 12)

Tenant Resolution Flow

How the system determines which tenant schema to use:

Tenant Resolution Flow
======================

                     +---------------------------+
                     |   Incoming Request        |
                     |   nexus.pos-platform.com  |
                     +-------------+-------------+
                                   |
                                   v
                     +---------------------------+
                     |   Extract Subdomain       |
                     |   subdomain = "nexus"     |
                     +-------------+-------------+
                                   |
                                   v
                     +---------------------------+
                     |   Lookup in shared.tenants|
                     |   WHERE subdomain = ?     |
                     +-------------+-------------+
                                   |
            +----------------------+----------------------+
            |                                             |
      [Found]                                       [Not Found]
            |                                             |
            v                                             v
+---------------------------+               +---------------------------+
| Set PostgreSQL            |               | Return 404               |
| search_path TO            |               | "Tenant not found"       |
| tenant_nexus, shared      |               +---------------------------+
+-------------+-------------+
              |
              v
+---------------------------+
| Continue with request     |
| All queries now use       |
| tenant_nexus schema       |
+---------------------------+

Tenant Middleware Implementation

ASP.NET Core Middleware

// TenantMiddleware.cs

public class TenantMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantMiddleware> _logger;

    public TenantMiddleware(RequestDelegate next, ILogger<TenantMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, ITenantService tenantService, IDbContextFactory<PosDbContext> dbFactory)
    {
        // 1. Extract subdomain from host
        var host = context.Request.Host.Host;
        var subdomain = ExtractSubdomain(host);

        if (string.IsNullOrEmpty(subdomain))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Invalid tenant" });
            return;
        }

        // 2. Lookup tenant in shared schema
        var tenant = await tenantService.GetBySubdomainAsync(subdomain);

        if (tenant == null)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not found" });
            return;
        }

        if (tenant.Status == "suspended")
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new { error = "Account suspended" });
            return;
        }

        // 3. Store tenant in HttpContext for downstream use
        context.Items["Tenant"] = tenant;
        context.Items["TenantSchema"] = tenant.SchemaName;

        _logger.LogDebug("Resolved tenant: {TenantSlug} -> {Schema}", tenant.Slug, tenant.SchemaName);

        // 4. Continue pipeline
        await _next(context);
    }

    private string? ExtractSubdomain(string host)
    {
        // nexus.pos-platform.com -> nexus
        // localhost:5000 -> null (development fallback)

        var parts = host.Split('.');
        if (parts.Length >= 3)
        {
            return parts[0];
        }

        // Development fallback: check header
        return null;
    }
}

// ITenantService.cs
public interface ITenantService
{
    Task<Tenant?> GetBySubdomainAsync(string subdomain);
    Task<Tenant?> GetBySlugAsync(string slug);
    Task<string> CreateTenantAsync(CreateTenantRequest request);
}

// TenantService.cs
public class TenantService : ITenantService
{
    private readonly IDbContextFactory<SharedDbContext> _dbFactory;
    private readonly ILogger<TenantService> _logger;

    public TenantService(IDbContextFactory<SharedDbContext> dbFactory, ILogger<TenantService> logger)
    {
        _dbFactory = dbFactory;
        _logger = logger;
    }

    public async Task<Tenant?> GetBySubdomainAsync(string subdomain)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();
        return await db.Tenants
            .AsNoTracking()
            .FirstOrDefaultAsync(t => t.Subdomain == subdomain);
    }

    public async Task<string> CreateTenantAsync(CreateTenantRequest request)
    {
        var schemaName = $"tenant_{request.Slug}";

        await using var db = await _dbFactory.CreateDbContextAsync();

        // 1. Create tenant record
        var tenant = new Tenant
        {
            Slug = request.Slug,
            Name = request.Name,
            Subdomain = request.Subdomain,
            SchemaName = schemaName,
            PlanId = request.PlanId,
            Status = "active"
        };

        db.Tenants.Add(tenant);
        await db.SaveChangesAsync();

        // 2. Create schema (raw SQL)
        await db.Database.ExecuteSqlRawAsync($"CREATE SCHEMA {schemaName}");

        // 3. Run migrations on new schema
        await RunMigrationsAsync(schemaName);

        _logger.LogInformation("Created tenant: {Slug} with schema {Schema}", request.Slug, schemaName);

        return tenant.Id.ToString();
    }

    private async Task RunMigrationsAsync(string schemaName)
    {
        // Apply all tenant schema tables
        // This would run the full schema creation script from Chapter 12
    }
}

DbContext with Dynamic Schema

// PosDbContext.cs

public class PosDbContext : DbContext
{
    private readonly string _schemaName;

    public PosDbContext(DbContextOptions<PosDbContext> options, IHttpContextAccessor httpContextAccessor)
        : base(options)
    {
        // Get schema from HttpContext (set by TenantMiddleware)
        _schemaName = httpContextAccessor.HttpContext?.Items["TenantSchema"]?.ToString()
            ?? "tenant_default";
    }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Sale> Sales => Set<Sale>();
    public DbSet<Customer> Customers => Set<Customer>();
    public DbSet<Employee> Employees => Set<Employee>();
    public DbSet<Location> Locations => Set<Location>();
    // ... other DbSets

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Set default schema for all entities
        modelBuilder.HasDefaultSchema(_schemaName);

        // Apply entity configurations
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(PosDbContext).Assembly);
    }
}

Connection String with search_path

Alternative approach using connection string:

// TenantDbContextFactory.cs

public class TenantDbContextFactory : IDbContextFactory<PosDbContext>
{
    private readonly IConfiguration _config;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantDbContextFactory(IConfiguration config, IHttpContextAccessor httpContextAccessor)
    {
        _config = config;
        _httpContextAccessor = httpContextAccessor;
    }

    public PosDbContext CreateDbContext()
    {
        var schemaName = _httpContextAccessor.HttpContext?.Items["TenantSchema"]?.ToString()
            ?? throw new InvalidOperationException("No tenant context");

        var baseConnectionString = _config.GetConnectionString("DefaultConnection");

        // Append search_path to connection string
        var connectionString = $"{baseConnectionString};Search Path={schemaName},shared";

        var optionsBuilder = new DbContextOptionsBuilder<PosDbContext>();
        optionsBuilder.UseNpgsql(connectionString);

        return new PosDbContext(optionsBuilder.Options);
    }
}

Tenant Provisioning Workflow

New Tenant Signup Flow
======================

[Admin Portal]                      [API]                          [Database]
      |                               |                                  |
      | 1. POST /tenants              |                                  |
      |   { name, slug, plan }        |                                  |
      |------------------------------>|                                  |
      |                               |                                  |
      |                               | 2. Validate slug uniqueness      |
      |                               |--------------------------------->|
      |                               |                                  |
      |                               | 3. Insert into shared.tenants    |
      |                               |--------------------------------->|
      |                               |                                  |
      |                               | 4. CREATE SCHEMA tenant_{slug}   |
      |                               |--------------------------------->|
      |                               |                                  |
      |                               | 5. Run schema migrations         |
      |                               |   (create all tables)            |
      |                               |--------------------------------->|
      |                               |                                  |
      |                               | 6. Seed default data             |
      |                               |   (roles, permissions)           |
      |                               |--------------------------------->|
      |                               |                                  |
      |                               | 7. Create admin user             |
      |                               |--------------------------------->|
      |                               |                                  |
      | 8. Return tenant details      |                                  |
      |   { id, subdomain, status }   |                                  |
      |<------------------------------|                                  |
      |                               |                                  |
      | 9. Redirect to tenant portal  |                                  |
      |   nexus.pos-platform.com      |                                  |
      |                               |                                  |

Tenant Isolation Benefits

1. Data Isolation

Tenant A cannot access Tenant B's data
======================================

-- Query from Tenant A's context (search_path = tenant_nexus)
SELECT * FROM products;
-- Returns only Nexus products

-- Even if someone tries:
SELECT * FROM tenant_acme.products;
-- ERROR: permission denied for schema tenant_acme

2. Easy Backup/Restore

# Backup single tenant
pg_dump -h localhost -U postgres -n tenant_nexus pos_platform > nexus_backup.sql

# Restore tenant to new database
psql -h localhost -U postgres -d pos_platform_restore < nexus_backup.sql

# Clone tenant for testing
pg_dump -h localhost -U postgres -n tenant_nexus pos_platform | \
  sed 's/tenant_nexus/tenant_nexus_test/g' | \
  psql -h localhost -U postgres -d pos_platform

3. Per-Tenant Maintenance

-- Vacuum single tenant
VACUUM ANALYZE tenant_nexus.products;
VACUUM ANALYZE tenant_nexus.sales;

-- Reindex single tenant
REINDEX SCHEMA tenant_nexus;

-- Drop tenant (complete removal)
DROP SCHEMA tenant_nexus CASCADE;
DELETE FROM shared.tenants WHERE slug = 'nexus';

4. Compliance

SOC 2 / GDPR Compliance
=======================

Requirement: "Customer data must be logically separated"

With schema-per-tenant:
- Each customer's data in isolated schema
- No risk of WHERE clause forgetting tenant_id
- Clear audit trail per schema
- Easy data export for GDPR requests
- Simple data deletion for "right to be forgotten"

Performance Considerations

Connection Pooling

Connection Pool Strategy
========================

                    +------------------+
                    |  Connection Pool |
                    |  (PgBouncer)     |
                    +--------+---------+
                             |
        +--------------------+--------------------+
        |                    |                    |
        v                    v                    v
+-------+-------+   +--------+------+   +---------+-----+
| Connection 1  |   | Connection 2  |   | Connection 3  |
| search_path:  |   | search_path:  |   | search_path:  |
| tenant_nexus  |   | tenant_acme   |   | tenant_nexus  |
+---------------+   +---------------+   +---------------+

Note: search_path is set per-connection, not per-query.
Use transaction pooling mode in PgBouncer.

Query Performance

-- Index per schema (automatically namespaced)
CREATE INDEX idx_products_sku ON tenant_nexus.products(sku);
CREATE INDEX idx_products_sku ON tenant_acme.products(sku);

-- No tenant_id in WHERE clause needed
-- Simpler, faster queries:
SELECT * FROM products WHERE sku = 'NXP0001';
-- vs (row-level tenancy):
SELECT * FROM products WHERE tenant_id = ? AND sku = 'NXP0001';

Migration Strategy

Applying Migrations to All Tenants

// TenantMigrationService.cs

public class TenantMigrationService
{
    private readonly SharedDbContext _sharedDb;
    private readonly ILogger<TenantMigrationService> _logger;

    public async Task ApplyMigrationToAllTenantsAsync(string migrationScript)
    {
        var tenants = await _sharedDb.Tenants.ToListAsync();

        foreach (var tenant in tenants)
        {
            try
            {
                _logger.LogInformation("Applying migration to {Schema}", tenant.SchemaName);

                await _sharedDb.Database.ExecuteSqlRawAsync(
                    $"SET search_path TO {tenant.SchemaName}; {migrationScript}"
                );

                _logger.LogInformation("Migration complete for {Schema}", tenant.SchemaName);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Migration failed for {Schema}", tenant.SchemaName);
                // Continue with other tenants or abort based on policy
            }
        }
    }
}

Migration Script Example

-- Migration: Add loyalty_tier to customers
-- File: 2025-01-15_add_loyalty_tier.sql

DO $$
DECLARE
    tenant_schema TEXT;
BEGIN
    FOR tenant_schema IN
        SELECT schema_name FROM shared.tenants WHERE status = 'active'
    LOOP
        EXECUTE format('ALTER TABLE %I.customers ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20) DEFAULT ''bronze''', tenant_schema);
    END LOOP;
END $$;

Summary

The schema-per-tenant architecture provides:

  1. Strong isolation - Complete data separation without row-level complexity
  2. Simple queries - No tenant_id required in every WHERE clause
  3. Easy operations - Per-tenant backup, restore, and maintenance
  4. Compliance ready - Clear boundaries for SOC 2, GDPR, HIPAA
  5. Scalable - PostgreSQL handles thousands of schemas efficiently

The tenant middleware automatically resolves the correct schema for every request, making multi-tenancy transparent to the application code.


Next: Chapter 07: Domain Model

Chapter 07: Domain Model

Bounded Contexts and Entity Definitions

This chapter defines the complete domain model for the POS Platform. We organize the system into six bounded contexts, each with clear responsibilities and well-defined entities.


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 1: Catalog

The Catalog context manages all product-related data.

Entity: Product

+------------------------------------------------------------------+
|                           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             |
+------------------------------------------------------------------+

Entity: ProductVariant

+------------------------------------------------------------------+
|                       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                |
+------------------------------------------------------------------+

Entity: Category

+------------------------------------------------------------------+
|                          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                |
+------------------------------------------------------------------+

Entity: PricingRule

+------------------------------------------------------------------+
|                        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                |
+------------------------------------------------------------------+

Context 2: Sales

The Sales context handles all transaction-related data.

Entity: Sale

+------------------------------------------------------------------+
|                            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                       |
+------------------------------------------------------------------+

Entity: SaleLineItem

+------------------------------------------------------------------+
|                       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                |
+------------------------------------------------------------------+

Entity: Payment

+------------------------------------------------------------------+
|                          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                 |
+------------------------------------------------------------------+

Entity: Refund

+------------------------------------------------------------------+
|                           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                  |
+------------------------------------------------------------------+

Entity: RefundLineItem

+------------------------------------------------------------------+
|                     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                |
+------------------------------------------------------------------+

Context 3: Inventory

The Inventory context manages stock levels and movements.

Entity: InventoryItem

+------------------------------------------------------------------+
|                      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                       |
+------------------------------------------------------------------+

Entity: InventoryAdjustment

+------------------------------------------------------------------+
|                   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                    |
+------------------------------------------------------------------+

Entity: InventoryAdjustmentItem

+------------------------------------------------------------------+
|                 INVENTORY_ADJUSTMENT_ITEM                         |
+------------------------------------------------------------------+
| id              | UUID        | Primary key                       |
| adjustment_id   | UUID        | FK to InventoryAdjustment         |
| product_id      | UUID        | FK to Product                     |
| variant_id      | UUID        | FK to ProductVariant              |
| expected_quantity | Integer   | Quantity before adjustment        |
| actual_quantity | Integer     | New quantity                      |
| quantity_change | Integer     | Difference (can be negative)      |
| created_at      | Timestamp   | Creation timestamp                |
+------------------------------------------------------------------+

Entity: InventoryTransfer

+------------------------------------------------------------------+
|                    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                |
+------------------------------------------------------------------+

Entity: InventoryTransferItem

+------------------------------------------------------------------+
|                  INVENTORY_TRANSFER_ITEM                          |
+------------------------------------------------------------------+
| id              | UUID        | Primary key                       |
| transfer_id     | UUID        | FK to InventoryTransfer           |
| product_id      | UUID        | FK to Product                     |
| variant_id      | UUID        | FK to ProductVariant              |
| quantity_sent   | Integer     | Quantity shipped                  |
| quantity_received | Integer   | Quantity actually received        |
| created_at      | Timestamp   | Creation timestamp                |
+------------------------------------------------------------------+

Context 4: Customer

The Customer context manages customer data and loyalty.

Entity: Customer

+------------------------------------------------------------------+
|                          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                       |
+------------------------------------------------------------------+

Entity: CustomerAddress

+------------------------------------------------------------------+
|                     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                |
+------------------------------------------------------------------+

Entity: StoreCredit

+------------------------------------------------------------------+
|                       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                |
+------------------------------------------------------------------+

Entity: LoyaltyTransaction

+------------------------------------------------------------------+
|                   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             |
+------------------------------------------------------------------+

Context 5: Employee

The Employee context manages users, roles, and shifts.

Entity: Employee

+------------------------------------------------------------------+
|                          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                       |
+------------------------------------------------------------------+

Entity: Role

+------------------------------------------------------------------+
|                            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                |
+------------------------------------------------------------------+

Entity: Permission

+------------------------------------------------------------------+
|                         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                |
+------------------------------------------------------------------+

Entity: RolePermission

+------------------------------------------------------------------+
|                     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)                              |
+------------------------------------------------------------------+

Entity: Shift

+------------------------------------------------------------------+
|                           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                |
+------------------------------------------------------------------+

Context 6: Location

The Location context manages stores, registers, and settings.

Entity: Location

+------------------------------------------------------------------+
|                          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                       |
+------------------------------------------------------------------+

Entity: Register

+------------------------------------------------------------------+
|                          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                |
+------------------------------------------------------------------+

Entity: CashDrawer

+------------------------------------------------------------------+
|                        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                |
+------------------------------------------------------------------+

Entity: TaxRate

+------------------------------------------------------------------+
|                         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                |
+------------------------------------------------------------------+

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)           |
+------------------------------------------+

Summary

The domain model defines 6 bounded contexts with 30+ entities:

ContextEntitiesPurpose
CatalogProduct, Variant, Category, PricingRuleProduct management
SalesSale, LineItem, Payment, RefundTransaction processing
InventoryInventoryItem, Adjustment, TransferStock management
CustomerCustomer, Address, Credit, LoyaltyCustomer management
EmployeeEmployee, Role, Permission, ShiftStaff management
LocationLocation, Register, Drawer, TaxRateStore configuration

All entities use UUIDs as primary keys for distributed system compatibility and avoid any legacy system-specific fields.


Next: Chapter 08: Event Sourcing & CQRS

Chapter 08: Event Sourcing & CQRS

Append-Only Event Architecture

This chapter details the event sourcing architecture for the POS Platform. Event sourcing provides complete audit trails, temporal queries, and enables offline conflict resolution - critical capabilities for a retail POS system.


Why Event Sourcing for POS?

Traditional CRUD systems store only current state. Event sourcing stores every change as an immutable event, enabling:

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

BenefitDescription
Complete Audit TrailEvery sale, void, refund, adjustment is recorded forever
Temporal Queries“What was our inventory on December 15th at 3pm?”
Offline SyncEvents queue locally, merge when online
Conflict ResolutionCompare event streams, not states
DebuggingReplay events to reproduce issues
CompliancePCI-DSS, SOX require transaction logs

Event Sourcing Architecture

Event Sourcing Architecture
===========================

+-------------------------------------------------------------------------+
|                              POS CLIENT                                  |
|                                                                          |
|   +------------------+    +-------------------+    +-----------------+   |
|   |  Command Handler |    |   Event Store     |    |   Projector     |   |
|   |                  |    |   (Local SQLite)  |    |   (Read Model)  |   |
|   |  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

Command Query Responsibility Segregation separates write and read models:

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
public record CreateSaleCommand(
    Guid SaleId,
    Guid LocationId,
    Guid EmployeeId,
    Guid? CustomerId,
    List<SaleLineItemDto> LineItems
);

public record VoidSaleCommand(
    Guid SaleId,
    Guid EmployeeId,
    string Reason
);

public record AddPaymentCommand(
    Guid SaleId,
    string PaymentMethod,
    decimal Amount,
    string? Reference
);

Read Side (Queries)

// Queries - Request data
public record GetSaleByIdQuery(Guid SaleId);
public record GetDailySalesQuery(Guid LocationId, DateTime Date);
public record GetInventoryLevelQuery(string Sku, Guid LocationId);

// Read models - Optimized for queries
public class SaleSummaryView
{
    public Guid Id { get; set; }
    public string SaleNumber { get; set; }
    public string CustomerName { get; set; }  // Denormalized
    public string EmployeeName { get; set; }  // Denormalized
    public decimal Total { get; set; }
    public string Status { get; set; }
    public DateTime CreatedAt { get; set; }
}

Event Store Schema

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,     -- 'signalr', 'webhook', 'sync'
    status          VARCHAR(20) DEFAULT 'pending',
    attempts        INTEGER DEFAULT 0,
    last_error      TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    processed_at    TIMESTAMPTZ
);

Domain Events Catalog

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                              |
+-----------------------+----------------------------------------+

Event Projection Patterns

Projections transform events into read models:

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)      |
+-------------------+     +-------------------+     +-------------------+

Example Projector Implementation

// SaleProjector.cs

public class SaleProjector : IEventHandler
{
    private readonly IDbContextFactory<ReadModelDbContext> _dbFactory;

    public SaleProjector(IDbContextFactory<ReadModelDbContext> dbFactory)
    {
        _dbFactory = dbFactory;
    }

    public async Task HandleAsync(SaleCreated @event)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();

        var view = new SaleSummaryView
        {
            Id = @event.SaleId,
            SaleNumber = @event.SaleNumber,
            LocationId = @event.LocationId,
            EmployeeId = @event.EmployeeId,
            CustomerId = @event.CustomerId,
            Status = "draft",
            Subtotal = 0,
            Total = 0,
            CreatedAt = @event.CreatedAt
        };

        db.SaleSummaries.Add(view);
        await db.SaveChangesAsync();
    }

    public async Task HandleAsync(SaleLineItemAdded @event)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();

        var sale = await db.SaleSummaries.FindAsync(@event.SaleId);
        if (sale == null) return;

        var lineTotal = @event.Quantity * @event.UnitPrice - @event.DiscountAmount;
        sale.Subtotal += lineTotal;
        sale.ItemCount += @event.Quantity;

        await db.SaveChangesAsync();
    }

    public async Task HandleAsync(SaleCompleted @event)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();

        var sale = await db.SaleSummaries.FindAsync(@event.SaleId);
        if (sale == null) return;

        sale.Status = "completed";
        sale.DiscountTotal = @event.DiscountTotal;
        sale.TaxTotal = @event.TaxTotal;
        sale.Total = @event.Total;
        sale.CompletedAt = @event.CompletedAt;

        await db.SaveChangesAsync();
    }

    public async Task HandleAsync(SaleVoided @event)
    {
        await using var db = await _dbFactory.CreateDbContextAsync();

        var sale = await db.SaleSummaries.FindAsync(@event.SaleId);
        if (sale == null) return;

        sale.Status = "voided";
        sale.VoidedAt = @event.VoidedAt;
        sale.VoidedBy = @event.VoidedBy;
        sale.VoidReason = @event.Reason;

        await db.SaveChangesAsync();
    }
}

Temporal Queries

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;

Snapshots for Performance

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

// AggregateRepository.cs

public class AggregateRepository<T> where T : AggregateRoot
{
    private readonly IEventStore _eventStore;
    private readonly ISnapshotStore _snapshotStore;
    private const int SNAPSHOT_THRESHOLD = 100;

    public async Task<T> LoadAsync(Guid id)
    {
        var aggregate = Activator.CreateInstance<T>();

        // 1. Try to load snapshot
        var snapshot = await _snapshotStore.GetAsync<T>(id);
        int fromVersion = 0;

        if (snapshot != null)
        {
            aggregate.RestoreFromSnapshot(snapshot.State);
            fromVersion = snapshot.Version;
        }

        // 2. Load events after snapshot
        var events = await _eventStore.GetEventsAsync(id, fromVersion);

        foreach (var @event in events)
        {
            aggregate.Apply(@event);
        }

        return aggregate;
    }

    public async Task SaveAsync(T aggregate)
    {
        var newEvents = aggregate.GetUncommittedEvents();

        // 1. Append events
        await _eventStore.AppendAsync(aggregate.Id, newEvents, aggregate.Version);

        // 2. Create snapshot if threshold reached
        if (aggregate.Version % SNAPSHOT_THRESHOLD == 0)
        {
            var snapshot = aggregate.CreateSnapshot();
            await _snapshotStore.SaveAsync(aggregate.Id, aggregate.Version, snapshot);
        }

        aggregate.ClearUncommittedEvents();
    }
}

Benefits Summary

CapabilityHow Event Sourcing Enables It
Audit TrailEvery change is an immutable event
Temporal QueriesReplay events to any point in time
Offline SupportEvents queue locally, merge later
DebuggingReproduce any state by replaying events
AnalyticsRich historical data for ML/reporting
CompliancePCI-DSS, SOX audit requirements
Undo/RedoApply compensating events
TestingGiven-When-Then with events

Summary

Event sourcing with CQRS provides:

  1. Immutable audit log of every business event
  2. Temporal queries to answer “what was the state at time X?”
  3. Offline capability through local event queues
  4. Conflict resolution by comparing event streams
  5. Optimized reads through projected read models
  6. Performance via snapshots for long event streams

The event store schema and domain events catalog in this chapter form the foundation for the offline-first architecture described in Chapter 09.


Next: Chapter 09: Offline-First Design

Chapter 09: Offline-First Design

POS Client Architecture for Unreliable Networks

This chapter details the offline-first architecture for POS clients. Retail environments frequently experience network issues - internet outages, WiFi dropouts, or slow connections during peak hours. The POS must continue operating seamlessly regardless of connectivity.


Why Offline-First for POS?

Retail Network Reality
======================

Internet Outage at 2pm on Black Friday?
  Traditional POS: "Network Error - Cannot Process Sale" (DISASTER)
  Offline-First POS: Works normally, syncs when online (BUSINESS CONTINUES)

Slow WiFi during holiday rush?
  Traditional POS: 5-second delay per sale (FRUSTRATED CUSTOMERS)
  Offline-First POS: Instant response, sync in background (HAPPY CUSTOMERS)

Server maintenance window?
  Traditional POS: Store closes or uses manual paper (LOST REVENUE)
  Offline-First POS: No impact to operations (FULL REVENUE)

Offline-First Principles

PrincipleDescription
Local-FirstAll operations work against local database first
Async SyncSync happens in background, not blocking UI
Queue EverythingChanges queue when offline, sync when online
Conflict ResolutionDeterministic rules for conflicting changes
Eventual ConsistencyAccept that data may be temporarily out of sync

POS Client Architecture

POS Client Architecture
=======================

+-----------------------------------------------------------------------+
|                           POS CLIENT                                   |
|                                                                        |
|  +------------------------+        +-------------------------------+  |
|  |      Presentation      |        |        Local Storage          |  |
|  |                        |        |                               |  |
|  |  +------------------+  |        |  +-------------------------+  |  |
|  |  |   Sales Screen   |  |        |  |      SQLite Database     |  |  |
|  |  +------------------+  |        |  |                         |  |  |
|  |  |  Product Grid    |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | products_cache      | |  |  |
|  |  |   Cart Panel     |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | pending_sales       | |  |  |
|  |  |  Payment Dialog  |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | sync_queue          | |  |  |
|  |  |  Receipt Print   |  |        |  | +---------------------+ |  |  |
|  |  +------------------+  |        |  | | events (local)      | |  |  |
|  +------------------------+        |  | +---------------------+ |  |  |
|             |                      |  | | customer_cache      | |  |  |
|             v                      |  | +---------------------+ |  |  |
|  +------------------------+        |  +-------------------------+  |  |
|  |   Application Layer    |        |                               |  |
|  |                        |        +-------------------------------+  |
|  |  +------------------+  |                      ^                    |
|  |  |   SaleService    |------------------------>|                    |
|  |  +------------------+  |                      |                    |
|  |  | InventoryService |------------------------>|                    |
|  |  +------------------+  |                      |                    |
|  |  | CustomerService  |------------------------>|                    |
|  |  +------------------+  |                                           |
|  +------------------------+                                           |
|             |                                                         |
|             v                                                         |
|  +------------------------+        +-------------------------------+  |
|  |     Sync Service       |        |     Connection Monitor        |  |
|  |                        |        |                               |  |
|  |  - Queue Manager       |<------>|  - Ping Central API           |  |
|  |  - Conflict Resolver   |        |  - Track online/offline       |  |
|  |  - Retry Handler       |        |  - Trigger sync when online   |  |
|  |  - Batch Uploader      |        |                               |  |
|  +------------------------+        +-------------------------------+  |
|             |                                                         |
+-------------|----------------------------------------------------------+
              |
              v (when online)
+-----------------------------------------------------------------------+
|                          CENTRAL API                                   |
+-----------------------------------------------------------------------+

Local Database Schema (SQLite)

The POS client maintains a local SQLite database for offline operation:

-- SQLite Schema for POS Client

-- Product cache (synced from server)
CREATE TABLE products_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,
    track_inventory INTEGER DEFAULT 1,
    image_url       TEXT,
    variants_json   TEXT,              -- JSON array of variants
    synced_at       TEXT NOT NULL,     -- When last synced from server
    created_at      TEXT DEFAULT (datetime('now'))
);

CREATE INDEX idx_products_barcode ON products_cache(barcode);
CREATE INDEX idx_products_name ON products_cache(name);

-- Inventory cache (synced from server)
CREATE TABLE inventory_cache (
    product_id      TEXT NOT NULL,
    variant_id      TEXT,
    location_id     TEXT NOT NULL,
    quantity        INTEGER NOT NULL,
    synced_at       TEXT NOT NULL,
    PRIMARY KEY (product_id, variant_id, location_id)
);

-- Customer cache (synced from server)
CREATE TABLE customers_cache (
    id              TEXT PRIMARY KEY,
    customer_number TEXT UNIQUE,
    first_name      TEXT,
    last_name       TEXT,
    email           TEXT,
    phone           TEXT,
    loyalty_points  INTEGER DEFAULT 0,
    store_credit    REAL DEFAULT 0,
    synced_at       TEXT NOT NULL
);

-- Local sales (created offline, pending sync)
CREATE TABLE local_sales (
    id              TEXT PRIMARY KEY,
    sale_number     TEXT UNIQUE NOT NULL,
    location_id     TEXT NOT NULL,
    register_id     TEXT NOT NULL,
    employee_id     TEXT NOT NULL,
    customer_id     TEXT,
    status          TEXT DEFAULT 'completed',
    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
);

CREATE INDEX idx_local_sales_synced ON local_sales(synced_at);

-- Event queue (append-only, sync to server)
CREATE TABLE event_queue (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    event_id        TEXT UNIQUE NOT NULL,
    aggregate_type  TEXT NOT NULL,
    aggregate_id    TEXT NOT NULL,
    event_type      TEXT NOT NULL,
    event_data      TEXT NOT NULL,     -- JSON
    created_at      TEXT NOT NULL,
    created_by      TEXT,
    synced_at       TEXT,              -- NULL until synced
    sync_attempts   INTEGER DEFAULT 0,
    last_error      TEXT
);

CREATE INDEX idx_event_queue_pending ON event_queue(synced_at) WHERE synced_at IS NULL;

-- Sync metadata
CREATE TABLE sync_status (
    key             TEXT PRIMARY KEY,
    value           TEXT NOT NULL,
    updated_at      TEXT DEFAULT (datetime('now'))
);

-- Track what we've synced
INSERT INTO sync_status (key, value) VALUES
    ('last_product_sync', '1970-01-01T00:00:00Z'),
    ('last_inventory_sync', '1970-01-01T00:00:00Z'),
    ('last_customer_sync', '1970-01-01T00:00:00Z'),
    ('last_event_push', '1970-01-01T00:00:00Z');

Sync Queue Design

The sync queue manages all pending changes:

Sync Queue Architecture
=======================

+-------------------+     +-------------------+     +-------------------+
|   Sale Created    |     |  Inventory Adj    |     | Customer Created  |
|   (Offline)       |     |  (Offline)        |     | (Offline)         |
+--------+----------+     +--------+----------+     +--------+----------+
         |                         |                         |
         v                         v                         v
+-----------------------------------------------------------------------+
|                          SYNC QUEUE                                    |
|                                                                        |
|  Priority  | Type              | Status    | Retries | Last Error     |
|  ---------------------------------------------------------------      |
|  1         | SaleCreated       | pending   | 0       |                |
|  1         | PaymentReceived   | pending   | 0       |                |
|  2         | InventoryAdjusted | pending   | 0       |                |
|  3         | CustomerCreated   | failed    | 3       | Timeout        |
|  1         | SaleCompleted     | pending   | 0       |                |
|                                                                        |
|  Priority Legend:                                                      |
|  1 = Critical (sales, payments) - sync immediately                    |
|  2 = Important (inventory) - sync within minutes                      |
|  3 = Normal (customers) - sync when convenient                        |
+-----------------------------------------------------------------------+
              |
              | Sync Processor (runs when online)
              v
+-----------------------------------------------------------------------+
|                          CENTRAL API                                   |
|                                                                        |
|  POST /api/sync/events                                                |
|  [                                                                     |
|    { eventType: "SaleCreated", ... },                                 |
|    { eventType: "PaymentReceived", ... },                             |
|    ...                                                                 |
|  ]                                                                     |
|                                                                        |
|  Response: { synced: 5, conflicts: 0, errors: [] }                    |
+-----------------------------------------------------------------------+

Sync Priority Rules

PriorityEvent TypesSync Timing
1 (Critical)Sales, Payments, Refunds, VoidsImmediate when online
2 (Important)Inventory adjustments, TransfersWithin 5 minutes
3 (Normal)Customer updates, Loyalty changesWithin 15 minutes
4 (Low)Analytics events, LogsBatch sync hourly

Conflict Resolution Strategies

Different data types require different conflict resolution approaches:

Conflict Resolution Matrix
==========================

+------------------+---------------------+--------------------------------+
| Data Type        | Strategy            | Reasoning                      |
+------------------+---------------------+--------------------------------+
| Sales            | Append-Only         | Each sale is unique, no        |
|                  | (No Conflicts)      | conflicts possible             |
+------------------+---------------------+--------------------------------+
| Inventory        | Last-Write-Wins     | Central server is authority,   |
|                  | (Server Wins)       | client updates are suggestions |
+------------------+---------------------+--------------------------------+
| Customers        | Merge on Key        | Merge by email, combine        |
|                  | (Email = Key)       | non-conflicting fields         |
+------------------+---------------------+--------------------------------+
| Products         | Server Authority    | Product catalog managed        |
|                  | (Read-Only Client)  | centrally, client is cache     |
+------------------+---------------------+--------------------------------+
| Employees        | Server Authority    | HR data managed centrally      |
|                  | (Read-Only Client)  |                                |
+------------------+---------------------+--------------------------------+
| Settings         | Server Authority    | Config managed by admin        |
|                  | (Read-Only Client)  |                                |
+------------------+---------------------+--------------------------------+

Strategy 1: Append-Only (Sales)

Sales never conflict because each sale has a unique UUID generated locally:

Sale Conflict Resolution: None Required
========================================

Client A (Offline):                  Client B (Offline):
  Sale S-001 created @ 10:15           Sale S-002 created @ 10:16
  LineItem: Product X, Qty 2           LineItem: Product Y, Qty 1
  Payment: $50 cash                    Payment: $25 credit

When both sync:
  Server: Accepts S-001 (unique ID)
  Server: Accepts S-002 (unique ID)
  Result: Both sales recorded, no conflict

Strategy 2: Last-Write-Wins (Inventory)

Central server maintains authoritative inventory; client adjustments are “suggestions”:

Inventory Conflict Resolution: Server Authority
===============================================

Server State:
  Product X @ Location HQ: 100 units

Client A (Offline):                  Client B (Offline):
  Sells 5 units of Product X           Sells 3 units of Product X
  Local: 95 units                      Local: 97 units

When both sync:
  Server receives: "Sold 5 units" from A
  Server receives: "Sold 3 units" from B
  Server calculates: 100 - 5 - 3 = 92 units
  Server pushes new quantity to all clients

Result:
  All clients update to 92 units
  Individual decrements preserved
  No quantity lost or duplicated

Strategy 3: Merge on Key (Customers)

Customer records merge based on email as the unique identifier:

Customer Conflict Resolution: Merge
===================================

Server State:
  Customer email: john@example.com
  Name: John Doe
  Phone: (blank)
  Loyalty: 500 points

Client A (Offline):                  Client B (Offline):
  Updates phone to 555-1234            Updates loyalty to 600 points

When both sync:
  Server merges non-conflicting fields:
    Name: John Doe (unchanged)
    Phone: 555-1234 (from A)
    Loyalty: 600 points (from B)

If same field changed:
  Server uses timestamp to pick latest
  Or prompts admin for resolution

Sync Processor Workflow

Sync Processor State Machine
============================

                    +-------------+
                    |    IDLE     |
                    +------+------+
                           |
                           | Connection detected
                           v
                    +-------------+
                    |   SYNCING   |
                    +------+------+
                           |
        +------------------+------------------+
        |                  |                  |
        v                  v                  v
+-------------+    +-------------+    +-------------+
| PUSH EVENTS |    | PULL DATA   |    |  COMPLETE   |
|             |    |             |    |             |
| - Sales     |    | - Products  |    | - Update    |
| - Payments  |    | - Inventory |    |   metadata  |
| - Inventory |    | - Customers |    | - Return    |
|   changes   |    | - Settings  |    |   to IDLE   |
+------+------+    +------+------+    +-------------+
       |                  |
       +------------------+
                |
                v
        +-------------+
        | HANDLE      |
        | CONFLICTS   |
        +------+------+
               |
               v
        +-------------+
        |  COMPLETE   |
        +-------------+

Sync Service Implementation

// SyncService.cs

public class SyncService : IHostedService
{
    private readonly ILocalDatabase _localDb;
    private readonly IApiClient _apiClient;
    private readonly IConnectionMonitor _connectionMonitor;
    private readonly IConflictResolver _conflictResolver;
    private readonly ILogger<SyncService> _logger;

    private Timer? _syncTimer;
    private bool _isSyncing = false;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _connectionMonitor.OnlineStatusChanged += HandleConnectionChange;

        // Check for pending sync every 30 seconds
        _syncTimer = new Timer(
            async _ => await TrySyncAsync(),
            null,
            TimeSpan.Zero,
            TimeSpan.FromSeconds(30)
        );
    }

    private async void HandleConnectionChange(object? sender, bool isOnline)
    {
        if (isOnline)
        {
            _logger.LogInformation("Connection restored, starting sync");
            await TrySyncAsync();
        }
    }

    private async Task TrySyncAsync()
    {
        if (_isSyncing) return;
        if (!_connectionMonitor.IsOnline) return;

        _isSyncing = true;
        try
        {
            // 1. Push local events to server
            await PushEventsAsync();

            // 2. Pull updated data from server
            await PullProductsAsync();
            await PullInventoryAsync();
            await PullCustomersAsync();

            // 3. Update sync timestamps
            await UpdateSyncMetadataAsync();

            _logger.LogInformation("Sync completed successfully");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Sync failed");
        }
        finally
        {
            _isSyncing = false;
        }
    }

    private async Task PushEventsAsync()
    {
        // Get pending events ordered by priority
        var pendingEvents = await _localDb.GetPendingEventsAsync();

        if (!pendingEvents.Any()) return;

        // Batch events (max 100 per request)
        var batches = pendingEvents.Chunk(100);

        foreach (var batch in batches)
        {
            try
            {
                var response = await _apiClient.PostEventsAsync(batch);

                // Mark synced events
                foreach (var evt in response.Synced)
                {
                    await _localDb.MarkEventSyncedAsync(evt.EventId);
                }

                // Handle conflicts
                foreach (var conflict in response.Conflicts)
                {
                    await _conflictResolver.ResolveAsync(conflict);
                }
            }
            catch (HttpRequestException)
            {
                // Network error, increment retry count
                foreach (var evt in batch)
                {
                    await _localDb.IncrementEventRetryAsync(evt.EventId);
                }
                throw;
            }
        }
    }

    private async Task PullProductsAsync()
    {
        var lastSync = await _localDb.GetSyncTimestampAsync("products");

        var products = await _apiClient.GetProductsUpdatedSinceAsync(lastSync);

        foreach (var product in products)
        {
            await _localDb.UpsertProductCacheAsync(product);
        }
    }

    private async Task PullInventoryAsync()
    {
        var locationId = await GetCurrentLocationIdAsync();
        var lastSync = await _localDb.GetSyncTimestampAsync("inventory");

        var inventory = await _apiClient.GetInventoryUpdatedSinceAsync(locationId, lastSync);

        foreach (var item in inventory)
        {
            // Apply server's quantity (server is authority)
            await _localDb.UpdateInventoryCacheAsync(item);
        }
    }
}

Sale Creation Flow (Offline-Capable)

Offline Sale Flow
=================

1. Cashier scans items
   +----------------+
   | Local Lookup   |
   | products_cache |
   +----------------+
         |
         v
2. Add to cart (no network needed)
   +----------------+
   | In-Memory Cart |
   +----------------+
         |
         v
3. Customer pays
   +----------------+
   | Payment Dialog |
   | (card or cash) |
   +----------------+
         |
         v
4. Save sale locally
   +----------------+
   | local_sales    |
   | (SQLite)       |
   +----------------+
         |
         v
5. Queue sync events
   +----------------+
   | event_queue    |
   | SaleCreated    |
   | ItemAdded x N  |
   | PaymentRcvd    |
   | SaleCompleted  |
   +----------------+
         |
         v
6. Decrement local inventory
   +----------------+
   | inventory_cache|
   | (optimistic)   |
   +----------------+
         |
         v
7. Print receipt
   +----------------+
   | Receipt ready  |
   | (no waiting)   |
   +----------------+
         |
         v
8. Background sync (when online)
   +----------------+
   | SyncService    |
   | pushes events  |
   +----------------+

Sale Service Implementation

// SaleService.cs

public class SaleService
{
    private readonly ILocalDatabase _localDb;
    private readonly IEventQueue _eventQueue;
    private readonly IReceiptPrinter _printer;

    public async Task<Sale> CompleteSaleAsync(Cart cart, List<Payment> payments)
    {
        // 1. Generate local IDs
        var saleId = Guid.NewGuid();
        var saleNumber = GenerateSaleNumber();

        // 2. Create sale record
        var sale = new Sale
        {
            Id = saleId,
            SaleNumber = saleNumber,
            LocationId = GetCurrentLocationId(),
            RegisterId = GetCurrentRegisterId(),
            EmployeeId = GetCurrentEmployeeId(),
            CustomerId = cart.CustomerId,
            Status = "completed",
            Subtotal = cart.Subtotal,
            DiscountTotal = cart.DiscountTotal,
            TaxTotal = cart.TaxTotal,
            Total = cart.Total,
            LineItems = cart.Items.Select(MapToLineItem).ToList(),
            Payments = payments,
            CreatedAt = DateTime.UtcNow
        };

        // 3. Save to local database
        await _localDb.InsertSaleAsync(sale);

        // 4. Queue events for sync
        await _eventQueue.EnqueueAsync(new SaleCreated
        {
            SaleId = saleId,
            SaleNumber = saleNumber,
            LocationId = sale.LocationId,
            EmployeeId = sale.EmployeeId,
            CustomerId = sale.CustomerId,
            CreatedAt = sale.CreatedAt
        });

        foreach (var item in sale.LineItems)
        {
            await _eventQueue.EnqueueAsync(new SaleLineItemAdded
            {
                SaleId = saleId,
                LineItemId = item.Id,
                ProductId = item.ProductId,
                Sku = item.Sku,
                Name = item.Name,
                Quantity = item.Quantity,
                UnitPrice = item.UnitPrice
            });

            // 5. Decrement local inventory (optimistic)
            await _localDb.DecrementInventoryAsync(
                item.ProductId,
                item.VariantId,
                sale.LocationId,
                item.Quantity
            );
        }

        foreach (var payment in payments)
        {
            await _eventQueue.EnqueueAsync(new PaymentReceived
            {
                SaleId = saleId,
                PaymentId = payment.Id,
                PaymentMethod = payment.Method,
                Amount = payment.Amount
            });
        }

        await _eventQueue.EnqueueAsync(new SaleCompleted
        {
            SaleId = saleId,
            Total = sale.Total,
            CompletedAt = DateTime.UtcNow
        });

        // 6. Print receipt (async, don't wait)
        _ = _printer.PrintReceiptAsync(sale);

        return sale;
    }

    private string GenerateSaleNumber()
    {
        // Format: HQ-20251229-0001
        // Location-Date-Sequence
        var location = GetCurrentLocationCode();
        var date = DateTime.Now.ToString("yyyyMMdd");
        var sequence = GetNextLocalSequence();
        return $"{location}-{date}-{sequence:D4}";
    }
}

Connection Monitor

// ConnectionMonitor.cs

public class ConnectionMonitor : IHostedService
{
    private readonly IApiClient _apiClient;
    private readonly ILogger<ConnectionMonitor> _logger;

    private Timer? _pingTimer;
    private bool _isOnline = false;

    public bool IsOnline => _isOnline;
    public event EventHandler<bool>? OnlineStatusChanged;

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Ping server every 10 seconds
        _pingTimer = new Timer(
            async _ => await CheckConnectionAsync(),
            null,
            TimeSpan.Zero,
            TimeSpan.FromSeconds(10)
        );

        return Task.CompletedTask;
    }

    private async Task CheckConnectionAsync()
    {
        var wasOnline = _isOnline;

        try
        {
            // Simple health check endpoint
            var response = await _apiClient.PingAsync();
            _isOnline = response.IsSuccessStatusCode;
        }
        catch
        {
            _isOnline = false;
        }

        if (_isOnline != wasOnline)
        {
            _logger.LogInformation(
                "Connection status changed: {Status}",
                _isOnline ? "ONLINE" : "OFFLINE"
            );

            OnlineStatusChanged?.Invoke(this, _isOnline);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _pingTimer?.Dispose();
        return Task.CompletedTask;
    }
}

Offline Indicator UI

Offline Indicator Design
========================

When ONLINE:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                                    [GM Store] [John D] |
|  Status: Connected                                                     |
+-----------------------------------------------------------------------+

When OFFLINE:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                          [!] OFFLINE MODE   [GM Store] |
|  +-----------------------------------------------------------------+  |
|  | Working offline. 5 sales pending sync.                          |  |
|  +-----------------------------------------------------------------+  |
+-----------------------------------------------------------------------+

When SYNCING:
+-----------------------------------------------------------------------+
|  [=] NEXUS POS                     [<->] Syncing... 3/5   [GM Store]  |
+-----------------------------------------------------------------------+

Summary

The offline-first architecture ensures:

  1. Continuous Operation - Sales never blocked by network issues
  2. Instant Response - All operations work against local database
  3. Reliable Sync - Event queue with retry and conflict resolution
  4. Data Integrity - Event sourcing enables deterministic merging
  5. User Confidence - Clear offline indicator and sync status

Key components:

  • Local SQLite database with product, inventory, and customer caches
  • Event queue for all changes with priority-based sync
  • Conflict resolution matrix per data type
  • Connection monitor with automatic sync trigger
  • Sync service with batch upload and pull

Next: Chapter 10: Architecture Decision Records

Chapter 10: 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: Schema-Per-Tenant Multi-Tenancy

+==================================================================+
|  ADR-001: Schema-Per-Tenant Multi-Tenancy                        |
+==================================================================+
|  Status: ACCEPTED                                                 |
|  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. Simple data model without tenant_id on every table
5. Compliance with SOC 2 and potential HIPAA requirements

We evaluated three multi-tenancy strategies:

  Strategy A: Shared Tables (Row-Level)
  - All tenants share tables
  - tenant_id column on every table
  - WHERE tenant_id = ? on every query

  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 SCHEMA-PER-TENANT multi-tenancy (Strategy C).

Each tenant gets a dedicated PostgreSQL schema:
  - shared schema: Platform-wide data (tenants, plans, features)
  - tenant_xxx schema: All tenant-specific tables

The tenant is resolved from the subdomain (e.g., nexus.pos-platform.com)
and the database search_path is set accordingly.

CONSEQUENCES
------------
Positive:
  + Strong logical isolation between tenants
  + No tenant_id needed on every table (cleaner data model)
  + Easy per-tenant backup: pg_dump -n tenant_xxx
  + Easy per-tenant restore without affecting other tenants
  + Single connection pool serves all tenants
  + Simpler queries (no WHERE tenant_id = ?)
  + Compliance-friendly for audits and data requests

Negative:
  - Migrations must be applied to all tenant schemas
  - Cross-tenant queries require explicit schema references
  - PostgreSQL has soft limit (~10,000 schemas per database)
  - Slight complexity in tenant provisioning

Risks:
  - Must ensure search_path is ALWAYS set correctly
  - Schema migration failures could leave tenants inconsistent
  - Need robust tenant provisioning automation

Mitigations:
  - Middleware validates and sets search_path on every request
  - Migration runner applies changes atomically per tenant
  - Tenant provisioning is scripted and tested

ADR-002: Offline-First POS Architecture

+==================================================================+
|  ADR-002: Offline-First POS Architecture                         |
+==================================================================+
|  Status: ACCEPTED                                                 |
|  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 admin portal access

DECISION
--------
We will implement a HYBRID authentication system:

1. JWT for API Authentication
   - Admin portal 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. Schema-per-tenant 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 schema support for multi-tenancy
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:
  + Perfect fit for schema-per-tenant
  + 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: ASP.NET Core for Central API

+==================================================================+
|  ADR-006: ASP.NET Core for Central API                           |
+==================================================================+
|  Status: ACCEPTED                                                 |
|  Date: 2025-12-29                                                |
|  Deciders: Architecture Team                                      |
+==================================================================+

CONTEXT
-------
We need a backend framework that supports:

1. High-performance API serving
2. Strong typing for complex domain
3. Entity Framework for database access
4. SignalR for real-time features
5. Docker deployment
6. Team expertise alignment

Options considered:
- ASP.NET Core (C#): Performance, typing, EF Core
- Node.js (Express): Fast dev, but weak typing
- Go (Gin): Performance, but less ecosystem
- Python (FastAPI): ML integration, but slower
- Java (Spring): Enterprise, but verbose

Team context:
- Existing .NET experience from Bridge project
- C# used for MAUI mobile app
- Entity Framework expertise available

DECISION
--------
We will use ASP.NET CORE 8.0 for the Central API.

Justifications:
1. Exceptional performance (near Go levels)
2. Strong typing catches bugs at compile time
3. Entity Framework Core for PostgreSQL
4. Built-in SignalR for real-time
5. Excellent Docker support
6. Team already proficient in C#
7. Same language as POS client and mobile app

CONSEQUENCES
------------
Positive:
  + High performance for API workloads
  + Strong typing reduces runtime errors
  + Seamless EF Core integration
  + Built-in dependency injection
  + Excellent tooling (Visual Studio, Rider)
  + C# across entire stack (API, Client, Mobile)

Negative:
  - Larger runtime than Go or Rust
  - Windows-centric tooling (though Linux deployment)
  - C# developers cost more than Node.js

Mitigations:
  - Alpine-based Docker images minimize size
  - Use VS Code or Rider on Mac/Linux
  - Leverage existing team expertise

ADR Index

ADRTitleStatusDate
ADR-001Schema-Per-Tenant Multi-TenancyAccepted2025-12-29
ADR-002Offline-First POS ArchitectureAccepted2025-12-29
ADR-003Event Sourcing for Sales DomainAccepted2025-12-29
ADR-004JWT + PIN AuthenticationAccepted2025-12-29
ADR-005PostgreSQL as Primary DatabaseAccepted2025-12-29
ADR-006ASP.NET Core for Central APIAccepted2025-12-29

Future ADRs (Planned)

ADRTitleStatus
ADR-007React for Admin PortalProposed
ADR-008Electron vs Tauri for POS ClientProposed
ADR-009Redis for Session & CacheProposed
ADR-010Shopify Sync StrategyProposed
ADR-011Payment Gateway IntegrationProposed
ADR-012Logging and Monitoring StackProposed

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

ADR Template

# ADR-XXX: [Title]

**Status**: proposed | accepted | deprecated | superseded
**Date**: YYYY-MM-DD
**Deciders**: [Names or roles]

## Context

[What is the issue? What forces are at play?]

## Decision

[What is the change? What did we choose?]

## Consequences

### Positive
- [Benefit 1]
- [Benefit 2]

### Negative
- [Drawback 1]
- [Drawback 2]

### Risks
- [Risk 1]
- [Risk 2]

### Mitigations
- [Mitigation 1]
- [Mitigation 2]

Summary

These Architecture Decision Records capture the foundational technical decisions for the POS Platform:

ADRKey DecisionPrimary Benefit
ADR-001Schema-per-tenantStrong isolation without complexity
ADR-002Offline-firstSales never blocked by network
ADR-003Event sourcingComplete audit trail and temporal queries
ADR-004JWT + PINSecure API + fast cashier workflow
ADR-005PostgreSQLSchema support and JSONB flexibility
ADR-006ASP.NET CorePerformance and unified C# stack

These decisions form the architectural foundation upon which the rest of the system is built.


End of Part II: Architecture

Next: Part III: Database - Chapter 11: Database Strategy

Chapter 11: Database Strategy

PostgreSQL 16 on Shared Infrastructure


11.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

DecisionChoiceRationale
Database EnginePostgreSQL 16JSONB support, excellent concurrency, mature ecosystem
Multi-TenancySchema-per-tenantStrong isolation, easy backup/restore, no RLS overhead
Shared Tablesshared. schemaPlatform-wide users, tenants, sessions
Connection PoolingPgBouncerEssential for multi-tenant connection efficiency
HostingShared container (postgres16)Existing infrastructure, reduced ops complexity

11.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    │  │ tenant_0001 │  │ tenant_0002 │  ...      │ │   │
│   │   │   │   schema    │  │   schema    │  │   schema    │           │ │   │
│   │   │   └─────────────┘  └─────────────┘  └─────────────┘           │ │   │
│   │   │                                                                │ │   │
│   │   └───────────────────────────────────────────────────────────────┘ │   │
│   │                                                                      │   │
│   │   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 tables
CREATE SCHEMA shared;

-- Grant usage to application role
GRANT USAGE ON SCHEMA shared TO pos_app;

11.3 Schema-Per-Tenant Architecture

Why Schema Isolation?

ApproachProsConsOur Choice
Row-level (tenant_id)Simple, single schemaRLS complexity, performance, no backup isolationNo
Schema-per-tenantStrong isolation, easy backup, no RLSMany schemas, connection overheadYes
Database-per-tenantMaximum isolationHigh resource usage, complex managementNo

Schema Naming Convention

shared                  -- Platform-wide tables (tenants, users, sessions)
tenant_0001             -- Tenant with ID 0001
tenant_0002             -- Tenant with ID 0002
tenant_XXXX             -- Pattern: tenant_{4-digit-id}

Schema Provisioning Workflow

-- Function to provision a new tenant schema
CREATE OR REPLACE FUNCTION shared.provision_tenant_schema(
    p_tenant_id UUID,
    p_schema_name VARCHAR(63)
)
RETURNS VOID AS $$
DECLARE
    v_schema_exists BOOLEAN;
BEGIN
    -- Check if schema already exists
    SELECT EXISTS(
        SELECT 1 FROM information_schema.schemata
        WHERE schema_name = p_schema_name
    ) INTO v_schema_exists;

    IF v_schema_exists THEN
        RAISE EXCEPTION 'Schema % already exists', p_schema_name;
    END IF;

    -- Create the tenant schema
    EXECUTE format('CREATE SCHEMA %I', p_schema_name);

    -- Grant permissions to application role
    EXECUTE format('GRANT USAGE ON SCHEMA %I TO pos_app', p_schema_name);
    EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %I TO pos_app', p_schema_name);
    EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %I TO pos_app', p_schema_name);

    -- Set default privileges for future tables
    EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO pos_app', p_schema_name);
    EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO pos_app', p_schema_name);

    -- Create all tenant tables using template
    PERFORM shared.create_tenant_tables(p_schema_name);

    -- Seed default data (roles, settings)
    PERFORM shared.seed_tenant_data(p_schema_name, p_tenant_id);

    -- Update tenant record
    UPDATE shared.tenants
    SET status = 'active', schema_name = p_schema_name
    WHERE id = p_tenant_id;

    RAISE NOTICE 'Tenant schema % provisioned successfully', p_schema_name;
END;
$$ LANGUAGE plpgsql;

Connection Search Path Pattern

-- Application layer sets search_path per request
-- This enables transparent schema resolution

-- Example: User authenticated for tenant_0001
SET search_path TO tenant_0001, shared;

-- Queries now resolve correctly:
SELECT * FROM products;          -- → tenant_0001.products
SELECT * FROM tenants;           -- → shared.tenants (fallback)
SELECT * FROM users;             -- → shared.users (fallback)

-- Cross-schema join example
SELECT u.email, tu.role
FROM users u                     -- shared.users
JOIN tenant_users tu ON u.id = tu.user_id  -- tenant_0001.tenant_users
WHERE tu.is_active = TRUE;

11.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)
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
; For multi-tenant: (tenants * 2) + (admin connections)
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

ModeBehaviorUse Case
SessionConnection per sessionLong-running sessions
TransactionConnection per transactionMulti-tenant APIs
StatementConnection per statementRead replicas only

Recommendation: Use transaction mode for the POS API to maximize connection reuse across tenants.

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

11.5 Backup and Restore Per-Tenant

Backup Strategy Overview

Backup TypeFrequencyRetentionPurpose
Full DatabaseDaily30 daysDisaster recovery
Tenant SchemaOn-demand90 daysTenant migration, recovery
WAL ArchivesContinuous7 daysPoint-in-time recovery
Shared SchemaDaily30 daysPlatform recovery

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 Backup

#!/bin/bash
# /volume1/docker/scripts/backup-tenant.sh
# Usage: ./backup-tenant.sh tenant_0001

TENANT_SCHEMA=$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_SCHEMA" ]; then
    echo "Usage: $0 <tenant_schema>"
    exit 1
fi

# Create backup directory
mkdir -p "$BACKUP_DIR/$TENANT_SCHEMA"

# Backup tenant schema only
docker exec $CONTAINER pg_dump \
    -U postgres \
    -d $DB_NAME \
    -n $TENANT_SCHEMA \
    -Fc \
    -Z 9 \
    -f /tmp/${TENANT_SCHEMA}_${DATE}.dump

# Copy to backup location
docker cp $CONTAINER:/tmp/${TENANT_SCHEMA}_${DATE}.dump \
    "$BACKUP_DIR/$TENANT_SCHEMA/"

# Cleanup
docker exec $CONTAINER rm /tmp/${TENANT_SCHEMA}_${DATE}.dump

echo "Tenant backup completed: ${TENANT_SCHEMA}_${DATE}.dump"

Tenant Restore Procedure

#!/bin/bash
# /volume1/docker/scripts/restore-tenant.sh
# Usage: ./restore-tenant.sh tenant_0001 backup_file.dump

TENANT_SCHEMA=$1
BACKUP_FILE=$2
CONTAINER="postgres16"
DB_NAME="pos_platform"

if [ -z "$TENANT_SCHEMA" ] || [ -z "$BACKUP_FILE" ]; then
    echo "Usage: $0 <tenant_schema> <backup_file>"
    exit 1
fi

# Copy backup to container
docker cp "$BACKUP_FILE" $CONTAINER:/tmp/restore.dump

# Drop existing schema (if restoring over existing)
docker exec $CONTAINER psql -U postgres -d $DB_NAME -c \
    "DROP SCHEMA IF EXISTS $TENANT_SCHEMA CASCADE;"

# Restore schema
docker exec $CONTAINER pg_restore \
    -U postgres \
    -d $DB_NAME \
    -n $TENANT_SCHEMA \
    /tmp/restore.dump

# Cleanup
docker exec $CONTAINER rm /tmp/restore.dump

echo "Tenant restore completed: $TENANT_SCHEMA"

Tenant Migration (Between Databases)

-- Export tenant to SQL file for migration to different server
-- Run on source server

-- 1. Create migration dump
pg_dump -U postgres -d pos_platform \
    -n tenant_0001 \
    -n shared \
    --no-owner \
    --no-privileges \
    -f tenant_0001_migration.sql

-- 2. On target server, restore
psql -U postgres -d pos_platform_new < tenant_0001_migration.sql

-- 3. Update tenant registry on target
UPDATE shared.tenants
SET status = 'active',
    schema_name = 'tenant_0001'
WHERE id = 'original-tenant-uuid';

11.6 Performance Considerations

Table Partitioning Strategy

For high-volume time-series tables, use declarative partitioning:

-- Partition inventory_transactions by month
CREATE TABLE tenant_0001.inventory_transactions (
    id BIGSERIAL,
    variant_id INT NOT NULL,
    location_id INT NOT NULL,
    transaction_type VARCHAR(20) NOT NULL,
    quantity_change INT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    -- ... other columns
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- Create monthly partitions
CREATE TABLE tenant_0001.inventory_transactions_2025_01
    PARTITION OF tenant_0001.inventory_transactions
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE tenant_0001.inventory_transactions_2025_02
    PARTITION OF tenant_0001.inventory_transactions
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Automate partition creation
CREATE OR REPLACE FUNCTION shared.create_monthly_partitions()
RETURNS VOID AS $$
DECLARE
    r RECORD;
    next_month DATE;
    partition_name TEXT;
    start_date DATE;
    end_date DATE;
BEGIN
    -- Get all tenant schemas
    FOR r IN SELECT schema_name FROM shared.tenants WHERE status = 'active'
    LOOP
        -- 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 := r.schema_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 = r.schema_name
                AND c.relname = 'inventory_transactions_' || to_char(next_month, 'YYYY_MM')
            ) THEN
                EXECUTE format(
                    'CREATE TABLE %I.inventory_transactions_%s
                     PARTITION OF %I.inventory_transactions
                     FOR VALUES FROM (%L) TO (%L)',
                    r.schema_name, to_char(next_month, 'YYYY_MM'),
                    r.schema_name, start_date, end_date
                );
            END IF;
        END LOOP;
    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

# Shared memory (25% of RAM)
shared_buffers = 4GB

# Work memory per query (be conservative with many 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

11.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:

// Database connection factory
class DatabaseConnection {
  getPrimaryConnection(): Pool {
    return new Pool({
      connectionString: process.env.DATABASE_URL
    });
  }

  getReadReplicaConnection(): Pool {
    return new Pool({
      connectionString: process.env.DATABASE_URL_READONLY
    });
  }

  // Route based on operation type
  getConnection(operation: 'read' | 'write'): Pool {
    return operation === 'read'
      ? this.getReadReplicaConnection()
      : this.getPrimaryConnection();
  }
}

11.8 Monitoring and Alerting

Key Database Metrics

-- Database size by schema
SELECT
    schemaname AS schema,
    pg_size_pretty(SUM(pg_total_relation_size(schemaname || '.' || tablename))) AS total_size
FROM pg_tables
WHERE schemaname LIKE 'tenant_%' OR schemaname = 'shared'
GROUP BY schemaname
ORDER BY SUM(pg_total_relation_size(schemaname || '.' || tablename)) DESC;

-- Active connections by tenant
SELECT
    CASE
        WHEN query LIKE '%search_path%tenant_%' THEN
            substring(query FROM 'tenant_[0-9]+')
        ELSE 'unknown'
    END AS tenant,
    COUNT(*) AS connections
FROM pg_stat_activity
WHERE datname = 'pos_platform'
  AND state = 'active'
GROUP BY 1
ORDER BY 2 DESC;

-- 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 LIKE 'tenant_%'
  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

MetricWarningCriticalAction
Connection usage70%90%Scale pool, investigate
Disk usage70%85%Cleanup, expand storage
Replication lag10s60sCheck network, replica health
Long-running queries30s60sInvestigate, possibly kill
Dead tuples1M5MForce vacuum
Cache hit ratio<95%<90%Increase shared_buffers

11.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;
-- Tenant schemas granted during provisioning

-- Create admin role (elevated privileges)
CREATE ROLE pos_admin WITH LOGIN PASSWORD 'admin_password';
GRANT ALL PRIVILEGES ON DATABASE pos_platform TO pos_admin;

-- Create read-only role (for reporting)
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 tenant schemas as needed

-- Prevent direct access to sensitive columns
REVOKE ALL ON shared.users FROM PUBLIC;
GRANT SELECT (id, email, first_name, last_name, is_platform_admin, email_verified, created_at)
    ON shared.users TO pos_app;
-- password_hash, mfa_secret not accessible

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

11.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 tenant schemas
docker exec postgres16 psql -U postgres -d pos_platform -c \
    "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%';"

# Check tenant table counts
docker exec postgres16 psql -U postgres -d pos_platform -c \
    "SELECT schemaname, COUNT(*) FROM pg_tables WHERE schemaname LIKE 'tenant_%' GROUP BY 1;"

# Backup specific tenant
./backup-tenant.sh tenant_0001

# Vacuum full (maintenance window only)
docker exec postgres16 psql -U postgres -d pos_platform -c "VACUUM FULL ANALYZE tenant_0001.products;"

Next Chapter: Chapter 12: Schema Design - Detailed schema structure with 51 tables across 13 domains.


Chapter 11 | Database Strategy | POS Platform Blueprint v1.0.0

Chapter 12: Schema Design

51 Tables Across 13 Domains


12.1 Overview

The POS Platform database consists of 51 tables organized into 13 functional domains. This chapter provides the complete schema design with the shared schema definition and tenant schema template.

Domain Summary

DomainSchemaTablesPurpose
1. Products & Variantstenant5Product catalog with SKU/variant model
2. Categories & Tagstenant5Flexible product organization
3. Product Attributestenant4Brand, gender, origin, fabric attributes
4. Inventory & Locationstenant3Multi-location inventory tracking
5. Tax Configurationtenant2Location-specific tax rates
6. Orders & Customerstenant3Transactions and customer profiles
7. User Preferencestenant1Per-user view settings
8. Tenant Managementshared3Platform tenant registry
9. Authentication & Authorizationshared + tenant7Users, sessions, roles
10. Offline Sync Infrastructuretenant4Device sync and conflicts
11. Cash Drawer Operationstenant6Shift and cash management
12. Payment Processingtenant4Terminals and settlements
13. RFID Module (Optional)tenant7Tag printing and scanning
TOTAL51

12.2 Schema Architecture

Visual Diagram

┌─────────────────────────────────────────────────────────────────────────────┐
│                              pos_platform                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         shared SCHEMA (6 tables)                     │   │
│  │                                                                      │   │
│  │  ┌─────────────┐ ┌───────────────────┐ ┌─────────────────────────┐  │   │
│  │  │   tenants   │ │tenant_subscriptions│ │    tenant_modules      │  │   │
│  │  │  (registry) │ │   (billing)        │ │ (feature add-ons)      │  │   │
│  │  └─────────────┘ └───────────────────┘ └─────────────────────────┘  │   │
│  │  ┌─────────────────┐ ┌─────────────────────┐ ┌───────────────────┐  │   │
│  │  │     users       │ │    user_sessions    │ │  password_resets  │  │   │
│  │  │ (platform auth) │ │ (session tracking)  │ │   (recovery)      │  │   │
│  │  └─────────────────┘ └─────────────────────┘ └───────────────────┘  │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                     │                                       │
│                    ┌────────────────┼────────────────┐                     │
│                    │                │                │                      │
│                    ▼                ▼                ▼                      │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │              tenant_XXXX SCHEMAS (45 tables each)                      │ │
│  │                                                                        │ │
│  │  Domain 1-3: Catalog        Domain 4-5: Inventory    Domain 6: Sales   │ │
│  │  ┌────────────────────┐    ┌───────────────────┐   ┌────────────────┐ │ │
│  │  │ products           │    │ locations         │   │ customers      │ │ │
│  │  │ variants           │    │ inventory_levels  │   │ orders         │ │ │
│  │  │ brands             │    │ inventory_trans   │   │ order_items    │ │ │
│  │  │ categories         │    │ taxes             │   └────────────────┘ │ │
│  │  │ collections        │    │ location_tax      │                      │ │
│  │  │ tags               │    └───────────────────┘   Domain 9: Auth     │ │
│  │  │ product_collection │                            ┌────────────────┐ │ │
│  │  │ product_tag        │    Domain 10: Sync         │ roles          │ │ │
│  │  │ product_groups     │    ┌───────────────────┐   │ role_perms     │ │ │
│  │  │ genders            │    │ devices           │   │ tenant_users   │ │ │
│  │  │ origins            │    │ sync_queue        │   │ tenant_settings│ │ │
│  │  │ fabrics            │    │ sync_conflicts    │   └────────────────┘ │ │
│  │  └────────────────────┘    │ sync_checkpoints  │                      │ │
│  │                            └───────────────────┘   Domain 11-12: Ops  │ │
│  │  Domain 7: Prefs                                   ┌────────────────┐ │ │
│  │  ┌────────────────────┐    Domain 13: RFID         │ shifts         │ │ │
│  │  │ item_view_settings │    ┌───────────────────┐   │ cash_drawers   │ │ │
│  │  └────────────────────┘    │ rfid_config       │   │ cash_counts    │ │ │
│  │                            │ rfid_printers     │   │ cash_movements │ │ │
│  │                            │ rfid_templates    │   │ cash_drops     │ │ │
│  │                            │ rfid_print_jobs   │   │ cash_pickups   │ │ │
│  │                            │ rfid_tags         │   │ payment_terms  │ │ │
│  │                            │ rfid_scan_sessions│   │ payment_attemps│ │ │
│  │                            │ rfid_scan_events  │   │ payment_batches│ │ │
│  │                            └───────────────────┘   │ payment_recon  │ │ │
│  │                                                    └────────────────┘ │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

12.3 Shared Schema: Complete CREATE TABLE Statements

The shared schema contains platform-wide tables for tenant management and authentication.

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;

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,
    schema_name VARCHAR(63) 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 TIMESTAMP,
    metadata JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    -- Constraints
    CONSTRAINT tenants_slug_unique UNIQUE (slug),
    CONSTRAINT tenants_schema_unique UNIQUE (schema_name),
    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.schema_name IS 'PostgreSQL schema name (tenant_XXXX format)';
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 SERIAL PRIMARY KEY,
    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 TIMESTAMP NOT NULL,
    current_period_end TIMESTAMP NOT NULL,
    cancelled_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP 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 TIMESTAMP NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMP,
    monthly_fee_cents INT,
    trial_days_remaining INT,
    configuration JSONB DEFAULT '{}',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP 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 TIMESTAMP,
    last_login_at TIMESTAMP,
    failed_login_count INT DEFAULT 0,
    locked_until TIMESTAMP,
    mfa_enabled BOOLEAN DEFAULT FALSE,
    mfa_secret VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP 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 TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP NOT NULL,
    last_activity_at TIMESTAMP 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 SERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP NOT NULL,
    used_at TIMESTAMP,
    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)';

12.4 Tenant Schema Template

When a new tenant is provisioned, the following tables are created in their dedicated schema.

Schema Creation Function

-- Master function to create all tenant tables
CREATE OR REPLACE FUNCTION shared.create_tenant_tables(p_schema_name VARCHAR(63))
RETURNS VOID AS $$
BEGIN
    -- Create all tables in order (respecting foreign key dependencies)

    -- Domain 3: Product Attributes (no dependencies)
    PERFORM shared.create_tenant_attributes_tables(p_schema_name);

    -- Domain 2: Categories & Tags (no dependencies)
    PERFORM shared.create_tenant_category_tables(p_schema_name);

    -- Domain 4: Locations (no dependencies)
    PERFORM shared.create_tenant_location_tables(p_schema_name);

    -- Domain 5: Tax Configuration (depends on locations)
    PERFORM shared.create_tenant_tax_tables(p_schema_name);

    -- Domain 9: Roles & Permissions (minimal dependencies)
    PERFORM shared.create_tenant_auth_tables(p_schema_name);

    -- Domain 1: Products & Variants (depends on attributes)
    PERFORM shared.create_tenant_product_tables(p_schema_name);

    -- Domain 4 continued: Inventory (depends on variants, locations)
    PERFORM shared.create_tenant_inventory_tables(p_schema_name);

    -- Domain 6: Orders & Customers (depends on variants, locations)
    PERFORM shared.create_tenant_order_tables(p_schema_name);

    -- Domain 7: User Preferences
    PERFORM shared.create_tenant_preference_tables(p_schema_name);

    -- Domain 10: Offline Sync (depends on devices, locations)
    PERFORM shared.create_tenant_sync_tables(p_schema_name);

    -- Domain 11: Cash Drawer Operations (depends on locations)
    PERFORM shared.create_tenant_cash_tables(p_schema_name);

    -- Domain 12: Payment Processing (depends on locations, orders)
    PERFORM shared.create_tenant_payment_tables(p_schema_name);

    -- Domain 13: RFID Module (optional, depends on variants)
    PERFORM shared.create_tenant_rfid_tables(p_schema_name);

    RAISE NOTICE 'All tables created for schema: %', p_schema_name;
END;
$$ LANGUAGE plpgsql;

Example: Create Product Tables Function

-- Create Domain 1: Products & Variants tables
CREATE OR REPLACE FUNCTION shared.create_tenant_product_tables(p_schema_name VARCHAR(63))
RETURNS VOID AS $$
BEGIN
    -- products table
    EXECUTE format($DDL$
        CREATE TABLE %I.products (
            id SERIAL PRIMARY KEY,
            sku VARCHAR(50) NOT NULL,
            name VARCHAR(255) NOT NULL,
            description TEXT,
            brand_id INT REFERENCES %I.brands(id),
            product_group_id INT REFERENCES %I.product_groups(id),
            gender_id INT REFERENCES %I.genders(id),
            origin_id INT REFERENCES %I.origins(id),
            fabric_id INT REFERENCES %I.fabrics(id),
            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 TIMESTAMP,
            deleted_by UUID REFERENCES shared.users(id),
            created_at TIMESTAMP DEFAULT NOW(),
            updated_at TIMESTAMP DEFAULT NOW()
        )
    $DDL$, p_schema_name, p_schema_name, p_schema_name,
           p_schema_name, p_schema_name, p_schema_name);

    -- Products indexes
    EXECUTE format($DDL$
        CREATE UNIQUE INDEX idx_%I_products_sku ON %I.products(sku) WHERE deleted_at IS NULL
    $DDL$, p_schema_name, p_schema_name);

    EXECUTE format($DDL$
        CREATE INDEX idx_%I_products_brand ON %I.products(brand_id)
    $DDL$, p_schema_name, p_schema_name);

    EXECUTE format($DDL$
        CREATE INDEX idx_%I_products_active ON %I.products(is_active)
            WHERE is_active = TRUE AND deleted_at IS NULL
    $DDL$, p_schema_name, p_schema_name);

    -- variants table
    EXECUTE format($DDL$
        CREATE TABLE %I.variants (
            id SERIAL PRIMARY KEY,
            product_id INT NOT NULL REFERENCES %I.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 TIMESTAMP,
            deleted_by UUID REFERENCES shared.users(id),
            created_at TIMESTAMP DEFAULT NOW(),
            updated_at TIMESTAMP DEFAULT NOW()
        )
    $DDL$, p_schema_name, p_schema_name);

    -- Variants indexes
    EXECUTE format($DDL$
        CREATE UNIQUE INDEX idx_%I_variants_sku ON %I.variants(sku) WHERE deleted_at IS NULL
    $DDL$, p_schema_name, p_schema_name);

    EXECUTE format($DDL$
        CREATE UNIQUE INDEX idx_%I_variants_barcode ON %I.variants(barcode)
            WHERE barcode IS NOT NULL AND deleted_at IS NULL
    $DDL$, p_schema_name, p_schema_name);

    EXECUTE format($DDL$
        CREATE INDEX idx_%I_variants_product ON %I.variants(product_id)
    $DDL$, p_schema_name, p_schema_name);

    -- Junction tables for product collections and tags
    EXECUTE format($DDL$
        CREATE TABLE %I.product_collection (
            id SERIAL PRIMARY KEY,
            product_id INT NOT NULL REFERENCES %I.products(id) ON DELETE CASCADE,
            collection_id INT NOT NULL REFERENCES %I.collections(id) ON DELETE CASCADE,
            display_order INT DEFAULT 0,
            UNIQUE (product_id, collection_id)
        )
    $DDL$, p_schema_name, p_schema_name, p_schema_name);

    EXECUTE format($DDL$
        CREATE TABLE %I.product_tag (
            id SERIAL PRIMARY KEY,
            product_id INT NOT NULL REFERENCES %I.products(id) ON DELETE CASCADE,
            tag_id INT NOT NULL REFERENCES %I.tags(id) ON DELETE CASCADE,
            UNIQUE (product_id, tag_id)
        )
    $DDL$, p_schema_name, p_schema_name, p_schema_name);

    RAISE NOTICE 'Product tables created for schema: %', p_schema_name;
END;
$$ LANGUAGE plpgsql;

12.5 Seed Data Function

-- Seed default data for a new tenant
CREATE OR REPLACE FUNCTION shared.seed_tenant_data(
    p_schema_name VARCHAR(63),
    p_tenant_id UUID
)
RETURNS VOID AS $$
DECLARE
    v_owner_role_id INT;
    v_admin_role_id INT;
    v_manager_role_id INT;
    v_cashier_role_id INT;
    v_viewer_role_id INT;
BEGIN
    -- Seed default roles
    EXECUTE format($SQL$
        INSERT INTO %I.roles (name, display_name, description, is_system)
        VALUES
            ('owner', 'Owner', 'Full access to all features and settings', TRUE),
            ('admin', 'Administrator', 'Administrative access excluding billing', TRUE),
            ('manager', 'Manager', 'Store management and reporting access', TRUE),
            ('cashier', 'Cashier', 'Sales and basic customer operations', TRUE),
            ('viewer', 'Viewer', 'Read-only access to reports', TRUE)
        RETURNING id
    $SQL$, p_schema_name);

    -- Get role IDs for permission assignment
    EXECUTE format('SELECT id FROM %I.roles WHERE name = ''owner''', p_schema_name)
        INTO v_owner_role_id;
    EXECUTE format('SELECT id FROM %I.roles WHERE name = ''admin''', p_schema_name)
        INTO v_admin_role_id;
    EXECUTE format('SELECT id FROM %I.roles WHERE name = ''manager''', p_schema_name)
        INTO v_manager_role_id;
    EXECUTE format('SELECT id FROM %I.roles WHERE name = ''cashier''', p_schema_name)
        INTO v_cashier_role_id;

    -- Seed role permissions (Owner gets all)
    EXECUTE format($SQL$
        INSERT INTO %I.role_permissions (role_id, permission, granted) VALUES
        -- Owner permissions (all)
        (%s, 'products.*', TRUE),
        (%s, 'inventory.*', TRUE),
        (%s, 'orders.*', TRUE),
        (%s, 'customers.*', TRUE),
        (%s, 'reports.*', TRUE),
        (%s, 'settings.*', TRUE),
        (%s, 'users.*', TRUE),
        (%s, 'billing.*', TRUE),
        -- Manager permissions
        (%s, 'products.view', TRUE),
        (%s, 'products.edit', TRUE),
        (%s, 'inventory.*', TRUE),
        (%s, 'orders.*', TRUE),
        (%s, 'customers.*', TRUE),
        (%s, 'reports.view', TRUE),
        (%s, 'shifts.*', TRUE),
        -- Cashier permissions
        (%s, 'products.view', TRUE),
        (%s, 'orders.create', TRUE),
        (%s, 'orders.view', TRUE),
        (%s, 'customers.view', TRUE),
        (%s, 'customers.create', TRUE),
        (%s, 'shifts.open', TRUE),
        (%s, 'shifts.close', TRUE)
    $SQL$, p_schema_name,
        v_owner_role_id, v_owner_role_id, v_owner_role_id, v_owner_role_id,
        v_owner_role_id, v_owner_role_id, v_owner_role_id, v_owner_role_id,
        v_manager_role_id, v_manager_role_id, v_manager_role_id, v_manager_role_id,
        v_manager_role_id, v_manager_role_id, v_manager_role_id,
        v_cashier_role_id, v_cashier_role_id, v_cashier_role_id,
        v_cashier_role_id, v_cashier_role_id, v_cashier_role_id, v_cashier_role_id);

    -- Seed default genders
    EXECUTE format($SQL$
        INSERT INTO %I.genders (name) VALUES
            ('Men'), ('Women'), ('Unisex'), ('Kids'), ('Boys'), ('Girls')
    $SQL$, p_schema_name);

    -- Seed default tenant settings
    EXECUTE format($SQL$
        INSERT INTO %I.tenant_settings (category, key, value, value_type, description) VALUES
            ('general', 'business_name', '"New Business"', 'string', 'Business display name'),
            ('general', 'timezone', '"UTC"', 'string', 'Default timezone'),
            ('pos', 'require_customer', 'false', 'boolean', 'Require customer for sales'),
            ('pos', 'allow_negative_inventory', 'false', 'boolean', 'Allow selling without stock'),
            ('pos', 'receipt_footer', '"Thank you for your business!"', 'string', 'Receipt footer message'),
            ('inventory', 'low_stock_threshold', '5', 'number', 'Low stock alert threshold'),
            ('cash', 'require_drawer_count', 'true', 'boolean', 'Require cash count at shift open/close'),
            ('loyalty', 'points_per_dollar', '1', 'number', 'Loyalty points earned per dollar spent')
    $SQL$, p_schema_name);

    RAISE NOTICE 'Seed data inserted for schema: %', p_schema_name;
END;
$$ LANGUAGE plpgsql;

12.6 Schema Provisioning SQL Script

Complete script to provision a new tenant:

-- ============================================================
-- TENANT PROVISIONING SCRIPT
-- Run this to create a new tenant
-- ============================================================

-- Variables (replace with actual values)
\set tenant_name 'Acme Retail'
\set tenant_slug 'acme-retail'
\set contact_email 'admin@acmeretail.com'
\set schema_id '0001'

-- Begin transaction
BEGIN;

-- Step 1: Create tenant record
INSERT INTO shared.tenants (
    name, slug, schema_name, status, tier, contact_email
) VALUES (
    :'tenant_name',
    :'tenant_slug',
    'tenant_' || :'schema_id',
    '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: Create tenant schema
SELECT shared.provision_tenant_schema(:'tenant_id'::uuid, 'tenant_' || :'schema_id');

-- Step 4: Verify creation
SELECT
    t.name,
    t.slug,
    t.schema_name,
    t.status,
    (SELECT COUNT(*) FROM information_schema.tables
     WHERE table_schema = t.schema_name) AS table_count
FROM shared.tenants t
WHERE t.id = :'tenant_id'::uuid;

COMMIT;

-- Success message
\echo 'Tenant provisioned successfully!'
\echo 'Schema: tenant_' || :'schema_id'

12.7 Table Count by Domain

DomainTablesSharedTenant
1. Products & Variants505
2. Categories & Tags505
3. Product Attributes404
4. Inventory & Locations303
5. Tax Configuration202
6. Orders & Customers303
7. User Preferences101
8. Tenant Management330
9. Auth & Authorization734
10. Offline Sync404
11. Cash Drawer606
12. Payment Processing404
13. RFID Module707
TOTAL51645

12.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

Tenant Schema Tables (45)

-- Domain 1: Products (5)
products, variants, product_collection, product_tag, brands

-- Domain 2: Categories (5)
categories, collections, tags, (product_collection, product_tag counted above)

-- Domain 3: Attributes (4)
product_groups, genders, origins, fabrics

-- Domain 4: Inventory (3)
locations, inventory_levels, inventory_transactions

-- Domain 5: Tax (2)
taxes, location_tax

-- Domain 6: Orders (3)
customers, orders, order_items

-- Domain 7: Preferences (1)
item_view_settings

-- Domain 9: Auth (4 tenant-specific)
roles, role_permissions, tenant_users, tenant_settings

-- Domain 10: Sync (4)
devices, sync_queue, sync_conflicts, sync_checkpoints

-- Domain 11: Cash (6)
shifts, cash_drawers, cash_counts, cash_movements, cash_drops, cash_pickups

-- Domain 12: Payment (4)
payment_terminals, payment_attempts, payment_batches, payment_reconciliation

-- Domain 13: RFID (7)
rfid_config, rfid_printers, rfid_print_templates, rfid_print_jobs,
rfid_tags, rfid_scan_sessions, rfid_scan_events

Next Chapter: Chapter 13: Entity Specifications - Complete CREATE TABLE statements for all 51 tables organized by domain.


Chapter 12 | Schema Design | POS Platform Blueprint v1.0.0

Chapter 13: Entity Specifications

Complete SQL for All 51 Tables


13.1 Overview

This chapter provides complete CREATE TABLE statements for all 51 tables in the POS Platform database. 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.


Domain 1-2: Catalog (Products, Categories, Tags)

brands

-- Brand/manufacturer reference data
CREATE TABLE brands (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    logo_url VARCHAR(500),
    description TEXT,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT brands_name_unique UNIQUE (name)
);

CREATE INDEX idx_brands_active ON brands(is_active) WHERE is_active = TRUE;

COMMENT ON TABLE brands IS 'Brand/manufacturer reference data for product categorization';

product_groups

-- High-level product type categorization
CREATE TABLE product_groups (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    description TEXT,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT product_groups_name_unique UNIQUE (name)
);

COMMENT ON TABLE product_groups IS 'High-level product types (Tops, Bottoms, Accessories, etc.)';

genders

-- Target demographic for products
CREATE TABLE genders (
    id SERIAL PRIMARY KEY,
    name VARCHAR(20) NOT NULL,

    CONSTRAINT genders_name_unique UNIQUE (name)
);

COMMENT ON TABLE genders IS 'Target demographic (Men, Women, Unisex, Kids)';

origins

-- Country of origin for compliance tracking
CREATE TABLE origins (
    id SERIAL PRIMARY KEY,
    country VARCHAR(100) NOT NULL,
    code VARCHAR(3),

    CONSTRAINT origins_country_unique UNIQUE (country),
    CONSTRAINT origins_code_unique UNIQUE (code)
);

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 SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    care_instructions TEXT,

    CONSTRAINT fabrics_name_unique UNIQUE (name)
);

COMMENT ON TABLE fabrics IS 'Fabric/material composition (100% Cotton, Polyester Blend, etc.)';

products

-- Master product record containing shared attributes
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    sku VARCHAR(50) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    brand_id INT REFERENCES brands(id) ON DELETE SET NULL,
    product_group_id INT REFERENCES product_groups(id) ON DELETE SET NULL,
    gender_id INT REFERENCES genders(id) ON DELETE SET NULL,
    origin_id INT REFERENCES origins(id) ON DELETE SET NULL,
    fabric_id INT 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 TIMESTAMP,
    deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT products_price_positive CHECK (base_price >= 0),
    CONSTRAINT products_cost_positive CHECK (cost_price >= 0)
);

-- Indexes
CREATE UNIQUE INDEX idx_products_sku ON products(sku) WHERE deleted_at IS NULL;
CREATE INDEX idx_products_brand ON products(brand_id);
CREATE INDEX idx_products_group ON products(product_group_id);
CREATE INDEX idx_products_active ON products(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));

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 SERIAL PRIMARY KEY,
    product_id INT 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 TIMESTAMP,
    deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Indexes
CREATE UNIQUE INDEX idx_variants_sku ON variants(sku) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_variants_barcode ON variants(barcode)
    WHERE barcode IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_variants_product ON variants(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;

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 SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
    description TEXT,
    display_order INT DEFAULT 0,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT categories_name_unique UNIQUE (name)
);

-- Indexes
CREATE INDEX idx_categories_parent ON categories(parent_id);
CREATE INDEX idx_categories_display ON categories(display_order);
CREATE INDEX idx_categories_active ON categories(is_active) WHERE is_active = TRUE;

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 SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    image_url VARCHAR(500),
    is_active BOOLEAN DEFAULT TRUE,
    start_date TIMESTAMP,
    end_date TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT collections_name_unique UNIQUE (name),
    CONSTRAINT collections_date_order CHECK (end_date IS NULL OR start_date IS NULL OR end_date > start_date)
);

-- Indexes
CREATE INDEX idx_collections_active ON collections(is_active, start_date, end_date);
CREATE INDEX idx_collections_current ON collections(start_date, end_date)
    WHERE is_active = TRUE;

COMMENT ON TABLE collections IS 'Marketing collections (Summer 2025, Clearance, New Arrivals)';

tags

-- Flexible product tagging
CREATE TABLE tags (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL,
    color VARCHAR(7),

    CONSTRAINT tags_name_unique UNIQUE (name),
    CONSTRAINT tags_color_hex CHECK (color IS NULL OR color ~ '^#[0-9A-Fa-f]{6}$')
);

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 SERIAL PRIMARY KEY,
    product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    collection_id INT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
    display_order INT DEFAULT 0,

    CONSTRAINT product_collection_unique UNIQUE (product_id, collection_id)
);

CREATE INDEX idx_product_collection_product ON product_collection(product_id);
CREATE INDEX idx_product_collection_collection ON product_collection(collection_id);

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 SERIAL PRIMARY KEY,
    product_id INT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
    tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,

    CONSTRAINT product_tag_unique UNIQUE (product_id, tag_id)
);

CREATE INDEX idx_product_tag_product ON product_tag(product_id);
CREATE INDEX idx_product_tag_tag ON product_tag(tag_id);

COMMENT ON TABLE product_tag IS 'Links products to tags for flexible categorization';

Domain 3: Inventory

locations

-- Physical stores, warehouses, and fulfillment centers
CREATE TABLE locations (
    id SERIAL PRIMARY KEY,
    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 'America/New_York',
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT locations_code_unique UNIQUE (code),
    CONSTRAINT locations_type_check CHECK (type IN ('store', 'warehouse', 'online', 'popup'))
);

CREATE INDEX idx_locations_type ON locations(type);
CREATE INDEX idx_locations_active ON locations(is_active) WHERE is_active = TRUE;

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
CREATE TABLE inventory_levels (
    id SERIAL PRIMARY KEY,
    variant_id INT NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
    location_id INT NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
    quantity_on_hand INT DEFAULT 0,
    quantity_reserved INT DEFAULT 0,
    quantity_available INT GENERATED ALWAYS AS (quantity_on_hand - quantity_reserved) STORED,
    reorder_point INT DEFAULT 0,
    reorder_quantity INT DEFAULT 0,
    last_counted TIMESTAMP,
    deleted_at TIMESTAMP,
    deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT inventory_levels_on_hand_check CHECK (quantity_on_hand >= 0 OR
        (SELECT value::boolean FROM tenant_settings WHERE key = 'allow_negative_inventory')),
    CONSTRAINT inventory_levels_reserved_check CHECK (quantity_reserved >= 0)
);

-- Indexes
CREATE UNIQUE INDEX idx_inventory_levels_lookup ON inventory_levels(variant_id, location_id)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_location ON inventory_levels(location_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_low_stock ON inventory_levels(location_id, quantity_on_hand)
    WHERE quantity_on_hand <= reorder_point AND deleted_at IS NULL;
CREATE INDEX idx_inventory_levels_variant ON inventory_levels(variant_id);

COMMENT ON TABLE inventory_levels IS 'Current inventory quantities per variant per location';
COMMENT ON COLUMN inventory_levels.quantity_available IS 'Computed: on_hand - reserved';
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 BIGSERIAL PRIMARY KEY,
    variant_id INT NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
    location_id INT 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 INT,
    notes TEXT,
    user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    created_at TIMESTAMP 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 for lookups)
CREATE INDEX idx_inventory_trans_date ON inventory_transactions USING BRIN (created_at);
CREATE INDEX idx_inventory_trans_variant ON inventory_transactions(variant_id, created_at DESC);
CREATE INDEX idx_inventory_trans_location ON inventory_transactions(location_id, created_at DESC);
CREATE INDEX idx_inventory_trans_reference ON inventory_transactions(reference_type, reference_id)
    WHERE reference_type IS NOT NULL;
CREATE INDEX idx_inventory_trans_type ON inventory_transactions(transaction_type, created_at DESC);

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)';

Domain 4: Sales

customers

-- Customer profiles with loyalty tracking
CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    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(12,2) DEFAULT 0,
    visit_count INT DEFAULT 0,
    first_visit TIMESTAMP,
    last_visit TIMESTAMP,
    deleted_at TIMESTAMP,
    deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    anonymized_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT customers_points_positive CHECK (loyalty_points >= 0),
    CONSTRAINT customers_spent_positive CHECK (total_spent >= 0)
);

-- Indexes
CREATE UNIQUE INDEX idx_customers_loyalty ON customers(loyalty_number)
    WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX idx_customers_email ON customers(email)
    WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_customers_name ON customers(last_name, first_name) WHERE deleted_at IS NULL;
CREATE INDEX idx_customers_phone ON customers(phone) WHERE phone IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_customers_last_visit ON customers(last_visit DESC);

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 SERIAL PRIMARY KEY,
    order_number VARCHAR(20) NOT NULL,
    customer_id INT REFERENCES customers(id) ON DELETE SET NULL,
    location_id INT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
    user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    shift_id INT REFERENCES shifts(id) ON DELETE SET NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    subtotal DECIMAL(12,2) NOT NULL,
    tax_amount DECIMAL(12,2) NOT NULL,
    discount_amount DECIMAL(12,2) DEFAULT 0,
    total_amount DECIMAL(12,2) NOT NULL,
    payment_method VARCHAR(20) NOT NULL,
    payment_reference VARCHAR(100),
    notes TEXT,
    deleted_at TIMESTAMP,
    deleted_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    void_reason VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    completed_at TIMESTAMP,
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT orders_number_unique UNIQUE (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
CREATE INDEX idx_orders_date ON orders(created_at DESC);
CREATE INDEX idx_orders_location ON orders(location_id, created_at DESC);
CREATE INDEX idx_orders_customer ON orders(customer_id) WHERE customer_id IS NOT NULL;
CREATE INDEX idx_orders_shift ON orders(shift_id) WHERE shift_id IS NOT NULL;
CREATE INDEX idx_orders_status ON orders(status, created_at DESC);
CREATE INDEX idx_orders_number ON orders(order_number);

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 SERIAL PRIMARY KEY,
    order_id INT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    variant_id INT 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(10,2) NOT NULL,
    discount_amount DECIMAL(10,2) DEFAULT 0,
    tax_amount DECIMAL(10,2) NOT NULL,
    line_total DECIMAL(10,2) NOT NULL,
    is_returned BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP 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
CREATE INDEX idx_order_items_order ON order_items(order_id);
CREATE INDEX idx_order_items_variant ON order_items(variant_id);
CREATE INDEX idx_order_items_sku ON order_items(sku);
CREATE INDEX idx_order_items_returned ON order_items(order_id) WHERE is_returned = TRUE;

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

loyalty_accounts

-- Customer loyalty program accounts
CREATE TABLE loyalty_accounts (
    id SERIAL PRIMARY KEY,
    customer_id INT 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 TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT loyalty_accounts_customer_unique UNIQUE (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_tier ON loyalty_accounts(tier) WHERE is_active = TRUE;

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 BIGSERIAL PRIMARY KEY,
    loyalty_account_id INT NOT NULL REFERENCES loyalty_accounts(id) ON DELETE CASCADE,
    order_id INT 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 TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT loyalty_trans_type_check CHECK (transaction_type IN (
        'earn', 'redeem', 'expire', 'adjust', 'bonus', 'transfer'
    ))
);

CREATE INDEX idx_loyalty_trans_account ON loyalty_transactions(loyalty_account_id, created_at DESC);
CREATE INDEX idx_loyalty_trans_order ON loyalty_transactions(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';

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 SERIAL PRIMARY KEY,
    card_number VARCHAR(20) NOT NULL,
    pin_hash VARCHAR(255),
    initial_balance DECIMAL(10,2) NOT NULL,
    current_balance DECIMAL(10,2) NOT NULL,
    currency_code CHAR(3) DEFAULT 'USD',
    issued_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP,
    issued_by UUID REFERENCES shared.users(id),
    issued_location_id INT REFERENCES locations(id),
    purchased_order_id INT REFERENCES orders(id),
    is_active BOOLEAN DEFAULT TRUE,
    deactivated_at TIMESTAMP,
    deactivated_reason VARCHAR(255),
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT gift_cards_number_unique UNIQUE (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_number ON gift_cards(card_number);
CREATE INDEX idx_gift_cards_active ON gift_cards(is_active, expires_at);

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 BIGSERIAL PRIMARY KEY,
    gift_card_id INT NOT NULL REFERENCES gift_cards(id) ON DELETE CASCADE,
    order_id INT REFERENCES orders(id) ON DELETE SET NULL,
    transaction_type VARCHAR(20) NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    balance_after DECIMAL(10,2) NOT NULL,
    location_id INT REFERENCES locations(id),
    user_id UUID REFERENCES shared.users(id),
    created_at TIMESTAMP 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_card ON gift_card_transactions(gift_card_id, created_at DESC);
CREATE INDEX idx_gift_card_trans_order ON gift_card_transactions(order_id) WHERE order_id IS NOT NULL;

COMMENT ON TABLE gift_card_transactions IS 'Audit trail of gift card balance changes';

Domain 6-7: Returns & Reporting

returns

-- Return/exchange header
CREATE TABLE returns (
    id SERIAL PRIMARY KEY,
    return_number VARCHAR(20) NOT NULL,
    original_order_id INT NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
    customer_id INT REFERENCES customers(id) ON DELETE SET NULL,
    location_id INT 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(12,2) NOT NULL,
    tax_amount DECIMAL(12,2) NOT NULL,
    refund_amount DECIMAL(12,2) NOT NULL,
    refund_method VARCHAR(20) NOT NULL,
    reason VARCHAR(255),
    notes TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    completed_at TIMESTAMP,

    CONSTRAINT returns_number_unique UNIQUE (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_order ON returns(original_order_id);
CREATE INDEX idx_returns_date ON returns(created_at DESC);
CREATE INDEX idx_returns_status ON returns(status);

COMMENT ON TABLE returns IS 'Return and exchange transaction headers';

return_items

-- Individual items being returned
CREATE TABLE return_items (
    id SERIAL PRIMARY KEY,
    return_id INT NOT NULL REFERENCES returns(id) ON DELETE CASCADE,
    order_item_id INT NOT NULL REFERENCES order_items(id) ON DELETE RESTRICT,
    variant_id INT NOT NULL REFERENCES variants(id) ON DELETE RESTRICT,
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    refund_amount DECIMAL(10,2) NOT NULL,
    reason VARCHAR(50),
    condition VARCHAR(20) DEFAULT 'sellable',
    restocked BOOLEAN DEFAULT FALSE,
    restocked_location_id INT REFERENCES locations(id),
    created_at TIMESTAMP 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_return ON return_items(return_id);
CREATE INDEX idx_return_items_variant ON return_items(variant_id);

COMMENT ON TABLE return_items IS 'Individual items in a return transaction';

reports (User Preferences)

-- Saved report configurations
CREATE TABLE reports (
    id SERIAL PRIMARY KEY,
    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 TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_reports_type ON reports(report_type);
CREATE INDEX idx_reports_public ON reports(is_public) WHERE is_public = TRUE;

COMMENT ON TABLE reports IS 'Saved report configurations and schedules';

item_view_settings (user_preferences)

-- Personalized view preferences for inventory screens
CREATE TABLE item_view_settings (
    id SERIAL PRIMARY KEY,
    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 TIMESTAMP DEFAULT NOW(),

    CONSTRAINT item_view_settings_user_unique UNIQUE (user_id),
    CONSTRAINT item_view_settings_type_check CHECK (view_type IN ('list', 'grid', 'compact'))
);

COMMENT ON TABLE item_view_settings IS 'User-specific inventory view preferences';

Domain 8: Multi-tenant (Shared Schema)

See Chapter 12 for complete shared schema tables: tenants, tenant_subscriptions, tenant_modules


Domain 9: Authentication & Authorization

roles (Tenant Schema)

-- Role definitions per tenant
CREATE TABLE roles (
    id SERIAL PRIMARY KEY,
    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 TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT roles_name_unique UNIQUE (name)
);

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 SERIAL PRIMARY KEY,
    role_id INT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    permission VARCHAR(100) NOT NULL,
    granted BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT role_permissions_unique UNIQUE (role_id, permission)
);

CREATE INDEX idx_role_permissions_role ON role_permissions(role_id);
CREATE INDEX idx_role_permissions_permission ON role_permissions(permission);

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 SERIAL PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
    role_id INT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
    employee_id VARCHAR(20),
    pin_hash VARCHAR(255),
    hourly_rate DECIMAL(8,2),
    commission_rate DECIMAL(5,4),
    default_location_id INT REFERENCES locations(id) ON DELETE SET NULL,
    is_active BOOLEAN DEFAULT TRUE,
    hired_at DATE,
    terminated_at DATE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT tenant_users_user_unique UNIQUE (user_id),
    CONSTRAINT tenant_users_employee_unique UNIQUE (employee_id)
);

CREATE INDEX idx_tenant_users_role ON tenant_users(role_id);
CREATE INDEX idx_tenant_users_location ON tenant_users(default_location_id);
CREATE INDEX idx_tenant_users_active ON tenant_users(is_active) WHERE is_active = TRUE;

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,
    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 TIMESTAMP DEFAULT NOW(),

    CONSTRAINT tenant_settings_key_unique UNIQUE (category, key),
    CONSTRAINT tenant_settings_type_check CHECK (value_type IN ('string', 'number', 'boolean', 'json'))
);

CREATE INDEX idx_tenant_settings_category ON tenant_settings(category);

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 10: Offline Sync Infrastructure

devices

-- POS terminals and device registration
CREATE TABLE devices (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    device_type VARCHAR(30) NOT NULL,
    hardware_id VARCHAR(255) NOT NULL,
    location_id INT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
    cash_drawer_id INT REFERENCES cash_drawers(id) ON DELETE SET NULL,
    payment_terminal_id INT REFERENCES payment_terminals(id) ON DELETE SET NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    last_sync_at TIMESTAMP,
    last_seen_at TIMESTAMP,
    app_version VARCHAR(20),
    os_version VARCHAR(50),
    ip_address INET,
    push_token VARCHAR(500),
    settings JSONB,
    registered_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT devices_hardware_unique UNIQUE (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_location ON devices(location_id);
CREATE INDEX idx_devices_status ON devices(status);
CREATE INDEX idx_devices_last_seen ON devices(last_seen_at);

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,
    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,
    causality_version BIGINT NOT NULL DEFAULT 0,
    priority INT DEFAULT 5,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    attempts INT DEFAULT 0,
    error_message TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    processed_at TIMESTAMP,

    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_device ON sync_queue(device_id, sequence_number);
CREATE INDEX idx_sync_queue_status ON sync_queue(status, priority, created_at);
CREATE INDEX idx_sync_queue_entity ON sync_queue(entity_type, entity_id);
CREATE INDEX idx_sync_queue_pending ON sync_queue(device_id, processed_at) WHERE processed_at IS NULL;

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';
COMMENT ON COLUMN sync_queue.causality_version IS 'Lamport timestamp for event ordering';

sync_conflicts

-- Conflict types enum
CREATE TYPE conflict_type_enum AS ENUM (
    'update_update',
    'update_delete',
    'delete_update',
    'version_mismatch',
    'schema_change'
);

CREATE TYPE resolution_strategy_enum AS ENUM (
    'keep_local',
    'keep_server',
    'merge',
    'ignore',
    'auto_local',
    'auto_server'
);

-- Conflict tracking requiring resolution
CREATE TABLE sync_conflicts (
    id SERIAL PRIMARY KEY,
    sync_queue_id BIGINT NOT NULL REFERENCES sync_queue(id) ON DELETE CASCADE,
    entity_type VARCHAR(50) NOT NULL,
    entity_id VARCHAR(100) NOT NULL,
    local_data JSONB NOT NULL,
    server_data JSONB NOT NULL,
    conflict_type conflict_type_enum NOT NULL,
    resolution resolution_strategy_enum,
    resolution_data JSONB,
    resolution_notes TEXT,
    resolved_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    resolved_at TIMESTAMP,
    auto_resolved BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_sync_conflicts_entity ON sync_conflicts(entity_type, entity_id);
CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(created_at) WHERE resolved_at IS NULL;
CREATE INDEX idx_sync_conflicts_type ON sync_conflicts(conflict_type);

COMMENT ON TABLE sync_conflicts IS 'Sync conflicts requiring manual or automatic resolution';
COMMENT ON COLUMN sync_conflicts.auto_resolved IS 'TRUE if resolved by policy without human intervention';

sync_checkpoints

-- Sync progress tracking per device
CREATE TABLE sync_checkpoints (
    id SERIAL PRIMARY KEY,
    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 TIMESTAMP NOT NULL,
    last_sequence BIGINT NOT NULL,
    last_server_timestamp TIMESTAMP,
    records_synced INT DEFAULT 0,
    error_count INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP 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_device ON sync_checkpoints(device_id);

COMMENT ON TABLE sync_checkpoints IS 'Tracks sync progress for incremental synchronization';

Domain 11: Cash Drawer Operations

shifts

-- Shift lifecycle management
CREATE TABLE shifts (
    id SERIAL PRIMARY KEY,
    shift_number VARCHAR(20) NOT NULL,
    location_id INT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
    cash_drawer_id INT 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 TIMESTAMP NOT NULL DEFAULT NOW(),
    closed_at TIMESTAMP,
    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 TIMESTAMP DEFAULT NOW(),

    CONSTRAINT shifts_number_unique UNIQUE (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_location ON shifts(location_id, opened_at DESC);
CREATE INDEX idx_shifts_drawer_open ON shifts(cash_drawer_id, status) WHERE status = 'open';
CREATE INDEX idx_shifts_date ON shifts(opened_at DESC);

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,
    name VARCHAR(50) NOT NULL,
    drawer_number VARCHAR(20) NOT NULL,
    location_id INT 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 TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT cash_drawers_number_unique UNIQUE (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_location ON cash_drawers(location_id);
CREATE INDEX idx_cash_drawers_status ON cash_drawers(status, location_id);

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,
    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 TIMESTAMP 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_shift ON cash_counts(shift_id, count_type);
CREATE INDEX idx_cash_counts_timestamp ON cash_counts(count_timestamp DESC);

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,
    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 TIMESTAMP 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_shift ON cash_movements(shift_id);
CREATE INDEX idx_cash_movements_drawer ON cash_movements(cash_drawer_id, created_at DESC);
CREATE INDEX idx_cash_movements_type ON cash_movements(movement_type);
CREATE INDEX idx_cash_movements_reference ON cash_movements(reference_type, reference_id)
    WHERE reference_type IS NOT NULL;

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,
    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 TIMESTAMP NOT NULL DEFAULT NOW(),
    verified_at TIMESTAMP,

    CONSTRAINT cash_drops_number_unique UNIQUE (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_shift ON cash_drops(shift_id);
CREATE INDEX idx_cash_drops_status ON cash_drops(status) WHERE status = 'pending';

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,
    location_id INT 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 TIMESTAMP NOT NULL DEFAULT NOW(),
    deposited_at TIMESTAMP,

    CONSTRAINT cash_pickups_number_unique UNIQUE (pickup_number),
    CONSTRAINT cash_pickups_status_check CHECK (status IN (
        'picked_up', 'in_transit', 'deposited', 'variance'
    ))
);

CREATE INDEX idx_cash_pickups_location ON cash_pickups(location_id, pickup_date DESC);
CREATE INDEX idx_cash_pickups_status ON cash_pickups(status);

COMMENT ON TABLE cash_pickups IS 'Armored car pickup and bank deposit tracking';

Domain 12: Payment Processing

payment_terminals

-- Payment device registration
CREATE TABLE payment_terminals (
    id SERIAL PRIMARY KEY,
    terminal_id VARCHAR(50) NOT NULL,
    name VARCHAR(100) NOT NULL,
    location_id INT 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 TIMESTAMP,
    last_batch_at TIMESTAMP,
    configuration JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT payment_terminals_id_unique UNIQUE (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_location ON payment_terminals(location_id);
CREATE INDEX idx_payment_terminals_status ON payment_terminals(status);
CREATE INDEX idx_payment_terminals_device ON payment_terminals(device_id) WHERE device_id IS NOT NULL;

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,
    order_id INT NOT NULL REFERENCES orders(id) ON DELETE RESTRICT,
    terminal_id INT 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 TIMESTAMP DEFAULT NOW(),
    processed_at TIMESTAMP,

    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_order ON payment_attempts(order_id);
CREATE INDEX idx_payment_attempts_status ON payment_attempts(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_date ON payment_attempts(created_at DESC);

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,
    batch_number VARCHAR(50) NOT NULL,
    location_id INT 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 TIMESTAMP NOT NULL DEFAULT NOW(),
    submitted_at TIMESTAMP,
    settled_at TIMESTAMP,
    deposit_reference VARCHAR(100),
    notes TEXT,

    CONSTRAINT payment_batches_number_unique UNIQUE (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_location ON payment_batches(location_id, batch_date DESC);
CREATE INDEX idx_payment_batches_status ON payment_batches(status) WHERE status IN ('open', 'pending');
CREATE INDEX idx_payment_batches_date ON payment_batches(batch_date DESC);

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,
    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 TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT payment_recon_status_check CHECK (status IN (
        'pending', 'matched', 'variance', 'resolved'
    ))
);

CREATE INDEX idx_payment_recon_batch ON payment_reconciliation(batch_id);
CREATE INDEX idx_payment_recon_date ON payment_reconciliation(reconciliation_date DESC);
CREATE INDEX idx_payment_recon_status ON payment_reconciliation(status)
    WHERE status IN ('pending', 'variance');

COMMENT ON TABLE payment_reconciliation IS 'Payment reconciliation with variance tracking';

Domain 13: RFID Module (Optional)

taxes

-- Tax rate definitions
CREATE TABLE taxes (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    code VARCHAR(20) NOT NULL,
    rate DECIMAL(5,4) NOT NULL,
    is_compound BOOLEAN DEFAULT FALSE,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT taxes_name_unique UNIQUE (name),
    CONSTRAINT taxes_code_unique UNIQUE (code),
    CONSTRAINT taxes_rate_check CHECK (rate >= 0 AND rate <= 1)
);

COMMENT ON TABLE taxes IS 'Tax rate definitions (rate as decimal: 0.0825 = 8.25%)';

location_tax

-- Junction: taxes to locations with date ranges
CREATE TABLE location_tax (
    id SERIAL PRIMARY KEY,
    location_id INT NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
    tax_id INT NOT NULL REFERENCES taxes(id) ON DELETE CASCADE,
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP,

    CONSTRAINT location_tax_dates CHECK (effective_to IS NULL OR effective_to > effective_from)
);

CREATE INDEX idx_location_tax_effective ON location_tax(location_id, effective_from, effective_to);

COMMENT ON TABLE location_tax IS 'Assigns tax rates to locations with effective date ranges';

rfid_config

-- Tenant RFID configuration
CREATE TABLE rfid_config (
    id SERIAL PRIMARY KEY,
    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,
    last_serial_number BIGINT NOT NULL DEFAULT 0,
    default_template_id UUID,
    default_printer_id UUID,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

COMMENT ON TABLE rfid_config IS 'Tenant RFID configuration for EPC encoding';
COMMENT ON COLUMN rfid_config.last_serial_number IS 'Auto-incrementing serial (never decrements)';

rfid_printers

-- Registered RFID printers
CREATE TABLE rfid_printers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    location_id INT 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 TIMESTAMP,
    error_message TEXT,
    is_default BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP 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;

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(),
    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 TIMESTAMP,
    completed_at TIMESTAMP,
    created_at TIMESTAMP 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);

COMMENT ON TABLE rfid_print_jobs IS 'RFID tag print job queue with progress tracking';

rfid_tags

-- Individual RFID tags linked to variants
CREATE TABLE rfid_tags (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    epc VARCHAR(24) NOT NULL,
    variant_id INT 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 TIMESTAMP,
    printed_by UUID REFERENCES shared.users(id) ON DELETE SET NULL,
    current_location_id INT NOT NULL REFERENCES locations(id) ON DELETE RESTRICT,
    last_scanned_at TIMESTAMP,
    last_scanned_session_id UUID,
    sold_at TIMESTAMP,
    sold_order_id INT REFERENCES orders(id) ON DELETE SET NULL,
    transferred_at TIMESTAMP,
    notes TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT rfid_tags_epc_unique UNIQUE (epc),
    CONSTRAINT rfid_tags_status_check CHECK (status IN (
        'active', 'sold', 'transferred', 'lost', 'void', 'returned'
    ))
);

CREATE INDEX idx_rfid_tags_variant ON rfid_tags(variant_id);
CREATE INDEX idx_rfid_tags_location ON rfid_tags(current_location_id, status);
CREATE INDEX idx_rfid_tags_serial ON rfid_tags(serial_number);
CREATE INDEX idx_rfid_tags_status ON rfid_tags(status) WHERE status = 'active';
CREATE INDEX idx_rfid_tags_scan ON rfid_tags(last_scanned_at DESC) WHERE last_scanned_at IS NOT NULL;

COMMENT ON TABLE rfid_tags IS 'Individual RFID tags with lifecycle tracking';
COMMENT ON COLUMN rfid_tags.epc IS 'SGTIN-96 Electronic Product Code (hex)';

rfid_scan_sessions

-- Inventory scan sessions
CREATE TABLE rfid_scan_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    session_number VARCHAR(20) NOT NULL,
    location_id INT 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 TIMESTAMP NOT NULL DEFAULT NOW(),
    completed_at TIMESTAMP,
    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 TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT rfid_sessions_number_unique UNIQUE (session_number),
    CONSTRAINT rfid_sessions_type_check CHECK (session_type IN (
        'full_inventory', 'cycle_count', 'spot_check', 'find_item', 'receiving'
    )),
    CONSTRAINT rfid_sessions_status_check CHECK (status IN (
        'in_progress', 'completed', 'cancelled', 'uploaded'
    ))
);

CREATE INDEX idx_rfid_sessions_location ON rfid_scan_sessions(location_id, started_at DESC);
CREATE INDEX idx_rfid_sessions_status ON rfid_scan_sessions(status) WHERE status = 'in_progress';

COMMENT ON TABLE rfid_scan_sessions IS 'RFID inventory scan sessions with variance calculation';

rfid_scan_events

-- Individual tag reads during scan sessions
CREATE TABLE rfid_scan_events (
    id BIGSERIAL PRIMARY KEY,
    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 TIMESTAMP NOT NULL,
    last_seen_at TIMESTAMP NOT NULL
);

-- Indexes (BRIN for high-volume time-series)
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;

COMMENT ON TABLE rfid_scan_events IS 'Individual RFID tag reads during scan sessions';
COMMENT ON COLUMN rfid_scan_events.rssi IS 'Signal strength (-127 to 0 dBm)';

13.2 Table Count Summary

DomainTablesSchema Location
1-2. Catalog12tenant
3. Inventory3tenant
4. Sales3tenant
5. Customer Loyalty4tenant
6-7. Returns & Reporting3tenant
8. Multi-tenant3shared
9. Auth & Authorization4tenant
10. Offline Sync4tenant
11. Cash Drawer6tenant
12. Payment Processing4tenant
13. RFID + Tax9tenant
TOTAL51

Next Chapter: Chapter 14: Indexes & Performance - Index strategy and query optimization.


Chapter 13 | Entity Specifications | POS Platform Blueprint v1.0.0

Chapter 14: Indexes & Performance

Query Optimization and Index Strategy


14.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.

Performance Targets

OperationTargetCritical Threshold
Product lookup by SKU< 5ms20ms
Product lookup by barcode< 5ms20ms
Inventory check (single location)< 10ms50ms
Order creation< 50ms200ms
Customer search by name< 20ms100ms
Daily sales report< 500ms2s
Inventory count by location< 100ms500ms

14.2 Index Types and When to Use Them

B-Tree Indexes (Default)

Best for: Equality comparisons, range queries, sorting

-- Equality lookup (most common)
CREATE INDEX idx_products_sku ON products(sku);

-- Range query support
CREATE INDEX idx_orders_date ON orders(created_at);

-- Composite for multiple conditions
CREATE INDEX idx_inventory_lookup ON inventory_levels(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);

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)
CREATE INDEX idx_products_active ON products(name)
    WHERE is_active = TRUE AND deleted_at IS NULL;

-- Only pending sync items
CREATE INDEX idx_sync_queue_pending ON sync_queue(device_id, priority, created_at)
    WHERE status = 'pending';

-- Only open shifts
CREATE INDEX idx_shifts_open ON shifts(location_id, cash_drawer_id)
    WHERE status = 'open';

-- Only unresolved conflicts
CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(entity_type, created_at)
    WHERE resolved_at IS NULL;

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(sku)
    INCLUDE (name, base_price, is_active)
    WHERE deleted_at IS NULL;

-- Inventory lookup includes quantity
CREATE INDEX idx_inventory_covering ON inventory_levels(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(loyalty_number)
    INCLUDE (first_name, last_name, loyalty_points)
    WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;

14.3 Index Strategy by Domain

Domain 1-2: Catalog (Products, Categories)

-- ============================================================
-- PRODUCT LOOKUP INDEXES
-- ============================================================

-- Primary product lookup by SKU (unique, filtered for soft delete)
CREATE UNIQUE INDEX idx_products_sku ON products(sku)
    WHERE deleted_at IS NULL;

-- Product search by name (full-text)
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_brand ON products(brand_id)
    WHERE is_active = TRUE AND deleted_at IS NULL;

-- Filter by product group (department browsing)
CREATE INDEX idx_products_group ON products(product_group_id)
    WHERE is_active = TRUE AND deleted_at IS NULL;

-- ============================================================
-- VARIANT LOOKUP INDEXES
-- ============================================================

-- Variant lookup by SKU (unique)
CREATE UNIQUE INDEX idx_variants_sku ON variants(sku)
    WHERE deleted_at IS NULL;

-- POS barcode scan (unique, critical for checkout speed)
CREATE UNIQUE INDEX idx_variants_barcode ON variants(barcode)
    WHERE barcode IS NOT NULL AND deleted_at IS NULL;

-- Product's variants list
CREATE INDEX idx_variants_product ON variants(product_id, size, color)
    WHERE is_active = TRUE AND deleted_at IS NULL;

-- ============================================================
-- CATEGORY NAVIGATION INDEXES
-- ============================================================

-- Category hierarchy traversal
CREATE INDEX idx_categories_parent ON categories(parent_id)
    WHERE is_active = TRUE;

-- Category sort order for UI
CREATE INDEX idx_categories_display ON categories(display_order, name)
    WHERE is_active = TRUE;

-- ============================================================
-- COLLECTION & TAG INDEXES
-- ============================================================

-- Active collections (marketing pages)
CREATE INDEX idx_collections_active ON collections(is_active, start_date, end_date)
    WHERE is_active = TRUE;

-- Products in collection
CREATE INDEX idx_product_collection_coll ON product_collection(collection_id, display_order);

-- Products with tag
CREATE INDEX idx_product_tag_tag ON product_tag(tag_id);

Domain 3: Inventory

-- ============================================================
-- INVENTORY LEVEL INDEXES
-- ============================================================

-- Primary lookup: variant + location (covered)
CREATE UNIQUE INDEX idx_inventory_lookup ON inventory_levels(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_by_location ON inventory_levels(location_id, variant_id)
    WHERE deleted_at IS NULL;

-- Low stock alerts (filtered, ordered by severity)
CREATE INDEX idx_inventory_low_stock ON inventory_levels(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_out_of_stock ON inventory_levels(location_id)
    WHERE quantity_on_hand <= 0
      AND deleted_at IS NULL;

-- ============================================================
-- INVENTORY TRANSACTION INDEXES (BRIN + B-Tree)
-- ============================================================

-- Time-series primary index (BRIN for efficiency)
CREATE INDEX idx_inventory_trans_date ON inventory_transactions
    USING BRIN (created_at);

-- Variant history (for product page history)
CREATE INDEX idx_inventory_trans_variant ON inventory_transactions(variant_id, created_at DESC);

-- Location activity (for location reports)
CREATE INDEX idx_inventory_trans_location ON inventory_transactions(location_id, created_at DESC);

-- Reference document lookup
CREATE INDEX idx_inventory_trans_ref ON inventory_transactions(reference_type, reference_id)
    WHERE reference_type IS NOT NULL;

-- Transaction type filtering
CREATE INDEX idx_inventory_trans_type ON inventory_transactions(transaction_type, created_at DESC);

Domain 4: Sales (Orders, Customers)

-- ============================================================
-- ORDER INDEXES
-- ============================================================

-- Order number lookup (receipt reprint)
CREATE UNIQUE INDEX idx_orders_number ON orders(order_number);

-- Orders by date (primary reporting index)
CREATE INDEX idx_orders_date ON orders(created_at DESC);

-- Orders by location + date (store reports)
CREATE INDEX idx_orders_location_date ON orders(location_id, created_at DESC);

-- Customer order history
CREATE INDEX idx_orders_customer ON orders(customer_id, created_at DESC)
    WHERE customer_id IS NOT NULL;

-- Shift reconciliation
CREATE INDEX idx_orders_shift ON orders(shift_id, status)
    WHERE shift_id IS NOT NULL;

-- Order status filtering
CREATE INDEX idx_orders_status ON orders(status, created_at DESC)
    WHERE status != 'completed';  -- Completed is default, filter for exceptions

-- ============================================================
-- ORDER ITEMS INDEXES
-- ============================================================

-- Line items for order
CREATE INDEX idx_order_items_order ON order_items(order_id);

-- Sales by variant (product performance)
CREATE INDEX idx_order_items_variant ON order_items(variant_id, created_at DESC);

-- Returns tracking
CREATE INDEX idx_order_items_returned ON order_items(order_id)
    WHERE is_returned = TRUE;

-- ============================================================
-- CUSTOMER INDEXES
-- ============================================================

-- Loyalty card lookup (POS checkout)
CREATE UNIQUE INDEX idx_customers_loyalty ON customers(loyalty_number)
    WHERE loyalty_number IS NOT NULL AND deleted_at IS NULL;

-- Email lookup (unique)
CREATE UNIQUE INDEX idx_customers_email ON customers(email)
    WHERE email IS NOT NULL AND deleted_at IS NULL;

-- Phone lookup
CREATE INDEX idx_customers_phone ON customers(phone)
    WHERE phone IS NOT NULL AND deleted_at IS NULL;

-- Name search (partial match supported)
CREATE INDEX idx_customers_name ON customers(last_name, first_name)
    WHERE deleted_at IS NULL;

-- Customer value ranking
CREATE INDEX idx_customers_value ON customers(total_spent DESC)
    WHERE deleted_at IS NULL;

-- Recent visitors
CREATE INDEX idx_customers_last_visit ON customers(last_visit DESC)
    WHERE deleted_at IS NULL;

Domain 10: Offline Sync

-- ============================================================
-- SYNC QUEUE INDEXES
-- ============================================================

-- Idempotency check (unique, critical for exactly-once processing)
CREATE UNIQUE INDEX idx_sync_queue_idempotency ON sync_queue(idempotency_key);

-- Device sync sequence (primary sync ordering)
CREATE INDEX idx_sync_queue_device_seq ON sync_queue(device_id, sequence_number);

-- Pending queue (worker polling)
CREATE INDEX idx_sync_queue_pending ON sync_queue(status, priority, created_at)
    WHERE status = 'pending';

-- Failed items for retry
CREATE INDEX idx_sync_queue_failed ON sync_queue(status, attempts, created_at)
    WHERE status = 'failed' AND attempts < 5;

-- Entity lookup for conflict detection
CREATE INDEX idx_sync_queue_entity ON sync_queue(entity_type, entity_id);

-- ============================================================
-- SYNC CONFLICT INDEXES
-- ============================================================

-- Unresolved conflicts (admin dashboard)
CREATE INDEX idx_sync_conflicts_unresolved ON sync_conflicts(created_at DESC)
    WHERE resolved_at IS NULL;

-- Conflicts by entity
CREATE INDEX idx_sync_conflicts_entity ON sync_conflicts(entity_type, entity_id);

-- Conflict type distribution
CREATE INDEX idx_sync_conflicts_type ON sync_conflicts(conflict_type)
    WHERE resolved_at IS NULL;

-- ============================================================
-- DEVICE INDEXES
-- ============================================================

-- Hardware ID (unique, device registration)
CREATE UNIQUE INDEX idx_devices_hardware ON devices(hardware_id);

-- Devices by location
CREATE INDEX idx_devices_location ON devices(location_id, status);

-- Stale devices (monitoring)
CREATE INDEX idx_devices_last_seen ON devices(last_seen_at)
    WHERE status = 'active';

Domain 11-12: Cash & Payment

-- ============================================================
-- SHIFT INDEXES
-- ============================================================

-- Shift number lookup
CREATE UNIQUE INDEX idx_shifts_number ON shifts(shift_number);

-- Open shifts by drawer (prevent duplicates)
CREATE UNIQUE INDEX idx_shifts_drawer_open ON shifts(cash_drawer_id)
    WHERE status = 'open';

-- Shifts by location + date (reports)
CREATE INDEX idx_shifts_location_date ON shifts(location_id, opened_at DESC);

-- Unreconciled shifts
CREATE INDEX idx_shifts_unreconciled ON shifts(location_id, opened_at)
    WHERE status IN ('closed', 'closing');

-- ============================================================
-- CASH MOVEMENT INDEXES
-- ============================================================

-- Movements by shift (reconciliation)
CREATE INDEX idx_cash_movements_shift ON cash_movements(shift_id, created_at);

-- Movements by type (auditing)
CREATE INDEX idx_cash_movements_type ON cash_movements(movement_type, created_at DESC);

-- Reference lookup
CREATE INDEX idx_cash_movements_ref ON cash_movements(reference_type, reference_id)
    WHERE reference_type IS NOT NULL;

-- ============================================================
-- PAYMENT ATTEMPT INDEXES
-- ============================================================

-- Payments by order
CREATE INDEX idx_payment_attempts_order ON payment_attempts(order_id);

-- Payment status monitoring
CREATE INDEX idx_payment_attempts_status ON payment_attempts(status, created_at DESC);

-- Processor transaction lookup (chargebacks)
CREATE INDEX idx_payment_attempts_processor ON payment_attempts(processor_transaction_id)
    WHERE processor_transaction_id IS NOT NULL;

-- Daily payment activity
CREATE INDEX idx_payment_attempts_date ON payment_attempts(created_at DESC);

-- ============================================================
-- PAYMENT BATCH INDEXES
-- ============================================================

-- Batch number lookup
CREATE UNIQUE INDEX idx_payment_batches_number ON payment_batches(batch_number);

-- Open batches (auto-close job)
CREATE INDEX idx_payment_batches_open ON payment_batches(location_id, batch_date)
    WHERE status = 'open';

-- Pending settlement
CREATE INDEX idx_payment_batches_pending ON payment_batches(submitted_at)
    WHERE status = 'pending';

Domain 13: RFID Module

-- ============================================================
-- RFID TAG INDEXES
-- ============================================================

-- EPC lookup (unique, critical for scan performance)
CREATE UNIQUE INDEX idx_rfid_tags_epc ON rfid_tags(epc);

-- Tags by variant (product inventory)
CREATE INDEX idx_rfid_tags_variant ON rfid_tags(variant_id, status)
    WHERE status = 'active';

-- Tags by location (location inventory)
CREATE INDEX idx_rfid_tags_location ON rfid_tags(current_location_id, status)
    WHERE status = 'active';

-- Serial number sequence
CREATE INDEX idx_rfid_tags_serial ON rfid_tags(serial_number);

-- Recently scanned (for duplicate detection)
CREATE INDEX idx_rfid_tags_scanned ON rfid_tags(last_scanned_at DESC)
    WHERE last_scanned_at IS NOT NULL;

-- ============================================================
-- RFID SCAN EVENT INDEXES (High-Volume)
-- ============================================================

-- Session events (BRIN for time-series)
CREATE INDEX idx_rfid_events_session ON rfid_scan_events(session_id);

-- EPC lookup (match to tag)
CREATE INDEX idx_rfid_events_epc ON rfid_scan_events(epc);

-- Unknown tags (for investigation)
CREATE INDEX idx_rfid_events_unknown ON rfid_scan_events(session_id)
    WHERE rfid_tag_id IS NULL;

-- Time-based partition key (if partitioning)
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(status, priority, created_at)
    WHERE status IN ('queued', 'printing');

-- Jobs by printer
CREATE INDEX idx_rfid_jobs_printer ON rfid_print_jobs(printer_id, created_at DESC);

14.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(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(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(
    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(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 = 'sold') AS sold_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;

14.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 LIKE 'tenant_%'
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 LIKE 'tenant_%'
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;

14.6 Index Maintenance

Routine Maintenance Commands

-- Reindex a specific table
REINDEX TABLE tenant_0001.products;

-- Reindex entire schema
REINDEX SCHEMA tenant_0001;

-- Concurrent reindex (no lock, PostgreSQL 12+)
REINDEX TABLE CONCURRENTLY tenant_0001.products;

-- Vacuum and analyze (update statistics)
VACUUM ANALYZE tenant_0001.products;

-- Full vacuum (reclaim space, requires exclusive lock)
VACUUM FULL tenant_0001.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 tenant_0001.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);

14.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

  1. Identify slow query via pg_stat_statements or application logs
  2. Run EXPLAIN ANALYZE to see execution plan
  3. Check for sequential scans on large tables
  4. Identify missing indexes or suboptimal index choice
  5. Create index (CONCURRENTLY for production)
  6. Verify improvement with EXPLAIN ANALYZE
  7. Monitor for regression

14.8 Quick Reference: Common Index Patterns

PatternIndex TypeExample
Unique lookupB-tree UNIQUECREATE UNIQUE INDEX ... ON orders(order_number)
Foreign keyB-treeCREATE INDEX ... ON order_items(order_id)
Range queryB-treeCREATE INDEX ... ON orders(created_at)
Time-seriesBRINCREATE INDEX ... USING BRIN (created_at)
Full-textGINCREATE INDEX ... USING GIN (to_tsvector(...))
JSONBGINCREATE INDEX ... USING GIN (settings)
Soft deletePartial B-treeCREATE INDEX ... WHERE deleted_at IS NULL
Status filterPartial B-treeCREATE INDEX ... WHERE status = 'pending'
CoveringINCLUDECREATE INDEX ...(sku) INCLUDE (name, price)

End of Part III: Database

Next: Part IV: Backend - API design and service layer implementation.


Chapter 14 | Indexes & Performance | POS Platform Blueprint v1.0.0

Chapter 15: API Design

RESTful API Architecture for Multi-Tenant POS

This chapter provides the complete API specification for the POS Platform, including all endpoints, request/response formats, and real-time communication patterns.


15.1 Base URL Structure

Tenant-Aware URL Pattern

https://{tenant}.pos-platform.com/api/v1/{resource}

Examples:

https://nexus.pos-platform.com/api/v1/items
https://acme-retail.pos-platform.com/api/v1/sales
https://fashion-outlet.pos-platform.com/api/v1/inventory

Alternative: Header-Based Tenancy

For single-domain deployments:

https://api.pos-platform.com/api/v1/{resource}
X-Tenant-Id: nexus

15.2 API Versioning Strategy

/api/v1/...  ← Current (stable)
/api/v2/...  ← Future (when breaking changes needed)

Version Header:

X-API-Version: 2025-01-15

15.3 Complete Endpoint Reference

15.3.1 Catalog Domain

Items (Products)

# List all items (paginated)
GET /api/v1/items?page=1&pageSize=50&category=apparel&active=true

# Get single item
GET /api/v1/items/{id}

# Get item by SKU
GET /api/v1/items/by-sku/{sku}

# Get item by barcode
GET /api/v1/items/by-barcode/{barcode}

# Create item
POST /api/v1/items

# Update item
PUT /api/v1/items/{id}

# Partial update
PATCH /api/v1/items/{id}

# Delete (soft delete)
DELETE /api/v1/items/{id}

# Bulk operations
POST /api/v1/items/bulk-create
PUT /api/v1/items/bulk-update
POST /api/v1/items/bulk-import

Create Item Request:

{
  "sku": "NXJ-1001-BLK-M",
  "barcode": "0123456789012",
  "name": "Classic Oxford Shirt",
  "description": "Premium cotton oxford shirt",
  "categoryId": "cat_apparel_shirts",
  "vendorId": "vendor_acme",
  "cost": 24.99,
  "price": 59.99,
  "taxable": true,
  "trackInventory": true,
  "reorderPoint": 10,
  "reorderQuantity": 50,
  "attributes": {
    "color": "Black",
    "size": "Medium",
    "material": "100% Cotton"
  }
}

Item Response:

{
  "id": "item_01HQWXYZ123",
  "tenantId": "tenant_nexus",
  "sku": "NXJ-1001-BLK-M",
  "barcode": "0123456789012",
  "name": "Classic Oxford Shirt",
  "description": "Premium cotton oxford shirt",
  "categoryId": "cat_apparel_shirts",
  "categoryName": "Shirts",
  "vendorId": "vendor_acme",
  "vendorName": "ACME Apparel",
  "cost": 24.99,
  "price": 59.99,
  "taxable": true,
  "trackInventory": true,
  "isActive": true,
  "reorderPoint": 10,
  "reorderQuantity": 50,
  "totalQuantityOnHand": 145,
  "attributes": {
    "color": "Black",
    "size": "Medium",
    "material": "100% Cotton"
  },
  "inventoryByLocation": [
    { "locationId": "loc_hq", "locationName": "Warehouse", "quantity": 100 },
    { "locationId": "loc_gm", "locationName": "Greenbrier", "quantity": 25 },
    { "locationId": "loc_lm", "locationName": "Lynnhaven", "quantity": 20 }
  ],
  "createdAt": "2025-01-15T10:30:00Z",
  "updatedAt": "2025-01-20T14:22:00Z",
  "_links": {
    "self": "/api/v1/items/item_01HQWXYZ123",
    "category": "/api/v1/categories/cat_apparel_shirts",
    "vendor": "/api/v1/vendors/vendor_acme",
    "inventory": "/api/v1/inventory?itemId=item_01HQWXYZ123"
  }
}

Categories

GET /api/v1/categories                    # List all (hierarchical)
GET /api/v1/categories/{id}               # Get single
GET /api/v1/categories/{id}/items         # Items in category
POST /api/v1/categories                   # Create
PUT /api/v1/categories/{id}               # Update
DELETE /api/v1/categories/{id}            # Delete

Vendors

GET /api/v1/vendors                       # List all
GET /api/v1/vendors/{id}                  # Get single
GET /api/v1/vendors/{id}/items            # Items from vendor
POST /api/v1/vendors                      # Create
PUT /api/v1/vendors/{id}                  # Update
DELETE /api/v1/vendors/{id}               # Delete

15.3.2 Sales Domain

# List sales (paginated, filtered)
GET /api/v1/sales?page=1&pageSize=50&locationId=loc_gm&from=2025-01-01&to=2025-01-31

# Get single sale
GET /api/v1/sales/{id}

# Create sale (complete transaction)
POST /api/v1/sales

# Process return
POST /api/v1/sales/{id}/return

# Void transaction
POST /api/v1/sales/{id}/void

# Get receipt
GET /api/v1/sales/{id}/receipt

# Reprint receipt
POST /api/v1/sales/{id}/receipt/print

Create Sale Request:

{
  "locationId": "loc_gm",
  "registerId": "reg_gm_01",
  "employeeId": "emp_john",
  "customerId": "cust_jane",
  "lineItems": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantity": 2,
      "unitPrice": 59.99,
      "discountAmount": 0
    },
    {
      "itemId": "item_02ABCDEF456",
      "quantity": 1,
      "unitPrice": 29.99,
      "discountAmount": 5.00
    }
  ],
  "discounts": [
    {
      "type": "percentage",
      "value": 10,
      "reason": "Loyalty Member Discount"
    }
  ],
  "payments": [
    {
      "method": "credit_card",
      "amount": 135.42,
      "reference": "ch_3MqL0Z2eZvKYlo2C",
      "cardLast4": "4242",
      "cardBrand": "visa"
    }
  ],
  "taxAmount": 10.45,
  "notes": "Customer requested gift receipt"
}

Sale Response:

{
  "id": "sale_01HQWXYZ789",
  "receiptNumber": "GM-20250115-0042",
  "tenantId": "tenant_nexus",
  "locationId": "loc_gm",
  "locationName": "Greenbrier Mall",
  "registerId": "reg_gm_01",
  "employeeId": "emp_john",
  "employeeName": "John Smith",
  "customerId": "cust_jane",
  "customerName": "Jane Doe",
  "status": "completed",
  "subtotal": 144.97,
  "discountTotal": 19.50,
  "taxTotal": 10.45,
  "grandTotal": 135.92,
  "lineItems": [
    {
      "id": "li_001",
      "itemId": "item_01HQWXYZ123",
      "sku": "NXJ-1001-BLK-M",
      "name": "Classic Oxford Shirt",
      "quantity": 2,
      "unitPrice": 59.99,
      "extendedPrice": 119.98,
      "discountAmount": 0,
      "netPrice": 119.98
    }
  ],
  "payments": [
    {
      "id": "pmt_001",
      "method": "credit_card",
      "amount": 135.92,
      "status": "captured",
      "cardLast4": "4242",
      "cardBrand": "visa"
    }
  ],
  "createdAt": "2025-01-15T14:32:00Z",
  "_links": {
    "self": "/api/v1/sales/sale_01HQWXYZ789",
    "receipt": "/api/v1/sales/sale_01HQWXYZ789/receipt",
    "customer": "/api/v1/customers/cust_jane"
  }
}

Return Request:

{
  "lineItems": [
    {
      "originalLineItemId": "li_001",
      "quantity": 1,
      "reason": "wrong_size"
    }
  ],
  "refundMethod": "original_payment",
  "employeeId": "emp_john"
}

15.3.3 Inventory Domain

# Get inventory levels
GET /api/v1/inventory?locationId=loc_gm&itemId=item_01HQWXYZ123

# Get inventory for all locations
GET /api/v1/inventory/by-item/{itemId}

# Adjust inventory
POST /api/v1/inventory/adjust

# Transfer between locations
POST /api/v1/inventory/transfer

# Start inventory count
POST /api/v1/inventory/count

# Submit count results
PUT /api/v1/inventory/count/{countId}

# Finalize count
POST /api/v1/inventory/count/{countId}/finalize

# Get adjustment history
GET /api/v1/inventory/adjustments?itemId={itemId}&from={date}&to={date}

Adjust Inventory Request:

{
  "locationId": "loc_gm",
  "adjustments": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantityChange": -2,
      "reason": "damaged",
      "notes": "Water damage from roof leak"
    }
  ],
  "employeeId": "emp_manager"
}

Transfer Request:

{
  "fromLocationId": "loc_hq",
  "toLocationId": "loc_gm",
  "items": [
    {
      "itemId": "item_01HQWXYZ123",
      "quantity": 20
    },
    {
      "itemId": "item_02ABCDEF456",
      "quantity": 15
    }
  ],
  "notes": "Weekly replenishment",
  "employeeId": "emp_warehouse"
}

Transfer Response:

{
  "id": "transfer_01HQWXYZ",
  "transferNumber": "TRF-20250115-001",
  "status": "pending",
  "fromLocationId": "loc_hq",
  "fromLocationName": "Warehouse",
  "toLocationId": "loc_gm",
  "toLocationName": "Greenbrier Mall",
  "items": [
    {
      "itemId": "item_01HQWXYZ123",
      "sku": "NXJ-1001-BLK-M",
      "name": "Classic Oxford Shirt",
      "quantity": 20
    }
  ],
  "createdAt": "2025-01-15T09:00:00Z",
  "createdBy": "emp_warehouse"
}

15.3.4 Customers Domain

# Search customers
GET /api/v1/customers/search?q=jane&email=jane@example.com

# List customers (paginated)
GET /api/v1/customers?page=1&pageSize=50

# Get single customer
GET /api/v1/customers/{id}

# Create customer
POST /api/v1/customers

# Update customer
PUT /api/v1/customers/{id}

# Get customer purchase history
GET /api/v1/customers/{id}/purchases

# Get customer loyalty points
GET /api/v1/customers/{id}/loyalty

Customer Response:

{
  "id": "cust_jane",
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane.doe@example.com",
  "phone": "+1-555-123-4567",
  "loyaltyTier": "gold",
  "loyaltyPoints": 2450,
  "totalPurchases": 45,
  "totalSpent": 3245.67,
  "lastVisit": "2025-01-15T14:32:00Z",
  "preferredLocationId": "loc_gm",
  "marketingOptIn": true,
  "createdAt": "2024-03-15T10:00:00Z"
}

15.3.5 Employees Domain

# List employees
GET /api/v1/employees?locationId=loc_gm&active=true

# Get single employee
GET /api/v1/employees/{id}

# Clock in
POST /api/v1/employees/{id}/clock-in

# Clock out
POST /api/v1/employees/{id}/clock-out

# Get time entries
GET /api/v1/employees/{id}/time-entries?from=2025-01-01&to=2025-01-15

# Get sales performance
GET /api/v1/employees/{id}/performance?period=month

Clock-In Request:

{
  "locationId": "loc_gm",
  "pin": "1234",
  "registerId": "reg_gm_01"
}

Clock-In Response:

{
  "timeEntryId": "time_01HQWXYZ",
  "employeeId": "emp_john",
  "employeeName": "John Smith",
  "locationId": "loc_gm",
  "clockInTime": "2025-01-15T09:00:00Z",
  "status": "clocked_in"
}

15.3.6 Reports Domain

# Sales Summary
GET /api/v1/reports/sales-summary?from=2025-01-01&to=2025-01-31&locationId=loc_gm

# Inventory Value
GET /api/v1/reports/inventory-value?locationId=loc_gm

# Employee Performance
GET /api/v1/reports/employee-performance?from=2025-01-01&to=2025-01-31

# Category Sales
GET /api/v1/reports/category-sales?from=2025-01-01&to=2025-01-31

# Top Sellers
GET /api/v1/reports/top-sellers?limit=20&period=month

# Slow Movers
GET /api/v1/reports/slow-movers?daysWithoutSale=30

Sales Summary Response:

{
  "period": {
    "from": "2025-01-01T00:00:00Z",
    "to": "2025-01-31T23:59:59Z"
  },
  "summary": {
    "totalTransactions": 1250,
    "totalGrossSales": 89500.00,
    "totalDiscounts": 4250.00,
    "totalReturns": 1200.00,
    "totalNetSales": 84050.00,
    "totalTax": 6723.00,
    "averageTransactionValue": 67.24,
    "itemsSold": 3450
  },
  "byLocation": [
    {
      "locationId": "loc_gm",
      "locationName": "Greenbrier Mall",
      "transactions": 450,
      "netSales": 32500.00
    }
  ],
  "byPaymentMethod": [
    { "method": "credit_card", "amount": 65000.00, "count": 980 },
    { "method": "cash", "amount": 15000.00, "count": 220 },
    { "method": "gift_card", "amount": 4050.00, "count": 50 }
  ]
}

15.3.7 Promotions Domain (Learned from Retail Pro)

Competitive Insight: Retail Pro’s promotions engine is their crown jewel - offering minute-level scheduling, complex stacking rules, and customer-specific discounts. We implement similar capabilities.

# List active promotions
GET /api/v1/promotions?active=true&locationId=loc_gm

# Get single promotion
GET /api/v1/promotions/{id}

# Create promotion
POST /api/v1/promotions

# Update promotion
PUT /api/v1/promotions/{id}

# Delete promotion
DELETE /api/v1/promotions/{id}

# Check applicable promotions for cart
POST /api/v1/promotions/evaluate

# Get promotion usage history
GET /api/v1/promotions/{id}/usage?from=2025-01-01&to=2025-01-31

Create Promotion Request:

{
  "name": "Holiday Weekend Sale",
  "code": "HOLIDAY25",
  "description": "25% off all apparel",
  "type": "percentage",
  "value": 25,
  "appliesTo": "category",
  "targetIds": ["cat_apparel"],
  "conditions": {
    "minPurchaseAmount": 50.00,
    "minQuantity": 2,
    "customerTypes": ["gold", "platinum"],
    "excludedItems": ["item_clearance_001"],
    "excludedCategories": ["cat_gift_cards"]
  },
  "schedule": {
    "startAt": "2025-12-20T00:00:00Z",
    "endAt": "2025-12-26T23:59:59Z",
    "activeDays": ["friday", "saturday", "sunday"],
    "activeHours": {
      "start": "10:00",
      "end": "21:00"
    }
  },
  "limits": {
    "maxUsesTotal": 1000,
    "maxUsesPerCustomer": 3,
    "maxUsesPerDay": 500
  },
  "stacking": {
    "stackable": false,
    "priority": 10,
    "excludeWithCodes": ["CLEARANCE", "EMPLOYEE"]
  },
  "locationIds": ["loc_gm", "loc_hm", "loc_lm"]
}

Promotion Response:

{
  "id": "promo_holiday25",
  "tenantId": "tenant_nexus",
  "name": "Holiday Weekend Sale",
  "code": "HOLIDAY25",
  "description": "25% off all apparel",
  "type": "percentage",
  "value": 25,
  "appliesTo": "category",
  "targetIds": ["cat_apparel"],
  "conditions": {
    "minPurchaseAmount": 50.00,
    "minQuantity": 2,
    "customerTypes": ["gold", "platinum"],
    "excludedItems": ["item_clearance_001"],
    "excludedCategories": ["cat_gift_cards"]
  },
  "schedule": {
    "startAt": "2025-12-20T00:00:00Z",
    "endAt": "2025-12-26T23:59:59Z",
    "activeDays": ["friday", "saturday", "sunday"],
    "activeHours": { "start": "10:00", "end": "21:00" }
  },
  "limits": {
    "maxUsesTotal": 1000,
    "maxUsesPerCustomer": 3,
    "maxUsesPerDay": 500,
    "currentUsesTotal": 245,
    "currentUsesToday": 32
  },
  "stacking": {
    "stackable": false,
    "priority": 10,
    "excludeWithCodes": ["CLEARANCE", "EMPLOYEE"]
  },
  "locationIds": ["loc_gm", "loc_hm", "loc_lm"],
  "status": "active",
  "createdAt": "2025-12-01T10:00:00Z",
  "updatedAt": "2025-12-15T14:30:00Z"
}

Evaluate Promotions Request (at checkout):

{
  "locationId": "loc_gm",
  "customerId": "cust_jane",
  "lineItems": [
    { "itemId": "item_shirt_001", "categoryId": "cat_apparel", "quantity": 2, "unitPrice": 45.00 },
    { "itemId": "item_pants_002", "categoryId": "cat_apparel", "quantity": 1, "unitPrice": 65.00 }
  ],
  "subtotal": 155.00,
  "appliedCodes": ["HOLIDAY25"]
}

Evaluate Promotions Response:

{
  "applicablePromotions": [
    {
      "promotionId": "promo_holiday25",
      "code": "HOLIDAY25",
      "name": "Holiday Weekend Sale",
      "discountAmount": 38.75,
      "appliedToItems": ["item_shirt_001", "item_pants_002"],
      "message": "25% off apparel applied!"
    }
  ],
  "autoAppliedPromotions": [
    {
      "promotionId": "promo_loyalty_gold",
      "name": "Gold Member Bonus",
      "discountAmount": 5.00,
      "message": "Gold member discount applied"
    }
  ],
  "ineligiblePromotions": [
    {
      "promotionId": "promo_clearance",
      "code": "CLEARANCE",
      "reason": "Cannot stack with HOLIDAY25"
    }
  ],
  "totalDiscount": 43.75,
  "newSubtotal": 111.25
}

Promotion Types:

TypeDescriptionExample
percentagePercentage off25% off
fixed_amountFixed dollar off$10 off
buy_x_get_yBuy X get Y free/discountedBuy 2 get 1 free
bundleBundle pricing3 shirts for $99
thresholdSpend X save YSpend $100 save $20
bogoBuy one get oneBOGO 50% off

Applies To Options:

TargetDescription
allEntire transaction
categorySpecific categories
itemSpecific items
vendorItems from vendor
tagItems with tag

15.3.8 UPC Lookup Service (Learned from Lightspeed)

Competitive Insight: Lightspeed has 8M+ preloaded products. We integrate with UPC databases to provide similar auto-fill capability.

# Lookup barcode in external database
GET /api/v1/upc/lookup/{barcode}

# Search external database
GET /api/v1/upc/search?q=nike+running+shoes

# Import from UPC lookup to catalog
POST /api/v1/upc/import

UPC Lookup Response:

{
  "found": true,
  "source": "upcitemdb",
  "barcode": "0883419084587",
  "suggestedData": {
    "name": "Nike Air Max 270 Running Shoes",
    "brand": "Nike",
    "category": "Footwear > Athletic > Running",
    "description": "Men's running shoes with Air Max cushioning",
    "imageUrl": "https://cdn.upcitemdb.com/images/883419084587.jpg",
    "msrp": 150.00,
    "attributes": {
      "color": "Black/White",
      "size": "Various",
      "material": "Mesh/Synthetic"
    }
  },
  "alreadyInCatalog": false
}

Import from UPC Request:

{
  "barcode": "0883419084587",
  "overrides": {
    "sku": "NIKE-AM270-BLK",
    "price": 139.99,
    "cost": 75.00,
    "categoryId": "cat_footwear"
  },
  "createVariants": true,
  "sizes": ["8", "9", "10", "11", "12"]
}

15.4 Pagination Pattern

All list endpoints use cursor-based or offset pagination:

Request:

GET /api/v1/items?page=2&pageSize=50&sortBy=name&sortOrder=asc

Response Envelope:

{
  "data": [...],
  "pagination": {
    "page": 2,
    "pageSize": 50,
    "totalItems": 1250,
    "totalPages": 25,
    "hasNextPage": true,
    "hasPreviousPage": true
  },
  "_links": {
    "self": "/api/v1/items?page=2&pageSize=50",
    "first": "/api/v1/items?page=1&pageSize=50",
    "prev": "/api/v1/items?page=1&pageSize=50",
    "next": "/api/v1/items?page=3&pageSize=50",
    "last": "/api/v1/items?page=25&pageSize=50"
  }
}

15.5 Error Response Format

All errors follow RFC 7807 Problem Details:

{
  "type": "https://pos-platform.com/errors/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "One or more validation errors occurred.",
  "instance": "/api/v1/items",
  "traceId": "00-abc123-def456-01",
  "errors": {
    "sku": ["SKU is required", "SKU must be unique"],
    "price": ["Price must be greater than 0"]
  }
}

Common Error Types:

StatusTypeDescription
400validation-errorRequest validation failed
401authentication-requiredNo valid credentials
403permission-deniedInsufficient permissions
404resource-not-foundEntity does not exist
409conflictDuplicate or state conflict
422business-rule-violationDomain logic failure
500internal-errorServer error

15.6 SignalR Hub Events

Real-time events for POS clients:

Hub Endpoint

wss://{tenant}.pos-platform.com/hubs/pos

Event Catalog

// Server → Client Events
public interface IPosHubClient
{
    // Inventory changes
    Task InventoryUpdated(InventoryUpdateEvent data);

    // Price changes
    Task PriceUpdated(PriceUpdateEvent data);

    // Item changes
    Task ItemUpdated(ItemUpdateEvent data);
    Task ItemCreated(ItemCreateEvent data);
    Task ItemDeleted(string itemId);

    // Register events
    Task RegisterStatusChanged(RegisterStatusEvent data);

    // Shift events
    Task ShiftStarted(ShiftEvent data);
    Task ShiftEnded(ShiftEvent data);

    // Sync commands
    Task SyncRequired(SyncCommand data);
    Task CacheInvalidated(CacheInvalidateEvent data);
}

Event Payload Examples:

// InventoryUpdated
{
  "eventType": "InventoryUpdated",
  "timestamp": "2025-01-15T14:32:00Z",
  "data": {
    "itemId": "item_01HQWXYZ123",
    "locationId": "loc_gm",
    "previousQuantity": 25,
    "newQuantity": 23,
    "changeType": "sale"
  }
}

// SyncRequired
{
  "eventType": "SyncRequired",
  "timestamp": "2025-01-15T14:32:00Z",
  "data": {
    "scope": "items",
    "reason": "bulk_import",
    "affectedCount": 150
  }
}

15.7 Request/Response Headers

Required Request Headers

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
Accept: application/json
X-Request-Id: uuid-for-tracing
X-Location-Id: loc_gm              # For POS operations
X-Register-Id: reg_gm_01           # For POS operations

Response Headers

X-Request-Id: uuid-for-tracing
X-Tenant-Id: tenant_nexus
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 995
X-RateLimit-Reset: 1705330800

15.8 Rate Limiting

Endpoint TypeLimitWindow
Standard API1000/hourPer tenant
Bulk Operations10/hourPer tenant
Reports100/hourPer tenant
Auth Endpoints20/minutePer IP

Response when rate limited:

{
  "type": "https://pos-platform.com/errors/rate-limit-exceeded",
  "title": "Rate Limit Exceeded",
  "status": 429,
  "detail": "You have exceeded the rate limit. Try again in 300 seconds.",
  "retryAfter": 300
}

15.9 API Controller Implementation

// File: src/POS.Api/Controllers/ItemsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using POS.Core.Catalog;
using POS.Core.Common;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/items")]
[Authorize]
public class ItemsController : ControllerBase
{
    private readonly IItemService _itemService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<ItemsController> _logger;

    public ItemsController(
        IItemService itemService,
        ITenantContext tenantContext,
        ILogger<ItemsController> logger)
    {
        _itemService = itemService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    [HttpGet]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<PagedResult<ItemDto>>> GetItems(
        [FromQuery] ItemQueryParams query,
        CancellationToken ct)
    {
        var result = await _itemService.GetItemsAsync(query, ct);
        return Ok(result);
    }

    [HttpGet("{id}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItem(string id, CancellationToken ct)
    {
        var item = await _itemService.GetByIdAsync(id, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", id));

        return Ok(item);
    }

    [HttpGet("by-sku/{sku}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItemBySku(string sku, CancellationToken ct)
    {
        var item = await _itemService.GetBySkuAsync(sku, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", sku));

        return Ok(item);
    }

    [HttpGet("by-barcode/{barcode}")]
    [Authorize(Policy = "catalog.items.read")]
    public async Task<ActionResult<ItemDto>> GetItemByBarcode(
        string barcode,
        CancellationToken ct)
    {
        var item = await _itemService.GetByBarcodeAsync(barcode, ct);
        if (item is null)
            return NotFound(ProblemFactory.NotFound("Item", barcode));

        return Ok(item);
    }

    [HttpPost]
    [Authorize(Policy = "catalog.items.write")]
    public async Task<ActionResult<ItemDto>> CreateItem(
        [FromBody] CreateItemRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.CreateAsync(request, ct);

        return result.Match<ActionResult<ItemDto>>(
            success => CreatedAtAction(
                nameof(GetItem),
                new { id = success.Id },
                success),
            error => BadRequest(ProblemFactory.FromError(error))
        );
    }

    [HttpPut("{id}")]
    [Authorize(Policy = "catalog.items.write")]
    public async Task<ActionResult<ItemDto>> UpdateItem(
        string id,
        [FromBody] UpdateItemRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.UpdateAsync(id, request, ct);

        return result.Match<ActionResult<ItemDto>>(
            success => Ok(success),
            error => error.Code switch
            {
                "NOT_FOUND" => NotFound(ProblemFactory.NotFound("Item", id)),
                _ => BadRequest(ProblemFactory.FromError(error))
            }
        );
    }

    [HttpDelete("{id}")]
    [Authorize(Policy = "catalog.items.delete")]
    public async Task<IActionResult> DeleteItem(string id, CancellationToken ct)
    {
        var result = await _itemService.DeleteAsync(id, ct);

        return result.Match<IActionResult>(
            success => NoContent(),
            error => NotFound(ProblemFactory.NotFound("Item", id))
        );
    }

    [HttpPost("bulk-import")]
    [Authorize(Policy = "catalog.items.bulk")]
    [RequestSizeLimit(10_000_000)] // 10MB
    public async Task<ActionResult<BulkImportResult>> BulkImport(
        [FromBody] BulkImportRequest request,
        CancellationToken ct)
    {
        var result = await _itemService.BulkImportAsync(request, ct);
        return Ok(result);
    }
}

15.10 Query Parameters and Filtering

// File: src/POS.Core/Common/ItemQueryParams.cs
public record ItemQueryParams
{
    public int Page { get; init; } = 1;
    public int PageSize { get; init; } = 50;
    public string? Search { get; init; }
    public string? CategoryId { get; init; }
    public string? VendorId { get; init; }
    public bool? Active { get; init; }
    public bool? TrackInventory { get; init; }
    public decimal? MinPrice { get; init; }
    public decimal? MaxPrice { get; init; }
    public string SortBy { get; init; } = "name";
    public string SortOrder { get; init; } = "asc";
}

Summary

This chapter defined the complete REST API structure for the POS Platform:

  • Tenant-aware URL structure with subdomain routing
  • Six domain areas: Catalog, Sales, Inventory, Customers, Employees, Reports
  • Consistent patterns for pagination, errors, and HATEOAS links
  • Real-time SignalR events for inventory and price updates
  • Complete controller implementation with authorization policies

Next: Chapter 16 covers the service layer that implements this API.

Chapter 16: Service Layer

Clean Architecture Implementation for Multi-Tenant POS

This chapter provides the complete service layer architecture, including interfaces, implementations, unit of work patterns, and transaction handling.


16.1 Clean Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        API Controllers                          │
│  ItemsController, SalesController, InventoryController, etc.   │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Application Services                        │
│  IOrderService, IInventoryService, ICustomerService, etc.       │
│  (Business logic, orchestration, validation)                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                        Domain Layer                              │
│  Entities, Value Objects, Domain Events, Business Rules         │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Infrastructure Layer                        │
│  Repositories, DbContext, External Services, Messaging          │
└─────────────────────────────────────────────────────────────────┘

16.2 Project Structure

src/
├── POS.Api/                      # ASP.NET Core Web API
│   ├── Controllers/
│   ├── Middleware/
│   └── Program.cs
│
├── POS.Application/              # Application Services
│   ├── Interfaces/
│   │   ├── IOrderService.cs
│   │   ├── IInventoryService.cs
│   │   ├── ICustomerService.cs
│   │   ├── IItemService.cs
│   │   └── IReportService.cs
│   ├── Services/
│   │   ├── OrderService.cs
│   │   ├── InventoryService.cs
│   │   └── ...
│   ├── DTOs/
│   └── Validators/
│
├── POS.Domain/                   # Domain Layer
│   ├── Entities/
│   ├── ValueObjects/
│   ├── Events/
│   └── Exceptions/
│
└── POS.Infrastructure/           # Infrastructure Layer
    ├── Persistence/
    │   ├── PosDbContext.cs
    │   ├── Repositories/
    │   └── Configurations/
    ├── External/
    └── Messaging/

16.3 Service Interfaces

16.3.1 IOrderService

// File: src/POS.Application/Interfaces/IOrderService.cs
using POS.Application.DTOs;
using POS.Domain.Common;

namespace POS.Application.Interfaces;

public interface IOrderService
{
    // Query operations
    Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
        OrderQueryParams query,
        CancellationToken ct = default);

    Task<OrderDto?> GetByIdAsync(string orderId, CancellationToken ct = default);

    Task<OrderDto?> GetByReceiptNumberAsync(
        string receiptNumber,
        CancellationToken ct = default);

    // Command operations
    Task<Result<OrderDto>> CreateOrderAsync(
        CreateOrderRequest request,
        CancellationToken ct = default);

    Task<Result<OrderDto>> ProcessReturnAsync(
        string orderId,
        ProcessReturnRequest request,
        CancellationToken ct = default);

    Task<Result<OrderDto>> VoidOrderAsync(
        string orderId,
        VoidOrderRequest request,
        CancellationToken ct = default);

    // Receipt operations
    Task<ReceiptDto> GetReceiptAsync(string orderId, CancellationToken ct = default);

    Task<Result> PrintReceiptAsync(
        string orderId,
        PrintReceiptRequest request,
        CancellationToken ct = default);

    // Held orders (park/recall)
    Task<Result<OrderDto>> HoldOrderAsync(
        HoldOrderRequest request,
        CancellationToken ct = default);

    Task<IReadOnlyList<HeldOrderDto>> GetHeldOrdersAsync(
        string locationId,
        CancellationToken ct = default);

    Task<Result<OrderDto>> RecallHeldOrderAsync(
        string heldOrderId,
        CancellationToken ct = default);
}

16.3.2 IInventoryService

// File: src/POS.Application/Interfaces/IInventoryService.cs
namespace POS.Application.Interfaces;

public interface IInventoryService
{
    // Query operations
    Task<InventoryLevelDto?> GetInventoryLevelAsync(
        string itemId,
        string locationId,
        CancellationToken ct = default);

    Task<IReadOnlyList<InventoryLevelDto>> GetInventoryByItemAsync(
        string itemId,
        CancellationToken ct = default);

    Task<PagedResult<InventoryLevelDto>> GetInventoryByLocationAsync(
        string locationId,
        InventoryQueryParams query,
        CancellationToken ct = default);

    // Adjustment operations
    Task<Result<AdjustmentDto>> AdjustInventoryAsync(
        AdjustInventoryRequest request,
        CancellationToken ct = default);

    Task<Result<TransferDto>> CreateTransferAsync(
        CreateTransferRequest request,
        CancellationToken ct = default);

    Task<Result<TransferDto>> ReceiveTransferAsync(
        string transferId,
        ReceiveTransferRequest request,
        CancellationToken ct = default);

    // Count operations
    Task<Result<CountDto>> StartCountAsync(
        StartCountRequest request,
        CancellationToken ct = default);

    Task<Result<CountDto>> UpdateCountAsync(
        string countId,
        UpdateCountRequest request,
        CancellationToken ct = default);

    Task<Result<CountDto>> FinalizeCountAsync(
        string countId,
        CancellationToken ct = default);

    // History
    Task<PagedResult<InventoryEventDto>> GetAdjustmentHistoryAsync(
        InventoryHistoryQuery query,
        CancellationToken ct = default);

    // Internal (called by other services)
    Task<Result> DeductInventoryAsync(
        DeductInventoryCommand command,
        CancellationToken ct = default);

    Task<Result> RestoreInventoryAsync(
        RestoreInventoryCommand command,
        CancellationToken ct = default);
}

16.3.3 ICustomerService

// File: src/POS.Application/Interfaces/ICustomerService.cs
namespace POS.Application.Interfaces;

public interface ICustomerService
{
    Task<PagedResult<CustomerSummaryDto>> GetCustomersAsync(
        CustomerQueryParams query,
        CancellationToken ct = default);

    Task<CustomerDto?> GetByIdAsync(string customerId, CancellationToken ct = default);

    Task<IReadOnlyList<CustomerSummaryDto>> SearchAsync(
        string searchTerm,
        int limit = 10,
        CancellationToken ct = default);

    Task<Result<CustomerDto>> CreateAsync(
        CreateCustomerRequest request,
        CancellationToken ct = default);

    Task<Result<CustomerDto>> UpdateAsync(
        string customerId,
        UpdateCustomerRequest request,
        CancellationToken ct = default);

    Task<PagedResult<OrderSummaryDto>> GetPurchaseHistoryAsync(
        string customerId,
        PurchaseHistoryQuery query,
        CancellationToken ct = default);

    Task<LoyaltyInfoDto> GetLoyaltyInfoAsync(
        string customerId,
        CancellationToken ct = default);

    Task<Result<LoyaltyInfoDto>> AddLoyaltyPointsAsync(
        string customerId,
        int points,
        string reason,
        CancellationToken ct = default);

    Task<Result<LoyaltyInfoDto>> RedeemLoyaltyPointsAsync(
        string customerId,
        int points,
        string orderId,
        CancellationToken ct = default);
}

16.3.4 IItemService

// File: src/POS.Application/Interfaces/IItemService.cs
namespace POS.Application.Interfaces;

public interface IItemService
{
    Task<PagedResult<ItemSummaryDto>> GetItemsAsync(
        ItemQueryParams query,
        CancellationToken ct = default);

    Task<ItemDto?> GetByIdAsync(string itemId, CancellationToken ct = default);
    Task<ItemDto?> GetBySkuAsync(string sku, CancellationToken ct = default);
    Task<ItemDto?> GetByBarcodeAsync(string barcode, CancellationToken ct = default);

    Task<Result<ItemDto>> CreateAsync(
        CreateItemRequest request,
        CancellationToken ct = default);

    Task<Result<ItemDto>> UpdateAsync(
        string itemId,
        UpdateItemRequest request,
        CancellationToken ct = default);

    Task<Result> DeleteAsync(string itemId, CancellationToken ct = default);

    Task<BulkImportResult> BulkImportAsync(
        BulkImportRequest request,
        CancellationToken ct = default);

    Task<IReadOnlyList<ItemDto>> GetByIdsAsync(
        IEnumerable<string> itemIds,
        CancellationToken ct = default);
}

16.4 Unit of Work Pattern

// File: src/POS.Application/Interfaces/IUnitOfWork.cs
namespace POS.Application.Interfaces;

public interface IUnitOfWork : IDisposable
{
    IItemRepository Items { get; }
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    IInventoryRepository Inventory { get; }
    IEmployeeRepository Employees { get; }
    ILocationRepository Locations { get; }

    Task<int> SaveChangesAsync(CancellationToken ct = default);
    Task BeginTransactionAsync(CancellationToken ct = default);
    Task CommitTransactionAsync(CancellationToken ct = default);
    Task RollbackTransactionAsync(CancellationToken ct = default);
}

// File: src/POS.Infrastructure/Persistence/UnitOfWork.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace POS.Infrastructure.Persistence;

public class UnitOfWork : IUnitOfWork
{
    private readonly PosDbContext _context;
    private IDbContextTransaction? _transaction;

    public IItemRepository Items { get; }
    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }
    public IInventoryRepository Inventory { get; }
    public IEmployeeRepository Employees { get; }
    public ILocationRepository Locations { get; }

    public UnitOfWork(
        PosDbContext context,
        IItemRepository items,
        IOrderRepository orders,
        ICustomerRepository customers,
        IInventoryRepository inventory,
        IEmployeeRepository employees,
        ILocationRepository locations)
    {
        _context = context;
        Items = items;
        Orders = orders;
        Customers = customers;
        Inventory = inventory;
        Employees = employees;
        Locations = locations;
    }

    public async Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        return await _context.SaveChangesAsync(ct);
    }

    public async Task BeginTransactionAsync(CancellationToken ct = default)
    {
        _transaction = await _context.Database.BeginTransactionAsync(ct);
    }

    public async Task CommitTransactionAsync(CancellationToken ct = default)
    {
        if (_transaction is not null)
        {
            await _transaction.CommitAsync(ct);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync(CancellationToken ct = default)
    {
        if (_transaction is not null)
        {
            await _transaction.RollbackAsync(ct);
            await _transaction.DisposeAsync();
            _transaction = null;
        }
    }

    public void Dispose()
    {
        _transaction?.Dispose();
        _context.Dispose();
    }
}

16.5 Complete OrderService Implementation

// File: src/POS.Application/Services/OrderService.cs
using Microsoft.Extensions.Logging;
using POS.Application.DTOs;
using POS.Application.Interfaces;
using POS.Domain.Common;
using POS.Domain.Entities;
using POS.Domain.Events;
using POS.Domain.Exceptions;

namespace POS.Application.Services;

public class OrderService : IOrderService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly IInventoryService _inventoryService;
    private readonly ICustomerService _customerService;
    private readonly IPaymentService _paymentService;
    private readonly IEventPublisher _eventPublisher;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IUnitOfWork unitOfWork,
        IInventoryService inventoryService,
        ICustomerService customerService,
        IPaymentService paymentService,
        IEventPublisher eventPublisher,
        ITenantContext tenantContext,
        ILogger<OrderService> logger)
    {
        _unitOfWork = unitOfWork;
        _inventoryService = inventoryService;
        _customerService = customerService;
        _paymentService = paymentService;
        _eventPublisher = eventPublisher;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task<Result<OrderDto>> CreateOrderAsync(
        CreateOrderRequest request,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Creating order for location {LocationId} with {ItemCount} items",
            request.LocationId,
            request.LineItems.Count);

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            // 1. Validate location and register
            var location = await _unitOfWork.Locations.GetByIdAsync(
                request.LocationId, ct);

            if (location is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Location", request.LocationId));

            // 2. Validate employee
            var employee = await _unitOfWork.Employees.GetByIdAsync(
                request.EmployeeId, ct);

            if (employee is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Employee", request.EmployeeId));

            // 3. Load items and validate inventory
            var itemIds = request.LineItems.Select(li => li.ItemId).ToList();
            var items = await _unitOfWork.Items.GetByIdsAsync(itemIds, ct);
            var itemLookup = items.ToDictionary(i => i.Id);

            foreach (var lineItem in request.LineItems)
            {
                if (!itemLookup.TryGetValue(lineItem.ItemId, out var item))
                {
                    return Result<OrderDto>.Failure(
                        DomainError.NotFound("Item", lineItem.ItemId));
                }

                // Check inventory if tracked
                if (item.TrackInventory)
                {
                    var inventory = await _inventoryService.GetInventoryLevelAsync(
                        item.Id, request.LocationId, ct);

                    if (inventory is null || inventory.QuantityOnHand < lineItem.Quantity)
                    {
                        return Result<OrderDto>.Failure(
                            DomainError.InsufficientInventory(
                                item.Sku,
                                lineItem.Quantity,
                                inventory?.QuantityOnHand ?? 0));
                    }
                }
            }

            // 4. Create order entity
            var order = new Order
            {
                Id = IdGenerator.NewId("order"),
                TenantId = _tenantContext.TenantId,
                LocationId = request.LocationId,
                RegisterId = request.RegisterId,
                EmployeeId = request.EmployeeId,
                CustomerId = request.CustomerId,
                ReceiptNumber = await GenerateReceiptNumberAsync(
                    request.LocationId, ct),
                Status = OrderStatus.Completed,
                CreatedAt = DateTime.UtcNow
            };

            // 5. Build line items
            decimal subtotal = 0;
            foreach (var li in request.LineItems)
            {
                var item = itemLookup[li.ItemId];
                var lineItem = new OrderLineItem
                {
                    Id = IdGenerator.NewId("li"),
                    OrderId = order.Id,
                    ItemId = item.Id,
                    Sku = item.Sku,
                    Name = item.Name,
                    Quantity = li.Quantity,
                    UnitPrice = li.UnitPrice ?? item.Price,
                    DiscountAmount = li.DiscountAmount,
                    Taxable = item.Taxable
                };

                lineItem.ExtendedPrice = lineItem.Quantity * lineItem.UnitPrice;
                lineItem.NetPrice = lineItem.ExtendedPrice - lineItem.DiscountAmount;
                subtotal += lineItem.NetPrice;

                order.LineItems.Add(lineItem);
            }

            // 6. Apply order-level discounts
            decimal discountTotal = 0;
            foreach (var discount in request.Discounts ?? [])
            {
                var discountAmount = discount.Type == DiscountType.Percentage
                    ? subtotal * (discount.Value / 100m)
                    : discount.Value;

                discountTotal += discountAmount;

                order.Discounts.Add(new OrderDiscount
                {
                    Id = IdGenerator.NewId("disc"),
                    OrderId = order.Id,
                    Type = discount.Type,
                    Value = discount.Value,
                    Amount = discountAmount,
                    Reason = discount.Reason
                });
            }

            // 7. Calculate tax
            decimal taxableAmount = order.LineItems
                .Where(li => li.Taxable)
                .Sum(li => li.NetPrice);

            // Apply discount proportionally to taxable amount
            if (subtotal > 0 && discountTotal > 0)
            {
                var taxableRatio = taxableAmount / subtotal;
                taxableAmount -= discountTotal * taxableRatio;
            }

            var taxRate = location.TaxRate;
            order.TaxAmount = Math.Round(taxableAmount * taxRate, 2);

            // 8. Set totals
            order.Subtotal = subtotal;
            order.DiscountTotal = discountTotal;
            order.GrandTotal = subtotal - discountTotal + order.TaxAmount;

            // 9. Process payments
            decimal paymentTotal = 0;
            foreach (var payment in request.Payments)
            {
                var paymentResult = await _paymentService.ProcessPaymentAsync(
                    new ProcessPaymentCommand
                    {
                        OrderId = order.Id,
                        Method = payment.Method,
                        Amount = payment.Amount,
                        Reference = payment.Reference
                    }, ct);

                if (!paymentResult.IsSuccess)
                {
                    await _unitOfWork.RollbackTransactionAsync(ct);
                    return Result<OrderDto>.Failure(paymentResult.Error!);
                }

                order.Payments.Add(new OrderPayment
                {
                    Id = IdGenerator.NewId("pmt"),
                    OrderId = order.Id,
                    Method = payment.Method,
                    Amount = payment.Amount,
                    Status = PaymentStatus.Captured,
                    Reference = paymentResult.Value!.TransactionId,
                    CardLast4 = payment.CardLast4,
                    CardBrand = payment.CardBrand
                });

                paymentTotal += payment.Amount;
            }

            // 10. Validate payment covers total
            if (paymentTotal < order.GrandTotal)
            {
                await _unitOfWork.RollbackTransactionAsync(ct);
                return Result<OrderDto>.Failure(
                    DomainError.InsufficientPayment(order.GrandTotal, paymentTotal));
            }

            order.ChangeGiven = paymentTotal - order.GrandTotal;

            // 11. Deduct inventory
            foreach (var lineItem in order.LineItems)
            {
                var item = itemLookup[lineItem.ItemId];
                if (item.TrackInventory)
                {
                    var deductResult = await _inventoryService.DeductInventoryAsync(
                        new DeductInventoryCommand
                        {
                            ItemId = lineItem.ItemId,
                            LocationId = request.LocationId,
                            Quantity = lineItem.Quantity,
                            Reason = InventoryChangeReason.Sale,
                            ReferenceId = order.Id,
                            ReferenceType = "Order"
                        }, ct);

                    if (!deductResult.IsSuccess)
                    {
                        await _unitOfWork.RollbackTransactionAsync(ct);
                        return Result<OrderDto>.Failure(deductResult.Error!);
                    }
                }
            }

            // 12. Award loyalty points
            if (request.CustomerId is not null)
            {
                var pointsToAward = CalculateLoyaltyPoints(order.GrandTotal);
                await _customerService.AddLoyaltyPointsAsync(
                    request.CustomerId,
                    pointsToAward,
                    $"Purchase: {order.ReceiptNumber}",
                    ct);
            }

            // 13. Save order
            await _unitOfWork.Orders.AddAsync(order, ct);
            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            // 14. Publish domain events
            await _eventPublisher.PublishAsync(new OrderCompletedEvent
            {
                OrderId = order.Id,
                TenantId = order.TenantId,
                LocationId = order.LocationId,
                ReceiptNumber = order.ReceiptNumber,
                GrandTotal = order.GrandTotal,
                ItemCount = order.LineItems.Count,
                CustomerId = order.CustomerId,
                EmployeeId = order.EmployeeId,
                OccurredAt = DateTime.UtcNow
            }, ct);

            _logger.LogInformation(
                "Order {OrderId} created successfully. Receipt: {ReceiptNumber}, Total: {Total}",
                order.Id,
                order.ReceiptNumber,
                order.GrandTotal);

            return Result<OrderDto>.Success(MapToDto(order));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to create order");
            throw;
        }
    }

    public async Task<Result<OrderDto>> ProcessReturnAsync(
        string orderId,
        ProcessReturnRequest request,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Processing return for order {OrderId}",
            orderId);

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            var originalOrder = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
            if (originalOrder is null)
                return Result<OrderDto>.Failure(
                    DomainError.NotFound("Order", orderId));

            if (originalOrder.Status == OrderStatus.Voided)
                return Result<OrderDto>.Failure(
                    DomainError.InvalidOperation("Cannot return a voided order"));

            // Create return order
            var returnOrder = new Order
            {
                Id = IdGenerator.NewId("order"),
                TenantId = _tenantContext.TenantId,
                LocationId = originalOrder.LocationId,
                RegisterId = request.RegisterId,
                EmployeeId = request.EmployeeId,
                CustomerId = originalOrder.CustomerId,
                ReceiptNumber = await GenerateReceiptNumberAsync(
                    originalOrder.LocationId, ct),
                Status = OrderStatus.Completed,
                OrderType = OrderType.Return,
                OriginalOrderId = orderId,
                CreatedAt = DateTime.UtcNow
            };

            decimal returnSubtotal = 0;

            foreach (var returnItem in request.LineItems)
            {
                var originalLineItem = originalOrder.LineItems
                    .FirstOrDefault(li => li.Id == returnItem.OriginalLineItemId);

                if (originalLineItem is null)
                    return Result<OrderDto>.Failure(
                        DomainError.NotFound("LineItem", returnItem.OriginalLineItemId));

                if (returnItem.Quantity > originalLineItem.Quantity)
                    return Result<OrderDto>.Failure(
                        DomainError.InvalidOperation(
                            $"Return quantity exceeds original quantity"));

                var returnLineItem = new OrderLineItem
                {
                    Id = IdGenerator.NewId("li"),
                    OrderId = returnOrder.Id,
                    ItemId = originalLineItem.ItemId,
                    Sku = originalLineItem.Sku,
                    Name = originalLineItem.Name,
                    Quantity = -returnItem.Quantity,
                    UnitPrice = originalLineItem.UnitPrice,
                    DiscountAmount = 0,
                    Taxable = originalLineItem.Taxable,
                    ReturnReason = returnItem.Reason
                };

                returnLineItem.ExtendedPrice = returnLineItem.Quantity *
                    returnLineItem.UnitPrice;
                returnLineItem.NetPrice = returnLineItem.ExtendedPrice;
                returnSubtotal += returnLineItem.NetPrice;

                returnOrder.LineItems.Add(returnLineItem);

                // Restore inventory
                var item = await _unitOfWork.Items.GetByIdAsync(
                    originalLineItem.ItemId, ct);

                if (item?.TrackInventory == true)
                {
                    await _inventoryService.RestoreInventoryAsync(
                        new RestoreInventoryCommand
                        {
                            ItemId = originalLineItem.ItemId,
                            LocationId = originalOrder.LocationId,
                            Quantity = returnItem.Quantity,
                            Reason = InventoryChangeReason.Return,
                            ReferenceId = returnOrder.Id,
                            ReferenceType = "Return"
                        }, ct);
                }
            }

            // Calculate return tax
            var location = await _unitOfWork.Locations.GetByIdAsync(
                originalOrder.LocationId, ct);
            decimal taxableReturnAmount = returnOrder.LineItems
                .Where(li => li.Taxable)
                .Sum(li => li.NetPrice);
            returnOrder.TaxAmount = Math.Round(
                Math.Abs(taxableReturnAmount) * location!.TaxRate, 2) * -1;

            returnOrder.Subtotal = returnSubtotal;
            returnOrder.GrandTotal = returnSubtotal + returnOrder.TaxAmount;

            // Process refund
            var refundResult = await _paymentService.ProcessRefundAsync(
                new ProcessRefundCommand
                {
                    OriginalOrderId = orderId,
                    RefundOrderId = returnOrder.Id,
                    Amount = Math.Abs(returnOrder.GrandTotal),
                    Method = request.RefundMethod
                }, ct);

            if (!refundResult.IsSuccess)
            {
                await _unitOfWork.RollbackTransactionAsync(ct);
                return Result<OrderDto>.Failure(refundResult.Error!);
            }

            returnOrder.Payments.Add(new OrderPayment
            {
                Id = IdGenerator.NewId("pmt"),
                OrderId = returnOrder.Id,
                Method = request.RefundMethod,
                Amount = returnOrder.GrandTotal,
                Status = PaymentStatus.Refunded,
                Reference = refundResult.Value!.TransactionId
            });

            await _unitOfWork.Orders.AddAsync(returnOrder, ct);
            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            await _eventPublisher.PublishAsync(new OrderReturnedEvent
            {
                OrderId = returnOrder.Id,
                OriginalOrderId = orderId,
                TenantId = returnOrder.TenantId,
                RefundAmount = Math.Abs(returnOrder.GrandTotal),
                OccurredAt = DateTime.UtcNow
            }, ct);

            return Result<OrderDto>.Success(MapToDto(returnOrder));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to process return for order {OrderId}", orderId);
            throw;
        }
    }

    public async Task<Result<OrderDto>> VoidOrderAsync(
        string orderId,
        VoidOrderRequest request,
        CancellationToken ct = default)
    {
        var order = await _unitOfWork.Orders.GetByIdAsync(orderId, ct);
        if (order is null)
            return Result<OrderDto>.Failure(DomainError.NotFound("Order", orderId));

        if (order.Status == OrderStatus.Voided)
            return Result<OrderDto>.Failure(
                DomainError.InvalidOperation("Order is already voided"));

        // Check void window (typically same day only)
        if (order.CreatedAt.Date != DateTime.UtcNow.Date)
            return Result<OrderDto>.Failure(
                DomainError.InvalidOperation("Orders can only be voided on the same day"));

        try
        {
            await _unitOfWork.BeginTransactionAsync(ct);

            // Void all payments
            foreach (var payment in order.Payments.Where(p =>
                p.Status == PaymentStatus.Captured))
            {
                var voidResult = await _paymentService.VoidPaymentAsync(
                    payment.Reference!, ct);

                if (!voidResult.IsSuccess)
                {
                    await _unitOfWork.RollbackTransactionAsync(ct);
                    return Result<OrderDto>.Failure(voidResult.Error!);
                }

                payment.Status = PaymentStatus.Voided;
            }

            // Restore inventory
            foreach (var lineItem in order.LineItems)
            {
                var item = await _unitOfWork.Items.GetByIdAsync(lineItem.ItemId, ct);
                if (item?.TrackInventory == true)
                {
                    await _inventoryService.RestoreInventoryAsync(
                        new RestoreInventoryCommand
                        {
                            ItemId = lineItem.ItemId,
                            LocationId = order.LocationId,
                            Quantity = lineItem.Quantity,
                            Reason = InventoryChangeReason.Void,
                            ReferenceId = order.Id,
                            ReferenceType = "VoidedOrder"
                        }, ct);
                }
            }

            // Reverse loyalty points
            if (order.CustomerId is not null)
            {
                var pointsToDeduct = CalculateLoyaltyPoints(order.GrandTotal);
                await _customerService.AddLoyaltyPointsAsync(
                    order.CustomerId,
                    -pointsToDeduct,
                    $"Voided: {order.ReceiptNumber}",
                    ct);
            }

            order.Status = OrderStatus.Voided;
            order.VoidedAt = DateTime.UtcNow;
            order.VoidedBy = request.EmployeeId;
            order.VoidReason = request.Reason;

            await _unitOfWork.SaveChangesAsync(ct);
            await _unitOfWork.CommitTransactionAsync(ct);

            await _eventPublisher.PublishAsync(new OrderVoidedEvent
            {
                OrderId = order.Id,
                TenantId = order.TenantId,
                Reason = request.Reason,
                VoidedBy = request.EmployeeId,
                OccurredAt = DateTime.UtcNow
            }, ct);

            return Result<OrderDto>.Success(MapToDto(order));
        }
        catch (Exception ex)
        {
            await _unitOfWork.RollbackTransactionAsync(ct);
            _logger.LogError(ex, "Failed to void order {OrderId}", orderId);
            throw;
        }
    }

    private async Task<string> GenerateReceiptNumberAsync(
        string locationId,
        CancellationToken ct)
    {
        var location = await _unitOfWork.Locations.GetByIdAsync(locationId, ct);
        var prefix = location?.Code ?? "XX";
        var date = DateTime.UtcNow.ToString("yyyyMMdd");
        var sequence = await _unitOfWork.Orders.GetNextSequenceAsync(locationId, ct);
        return $"{prefix}-{date}-{sequence:D4}";
    }

    private static int CalculateLoyaltyPoints(decimal amount)
    {
        return (int)Math.Floor(amount);
    }

    private static OrderDto MapToDto(Order order)
    {
        return new OrderDto
        {
            Id = order.Id,
            ReceiptNumber = order.ReceiptNumber,
            Status = order.Status.ToString(),
            // ... map all properties
        };
    }

    // ... other interface methods
}

16.6 Event Publishing Pattern

// File: src/POS.Application/Interfaces/IEventPublisher.cs
namespace POS.Application.Interfaces;

public interface IEventPublisher
{
    Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : IDomainEvent;

    Task PublishManyAsync<TEvent>(IEnumerable<TEvent> events, CancellationToken ct = default)
        where TEvent : IDomainEvent;
}

// File: src/POS.Infrastructure/Messaging/EventPublisher.cs
using MassTransit;
using Microsoft.AspNetCore.SignalR;
using POS.Api.Hubs;

namespace POS.Infrastructure.Messaging;

public class EventPublisher : IEventPublisher
{
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly IHubContext<PosHub, IPosHubClient> _hubContext;
    private readonly ILogger<EventPublisher> _logger;

    public EventPublisher(
        IPublishEndpoint publishEndpoint,
        IHubContext<PosHub, IPosHubClient> hubContext,
        ILogger<EventPublisher> logger)
    {
        _publishEndpoint = publishEndpoint;
        _hubContext = hubContext;
        _logger = logger;
    }

    public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct = default)
        where TEvent : IDomainEvent
    {
        // Publish to message bus (for background processing)
        await _publishEndpoint.Publish(@event, ct);

        // Publish to SignalR (for real-time UI updates)
        await PublishToSignalRAsync(@event, ct);

        _logger.LogDebug(
            "Published event {EventType} for tenant {TenantId}",
            typeof(TEvent).Name,
            @event.TenantId);
    }

    private async Task PublishToSignalRAsync<TEvent>(TEvent @event, CancellationToken ct)
        where TEvent : IDomainEvent
    {
        var tenantGroup = $"tenant:{@event.TenantId}";

        switch (@event)
        {
            case OrderCompletedEvent order:
                await _hubContext.Clients.Group(tenantGroup)
                    .OrderCompleted(new OrderCompletedNotification
                    {
                        OrderId = order.OrderId,
                        ReceiptNumber = order.ReceiptNumber,
                        GrandTotal = order.GrandTotal
                    });
                break;

            case InventoryUpdatedEvent inv:
                await _hubContext.Clients.Group(tenantGroup)
                    .InventoryUpdated(new InventoryUpdateNotification
                    {
                        ItemId = inv.ItemId,
                        LocationId = inv.LocationId,
                        NewQuantity = inv.NewQuantity
                    });
                break;
        }
    }
}

16.7 Dependency Injection Configuration

// File: src/POS.Api/Program.cs (partial)
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        // Application services
        services.AddScoped<IOrderService, OrderService>();
        services.AddScoped<IInventoryService, InventoryService>();
        services.AddScoped<ICustomerService, CustomerService>();
        services.AddScoped<IItemService, ItemService>();
        services.AddScoped<IEmployeeService, EmployeeService>();
        services.AddScoped<IReportService, ReportService>();
        services.AddScoped<IPaymentService, PaymentService>();

        // Infrastructure
        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddScoped<IEventPublisher, EventPublisher>();

        // Repositories
        services.AddScoped<IItemRepository, ItemRepository>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IInventoryRepository, InventoryRepository>();
        services.AddScoped<IEmployeeRepository, EmployeeRepository>();
        services.AddScoped<ILocationRepository, LocationRepository>();

        return services;
    }
}

Summary

This chapter defined the complete service layer architecture:

  • Clean architecture with clear separation of concerns
  • Service interfaces for all major domains
  • Unit of Work pattern for transaction management
  • Complete OrderService implementation with full transaction flow
  • Event publishing for real-time updates and background processing

Next: Chapter 17 covers security and authentication patterns.

Chapter 17: Security and Authentication

Multi-Mode Authentication for POS and Admin Portals

This chapter provides complete security implementation including dual authentication flows, JWT tokens, role-based access control, and tenant isolation.


17.1 Authentication Architecture Overview

┌───────────────────────────────────────────────────────────────────┐
│                     Authentication Flows                          │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  ┌─────────────────┐              ┌─────────────────────────┐    │
│  │   POS Client    │              │     Admin Portal        │    │
│  │  (Touch Screen) │              │   (Web Browser)         │    │
│  └────────┬────────┘              └───────────┬─────────────┘    │
│           │                                   │                   │
│           ▼                                   ▼                   │
│  ┌─────────────────┐              ┌─────────────────────────┐    │
│  │   PIN Login     │              │  Email/Password Login   │    │
│  │  (4-6 digits)   │              │  + Optional MFA         │    │
│  └────────┬────────┘              └───────────┬─────────────┘    │
│           │                                   │                   │
│           ▼                                   ▼                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                    JWT Token Issued                          │ │
│  │    • Short-lived for POS (8 hours = shift)                  │ │
│  │    • Longer for Admin (24 hours with refresh)               │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                                                                   │
└───────────────────────────────────────────────────────────────────┘

17.2 JWT Token Structure

{
  "header": {
    "alg": "RS256",
    "typ": "JWT",
    "kid": "key-2025-01"
  },
  "payload": {
    "sub": "emp_john_smith",
    "tid": "tenant_nexus",
    "lid": "loc_gm",
    "rid": "reg_gm_01",
    "name": "John Smith",
    "email": "john@nexus.com",
    "roles": ["cashier", "supervisor"],
    "permissions": [
      "pos.sale.create",
      "pos.sale.void",
      "pos.discount.apply",
      "pos.customer.view",
      "pos.customer.create"
    ],
    "auth_method": "pin",
    "iat": 1705320000,
    "exp": 1705348800,
    "iss": "https://auth.pos-platform.com",
    "aud": "pos-api"
  }
}

Token Claims Explained

ClaimDescription
subSubject (employee/user ID)
tidTenant ID
lidLocation ID (POS only)
ridRegister ID (POS only)
rolesRole names
permissionsFine-grained permissions
auth_method“pin” or “password”

17.3 Role-Based Permission Matrix

Role Definitions

// File: src/POS.Domain/Security/Roles.cs
namespace POS.Domain.Security;

public static class Roles
{
    public const string Cashier = "cashier";
    public const string Supervisor = "supervisor";
    public const string Manager = "manager";
    public const string Admin = "admin";
    public const string Owner = "owner";
}

Permission Catalog

// File: src/POS.Domain/Security/Permissions.cs
namespace POS.Domain.Security;

public static class Permissions
{
    // POS Operations
    public const string PosSaleCreate = "pos.sale.create";
    public const string PosSaleVoid = "pos.sale.void";
    public const string PosSaleReturn = "pos.sale.return";
    public const string PosDiscountApply = "pos.discount.apply";
    public const string PosDiscountOverride = "pos.discount.override";
    public const string PosPriceOverride = "pos.price.override";
    public const string PosDrawerOpen = "pos.drawer.open";
    public const string PosDrawerCount = "pos.drawer.count";
    public const string PosHoldRecall = "pos.hold.recall";

    // Customer Operations
    public const string CustomerView = "pos.customer.view";
    public const string CustomerCreate = "pos.customer.create";
    public const string CustomerUpdate = "pos.customer.update";
    public const string CustomerDelete = "pos.customer.delete";
    public const string CustomerLoyaltyAdjust = "pos.customer.loyalty.adjust";

    // Inventory Operations
    public const string InventoryView = "inventory.view";
    public const string InventoryAdjust = "inventory.adjust";
    public const string InventoryTransfer = "inventory.transfer";
    public const string InventoryCount = "inventory.count";
    public const string InventoryReceive = "inventory.receive";

    // Catalog Operations
    public const string CatalogItemView = "catalog.items.read";
    public const string CatalogItemCreate = "catalog.items.write";
    public const string CatalogItemUpdate = "catalog.items.write";
    public const string CatalogItemDelete = "catalog.items.delete";
    public const string CatalogItemBulk = "catalog.items.bulk";

    // Reports
    public const string ReportsView = "reports.view";
    public const string ReportsExport = "reports.export";
    public const string ReportsSalesDetail = "reports.sales.detail";
    public const string ReportsEmployeePerformance = "reports.employee.performance";

    // Administration
    public const string AdminEmployees = "admin.employees";
    public const string AdminLocations = "admin.locations";
    public const string AdminSettings = "admin.settings";
    public const string AdminIntegrations = "admin.integrations";
    public const string AdminBilling = "admin.billing";
    public const string AdminAuditLog = "admin.audit";
}

Role-Permission Mapping

PermissionCashierSupervisorManagerAdminOwner
pos.sale.createXXXXX
pos.sale.void-XXXX
pos.sale.return-XXXX
pos.discount.apply-XXXX
pos.discount.override--XXX
pos.price.override--XXX
pos.drawer.openXXXXX
pos.drawer.count-XXXX
pos.customer.viewXXXXX
pos.customer.createXXXXX
pos.customer.update-XXXX
pos.customer.delete--XXX
inventory.viewXXXXX
inventory.adjust--XXX
inventory.transfer--XXX
inventory.count-XXXX
catalog.items.readXXXXX
catalog.items.write--XXX
catalog.items.delete---XX
reports.view-XXXX
reports.export--XXX
admin.employees--XXX
admin.locations---XX
admin.settings---XX
admin.billing----X

17.4 Authentication Controller

// File: src/POS.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
    private readonly IEmployeeService _employeeService;
    private readonly IUserService _userService;
    private readonly ITenantService _tenantService;
    private readonly ITokenService _tokenService;
    private readonly IAuditLogger _auditLogger;
    private readonly ILogger<AuthController> _logger;

    public AuthController(
        IEmployeeService employeeService,
        IUserService userService,
        ITenantService tenantService,
        ITokenService tokenService,
        IAuditLogger auditLogger,
        ILogger<AuthController> logger)
    {
        _employeeService = employeeService;
        _userService = userService;
        _tenantService = tenantService;
        _tokenService = tokenService;
        _auditLogger = auditLogger;
        _logger = logger;
    }

    /// <summary>
    /// PIN-based login for POS terminals
    /// </summary>
    [HttpPost("pin-login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> PinLogin(
        [FromBody] PinLoginRequest request,
        CancellationToken ct)
    {
        // Validate tenant
        var tenant = await _tenantService.GetBySubdomainAsync(
            request.TenantSubdomain, ct);

        if (tenant is null || !tenant.IsActive)
        {
            _logger.LogWarning(
                "PIN login attempt for unknown tenant: {Tenant}",
                request.TenantSubdomain);
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Validate location
        var location = await _tenantService.GetLocationAsync(
            tenant.Id, request.LocationId, ct);

        if (location is null || !location.IsActive)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Location",
                Detail = "The specified location is not available."
            });
        }

        // Validate employee PIN
        var employee = await _employeeService.ValidatePinAsync(
            tenant.Id, request.Pin, ct);

        if (employee is null)
        {
            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = tenant.Id,
                EventType = "AuthFailure",
                Details = $"Failed PIN login attempt at {request.LocationId}",
                IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
            }, ct);

            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided PIN is incorrect."
            });
        }

        // Check employee has access to this location
        if (!employee.LocationIds.Contains(request.LocationId) &&
            !employee.Roles.Contains(Roles.Admin))
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Location Access Denied",
                Detail = "You do not have access to this location."
            });
        }

        // Generate token (8-hour shift duration)
        var token = await _tokenService.GenerateTokenAsync(new TokenRequest
        {
            Subject = employee.Id,
            TenantId = tenant.Id,
            LocationId = request.LocationId,
            RegisterId = request.RegisterId,
            Name = employee.FullName,
            Email = employee.Email,
            Roles = employee.Roles,
            AuthMethod = "pin",
            ExpiresIn = TimeSpan.FromHours(8)
        });

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenant.Id,
            EmployeeId = employee.Id,
            EventType = "PinLogin",
            Details = $"Logged in at {request.LocationId}, register {request.RegisterId}",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        _logger.LogInformation(
            "Employee {EmployeeId} logged in at {LocationId}",
            employee.Id, request.LocationId);

        return Ok(new LoginResponse
        {
            Token = token.AccessToken,
            ExpiresAt = token.ExpiresAt,
            Employee = new EmployeeInfo
            {
                Id = employee.Id,
                Name = employee.FullName,
                Roles = employee.Roles,
                Permissions = employee.Permissions
            }
        });
    }

    /// <summary>
    /// Email/password login for Admin portal
    /// </summary>
    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        // Validate tenant
        var tenant = await _tenantService.GetBySubdomainAsync(
            request.TenantSubdomain, ct);

        if (tenant is null || !tenant.IsActive)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Validate user credentials
        var user = await _userService.ValidateCredentialsAsync(
            tenant.Id, request.Email, request.Password, ct);

        if (user is null)
        {
            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = tenant.Id,
                EventType = "AuthFailure",
                Details = $"Failed login attempt for {request.Email}",
                IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
            }, ct);

            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Credentials",
                Detail = "The provided credentials are invalid."
            });
        }

        // Check if MFA is required
        if (user.MfaEnabled)
        {
            if (string.IsNullOrEmpty(request.MfaCode))
            {
                return Ok(new LoginResponse
                {
                    RequiresMfa = true,
                    MfaToken = await _tokenService.GenerateMfaTokenAsync(user.Id)
                });
            }

            var mfaValid = await _userService.ValidateMfaCodeAsync(
                user.Id, request.MfaCode, ct);

            if (!mfaValid)
            {
                return Unauthorized(new ProblemDetails
                {
                    Title = "Invalid MFA Code",
                    Detail = "The provided MFA code is incorrect."
                });
            }
        }

        // Generate tokens (24-hour access, 7-day refresh)
        var token = await _tokenService.GenerateTokenAsync(new TokenRequest
        {
            Subject = user.Id,
            TenantId = tenant.Id,
            Name = user.FullName,
            Email = user.Email,
            Roles = user.Roles,
            AuthMethod = "password",
            ExpiresIn = TimeSpan.FromHours(24),
            IncludeRefreshToken = true
        });

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenant.Id,
            UserId = user.Id,
            EventType = "Login",
            Details = "Admin portal login",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        return Ok(new LoginResponse
        {
            Token = token.AccessToken,
            RefreshToken = token.RefreshToken,
            ExpiresAt = token.ExpiresAt,
            User = new UserInfo
            {
                Id = user.Id,
                Name = user.FullName,
                Email = user.Email,
                Roles = user.Roles,
                Permissions = user.Permissions
            }
        });
    }

    /// <summary>
    /// Refresh access token using refresh token
    /// </summary>
    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> RefreshToken(
        [FromBody] RefreshTokenRequest request,
        CancellationToken ct)
    {
        var result = await _tokenService.RefreshTokenAsync(
            request.RefreshToken, ct);

        if (!result.IsSuccess)
        {
            return Unauthorized(new ProblemDetails
            {
                Title = "Invalid Token",
                Detail = "The refresh token is invalid or expired."
            });
        }

        return Ok(new LoginResponse
        {
            Token = result.Value!.AccessToken,
            RefreshToken = result.Value.RefreshToken,
            ExpiresAt = result.Value.ExpiresAt
        });
    }

    /// <summary>
    /// Logout and invalidate tokens
    /// </summary>
    [HttpPost("logout")]
    [Authorize]
    public async Task<IActionResult> Logout(CancellationToken ct)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");

        await _tokenService.RevokeAllTokensAsync(userId!, ct);

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = tenantId!,
            UserId = userId,
            EventType = "Logout",
            IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
        }, ct);

        return NoContent();
    }

    /// <summary>
    /// Change PIN (for POS employees)
    /// </summary>
    [HttpPost("change-pin")]
    [Authorize]
    public async Task<IActionResult> ChangePin(
        [FromBody] ChangePinRequest request,
        CancellationToken ct)
    {
        var employeeId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");

        var result = await _employeeService.ChangePinAsync(
            tenantId!, employeeId!, request.CurrentPin, request.NewPin, ct);

        if (!result.IsSuccess)
        {
            return BadRequest(new ProblemDetails
            {
                Title = "PIN Change Failed",
                Detail = result.Error!.Message
            });
        }

        return NoContent();
    }

    /// <summary>
    /// Validate current session
    /// </summary>
    [HttpGet("me")]
    [Authorize]
    public async Task<ActionResult<SessionInfo>> GetCurrentSession(
        CancellationToken ct)
    {
        var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
        var tenantId = User.FindFirstValue("tid");
        var authMethod = User.FindFirstValue("auth_method");

        if (authMethod == "pin")
        {
            var employee = await _employeeService.GetByIdAsync(
                tenantId!, userId!, ct);

            return Ok(new SessionInfo
            {
                UserId = userId!,
                TenantId = tenantId!,
                Name = employee!.FullName,
                Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
                Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
                LocationId = User.FindFirstValue("lid"),
                RegisterId = User.FindFirstValue("rid"),
                AuthMethod = authMethod
            });
        }
        else
        {
            var user = await _userService.GetByIdAsync(tenantId!, userId!, ct);

            return Ok(new SessionInfo
            {
                UserId = userId!,
                TenantId = tenantId!,
                Name = user!.FullName,
                Email = user.Email,
                Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
                Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
                AuthMethod = authMethod!
            });
        }
    }
}

17.5 Token Service Implementation

// File: src/POS.Infrastructure/Security/TokenService.cs
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;

namespace POS.Infrastructure.Security;

public class TokenService : ITokenService
{
    private readonly JwtSettings _jwtSettings;
    private readonly IRefreshTokenRepository _refreshTokenRepo;
    private readonly IRolePermissionResolver _permissionResolver;
    private readonly ILogger<TokenService> _logger;

    public TokenService(
        IOptions<JwtSettings> jwtSettings,
        IRefreshTokenRepository refreshTokenRepo,
        IRolePermissionResolver permissionResolver,
        ILogger<TokenService> logger)
    {
        _jwtSettings = jwtSettings.Value;
        _refreshTokenRepo = refreshTokenRepo;
        _permissionResolver = permissionResolver;
        _logger = logger;
    }

    public async Task<TokenResult> GenerateTokenAsync(TokenRequest request)
    {
        var permissions = await _permissionResolver.ResolvePermissionsAsync(
            request.Roles);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, request.Subject),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new("tid", request.TenantId),
            new("name", request.Name),
            new("auth_method", request.AuthMethod)
        };

        if (!string.IsNullOrEmpty(request.Email))
            claims.Add(new Claim(JwtRegisteredClaimNames.Email, request.Email));

        if (!string.IsNullOrEmpty(request.LocationId))
            claims.Add(new Claim("lid", request.LocationId));

        if (!string.IsNullOrEmpty(request.RegisterId))
            claims.Add(new Claim("rid", request.RegisterId));

        foreach (var role in request.Roles)
            claims.Add(new Claim(ClaimTypes.Role, role));

        foreach (var permission in permissions)
            claims.Add(new Claim("permission", permission));

        var key = new SymmetricSecurityKey(
            Convert.FromBase64String(_jwtSettings.SecretKey));

        var credentials = new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256);

        var expires = DateTime.UtcNow.Add(request.ExpiresIn);

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: expires,
            signingCredentials: credentials
        );

        var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

        var result = new TokenResult
        {
            AccessToken = accessToken,
            ExpiresAt = expires
        };

        if (request.IncludeRefreshToken)
        {
            var refreshToken = GenerateRefreshToken();
            await _refreshTokenRepo.StoreAsync(new RefreshTokenEntity
            {
                Token = refreshToken,
                UserId = request.Subject,
                TenantId = request.TenantId,
                ExpiresAt = DateTime.UtcNow.AddDays(7),
                CreatedAt = DateTime.UtcNow
            });

            result.RefreshToken = refreshToken;
        }

        return result;
    }

    public async Task<Result<TokenResult>> RefreshTokenAsync(
        string refreshToken,
        CancellationToken ct = default)
    {
        var stored = await _refreshTokenRepo.GetByTokenAsync(refreshToken, ct);

        if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
        {
            return Result<TokenResult>.Failure(
                DomainError.InvalidToken("Refresh token is invalid or expired"));
        }

        // Revoke old refresh token
        await _refreshTokenRepo.RevokeAsync(refreshToken, ct);

        // Generate new tokens
        var newToken = await GenerateTokenAsync(new TokenRequest
        {
            Subject = stored.UserId,
            TenantId = stored.TenantId,
            Name = stored.UserName,
            Email = stored.Email,
            Roles = stored.Roles,
            AuthMethod = "password",
            ExpiresIn = TimeSpan.FromHours(24),
            IncludeRefreshToken = true
        });

        return Result<TokenResult>.Success(newToken);
    }

    public async Task RevokeAllTokensAsync(string userId, CancellationToken ct = default)
    {
        await _refreshTokenRepo.RevokeAllForUserAsync(userId, ct);
    }

    private static string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

17.6 Tenant Context Middleware

// File: src/POS.Api/Middleware/TenantContextMiddleware.cs
using System.Security.Claims;

namespace POS.Api.Middleware;

public class TenantContextMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantContextMiddleware> _logger;

    public TenantContextMiddleware(
        RequestDelegate next,
        ILogger<TenantContextMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ITenantContext tenantContext,
        ITenantService tenantService)
    {
        string? tenantId = null;

        // 1. Try from JWT claims (authenticated requests)
        if (context.User.Identity?.IsAuthenticated == true)
        {
            tenantId = context.User.FindFirstValue("tid");
        }

        // 2. Try from subdomain
        if (string.IsNullOrEmpty(tenantId))
        {
            var host = context.Request.Host.Host;
            var subdomain = GetSubdomain(host);

            if (!string.IsNullOrEmpty(subdomain))
            {
                var tenant = await tenantService.GetBySubdomainAsync(
                    subdomain, context.RequestAborted);

                tenantId = tenant?.Id;
            }
        }

        // 3. Try from header (API integrations)
        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
        }

        if (!string.IsNullOrEmpty(tenantId))
        {
            tenantContext.SetTenant(tenantId);

            // Add to response headers for debugging
            context.Response.Headers["X-Tenant-Id"] = tenantId;
        }
        else if (!IsPublicEndpoint(context.Request.Path))
        {
            _logger.LogWarning(
                "Unable to resolve tenant for path {Path}",
                context.Request.Path);

            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Title = "Tenant Required",
                Detail = "Unable to determine tenant context.",
                Status = 400
            });
            return;
        }

        await _next(context);
    }

    private static string? GetSubdomain(string host)
    {
        var parts = host.Split('.');
        if (parts.Length >= 3 && parts[0] != "www" && parts[0] != "api")
        {
            return parts[0];
        }
        return null;
    }

    private static bool IsPublicEndpoint(PathString path)
    {
        var publicPaths = new[]
        {
            "/health",
            "/api/v1/auth/login",
            "/api/v1/auth/pin-login",
            "/swagger"
        };

        return publicPaths.Any(p =>
            path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
    }
}

// Tenant Context Interface and Implementation
public interface ITenantContext
{
    string? TenantId { get; }
    void SetTenant(string tenantId);
}

public class TenantContext : ITenantContext
{
    public string? TenantId { get; private set; }

    public void SetTenant(string tenantId)
    {
        TenantId = tenantId;
    }
}

17.7 API Key Authentication for Integrations

// File: src/POS.Api/Authentication/ApiKeyAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace POS.Api.Authentication;

public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private const string ApiKeyHeaderName = "X-API-Key";
    private readonly IApiKeyService _apiKeyService;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        IApiKeyService apiKeyService)
        : base(options, logger, encoder)
    {
        _apiKeyService = apiKeyService;
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeaderValues))
        {
            return AuthenticateResult.NoResult();
        }

        var providedApiKey = apiKeyHeaderValues.FirstOrDefault();

        if (string.IsNullOrEmpty(providedApiKey))
        {
            return AuthenticateResult.NoResult();
        }

        var apiKey = await _apiKeyService.ValidateApiKeyAsync(
            providedApiKey, Context.RequestAborted);

        if (apiKey is null)
        {
            return AuthenticateResult.Fail("Invalid API key");
        }

        if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt < DateTime.UtcNow)
        {
            return AuthenticateResult.Fail("API key has expired");
        }

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, apiKey.Id),
            new("tid", apiKey.TenantId),
            new("api_key_name", apiKey.Name),
            new("auth_method", "api_key")
        };

        foreach (var scope in apiKey.Scopes)
        {
            claims.Add(new Claim("scope", scope));
        }

        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        // Update last used
        await _apiKeyService.RecordUsageAsync(apiKey.Id, Context.RequestAborted);

        return AuthenticateResult.Success(ticket);
    }
}

public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { }

17.8 Authorization Policies

// File: src/POS.Api/Extensions/AuthorizationExtensions.cs
using Microsoft.AspNetCore.Authorization;

namespace POS.Api.Extensions;

public static class AuthorizationExtensions
{
    public static IServiceCollection AddPosAuthorization(
        this IServiceCollection services)
    {
        services.AddAuthorization(options =>
        {
            // POS Operations
            options.AddPolicy("pos.sale.create",
                policy => policy.RequireClaim("permission", Permissions.PosSaleCreate));

            options.AddPolicy("pos.sale.void",
                policy => policy.RequireClaim("permission", Permissions.PosSaleVoid));

            options.AddPolicy("pos.sale.return",
                policy => policy.RequireClaim("permission", Permissions.PosSaleReturn));

            options.AddPolicy("pos.discount.apply",
                policy => policy.RequireClaim("permission", Permissions.PosDiscountApply));

            // Inventory Operations
            options.AddPolicy("inventory.view",
                policy => policy.RequireClaim("permission", Permissions.InventoryView));

            options.AddPolicy("inventory.adjust",
                policy => policy.RequireClaim("permission", Permissions.InventoryAdjust));

            // Catalog Operations
            options.AddPolicy("catalog.items.read",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemView));

            options.AddPolicy("catalog.items.write",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemCreate));

            options.AddPolicy("catalog.items.delete",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemDelete));

            options.AddPolicy("catalog.items.bulk",
                policy => policy.RequireClaim("permission", Permissions.CatalogItemBulk));

            // Reports
            options.AddPolicy("reports.view",
                policy => policy.RequireClaim("permission", Permissions.ReportsView));

            // Admin
            options.AddPolicy("admin.settings",
                policy => policy.RequireClaim("permission", Permissions.AdminSettings));

            // Role-based policies
            options.AddPolicy("ManagerOrAbove",
                policy => policy.RequireRole(
                    Roles.Manager, Roles.Admin, Roles.Owner));

            options.AddPolicy("AdminOrOwner",
                policy => policy.RequireRole(Roles.Admin, Roles.Owner));

            // Integration API policy
            options.AddPolicy("api.integration",
                policy => policy.RequireAssertion(context =>
                    context.User.HasClaim("auth_method", "api_key") &&
                    context.User.HasClaim("scope", "integration")));
        });

        return services;
    }
}

17.9 Password Hashing

// File: src/POS.Infrastructure/Security/PasswordHasher.cs
using System.Security.Cryptography;

namespace POS.Infrastructure.Security;

public class PasswordHasher : IPasswordHasher
{
    private const int SaltSize = 16;
    private const int HashSize = 32;
    private const int Iterations = 100000;

    public string HashPassword(string password)
    {
        using var algorithm = new Rfc2898DeriveBytes(
            password,
            SaltSize,
            Iterations,
            HashAlgorithmName.SHA256);

        var salt = algorithm.Salt;
        var hash = algorithm.GetBytes(HashSize);

        var hashBytes = new byte[SaltSize + HashSize];
        Array.Copy(salt, 0, hashBytes, 0, SaltSize);
        Array.Copy(hash, 0, hashBytes, SaltSize, HashSize);

        return Convert.ToBase64String(hashBytes);
    }

    public bool VerifyPassword(string password, string hashedPassword)
    {
        var hashBytes = Convert.FromBase64String(hashedPassword);

        var salt = new byte[SaltSize];
        Array.Copy(hashBytes, 0, salt, 0, SaltSize);

        using var algorithm = new Rfc2898DeriveBytes(
            password,
            salt,
            Iterations,
            HashAlgorithmName.SHA256);

        var hash = algorithm.GetBytes(HashSize);

        for (var i = 0; i < HashSize; i++)
        {
            if (hashBytes[SaltSize + i] != hash[i])
                return false;
        }

        return true;
    }
}

public class PinHasher : IPinHasher
{
    public string HashPin(string pin)
    {
        using var sha256 = SHA256.Create();
        var bytes = System.Text.Encoding.UTF8.GetBytes(pin);
        var hash = sha256.ComputeHash(bytes);
        return Convert.ToBase64String(hash);
    }

    public bool VerifyPin(string pin, string hashedPin)
    {
        var hash = HashPin(pin);
        return hash == hashedPin;
    }
}

Summary

This chapter covered the complete security implementation:

  • Dual authentication flows: PIN for POS, Email/Password for Admin
  • JWT token structure with tenant, location, and permission claims
  • Role-based permission matrix from Cashier to Owner
  • Complete AuthController with all authentication endpoints
  • Tenant context middleware for multi-tenant isolation
  • API key authentication for external integrations

Next: Chapter 18 covers integration patterns for Shopify and payment processing.

Chapter 18: Integration Patterns

Shopify, Payment Processing, and External API Integration

This chapter provides complete implementation patterns for integrating with Shopify, payment processors (Stripe/Square), and external APIs with PCI-DSS compliance.


18.1 Integration Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                          POS Platform                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────┐   ┌─────────────────┐   ┌─────────────────────────┐   │
│  │   Shopify       │   │   Payment       │   │   Other Integrations   │   │
│  │   Integration   │   │   Processing    │   │   (Accounting, etc.)   │   │
│  └────────┬────────┘   └────────┬────────┘   └───────────┬─────────────┘   │
│           │                     │                        │                  │
│           ▼                     ▼                        ▼                  │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                    Integration Service Layer                         │   │
│  │  • Webhook handlers       • Payment abstraction                      │   │
│  │  • Retry logic            • Token management                         │   │
│  │  • Event publishing       • Audit logging                            │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
                │                     │                        │
                ▼                     ▼                        ▼
        ┌───────────────┐   ┌───────────────┐        ┌───────────────┐
        │    Shopify    │   │  Stripe API   │        │  QuickBooks   │
        │   Admin API   │   │  Square API   │        │   Online      │
        └───────────────┘   └───────────────┘        └───────────────┘

18.2 Shopify Integration

18.2.1 Webhook Configuration

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookConfig.cs
namespace POS.Infrastructure.Integrations.Shopify;

public static class ShopifyWebhookTopics
{
    // Order webhooks
    public const string OrdersCreate = "orders/create";
    public const string OrdersUpdated = "orders/updated";
    public const string OrdersCancelled = "orders/cancelled";
    public const string OrdersFulfilled = "orders/fulfilled";
    public const string OrdersPaid = "orders/paid";

    // Inventory webhooks
    public const string InventoryLevelsUpdate = "inventory_levels/update";
    public const string InventoryLevelsConnect = "inventory_levels/connect";
    public const string InventoryLevelsDisconnect = "inventory_levels/disconnect";

    // Product webhooks
    public const string ProductsCreate = "products/create";
    public const string ProductsUpdate = "products/update";
    public const string ProductsDelete = "products/delete";

    // Customer webhooks
    public const string CustomersCreate = "customers/create";
    public const string CustomersUpdate = "customers/update";

    // Refund webhooks
    public const string RefundsCreate = "refunds/create";
}

18.2.2 Webhook Controller

// File: src/POS.Api/Controllers/ShopifyWebhookController.cs
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/webhooks/shopify")]
public class ShopifyWebhookController : ControllerBase
{
    private readonly IShopifyWebhookHandler _webhookHandler;
    private readonly IShopifyCredentialService _credentialService;
    private readonly ILogger<ShopifyWebhookController> _logger;

    public ShopifyWebhookController(
        IShopifyWebhookHandler webhookHandler,
        IShopifyCredentialService credentialService,
        ILogger<ShopifyWebhookController> logger)
    {
        _webhookHandler = webhookHandler;
        _credentialService = credentialService;
        _logger = logger;
    }

    [HttpPost("{tenantId}")]
    public async Task<IActionResult> HandleWebhook(
        string tenantId,
        CancellationToken ct)
    {
        // Read raw body for HMAC verification
        Request.EnableBuffering();
        using var reader = new StreamReader(Request.Body, leaveOpen: true);
        var rawBody = await reader.ReadToEndAsync();
        Request.Body.Position = 0;

        // Verify HMAC signature
        var hmacHeader = Request.Headers["X-Shopify-Hmac-Sha256"].FirstOrDefault();
        if (string.IsNullOrEmpty(hmacHeader))
        {
            _logger.LogWarning("Missing HMAC header for tenant {TenantId}", tenantId);
            return Unauthorized();
        }

        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
        {
            _logger.LogWarning("No Shopify credentials for tenant {TenantId}", tenantId);
            return NotFound();
        }

        if (!VerifyHmac(rawBody, hmacHeader, credentials.WebhookSecret))
        {
            _logger.LogWarning("Invalid HMAC for tenant {TenantId}", tenantId);
            return Unauthorized();
        }

        // Extract webhook topic
        var topic = Request.Headers["X-Shopify-Topic"].FirstOrDefault();
        var shopDomain = Request.Headers["X-Shopify-Shop-Domain"].FirstOrDefault();
        var webhookId = Request.Headers["X-Shopify-Webhook-Id"].FirstOrDefault();

        _logger.LogInformation(
            "Received Shopify webhook {Topic} from {Shop} for tenant {TenantId}",
            topic, shopDomain, tenantId);

        // Queue for processing (respond quickly to Shopify)
        await _webhookHandler.QueueWebhookAsync(new ShopifyWebhookEvent
        {
            TenantId = tenantId,
            Topic = topic!,
            ShopDomain = shopDomain!,
            WebhookId = webhookId!,
            Payload = rawBody,
            ReceivedAt = DateTime.UtcNow
        }, ct);

        return Ok();
    }

    private static bool VerifyHmac(string body, string hmacHeader, string secret)
    {
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
        var computedHmac = Convert.ToBase64String(hash);
        return hmacHeader == computedHmac;
    }
}

18.2.3 Webhook Handler Implementation

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookHandler.cs
using System.Text.Json;
using MassTransit;

namespace POS.Infrastructure.Integrations.Shopify;

public class ShopifyWebhookHandler : IShopifyWebhookHandler
{
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly IInventoryService _inventoryService;
    private readonly IOrderService _orderService;
    private readonly IItemService _itemService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<ShopifyWebhookHandler> _logger;

    public ShopifyWebhookHandler(
        IPublishEndpoint publishEndpoint,
        IInventoryService inventoryService,
        IOrderService orderService,
        IItemService itemService,
        ITenantContext tenantContext,
        ILogger<ShopifyWebhookHandler> logger)
    {
        _publishEndpoint = publishEndpoint;
        _inventoryService = inventoryService;
        _orderService = orderService;
        _itemService = itemService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task QueueWebhookAsync(
        ShopifyWebhookEvent webhook,
        CancellationToken ct)
    {
        // Publish to message queue for async processing
        await _publishEndpoint.Publish(webhook, ct);
    }

    public async Task ProcessWebhookAsync(
        ShopifyWebhookEvent webhook,
        CancellationToken ct)
    {
        _tenantContext.SetTenant(webhook.TenantId);

        try
        {
            switch (webhook.Topic)
            {
                case ShopifyWebhookTopics.OrdersCreate:
                    await HandleOrderCreatedAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.OrdersUpdated:
                    await HandleOrderUpdatedAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.OrdersCancelled:
                    await HandleOrderCancelledAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.InventoryLevelsUpdate:
                    await HandleInventoryUpdateAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.ProductsCreate:
                case ShopifyWebhookTopics.ProductsUpdate:
                    await HandleProductUpdateAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.ProductsDelete:
                    await HandleProductDeleteAsync(webhook.Payload, ct);
                    break;

                default:
                    _logger.LogWarning(
                        "Unhandled webhook topic: {Topic}",
                        webhook.Topic);
                    break;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Error processing webhook {Topic} for tenant {TenantId}",
                webhook.Topic, webhook.TenantId);
            throw;
        }
    }

    private async Task HandleOrderCreatedAsync(string payload, CancellationToken ct)
    {
        var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (order is null) return;

        _logger.LogInformation(
            "Processing Shopify order {OrderNumber} ({OrderId})",
            order.OrderNumber, order.Id);

        // Import order to POS system
        var importResult = await _orderService.ImportShopifyOrderAsync(
            new ImportShopifyOrderCommand
            {
                ShopifyOrderId = order.Id.ToString(),
                OrderNumber = order.OrderNumber,
                CustomerEmail = order.Email,
                CustomerName = $"{order.Customer?.FirstName} {order.Customer?.LastName}",
                TotalPrice = order.TotalPrice,
                Currency = order.Currency,
                LineItems = order.LineItems.Select(li => new ImportedLineItem
                {
                    ShopifyLineItemId = li.Id.ToString(),
                    Sku = li.Sku,
                    Title = li.Title,
                    Quantity = li.Quantity,
                    Price = li.Price,
                    VariantId = li.VariantId?.ToString()
                }).ToList(),
                FulfillmentStatus = order.FulfillmentStatus,
                FinancialStatus = order.FinancialStatus,
                ShippingAddress = order.ShippingAddress != null
                    ? new AddressDto
                    {
                        Address1 = order.ShippingAddress.Address1,
                        Address2 = order.ShippingAddress.Address2,
                        City = order.ShippingAddress.City,
                        Province = order.ShippingAddress.Province,
                        Zip = order.ShippingAddress.Zip,
                        Country = order.ShippingAddress.Country
                    }
                    : null,
                CreatedAt = order.CreatedAt
            }, ct);

        if (!importResult.IsSuccess)
        {
            _logger.LogError(
                "Failed to import Shopify order {OrderNumber}: {Error}",
                order.OrderNumber, importResult.Error?.Message);
        }
    }

    private async Task HandleInventoryUpdateAsync(string payload, CancellationToken ct)
    {
        var update = JsonSerializer.Deserialize<ShopifyInventoryLevel>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (update is null) return;

        _logger.LogInformation(
            "Processing inventory update for variant {InventoryItemId} at location {LocationId}",
            update.InventoryItemId, update.LocationId);

        // Find item by Shopify inventory item ID
        var item = await _itemService.GetByShopifyInventoryItemIdAsync(
            update.InventoryItemId.ToString(), ct);

        if (item is null)
        {
            _logger.LogWarning(
                "Item not found for Shopify inventory item {InventoryItemId}",
                update.InventoryItemId);
            return;
        }

        // Find POS location by Shopify location ID
        var location = await _inventoryService.GetLocationByShopifyIdAsync(
            update.LocationId.ToString(), ct);

        if (location is null)
        {
            _logger.LogWarning(
                "Location not found for Shopify location {LocationId}",
                update.LocationId);
            return;
        }

        // Update inventory (from Shopify, not triggering sync back)
        await _inventoryService.SyncFromShopifyAsync(
            new SyncInventoryCommand
            {
                ItemId = item.Id,
                LocationId = location.Id,
                Quantity = update.Available,
                Source = "shopify_webhook",
                ShopifyUpdatedAt = update.UpdatedAt
            }, ct);
    }

    private async Task HandleProductUpdateAsync(string payload, CancellationToken ct)
    {
        var product = JsonSerializer.Deserialize<ShopifyProduct>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (product is null) return;

        _logger.LogInformation(
            "Processing product update for {ProductTitle} ({ProductId})",
            product.Title, product.Id);

        foreach (var variant in product.Variants)
        {
            var existingItem = await _itemService.GetByShopifyVariantIdAsync(
                variant.Id.ToString(), ct);

            if (existingItem is not null)
            {
                // Update existing item
                await _itemService.UpdateFromShopifyAsync(
                    existingItem.Id,
                    new UpdateFromShopifyCommand
                    {
                        Name = $"{product.Title} - {variant.Title}",
                        Sku = variant.Sku,
                        Barcode = variant.Barcode,
                        Price = variant.Price,
                        CompareAtPrice = variant.CompareAtPrice,
                        Weight = variant.Weight,
                        WeightUnit = variant.WeightUnit
                    }, ct);
            }
            else
            {
                _logger.LogInformation(
                    "New Shopify variant {VariantId} not linked to POS item",
                    variant.Id);
            }
        }
    }

    private async Task HandleOrderCancelledAsync(string payload, CancellationToken ct)
    {
        var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (order is null) return;

        await _orderService.CancelShopifyOrderAsync(order.Id.ToString(), ct);
    }

    private async Task HandleProductDeleteAsync(string payload, CancellationToken ct)
    {
        var deleteEvent = JsonSerializer.Deserialize<ShopifyProductDelete>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (deleteEvent is null) return;

        _logger.LogInformation(
            "Shopify product {ProductId} deleted - marking POS items as inactive",
            deleteEvent.Id);

        await _itemService.DeactivateByShopifyProductIdAsync(
            deleteEvent.Id.ToString(), ct);
    }
}

18.2.4 Shopify API Client

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyClient.cs
using System.Net.Http.Json;
using System.Text.Json;

namespace POS.Infrastructure.Integrations.Shopify;

public class ShopifyClient : IShopifyClient
{
    private readonly HttpClient _httpClient;
    private readonly IShopifyCredentialService _credentialService;
    private readonly ILogger<ShopifyClient> _logger;

    public ShopifyClient(
        HttpClient httpClient,
        IShopifyCredentialService credentialService,
        ILogger<ShopifyClient> logger)
    {
        _httpClient = httpClient;
        _credentialService = credentialService;
        _logger = logger;
    }

    public async Task<bool> UpdateInventoryLevelAsync(
        string tenantId,
        string inventoryItemId,
        string locationId,
        int quantity,
        CancellationToken ct)
    {
        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
            throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");

        var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";

        var request = new HttpRequestMessage(HttpMethod.Post,
            $"{baseUrl}/inventory_levels/set.json");

        request.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
        request.Content = JsonContent.Create(new
        {
            inventory_item_id = long.Parse(inventoryItemId),
            location_id = long.Parse(locationId),
            available = quantity
        });

        var response = await _httpClient.SendAsync(request, ct);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "Failed to update Shopify inventory: {StatusCode} - {Error}",
                response.StatusCode, error);
            return false;
        }

        return true;
    }

    public async Task<bool> FulfillOrderAsync(
        string tenantId,
        string orderId,
        string locationId,
        IEnumerable<FulfillmentLineItem> lineItems,
        string? trackingNumber,
        string? trackingCompany,
        CancellationToken ct)
    {
        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
            throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");

        var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";

        // First, get fulfillment order
        var fulfillmentOrderRequest = new HttpRequestMessage(HttpMethod.Get,
            $"{baseUrl}/orders/{orderId}/fulfillment_orders.json");
        fulfillmentOrderRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);

        var foResponse = await _httpClient.SendAsync(fulfillmentOrderRequest, ct);
        if (!foResponse.IsSuccessStatusCode)
        {
            _logger.LogError("Failed to get fulfillment orders for order {OrderId}", orderId);
            return false;
        }

        var foResult = await foResponse.Content.ReadFromJsonAsync<FulfillmentOrdersResponse>(ct);
        var fulfillmentOrder = foResult?.FulfillmentOrders?.FirstOrDefault();

        if (fulfillmentOrder is null)
        {
            _logger.LogWarning("No fulfillment order found for order {OrderId}", orderId);
            return false;
        }

        // Create fulfillment
        var fulfillmentRequest = new HttpRequestMessage(HttpMethod.Post,
            $"{baseUrl}/fulfillments.json");
        fulfillmentRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);

        var fulfillmentPayload = new
        {
            fulfillment = new
            {
                line_items_by_fulfillment_order = new[]
                {
                    new
                    {
                        fulfillment_order_id = fulfillmentOrder.Id,
                        fulfillment_order_line_items = lineItems.Select(li => new
                        {
                            id = li.FulfillmentOrderLineItemId,
                            quantity = li.Quantity
                        }).ToArray()
                    }
                },
                tracking_info = !string.IsNullOrEmpty(trackingNumber) ? new
                {
                    number = trackingNumber,
                    company = trackingCompany
                } : null,
                notify_customer = true
            }
        };

        fulfillmentRequest.Content = JsonContent.Create(fulfillmentPayload);

        var response = await _httpClient.SendAsync(fulfillmentRequest, ct);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "Failed to create Shopify fulfillment: {StatusCode} - {Error}",
                response.StatusCode, error);
            return false;
        }

        return true;
    }
}

18.3 Payment Processing

18.3.1 PCI-DSS Compliance Pattern

┌─────────────────────────────────────────────────────────────────────────────┐
│                     PCI-DSS Compliant Payment Flow                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. Card Data NEVER touches POS server                                     │
│   2. Use payment terminal or tokenization                                   │
│   3. Only store payment tokens                                              │
│                                                                             │
│   ┌─────────────┐        ┌─────────────┐        ┌─────────────┐            │
│   │  Customer   │        │  Payment    │        │  Payment    │            │
│   │  Card       │───────►│  Terminal   │───────►│  Processor  │            │
│   └─────────────┘        └─────────────┘        └──────┬──────┘            │
│                                                        │                    │
│                                                        ▼                    │
│   ┌─────────────┐        ┌─────────────┐        ┌─────────────┐            │
│   │    POS      │◄───────│  Token +    │◄───────│  Response   │            │
│   │   Server    │        │  Last 4     │        │  (Success)  │            │
│   └─────────────┘        └─────────────┘        └─────────────┘            │
│                                                                             │
│   Stored: payment_token, card_last_4, card_brand                           │
│   NOT Stored: card_number, cvv, expiry                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

18.3.2 Payment Service Interface

// File: src/POS.Application/Interfaces/IPaymentService.cs
namespace POS.Application.Interfaces;

public interface IPaymentService
{
    Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default);

    Task<Result<RefundResult>> ProcessRefundAsync(
        ProcessRefundCommand command,
        CancellationToken ct = default);

    Task<Result> VoidPaymentAsync(
        string transactionId,
        CancellationToken ct = default);

    Task<PaymentMethodsResult> GetAvailableMethodsAsync(
        string locationId,
        CancellationToken ct = default);

    // Terminal operations
    Task<TerminalStatus> GetTerminalStatusAsync(
        string terminalId,
        CancellationToken ct = default);

    Task<Result<TerminalPaymentIntent>> CreateTerminalPaymentIntentAsync(
        CreateTerminalPaymentCommand command,
        CancellationToken ct = default);

    Task<Result<PaymentResult>> CaptureTerminalPaymentAsync(
        string paymentIntentId,
        CancellationToken ct = default);

    Task<Result> CancelTerminalPaymentAsync(
        string paymentIntentId,
        CancellationToken ct = default);
}

18.3.3 Stripe Terminal Integration

// File: src/POS.Infrastructure/Payments/StripePaymentService.cs
using Stripe;
using Stripe.Terminal;

namespace POS.Infrastructure.Payments;

public class StripePaymentService : IPaymentService
{
    private readonly IPaymentCredentialService _credentialService;
    private readonly ITenantContext _tenantContext;
    private readonly IAuditLogger _auditLogger;
    private readonly ILogger<StripePaymentService> _logger;

    public StripePaymentService(
        IPaymentCredentialService credentialService,
        ITenantContext tenantContext,
        IAuditLogger auditLogger,
        ILogger<StripePaymentService> logger)
    {
        _credentialService = credentialService;
        _tenantContext = tenantContext;
        _auditLogger = auditLogger;
        _logger = logger;
    }

    public async Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<PaymentResult>.Failure(
                DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            switch (command.Method)
            {
                case PaymentMethod.CreditCard when command.TerminalId is not null:
                    return await ProcessTerminalPaymentAsync(command, ct);

                case PaymentMethod.CreditCard when command.PaymentToken is not null:
                    return await ProcessTokenPaymentAsync(command, ct);

                case PaymentMethod.Cash:
                    return await ProcessCashPaymentAsync(command, ct);

                default:
                    return Result<PaymentResult>.Failure(
                        DomainError.InvalidPaymentMethod(command.Method.ToString()));
            }
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe payment failed: {Code}", ex.StripeError?.Code);

            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    private async Task<Result<PaymentResult>> ProcessTerminalPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        _logger.LogInformation(
            "Processing terminal payment of {Amount} on terminal {TerminalId}",
            command.Amount, command.TerminalId);

        // Create PaymentIntent
        var paymentIntentService = new PaymentIntentService();
        var paymentIntent = await paymentIntentService.CreateAsync(
            new PaymentIntentCreateOptions
            {
                Amount = (long)(command.Amount * 100), // Convert to cents
                Currency = "usd",
                PaymentMethodTypes = new List<string> { "card_present" },
                CaptureMethod = "automatic",
                Metadata = new Dictionary<string, string>
                {
                    ["order_id"] = command.OrderId,
                    ["tenant_id"] = _tenantContext.TenantId!,
                    ["location_id"] = command.LocationId
                }
            }, cancellationToken: ct);

        // Process on terminal
        var readerService = new ReaderService();
        var processResult = await readerService.ProcessPaymentIntentAsync(
            command.TerminalId,
            new ReaderProcessPaymentIntentOptions
            {
                PaymentIntent = paymentIntent.Id
            }, cancellationToken: ct);

        // Wait for payment to complete (simplified - real impl would poll)
        var updatedIntent = await WaitForPaymentCompletionAsync(
            paymentIntent.Id, TimeSpan.FromSeconds(60), ct);

        if (updatedIntent.Status != "succeeded")
        {
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(
                    $"Terminal payment failed with status: {updatedIntent.Status}"));
        }

        var charge = updatedIntent.LatestCharge;

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = _tenantContext.TenantId!,
            EventType = "PaymentProcessed",
            Details = $"Card payment {command.Amount:C} via terminal {command.TerminalId}",
            ReferenceId = paymentIntent.Id,
            ReferenceType = "StripePaymentIntent"
        }, ct);

        return Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = paymentIntent.Id,
            ChargeId = charge?.Id,
            Amount = command.Amount,
            CardLast4 = charge?.PaymentMethodDetails?.CardPresent?.Last4,
            CardBrand = charge?.PaymentMethodDetails?.CardPresent?.Brand,
            AuthorizationCode = charge?.AuthorizationCode
        });
    }

    private async Task<Result<PaymentResult>> ProcessTokenPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        _logger.LogInformation(
            "Processing token payment of {Amount}",
            command.Amount);

        var paymentIntentService = new PaymentIntentService();
        var paymentIntent = await paymentIntentService.CreateAsync(
            new PaymentIntentCreateOptions
            {
                Amount = (long)(command.Amount * 100),
                Currency = "usd",
                PaymentMethod = command.PaymentToken,
                Confirm = true,
                Metadata = new Dictionary<string, string>
                {
                    ["order_id"] = command.OrderId,
                    ["tenant_id"] = _tenantContext.TenantId!
                }
            }, cancellationToken: ct);

        if (paymentIntent.Status != "succeeded")
        {
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(
                    $"Payment failed with status: {paymentIntent.Status}"));
        }

        var charge = paymentIntent.LatestCharge;

        return Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = paymentIntent.Id,
            ChargeId = charge?.Id,
            Amount = command.Amount,
            CardLast4 = charge?.PaymentMethodDetails?.Card?.Last4,
            CardBrand = charge?.PaymentMethodDetails?.Card?.Brand
        });
    }

    private Task<Result<PaymentResult>> ProcessCashPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        // Cash payments don't need external processing
        var transactionId = $"CASH-{Guid.NewGuid():N}"[..24];

        _logger.LogInformation(
            "Recording cash payment of {Amount}",
            command.Amount);

        return Task.FromResult(Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = transactionId,
            Amount = command.Amount
        }));
    }

    public async Task<Result<RefundResult>> ProcessRefundAsync(
        ProcessRefundCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<RefundResult>.Failure(
                DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            var refundService = new RefundService();
            var refund = await refundService.CreateAsync(
                new RefundCreateOptions
                {
                    PaymentIntent = command.OriginalTransactionId,
                    Amount = (long)(command.Amount * 100),
                    Reason = MapRefundReason(command.Reason),
                    Metadata = new Dictionary<string, string>
                    {
                        ["refund_order_id"] = command.RefundOrderId,
                        ["original_order_id"] = command.OriginalOrderId
                    }
                }, cancellationToken: ct);

            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = _tenantContext.TenantId!,
                EventType = "RefundProcessed",
                Details = $"Refund {command.Amount:C} for order {command.OriginalOrderId}",
                ReferenceId = refund.Id,
                ReferenceType = "StripeRefund"
            }, ct);

            return Result<RefundResult>.Success(new RefundResult
            {
                Success = true,
                TransactionId = refund.Id,
                Amount = command.Amount,
                Status = refund.Status
            });
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe refund failed: {Code}", ex.StripeError?.Code);

            return Result<RefundResult>.Failure(
                DomainError.RefundFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    public async Task<Result> VoidPaymentAsync(
        string transactionId,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result.Failure(DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            var paymentIntentService = new PaymentIntentService();
            await paymentIntentService.CancelAsync(transactionId, cancellationToken: ct);

            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = _tenantContext.TenantId!,
                EventType = "PaymentVoided",
                ReferenceId = transactionId,
                ReferenceType = "StripePaymentIntent"
            }, ct);

            return Result.Success();
        }
        catch (StripeException ex) when (ex.StripeError?.Code == "payment_intent_unexpected_state")
        {
            // Already captured - need to refund instead
            var refundService = new RefundService();
            await refundService.CreateAsync(
                new RefundCreateOptions { PaymentIntent = transactionId },
                cancellationToken: ct);

            return Result.Success();
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe void failed: {Code}", ex.StripeError?.Code);
            return Result.Failure(
                DomainError.VoidFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    private async Task<PaymentIntent> WaitForPaymentCompletionAsync(
        string paymentIntentId,
        TimeSpan timeout,
        CancellationToken ct)
    {
        var paymentIntentService = new PaymentIntentService();
        var startTime = DateTime.UtcNow;

        while (DateTime.UtcNow - startTime < timeout)
        {
            var intent = await paymentIntentService.GetAsync(
                paymentIntentId, cancellationToken: ct);

            if (intent.Status is "succeeded" or "canceled" or "requires_payment_method")
            {
                return intent;
            }

            await Task.Delay(1000, ct);
        }

        throw new TimeoutException("Payment processing timed out");
    }

    private static string MapRefundReason(RefundReason reason) => reason switch
    {
        RefundReason.CustomerRequest => "requested_by_customer",
        RefundReason.Duplicate => "duplicate",
        RefundReason.Fraudulent => "fraudulent",
        _ => "requested_by_customer"
    };
}

18.3.4 Square Integration Pattern

// File: src/POS.Infrastructure/Payments/SquarePaymentService.cs
using Square;
using Square.Models;

namespace POS.Infrastructure.Payments;

public class SquarePaymentService : IPaymentService
{
    private readonly IPaymentCredentialService _credentialService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<SquarePaymentService> _logger;

    public SquarePaymentService(
        IPaymentCredentialService credentialService,
        ITenantContext tenantContext,
        ILogger<SquarePaymentService> logger)
    {
        _credentialService = credentialService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetSquareCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<PaymentResult>.Failure(
                DomainError.PaymentNotConfigured("Square"));

        var client = new SquareClient.Builder()
            .Environment(credentials.IsSandbox
                ? Square.Environment.Sandbox
                : Square.Environment.Production)
            .AccessToken(credentials.AccessToken)
            .Build();

        try
        {
            // Create terminal checkout for card-present
            if (command.TerminalId is not null)
            {
                var checkoutRequest = new CreateTerminalCheckoutRequest.Builder(
                    Guid.NewGuid().ToString(),
                    new TerminalCheckout.Builder(
                        new Money.Builder()
                            .Amount((long)(command.Amount * 100))
                            .Currency("USD")
                            .Build(),
                        command.TerminalId)
                        .ReferenceId(command.OrderId)
                        .Build())
                    .Build();

                var checkoutResponse = await client.TerminalApi.CreateTerminalCheckoutAsync(
                    checkoutRequest);

                if (checkoutResponse.Errors?.Any() == true)
                {
                    var error = checkoutResponse.Errors.First();
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(error.Detail));
                }

                var checkout = checkoutResponse.Checkout;

                // Poll for completion
                var completedCheckout = await WaitForCheckoutCompletionAsync(
                    client, checkout.Id, TimeSpan.FromSeconds(60), ct);

                if (completedCheckout.Status != "COMPLETED")
                {
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(
                            $"Checkout failed with status: {completedCheckout.Status}"));
                }

                return Result<PaymentResult>.Success(new PaymentResult
                {
                    Success = true,
                    TransactionId = completedCheckout.PaymentIds?.FirstOrDefault(),
                    Amount = command.Amount,
                    CardLast4 = completedCheckout.CardDetails?.Card?.Last4,
                    CardBrand = completedCheckout.CardDetails?.Card?.CardBrand
                });
            }
            else
            {
                // Token-based payment
                var paymentRequest = new CreatePaymentRequest.Builder(
                    command.PaymentToken!,
                    Guid.NewGuid().ToString())
                    .AmountMoney(new Money.Builder()
                        .Amount((long)(command.Amount * 100))
                        .Currency("USD")
                        .Build())
                    .LocationId(credentials.LocationId)
                    .ReferenceId(command.OrderId)
                    .Build();

                var paymentResponse = await client.PaymentsApi.CreatePaymentAsync(
                    paymentRequest);

                if (paymentResponse.Errors?.Any() == true)
                {
                    var error = paymentResponse.Errors.First();
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(error.Detail));
                }

                var payment = paymentResponse.Payment;

                return Result<PaymentResult>.Success(new PaymentResult
                {
                    Success = true,
                    TransactionId = payment.Id,
                    Amount = command.Amount,
                    CardLast4 = payment.CardDetails?.Card?.Last4,
                    CardBrand = payment.CardDetails?.Card?.CardBrand
                });
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Square payment failed");
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(ex.Message));
        }
    }

    private async Task<TerminalCheckout> WaitForCheckoutCompletionAsync(
        SquareClient client,
        string checkoutId,
        TimeSpan timeout,
        CancellationToken ct)
    {
        var startTime = DateTime.UtcNow;

        while (DateTime.UtcNow - startTime < timeout)
        {
            var response = await client.TerminalApi.GetTerminalCheckoutAsync(checkoutId);
            var checkout = response.Checkout;

            if (checkout.Status is "COMPLETED" or "CANCELED")
            {
                return checkout;
            }

            await Task.Delay(1000, ct);
        }

        throw new TimeoutException("Checkout processing timed out");
    }

    // ... other interface methods
}

18.4 External API Patterns

18.4.1 Retry with Polly

// File: src/POS.Infrastructure/Http/HttpClientConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;

namespace POS.Infrastructure.Http;

public static class HttpClientConfiguration
{
    public static IServiceCollection AddExternalApiClients(
        this IServiceCollection services)
    {
        // Shopify client with retry
        services.AddHttpClient<IShopifyClient, ShopifyClient>()
            .AddPolicyHandler(GetRetryPolicy())
            .AddPolicyHandler(GetCircuitBreakerPolicy());

        // Payment clients
        services.AddHttpClient<IStripeClient, StripeClient>()
            .AddPolicyHandler(GetRetryPolicy());

        services.AddHttpClient<ISquareClient, SquareClient>()
            .AddPolicyHandler(GetRetryPolicy());

        return services;
    }

    private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
    {
        return HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(3, retryAttempt =>
                TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
    }

    private static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
    {
        return HttpPolicyExtensions
            .HandleTransientHttpError()
            .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
    }
}

18.4.2 Credential Management

// File: src/POS.Infrastructure/Security/CredentialService.cs
using Microsoft.Extensions.Caching.Memory;
using Azure.Security.KeyVault.Secrets;

namespace POS.Infrastructure.Security;

public class CredentialService : IPaymentCredentialService, IShopifyCredentialService
{
    private readonly IIntegrationCredentialRepository _repository;
    private readonly SecretClient? _keyVaultClient;
    private readonly IMemoryCache _cache;
    private readonly ILogger<CredentialService> _logger;

    public async Task<StripeCredentials?> GetStripeCredentialsAsync(
        string tenantId,
        CancellationToken ct)
    {
        var cacheKey = $"stripe:{tenantId}";

        if (_cache.TryGetValue(cacheKey, out StripeCredentials? cached))
            return cached;

        var integration = await _repository.GetByTypeAsync(
            tenantId, IntegrationType.Stripe, ct);

        if (integration is null)
            return null;

        // Decrypt secret key from Key Vault or encrypted storage
        var secretKey = _keyVaultClient is not null
            ? (await _keyVaultClient.GetSecretAsync(
                $"stripe-{tenantId}", cancellationToken: ct)).Value.Value
            : DecryptSecret(integration.EncryptedSecretKey);

        var credentials = new StripeCredentials
        {
            PublishableKey = integration.PublicKey,
            SecretKey = secretKey,
            WebhookSecret = integration.WebhookSecret
        };

        _cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));

        return credentials;
    }

    public async Task<ShopifyCredentials?> GetCredentialsAsync(
        string tenantId,
        CancellationToken ct)
    {
        var cacheKey = $"shopify:{tenantId}";

        if (_cache.TryGetValue(cacheKey, out ShopifyCredentials? cached))
            return cached;

        var integration = await _repository.GetByTypeAsync(
            tenantId, IntegrationType.Shopify, ct);

        if (integration is null)
            return null;

        var accessToken = _keyVaultClient is not null
            ? (await _keyVaultClient.GetSecretAsync(
                $"shopify-{tenantId}", cancellationToken: ct)).Value.Value
            : DecryptSecret(integration.EncryptedSecretKey);

        var credentials = new ShopifyCredentials
        {
            ShopDomain = integration.ExternalId,
            AccessToken = accessToken,
            WebhookSecret = integration.WebhookSecret
        };

        _cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));

        return credentials;
    }

    private static string DecryptSecret(string encryptedValue)
    {
        // Implementation depends on encryption strategy
        // Could use DPAPI, AES, etc.
        throw new NotImplementedException(
            "Implement based on your encryption strategy");
    }
}

Summary

This chapter covered complete integration patterns:

  • Shopify Integration: Webhooks for orders, inventory, and products with HMAC verification
  • Payment Processing: PCI-DSS compliant patterns with Stripe Terminal and Square
  • Token-only storage: Never store card numbers, only payment tokens
  • External API resilience: Retry policies and circuit breakers with Polly
  • Credential management: Secure storage with caching

Next: Part V covers frontend implementation with the POS Client application.

Chapter 19: POS Client Application

The Point of Sale Terminal

The POS Client is the primary interface for retail associates. It must be fast, reliable, and work offline when network connectivity is lost. This chapter provides complete specifications for building a production-grade POS terminal.


Technology Stack

ComponentTechnologyRationale
Framework.NET MAUI or Blazor HybridCross-platform, native performance
Local DatabaseSQLiteEmbedded, zero-config, reliable
State ManagementFluxor or custom MVVMPredictable state changes
Hardware APIPlatform Invoke (P/Invoke)Direct hardware access
Sync EngineCustom HTTP + SignalRReal-time + batch sync

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                        POS CLIENT APPLICATION                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐ │
│  │   Views     │  │  ViewModels │  │  Services   │  │  Hardware   │ │
│  │  (XAML/     │◄─┤  (State +   │◄─┤  (Business  │◄─┤  Drivers    │ │
│  │   Blazor)   │  │   Commands) │  │   Logic)    │  │             │ │
│  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘ │
│         │                │                │                │        │
│         └────────────────┴────────────────┴────────────────┘        │
│                                   │                                  │
│                          ┌────────▼────────┐                        │
│                          │  Local SQLite   │                        │
│                          │    Database     │                        │
│                          └────────┬────────┘                        │
│                                   │                                  │
│                          ┌────────▼────────┐                        │
│                          │   Sync Engine   │                        │
│                          │  (Online/Queue) │                        │
│                          └────────┬────────┘                        │
└──────────────────────────────────┬──────────────────────────────────┘
                                   │
                          ┌────────▼────────┐
                          │  Central API    │
                          │  (When Online)  │
                          └─────────────────┘

Screen Specifications

Screen 1: Login Screen

Purpose: Authenticate retail associates with fast PIN entry.

Route: /login

╔════════════════════════════════════════════════════════════════════╗
║                                                                    ║
║                    ┌──────────────────────────┐                    ║
║                    │                          │                    ║
║                    │       STORE LOGO         │                    ║
║                    │       [128x128]          │                    ║
║                    │                          │                    ║
║                    └──────────────────────────┘                    ║
║                                                                    ║
║                         NEXUS CLOTHING                             ║
║                      Greenbrier Mall (GM)                          ║
║                                                                    ║
║                    ┌──────────────────────────┐                    ║
║                    │                          │                    ║
║                    │  Enter Employee PIN      │                    ║
║                    │                          │                    ║
║                    │     ● ● ● ○ ○ ○          │                    ║
║                    │                          │                    ║
║                    └──────────────────────────┘                    ║
║                                                                    ║
║                    ┌─────┬─────┬─────┐                             ║
║                    │  1  │  2  │  3  │                             ║
║                    ├─────┼─────┼─────┤                             ║
║                    │  4  │  5  │  6  │                             ║
║                    ├─────┼─────┼─────┤                             ║
║                    │  7  │  8  │  9  │                             ║
║                    ├─────┼─────┼─────┤                             ║
║                    │ CLR │  0  │ ENT │                             ║
║                    └─────┴─────┴─────┘                             ║
║                                                                    ║
║                    [Manager Override]                               ║
║                                                                    ║
║  ─────────────────────────────────────────────────────────────     ║
║  Status: ● Online  |  Last Sync: 2 min ago  |  v1.2.0              ║
╚════════════════════════════════════════════════════════════════════╝

Components:

ComponentSpecification
Logo128x128px, tenant-specific
Store Name24px, Bold, Primary color
Location14px, Secondary text
PIN Display6 circles, filled = entered
Numpad80x80px buttons, touch-friendly
Clear (CLR)Resets PIN entry
Enter (ENT)Submits PIN for validation
Manager OverrideOpens manager auth dialog
Status BarConnection, sync, version

Behavior:

  • PIN validated locally first (hash comparison)
  • Failed attempts: 3 max before lockout
  • Lockout duration: 5 minutes (configurable)
  • Auto-login timeout: 30 seconds of inactivity returns to login

Screen 2: Main Sale Screen

Purpose: Primary transaction interface for ringing up sales.

Route: /sale

╔════════════════════════════════════════════════════════════════════╗
║ NEXUS CLOTHING - GM         Sarah M.          12/29/2024  2:45 PM  ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  ┌─────────────────────────────────────────────────────────────┐  ║
║  │ [Scan Item or Enter SKU...]                        [SEARCH] │  ║
║  └─────────────────────────────────────────────────────────────┘  ║
║                                                                    ║
║  ┌──────────────────────────────────┐  ┌───────────────────────┐  ║
║  │ CART                         (3) │  │ TOTALS                │  ║
║  ├──────────────────────────────────┤  ├───────────────────────┤  ║
║  │                                  │  │                       │  ║
║  │ 1. Galaxy V-Neck Tee        $29  │  │ Subtotal:    $104.00  │  ║
║  │    Size: M | Color: Navy         │  │                       │  ║
║  │    Qty: 2         [-] [+]   $58  │  │ Discount:     -$10.00 │  ║
║  │                           [DEL]  │  │                       │  ║
║  │ ─────────────────────────────────│  │ Tax (6%):      $5.64  │  ║
║  │ 2. Slim Fit Chinos          $46  │  │                       │  ║
║  │    Size: 32 | Color: Khaki       │  │ ─────────────────────  │  ║
║  │    Qty: 1         [-] [+]   $46  │  │                       │  ║
║  │                           [DEL]  │  │ TOTAL:        $99.64  │  ║
║  │ ─────────────────────────────────│  │                       │  ║
║  │                                  │  │                       │  ║
║  │                                  │  └───────────────────────┘  ║
║  │                                  │                             ║
║  │                                  │  ┌───────────────────────┐  ║
║  │                                  │  │ [DISCOUNT]  [HOLD]    │  ║
║  │                                  │  │                       │  ║
║  │                                  │  │ [CUSTOMER]  [VOID]    │  ║
║  │                                  │  │                       │  ║
║  └──────────────────────────────────┘  │ ┌───────────────────┐ │  ║
║                                        │ │                   │ │  ║
║  ┌──────────────────────────────────┐  │ │      PAY          │ │  ║
║  │ Customer: John Smith             │  │ │     $99.64        │ │  ║
║  │ Loyalty: Gold (2,450 pts)        │  │ │                   │ │  ║
║  │ [Remove Customer]                │  │ └───────────────────┘ │  ║
║  └──────────────────────────────────┘  └───────────────────────┘  ║
║                                                                    ║
╠════════════════════════════════════════════════════════════════════╣
║ [F1 Help] [F2 Lookup] [F3 Returns] [F4 Reports]  ● Online  Rcpt#42 ║
╚════════════════════════════════════════════════════════════════════╝

Layout Regions:

RegionWidthContent
Header100%Store, associate, date/time
Search Bar100%SKU/barcode entry with search
Cart Panel60%Line items with quantity controls
Totals Panel40%Running totals, discounts, tax
Action Buttons40%Discount, Hold, Customer, Void
Pay Button40%Large, prominent payment trigger
Customer Info60%Attached customer details
Footer100%Function keys, status, receipt #

Cart Item Layout:

┌─────────────────────────────────────────────────────────────┐
│ 1. Galaxy V-Neck Tee                                   $29  │
│    Size: M | Color: Navy                                    │
│    Qty: 2                  [-] [+]                     $58  │
│                                                      [DEL]  │
└─────────────────────────────────────────────────────────────┘

Keyboard Shortcuts:

KeyAction
F1Help overlay
F2Product lookup
F3Returns mode
F4Quick reports
F5Price check
F8Suspend sale
F9Recall sale
F12Manager functions
EnterAdd scanned item
EscCancel current action

Screen 3: Customer Lookup

Purpose: Find or create customer records for loyalty tracking.

Route: /customer-lookup (Modal overlay)

╔════════════════════════════════════════════════════════════════════╗
║ CUSTOMER LOOKUP                                              [X]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ [Search by name, phone, email, or loyalty #...]              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ RESULTS (3 found)                                            │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │                                                              │ ║
║  │  ○ John Smith                                                │ ║
║  │    Phone: (555) 123-4567                                     │ ║
║  │    Email: john.smith@email.com                               │ ║
║  │    Loyalty: Gold (2,450 pts)  |  Last Visit: 12/15/2024     │ ║
║  │                                                              │ ║
║  │  ───────────────────────────────────────────────────────────  │ ║
║  │                                                              │ ║
║  │  ○ Johnny Smith Jr.                                          │ ║
║  │    Phone: (555) 234-5678                                     │ ║
║  │    Email: johnny.jr@email.com                                │ ║
║  │    Loyalty: Silver (890 pts)  |  Last Visit: 11/20/2024     │ ║
║  │                                                              │ ║
║  │  ───────────────────────────────────────────────────────────  │ ║
║  │                                                              │ ║
║  │  ○ Jonathan Smithson                                         │ ║
║  │    Phone: (555) 345-6789                                     │ ║
║  │    Email: j.smithson@work.com                                │ ║
║  │    Loyalty: None  |  Last Visit: 10/05/2024                 │ ║
║  │                                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  [NEW CUSTOMER]                      [SELECT]   [CANCEL]       ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

New Customer Form:

╔════════════════════════════════════════════════════════════════════╗
║ NEW CUSTOMER                                                 [X]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  First Name *         Last Name *                                  ║
║  ┌──────────────────┐ ┌──────────────────────────────────────────┐ ║
║  │ John             │ │ Smith                                    │ ║
║  └──────────────────┘ └──────────────────────────────────────────┘ ║
║                                                                    ║
║  Phone *                        Email                              ║
║  ┌──────────────────────────┐   ┌────────────────────────────────┐ ║
║  │ (555) 123-4567           │   │ john.smith@email.com           │ ║
║  └──────────────────────────┘   └────────────────────────────────┘ ║
║                                                                    ║
║  Address Line 1                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ 123 Main Street                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  City                    State        ZIP                          ║
║  ┌────────────────────┐  ┌─────────┐  ┌───────────────────────────┐║
║  │ Virginia Beach     │  │ VA    ▼ │  │ 23451                     │║
║  └────────────────────┘  └─────────┘  └───────────────────────────┘║
║                                                                    ║
║  [ ] Enroll in Loyalty Program                                     ║
║  [ ] Subscribe to email marketing                                  ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │                                        [SAVE]   [CANCEL]       ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

Screen 4: Returns Processing

Purpose: Process merchandise returns and exchanges.

Route: /returns

╔════════════════════════════════════════════════════════════════════╗
║ RETURNS PROCESSING                                    [Exit Return]║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  STEP 1: FIND ORIGINAL TRANSACTION                                 ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ Receipt #: [________________]  OR  [Lookup by Customer]      │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ ORIGINAL TRANSACTION #12345                  12/20/2024      │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │ Customer: John Smith                                         │ ║
║  │ Payment: Visa ****4242                                       │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │                                                              │ ║
║  │  [x] 1. Galaxy V-Neck Tee (M, Navy)             $29.00       │ ║
║  │      Reason: [Wrong Size           ▼]                        │ ║
║  │      Condition: [Good - Resellable ▼]                        │ ║
║  │                                                              │ ║
║  │  [ ] 2. Slim Fit Chinos (32, Khaki)             $46.00       │ ║
║  │                                                              │ ║
║  │  [ ] 3. Leather Belt (M)                        $35.00       │ ║
║  │                                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌────────────────────────┐  ┌───────────────────────────────────┐║
║  │ RETURN SUMMARY         │  │ REFUND TO                         │║
║  ├────────────────────────┤  ├───────────────────────────────────┤║
║  │ Items: 1               │  │ ○ Original Payment (Visa ****42)  │║
║  │ Subtotal: $29.00       │  │ ○ Store Credit                    │║
║  │ Tax Refund: $1.74      │  │ ○ Cash                            │║
║  │ ──────────────────     │  │ ○ Exchange (Add to New Sale)      │║
║  │ TOTAL: $30.74          │  │                                   │║
║  └────────────────────────┘  └───────────────────────────────────┘║
║                                                                    ║
║  Manager Approval Required: [ ] Over $100  [ ] No Receipt         ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  [SCAN RETURN ITEMS]              [PROCESS RETURN]  [CANCEL]   ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

Return Reasons (Configurable):

  • Wrong Size
  • Wrong Color
  • Defective
  • Changed Mind
  • Gift Return
  • Price Adjustment
  • Other

Return Conditions:

  • Good - Resellable
  • Damaged - Cannot Resell
  • Missing Tags - Markdown

Screen 5: Inventory Lookup

Purpose: Check stock levels across all locations.

Route: /inventory (Modal overlay)

╔════════════════════════════════════════════════════════════════════╗
║ INVENTORY LOOKUP                                             [X]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ [Search by SKU, name, or scan barcode...]            [SEARCH]│ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │                                                              │ ║
║  │  Galaxy V-Neck Tee                                   $29.00  │ ║
║  │  SKU: NXJ1078-NAV-M                                          │ ║
║  │  ────────────────────────────────────────────────────────────│ ║
║  │                                                              │ ║
║  │  VARIANTS:                                                   │ ║
║  │  ┌────────────┬─────┬─────┬─────┬─────┬─────┬───────┐       │ ║
║  │  │ Size/Color │  HQ │  GM │  HM │  LM │  NM │ TOTAL │       │ ║
║  │  ├────────────┼─────┼─────┼─────┼─────┼─────┼───────┤       │ ║
║  │  │ S / Navy   │  12 │   3 │   2 │   4 │   1 │    22 │       │ ║
║  │  │ M / Navy   │  15 │   5*│   3 │   2 │   0 │    25 │       │ ║
║  │  │ L / Navy   │   8 │   4 │   1 │   3 │   2 │    18 │       │ ║
║  │  │ XL / Navy  │   4 │   2 │   0 │   1 │   1 │     8 │       │ ║
║  │  │ S / Black  │  10 │   2 │   3 │   2 │   2 │    19 │       │ ║
║  │  │ M / Black  │  18 │   6 │   4 │   5 │   3 │    36 │       │ ║
║  │  └────────────┴─────┴─────┴─────┴─────┴─────┴───────┘       │ ║
║  │                                                              │ ║
║  │  * Current Location (GM)                                     │ ║
║  │                                                              │ ║
║  │  Last Updated: 12/29/2024 2:30 PM                           │ ║
║  │                                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  [REQUEST TRANSFER]    [PRICE CHECK]           [CLOSE]         ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

Screen 6: End of Day

Purpose: Close register, balance cash, generate reports.

Route: /end-of-day

╔════════════════════════════════════════════════════════════════════╗
║ END OF DAY - Close Register                              [Cancel]  ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  Register: REGISTER-01 (GM)              Date: 12/29/2024          ║
║  Cashier: Sarah Miller                   Shift: 9:00 AM - 5:30 PM  ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ SALES SUMMARY                                                │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │                                                              │ ║
║  │  Total Transactions:            47                           │ ║
║  │  Gross Sales:               $3,245.67                        │ ║
║  │  Returns:                     -$125.00                       │ ║
║  │  Discounts:                   -$89.50                        │ ║
║  │  ──────────────────────────────────────                      │ ║
║  │  Net Sales:                 $3,031.17                        │ ║
║  │  Tax Collected:               $181.87                        │ ║
║  │                                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ CASH COUNT                                                   │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │                                                              │ ║
║  │  Starting Cash:      $200.00                                 │ ║
║  │  Cash Sales:         $845.50                                 │ ║
║  │  Cash Returns:       -$45.00                                 │ ║
║  │  ──────────────────────────────────────                      │ ║
║  │  Expected Cash:    $1,000.50                                 │ ║
║  │                                                              │ ║
║  │  Counted Cash:     [_______________]  <-- Enter amount       │ ║
║  │                                                              │ ║
║  │  Variance:         $___.__ (Calculates automatically)        │ ║
║  │                                                              │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ PAYMENT BREAKDOWN                                            │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │  Cash:                  $845.50   (28 trans)                 │ ║
║  │  Credit Card:         $1,856.32   (15 trans)                 │ ║
║  │  Debit Card:            $254.35   (3 trans)                  │ ║
║  │  Store Credit:           $75.00   (1 trans)                  │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  [PRINT REPORT]  [RECOUNT]           [CLOSE REGISTER]          ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

Payment Screen

Purpose: Process various payment methods.

╔════════════════════════════════════════════════════════════════════╗
║ PAYMENT                                                      [X]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║                    AMOUNT DUE: $99.64                              ║
║                                                                    ║
║  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐    ║
║  │                 │  │                 │  │                 │    ║
║  │   [CREDIT]      │  │   [DEBIT]       │  │   [CASH]        │    ║
║  │      CARD       │  │     CARD        │  │                 │    ║
║  │                 │  │                 │  │                 │    ║
║  └─────────────────┘  └─────────────────┘  └─────────────────┘    ║
║                                                                    ║
║  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐    ║
║  │                 │  │                 │  │                 │    ║
║  │   [GIFT]        │  │   [STORE]       │  │   [SPLIT]       │    ║
║  │    CARD         │  │   CREDIT        │  │   PAYMENT       │    ║
║  │                 │  │                 │  │                 │    ║
║  └─────────────────┘  └─────────────────┘  └─────────────────┘    ║
║                                                                    ║
║  ═══════════════════════════════════════════════════════════════  ║
║                                                                    ║
║  CASH QUICK AMOUNTS:                                               ║
║                                                                    ║
║  ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐     ║
║  │  $20  │ │  $50  │ │ $100  │ │ $120  │ │ EXACT │ │ OTHER │     ║
║  └───────┘ └───────┘ └───────┘ └───────┘ └───────┘ └───────┘     ║
║                                                                    ║
║                    Amount Tendered: $________                      ║
║                    Change Due:      $________                      ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │                                        [PROCESS]   [CANCEL]    ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

State Management

Application State Model

public class PosState
{
    // Authentication
    public AuthState Auth { get; set; }

    // Current Transaction
    public TransactionState Transaction { get; set; }

    // Cart Items
    public List<CartItem> Cart { get; set; }

    // Customer
    public CustomerState Customer { get; set; }

    // Register
    public RegisterState Register { get; set; }

    // Sync Status
    public SyncState Sync { get; set; }

    // UI State
    public UiState Ui { get; set; }
}

public class TransactionState
{
    public string TransactionId { get; set; }
    public TransactionType Type { get; set; }  // Sale, Return, Exchange
    public TransactionStatus Status { get; set; }
    public decimal Subtotal { get; set; }
    public decimal DiscountTotal { get; set; }
    public decimal TaxTotal { get; set; }
    public decimal GrandTotal { get; set; }
    public List<PaymentEntry> Payments { get; set; }
    public decimal BalanceDue { get; set; }
}

State Actions

ActionDescription
AddToCartAdd item with quantity
UpdateQuantityChange line item quantity
RemoveFromCartDelete line item
ApplyDiscountAdd transaction/line discount
AttachCustomerLink customer to sale
ProcessPaymentRecord payment entry
VoidTransactionCancel entire transaction
SuspendSalePark sale for later
RecallSaleResume suspended sale

Sync Service Design

Sync Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                        SYNC ENGINE                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌─────────────┐     ┌─────────────┐     ┌─────────────┐            │
│  │   OUTBOUND  │     │   INBOUND   │     │   CONFLICT  │            │
│  │    QUEUE    │────▶│   HANDLER   │────▶│   RESOLVER  │            │
│  │ (SQLite)    │     │  (API Sync) │     │             │            │
│  └─────────────┘     └─────────────┘     └─────────────┘            │
│         │                   │                   │                    │
│         ▼                   ▼                   ▼                    │
│  ┌─────────────────────────────────────────────────────┐            │
│  │              LOCAL SQLITE DATABASE                   │            │
│  │  - Transactions (pending sync)                       │            │
│  │  - Products (cached catalog)                         │            │
│  │  - Customers (cached records)                        │            │
│  │  - Inventory (last known levels)                     │            │
│  │  - Sync metadata (timestamps, versions)              │            │
│  └─────────────────────────────────────────────────────┘            │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Sync Priorities

PriorityData TypeFrequencyDirection
1 (Critical)TransactionsImmediateOutbound
2 (High)Inventory Changes5 minBoth
3 (Medium)Customers15 minBoth
4 (Low)Products1 hourInbound
5 (Batch)ReportsDailyOutbound

Offline Queue Schema

CREATE TABLE sync_queue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entity_type TEXT NOT NULL,       -- 'transaction', 'customer', etc.
    entity_id TEXT NOT NULL,
    action TEXT NOT NULL,            -- 'create', 'update', 'delete'
    payload TEXT NOT NULL,           -- JSON serialized data
    priority INTEGER DEFAULT 5,
    retry_count INTEGER DEFAULT 0,
    created_at TEXT NOT NULL,
    last_attempt TEXT,
    status TEXT DEFAULT 'pending'    -- 'pending', 'syncing', 'failed', 'synced'
);

CREATE INDEX idx_sync_queue_status ON sync_queue(status, priority);

Hardware Integration

Receipt Printer

public interface IReceiptPrinter
{
    Task<bool> PrintReceiptAsync(Receipt receipt);
    Task<bool> OpenCashDrawerAsync();
    Task<bool> CutPaperAsync();
    Task<PrinterStatus> GetStatusAsync();
}

public class EpsonTM88Printer : IReceiptPrinter
{
    private readonly string _portName;

    public async Task<bool> PrintReceiptAsync(Receipt receipt)
    {
        var commands = new List<byte>();

        // Initialize printer
        commands.AddRange(new byte[] { 0x1B, 0x40 });  // ESC @

        // Center align
        commands.AddRange(new byte[] { 0x1B, 0x61, 0x01 });  // ESC a 1

        // Store header (double width/height)
        commands.AddRange(new byte[] { 0x1D, 0x21, 0x11 });  // GS ! 0x11
        commands.AddRange(Encoding.ASCII.GetBytes(receipt.StoreName + "\n"));

        // Reset text size
        commands.AddRange(new byte[] { 0x1D, 0x21, 0x00 });

        // ... additional formatting

        // Cut paper
        commands.AddRange(new byte[] { 0x1D, 0x56, 0x00 });  // GS V 0

        return await SendToPortAsync(commands.ToArray());
    }
}

Barcode Scanner

public interface IBarcodeScanner
{
    event EventHandler<BarcodeScannedEventArgs> BarcodeScanned;
    Task StartListeningAsync();
    Task StopListeningAsync();
}

public class HoneywellScanner : IBarcodeScanner
{
    public event EventHandler<BarcodeScannedEventArgs> BarcodeScanned;

    private SerialPort _port;

    public async Task StartListeningAsync()
    {
        _port = new SerialPort("COM3", 9600);
        _port.DataReceived += OnDataReceived;
        _port.Open();
    }

    private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        var barcode = _port.ReadLine().Trim();
        BarcodeScanned?.Invoke(this, new BarcodeScannedEventArgs(barcode));
    }
}

Cash Drawer

public interface ICashDrawer
{
    Task<bool> OpenAsync();
    Task<bool> IsOpenAsync();
}

public class ApgCashDrawer : ICashDrawer
{
    private readonly IReceiptPrinter _printer;

    public async Task<bool> OpenAsync()
    {
        // Most cash drawers open via printer kick command
        return await _printer.OpenCashDrawerAsync();
    }
}

Local Database Schema

-- Products (cached from central)
CREATE TABLE products (
    id TEXT PRIMARY KEY,
    sku TEXT NOT NULL UNIQUE,
    barcode TEXT,
    name TEXT NOT NULL,
    description TEXT,
    price REAL NOT NULL,
    cost REAL,
    category_id TEXT,
    tax_rate REAL DEFAULT 0,
    is_active INTEGER DEFAULT 1,
    last_synced TEXT NOT NULL
);

-- Inventory (cached levels)
CREATE TABLE inventory (
    product_id TEXT NOT NULL,
    location_code TEXT NOT NULL,
    quantity INTEGER NOT NULL,
    last_synced TEXT NOT NULL,
    PRIMARY KEY (product_id, location_code)
);

-- Customers (cached)
CREATE TABLE customers (
    id TEXT PRIMARY KEY,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    phone TEXT,
    email TEXT,
    loyalty_tier TEXT,
    loyalty_points INTEGER DEFAULT 0,
    last_synced TEXT NOT NULL
);

-- Transactions (local first, then synced)
CREATE TABLE transactions (
    id TEXT PRIMARY KEY,
    transaction_number INTEGER NOT NULL,
    type TEXT NOT NULL,
    status TEXT NOT NULL,
    customer_id TEXT,
    associate_id TEXT NOT NULL,
    register_id TEXT NOT NULL,
    subtotal REAL NOT NULL,
    discount_total REAL DEFAULT 0,
    tax_total REAL NOT NULL,
    grand_total REAL NOT NULL,
    created_at TEXT NOT NULL,
    completed_at TEXT,
    synced_at TEXT,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

-- Transaction Line Items
CREATE TABLE transaction_items (
    id TEXT PRIMARY KEY,
    transaction_id TEXT NOT NULL,
    product_id TEXT NOT NULL,
    sku TEXT NOT NULL,
    name TEXT NOT NULL,
    quantity INTEGER NOT NULL,
    unit_price REAL NOT NULL,
    discount REAL DEFAULT 0,
    tax_amount REAL NOT NULL,
    line_total REAL NOT NULL,
    FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);

-- Payments
CREATE TABLE payments (
    id TEXT PRIMARY KEY,
    transaction_id TEXT NOT NULL,
    method TEXT NOT NULL,
    amount REAL NOT NULL,
    reference TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);

Performance Requirements

MetricTargetMeasurement
App Launch< 3 secondsCold start to login screen
Item Scan< 100msBarcode to cart display
Product Search< 200msKeystroke to results
Payment Process< 2 secondsButton tap to receipt
Offline SwitchInstantSeamless transition
Sync Latency< 5 secondsTransaction to central

Security Considerations

ConcernMitigation
PIN StorageHashed with bcrypt, salted
Local DBSQLCipher encryption
API TokensSecure storage (Keychain/DPAPI)
PCI ComplianceNo card data stored locally
Session TimeoutAuto-logout after inactivity
Audit TrailAll actions logged with timestamp

Role-Based Interface (Learned from Lightspeed)

Competitive Insight: Lightspeed separates Cashier and Manager views to reduce cognitive load. Cashiers see only what they need; managers have full access.

Cashier Mode vs Manager Mode

┌─────────────────────────────────────────────────────────────────────────┐
│                        ROLE-BASED UI SPLIT                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  CASHIER MODE                         MANAGER MODE                       │
│  ┌─────────────────────────┐          ┌─────────────────────────┐       │
│  │ • Sales Screen          │          │ • Full Dashboard        │       │
│  │ • Customer Lookup       │          │ • All Cashier Features  │       │
│  │ • Basic Returns         │          │ • Inventory Adjustments │       │
│  │ • Receipt Reprint       │          │ • Employee Management   │       │
│  │ • Price Check           │          │ • Reports & Analytics   │       │
│  │                         │          │ • System Settings       │       │
│  │ [Simplified Navigation] │          │ • Void/Override Powers  │       │
│  │ [Large Touch Targets]   │          │ • Cash Drawer Access    │       │
│  └─────────────────────────┘          │ • End of Day Close      │       │
│                                       └─────────────────────────┘       │
│                                                                          │
│  [Switch to Manager Mode] ─────────────────────────> [PIN Required]     │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Mode Switch Behavior

public class RoleModeService
{
    private UserRole _currentRole = UserRole.Cashier;

    public async Task<bool> SwitchToManagerMode(string managerPin)
    {
        var isValid = await _authService.ValidateManagerPin(managerPin);
        if (isValid)
        {
            _currentRole = UserRole.Manager;
            _auditLog.Log("MODE_SWITCH", "Switched to Manager mode");
            return true;
        }
        return false;
    }

    public void SwitchToCashierMode()
    {
        _currentRole = UserRole.Cashier;
        _auditLog.Log("MODE_SWITCH", "Switched to Cashier mode");
    }

    public bool CanAccess(string feature) => _rolePermissions[_currentRole].Contains(feature);
}

Actions Requiring Manager Override

ActionCashier Can DoManager PIN Required
Ring sale-
Apply discount > 20%
Void transaction
Return without receipt
Open cash drawer (no sale)
Price override
View reports
End of day close

View Preferences (Learned from Lightspeed)

Competitive Insight: Lightspeed offers Grid/List toggle and Dark Mode. Users have different preferences - some like images, some prefer text density.

Product Display Options

┌─────────────────────────────────────────────────────────────────────────┐
│ Products    [Grid ▣] [List ≡]    [🌙 Dark Mode]                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  GRID VIEW (Default)                  LIST VIEW                          │
│  ┌───────┐ ┌───────┐ ┌───────┐       ┌─────────────────────────────────┐│
│  │ [IMG] │ │ [IMG] │ │ [IMG] │       │ 👕 Galaxy Tee - Navy - $29  [+] ││
│  │ Tee   │ │ Pants │ │ Jacket│       │ 👖 Slim Chinos - Khaki - $46[+] ││
│  │ $29   │ │ $46   │ │ $89   │       │ 🧥 Bomber Jacket - $89      [+] ││
│  │  [+]  │ │  [+]  │ │  [+]  │       │ 👔 Oxford Shirt - $55       [+] ││
│  └───────┘ └───────┘ └───────┘       └─────────────────────────────────┘│
│                                                                          │
│  Best for: Visual products           Best for: High SKU count            │
│  Use when: Fashion, gifts            Use when: Hardware, parts           │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Dark Mode Support

┌─────────────────────────────────────────────────────────────────────────┐
│                          THEME MODES                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  LIGHT MODE (Default)                 DARK MODE                          │
│  ┌─────────────────────────┐          ┌─────────────────────────┐       │
│  │ Background: #FFFFFF     │          │ Background: #1a1a2e     │       │
│  │ Text: #1f2937           │          │ Text: #f3f4f6           │       │
│  │ Primary: #4f46e5        │          │ Primary: #818cf8        │       │
│  │ Cards: #f9fafb          │          │ Cards: #16213e          │       │
│  │ Borders: #e5e7eb        │          │ Borders: #374151        │       │
│  └─────────────────────────┘          └─────────────────────────┘       │
│                                                                          │
│  Benefits:                            Benefits:                          │
│  • Standard retail look               • Reduces eye strain (long shifts) │
│  • Better in bright stores            • Better for low-light stores      │
│  • Matches receipts                   • Saves battery (OLED screens)     │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

User Preferences Storage

public class UserPreferences
{
    public string EmployeeId { get; set; }
    public ViewMode ProductViewMode { get; set; } = ViewMode.Grid;
    public ThemeMode Theme { get; set; } = ThemeMode.Light;
    public int GridColumnsCount { get; set; } = 4;
    public bool ShowProductImages { get; set; } = true;
    public bool ShowQuickAccessBar { get; set; } = true;
    public List<string> FavoriteProducts { get; set; } = new();
    public string DefaultCategory { get; set; }
}

public enum ViewMode { Grid, List }
public enum ThemeMode { Light, Dark, System }

Quick Access Layout Editor (Learned from Lightspeed)

Competitive Insight: Lightspeed lets managers arrange product buttons visually. Frequently sold items get prime positions.

Layout Editor Screen

╔════════════════════════════════════════════════════════════════════╗
║ QUICK ACCESS LAYOUT EDITOR                        [Save] [Cancel]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                     ║
║  QUICK ACCESS BAR (Drag to arrange)                                ║
║  ┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┐     ║
║  │ Galaxy  │ Slim    │ Bomber  │ Gift    │ Bag     │ [Empty] │     ║
║  │ Tee     │ Chinos  │ Jacket  │ Card    │ Small   │  [+]    │     ║
║  │ $29     │ $46     │ $89     │ $25+    │ $3      │         │     ║
║  └─────────┴─────────┴─────────┴─────────┴─────────┴─────────┘     ║
║                                                                     ║
║  AVAILABLE PRODUCTS (Drag to Quick Access bar above)               ║
║  ┌──────────────────────────────────────────────────────────────┐  ║
║  │ Search: [_______________]  Category: [All        ▼]          │  ║
║  ├──────────────────────────────────────────────────────────────┤  ║
║  │                                                               │  ║
║  │  ⬚ Oxford Shirt     ⬚ Crew Neck       ⬚ Cargo Pants          │  ║
║  │  ⬚ Polo Classic     ⬚ Denim Jacket    ⬚ Belt Leather         │  ║
║  │  ⬚ Hoodie Basic     ⬚ V-Neck Tee      ⬚ Socks 3-Pack         │  ║
║  │                                                               │  ║
║  └──────────────────────────────────────────────────────────────┘  ║
║                                                                     ║
║  Layout applies to: ○ This register only  ● All registers at GM    ║
║                                                                     ║
╚════════════════════════════════════════════════════════════════════╝

Quick Access Data Model

public class QuickAccessLayout
{
    public string Id { get; set; }
    public string TenantId { get; set; }
    public string LocationId { get; set; }       // null = all locations
    public string RegisterId { get; set; }       // null = all registers
    public List<QuickAccessSlot> Slots { get; set; } = new();
    public DateTime UpdatedAt { get; set; }
    public string UpdatedBy { get; set; }
}

public class QuickAccessSlot
{
    public int Position { get; set; }           // 0-11 (2 rows of 6)
    public string ItemId { get; set; }
    public string CustomLabel { get; set; }     // Override display name
    public string CustomColor { get; set; }     // Button color (#hex)
}

Quick Access Display

┌─────────────────────────────────────────────────────────────────────────┐
│ QUICK ACCESS                                                     [Edit] │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐ │
│  │ #6366f1 │ │ #10b981 │ │ #f59e0b │ │ #ef4444 │ │ #8b5cf6 │ │ #ec4899│ │
│  │ Galaxy  │ │ Slim    │ │ Bomber  │ │ Gift    │ │ Bag     │ │ Hoodie │ │
│  │ Tee     │ │ Chinos  │ │ Jacket  │ │ Card    │ │ Small   │ │        │ │
│  │ $29     │ │ $46     │ │ $89     │ │ $25+    │ │ $3      │ │ $55    │ │
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └────────┘ │
│                                                                          │
│  One-tap access to best sellers. Manager can customize via [Edit].      │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Summary

The POS Client Application is designed for:

  1. Speed: Sub-second response times for all common operations
  2. Reliability: Full offline capability with automatic sync
  3. Usability: Touch-friendly, keyboard shortcuts, minimal training
  4. Security: PIN auth, encrypted storage, audit logging
  5. Integration: Hardware support for printers, scanners, drawers
  6. Role-Based UI: Simplified Cashier mode, full Manager mode (Lightspeed insight)
  7. Personalization: Grid/List toggle, Dark mode, Quick Access layout (Lightspeed insight)

Competitive Feature Matrix

FeatureOur POSRetail ProLightspeed
Offline Mode✅ Full✅ Full❌ None
Role-Based UI✅ Yes✅ Yes✅ Yes
Dark Mode✅ Yes❌ No✅ Yes
Grid/List Toggle✅ Yes❌ No✅ Yes
Quick Access Editor✅ Yes✅ Yes✅ Yes
Keyboard Shortcuts✅ F1-F12✅ Full⚠️ Limited
PIN Login✅ Yes✅ Yes✅ Yes
Manager Override✅ Yes✅ Yes✅ Yes

Implementation complete. Ready for engineer review.

Chapter 20: Admin Portal

The Back Office Command Center

The Admin Portal is the web-based management interface for store managers, regional managers, and system administrators. It provides comprehensive control over inventory, products, employees, and reporting.


Technology Stack

ComponentTechnologyRationale
FrameworkBlazor ServerReal-time updates, shared .NET codebase
StylingCustom CSS + Bootstrap 5Consistent design system
StateBlazor component state + servicesSimple, reactive
Real-timeSignalR (built-in)Live dashboard updates
ChartsChart.js or ApexChartsInteractive visualizations

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                      ADMIN PORTAL (Blazor Server)                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │                         HEADER                                   ││
│  │  [Logo]  [Breadcrumb]                [Notifications] [Profile]  ││
│  └─────────────────────────────────────────────────────────────────┘│
│  ┌────────────┬────────────────────────────────────────────────────┐│
│  │            │                                                    ││
│  │  SIDEBAR   │                    CONTENT AREA                    ││
│  │            │                                                    ││
│  │ Dashboard  │    ┌──────────────────────────────────────────┐   ││
│  │ Inventory  │    │                                          │   ││
│  │ Products   │    │           Page Content                   │   ││
│  │ Employees  │    │                                          │   ││
│  │ Reports    │    │                                          │   ││
│  │ Settings   │    └──────────────────────────────────────────┘   ││
│  │            │                                                    ││
│  └────────────┴────────────────────────────────────────────────────┘│
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │                         STATUS BAR                               ││
│  └─────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘

ADMIN PORTAL
│
├── Dashboard (/dashboard)
│   ├── KPIs
│   ├── Alerts
│   └── Activity Feed
│
├── Inventory (/inventory)
│   ├── Stock Levels (/inventory/levels)
│   ├── Transfers (/inventory/transfers)
│   │   ├── Create Transfer
│   │   └── Transfer History
│   ├── Counts (/inventory/counts)
│   │   ├── New Count
│   │   ├── In Progress
│   │   └── Completed
│   └── Adjustments (/inventory/adjustments)
│
├── Products (/products)
│   ├── Catalog (/products/catalog)
│   │   ├── Product List
│   │   ├── Product Detail
│   │   └── Add/Edit Product
│   ├── Categories (/products/categories)
│   ├── Pricing (/products/pricing)
│   └── Import/Export (/products/import)
│
├── Employees (/employees)
│   ├── Users (/employees/users)
│   ├── Roles (/employees/roles)
│   ├── Schedules (/employees/schedules)
│   └── Performance (/employees/performance)
│
├── Reports (/reports)
│   ├── Sales (/reports/sales)
│   ├── Inventory (/reports/inventory)
│   ├── Performance (/reports/performance)
│   └── Custom (/reports/custom)
│
├── RFID / Raptag (/rfid)                    ◄── RFID Module (Pro/Enterprise)
│   ├── Devices (/rfid/devices)
│   │   ├── Registered Devices
│   │   ├── Device Status
│   │   └── Add Device
│   ├── Sessions (/rfid/sessions)
│   │   ├── Active Sessions
│   │   ├── Completed Sessions
│   │   └── Discrepancy Review
│   ├── Tag Management (/rfid/tags)
│   │   ├── Tag Mappings (EPC → SKU)
│   │   ├── Print Queue
│   │   └── Encoding History
│   └── RFID Settings (/rfid/settings)
│       ├── Power Levels
│       ├── Zone Configuration
│       └── Thresholds
│
└── Settings (/settings)
    ├── Locations (/settings/locations)
    ├── Integrations (/settings/integrations)
    ├── System (/settings/system)
    └── Audit Log (/settings/audit)

Screen Specifications

Screen 1: Dashboard

Purpose: Executive overview with KPIs, alerts, and activity monitoring.

Route: /dashboard

╔════════════════════════════════════════════════════════════════════════════════╗
║ ADMIN PORTAL                                        [Bell] [?] [Admin User ▼]  ║
╠════════════════════════════════════════════════════════════════════════════════╣
║         │                                                                      ║
║ Dashbrd │  DASHBOARD                              Today  [Date Range ▼]        ║
║         │                                                                      ║
║ ─────── │  ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐        ║
║         │  │ TODAY'S    │ │ REVENUE    │ │ ITEMS      │ │ AVG ORDER  │        ║
║ INVNTRY │  │ SALES      │ │            │ │ SOLD       │ │ VALUE      │        ║
║  Levels │  │            │ │            │ │            │ │            │        ║
║  Transfr│  │   $12,450  │ │   $45,230  │ │     423    │ │    $78.50  │        ║
║  Counts │  │   +12% ▲   │ │   +8% ▲    │ │   +15% ▲   │ │   +3% ▲    │        ║
║  Adjust │  └────────────┘ └────────────┘ └────────────┘ └────────────┘        ║
║         │                                                                      ║
║ ─────── │  ┌────────────────────────────────┐ ┌──────────────────────────────┐║
║         │  │ SALES TREND (Last 7 Days)      │ │ ALERTS                  (5)  │║
║ PRODUCT │  │                                │ ├──────────────────────────────┤║
║  Catalog│  │         ╱╲      ╱╲             │ │ [!] Low Stock: NXJ1078      ││
║  Categry│  │    ╱╲  ╱  ╲    ╱  ╲            │ │     Only 3 units at GM      ││
║  Pricing│  │   ╱  ╲╱    ╲  ╱    ╲           │ │                              │║
║  Import │  │  ╱         ╲╱      ╲          │ │ [!] Price Mismatch: SKU-042  ││
║         │  │ ╱                   ╲_        │ │     Shopify: $29, POS: $32   ││
║ ─────── │  │                                │ │                              │║
║         │  │ Mon Tue Wed Thu Fri Sat Sun    │ │ [i] Transfer #1234 Ready     ││
║ EMPLOYE │  └────────────────────────────────┘ │     From HQ to GM (5 items)  ││
║  Users  │                                     │                              │║
║  Roles  │  ┌────────────────────────────────┐ │ [i] Count #567 Pending       ││
║  Sched  │  │ STORE PERFORMANCE              │ │     GM needs review          ││
║  Perform│  ├────────────────────────────────┤ │                              │║
║         │  │ Location  │ Sales  │ Trans │ % │ │ [!] Register offline: NM-02  ││
║ ─────── │  │───────────┼────────┼───────┼───│ │     Last seen: 15 min ago   ││
║         │  │ GM        │ $4,230 │   47  │32%│ └──────────────────────────────┘║
║ REPORTS │  │ HM        │ $3,890 │   42  │29%│                                ║
║  Sales  │  │ LM        │ $2,980 │   35  │22%│ ┌──────────────────────────────┐║
║  Invntry│  │ NM        │ $2,130 │   28  │16%│ │ RECENT ACTIVITY              │║
║  Perform│  └────────────────────────────────┘ ├──────────────────────────────┤║
║  Custom │                                     │ 2:45 PM - Sale #4521 ($89)   ││
║         │                                     │ 2:42 PM - Return processed   ││
║ ─────── │                                     │ 2:38 PM - New customer added ││
║         │                                     │ 2:35 PM - Inventory adjusted ││
║ SETTING │                                     │ 2:30 PM - Transfer completed ││
║  Locatns│                                     │                              │║
║  Integr │                                     │ [View All Activity]          │║
║  System │                                     └──────────────────────────────┘║
║  Audit  │                                                                      ║
╠════════════════════════════════════════════════════════════════════════════════╣
║ Connected: 4 registers  |  Last sync: 30 sec ago  |  System: Healthy           ║
╚════════════════════════════════════════════════════════════════════════════════╝

Dashboard Components:

ComponentSpecification
KPI Cards (4)Stat value, trend arrow, percentage change
Sales ChartLine chart, 7-day trend, interactive hover
Alerts PanelPriority-sorted, color-coded, actionable
Store TableSortable columns, percentage bar
Activity FeedReal-time, auto-scroll, clickable items

Screen 2: Inventory Management

Purpose: Monitor and manage stock levels across all locations.

Route: /inventory/levels

╔════════════════════════════════════════════════════════════════════════════════╗
║ INVENTORY > STOCK LEVELS                                      [Refresh] [Export]║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ [Search by SKU, name, barcode...]                   Location: [All ▼]   │  ║
║  │                                                                         │  ║
║  │ Category: [All Categories ▼]  Status: [All ▼]  Stock: [All Levels ▼]   │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ SKU        │ PRODUCT          │ CAT   │  HQ  │  GM  │  HM  │  LM  │  NM │  ║
║  ├────────────┼──────────────────┼───────┼──────┼──────┼──────┼──────┼─────┤  ║
║  │            │                  │       │      │      │      │      │     │  ║
║  │ NXJ1078    │ Galaxy V-Neck    │ Tops  │  45  │   3* │  12  │   8  │  15 │  ║
║  │            │                  │       │      │ LOW  │      │      │     │  ║
║  │────────────┼──────────────────┼───────┼──────┼──────┼──────┼──────┼─────│  ║
║  │ NXP0892    │ Slim Fit Chinos  │Bottms │  32  │  18  │  14  │   0* │  22 │  ║
║  │            │                  │       │      │      │      │ OUT  │     │  ║
║  │────────────┼──────────────────┼───────┼──────┼──────┼──────┼──────┼─────│  ║
║  │ NXA0234    │ Leather Belt     │Access │  60  │  25  │  20  │  15  │  18 │  ║
║  │            │                  │       │      │      │      │      │     │  ║
║  │────────────┼──────────────────┼───────┼──────┼──────┼──────┼──────┼─────│  ║
║  │ NXJ2156    │ Oxford Shirt     │ Tops  │  28  │  12  │   8  │  10  │   6 │  ║
║  │            │                  │       │      │      │      │      │     │  ║
║  │────────────┼──────────────────┼───────┼──────┼──────┼──────┼──────┼─────│  ║
║  │ NXP1045    │ Classic Jeans    │Bottms │  75  │  30  │  25  │  22  │  28 │  ║
║  │            │                  │       │      │      │      │      │     │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  Showing 1-25 of 1,245 items       << < Page 1 of 50 > >>    Items/page: [25▼]║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ BULK ACTIONS:  [Create Transfer]  [Request Recount]  [Adjust Stock]     │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
╚════════════════════════════════════════════════════════════════════════════════╝

Inventory Features:

FeatureDescription
Multi-location GridShows stock at all locations in one view
Status IndicatorsLOW (yellow), OUT (red), OK (green)
Click-to-FilterClick column headers to filter by location
Bulk ActionsSelect multiple items for batch operations
ExportCSV/Excel download with current filters

Transfer Creation Modal:

╔════════════════════════════════════════════════════════════════════╗
║ CREATE INVENTORY TRANSFER                                    [X]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  From Location:  [HQ - Headquarters        ▼]                      ║
║  To Location:    [GM - Greenbrier Mall     ▼]                      ║
║                                                                    ║
║  ┌──────────────────────────────────────────────────────────────┐ ║
║  │ ITEMS TO TRANSFER                                            │ ║
║  ├──────────────────────────────────────────────────────────────┤ ║
║  │ SKU       │ Product           │ Available │ Transfer Qty     │ ║
║  │───────────┼───────────────────┼───────────┼──────────────────│ ║
║  │ NXJ1078   │ Galaxy V-Neck (M) │    45     │ [     10     ]   │ ║
║  │ NXP0892   │ Slim Fit Chinos   │    32     │ [      5     ]   │ ║
║  └──────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  [+ Add More Items]                                                ║
║                                                                    ║
║  Notes: [________________________________]                          ║
║                                                                    ║
║  Priority:  ○ Normal  ○ Urgent                                     ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │                                   [CREATE TRANSFER]  [CANCEL]  ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════╝

Screen 3: Product Catalog

Purpose: Manage product information, variants, and pricing.

Route: /products/catalog

╔════════════════════════════════════════════════════════════════════════════════╗
║ PRODUCTS > CATALOG                                   [+ Add Product] [Import]  ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ [Search products...]                                                    │  ║
║  │                                                                         │  ║
║  │ Category: [All ▼]  Status: [Active ▼]  Price Range: [$0] to [$999]     │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ [x] │ PRODUCT              │ SKU       │ CATEGORY │ PRICE  │ STOCK│ ACT│  ║
║  ├─────┼──────────────────────┼───────────┼──────────┼────────┼──────┼────┤  ║
║  │     │                      │           │          │        │      │    │  ║
║  │ [ ] │ [img] Galaxy V-Neck  │ NXJ1078   │ Tops     │ $29.00 │  83  │[Ed]│  ║
║  │     │       3 variants     │           │          │        │      │[De]│  ║
║  │─────┼──────────────────────┼───────────┼──────────┼────────┼──────┼────│  ║
║  │ [ ] │ [img] Slim Fit Chino │ NXP0892   │ Bottoms  │ $46.00 │  86  │[Ed]│  ║
║  │     │       5 variants     │           │          │        │      │[De]│  ║
║  │─────┼──────────────────────┼───────────┼──────────┼────────┼──────┼────│  ║
║  │ [ ] │ [img] Oxford Shirt   │ NXJ2156   │ Tops     │ $54.00 │  64  │[Ed]│  ║
║  │     │       4 variants     │           │          │        │      │[De]│  ║
║  │─────┼──────────────────────┼───────────┼──────────┼────────┼──────┼────│  ║
║  │ [ ] │ [img] Leather Belt   │ NXA0234   │ Access.  │ $35.00 │ 138  │[Ed]│  ║
║  │     │       3 variants     │           │          │        │      │[De]│  ║
║  │─────┼──────────────────────┼───────────┼──────────┼────────┼──────┼────│  ║
║  │ [ ] │ [img] Classic Jeans  │ NXP1045   │ Bottoms  │ $59.00 │ 180  │[Ed]│  ║
║  │     │       6 variants     │           │          │        │      │[De]│  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  Selected: 0  |  Total Products: 1,245                    << < 1 of 50 > >>   ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ WITH SELECTED:  [Edit Category]  [Update Pricing]  [Archive]  [Delete]  │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
╚════════════════════════════════════════════════════════════════════════════════╝

Product Detail/Edit Modal:

╔════════════════════════════════════════════════════════════════════════════════╗
║ EDIT PRODUCT                                                             [X]   ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  [Basic Info] [Variants] [Pricing] [Inventory] [Media]                         ║
║  ═══════════════════════════════════════════════════════════════════════════   ║
║                                                                                ║
║  ┌────────────────────┐  PRODUCT INFORMATION                                   ║
║  │                    │                                                        ║
║  │   [Product Image]  │  Name *                                                ║
║  │                    │  ┌──────────────────────────────────────────────────┐ ║
║  │   [Upload Image]   │  │ Galaxy V-Neck Tee                                │ ║
║  │                    │  └──────────────────────────────────────────────────┘ ║
║  └────────────────────┘                                                        ║
║                          SKU *                    Barcode                      ║
║                          ┌────────────────────┐   ┌────────────────────────┐  ║
║                          │ NXJ1078            │   │ 0657381512532          │  ║
║                          └────────────────────┘   └────────────────────────┘  ║
║                                                                                ║
║  Category *              Brand                                                 ║
║  ┌────────────────────┐  ┌────────────────────────────────────────────────┐  ║
║  │ Tops           ▼   │  │ Nexus Originals                                │  ║
║  └────────────────────┘  └────────────────────────────────────────────────┘  ║
║                                                                                ║
║  Description                                                                   ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ Classic V-neck tee made from premium cotton blend. Features a          │  ║
║  │ relaxed fit and reinforced stitching for durability.                    │  ║
║  │                                                                         │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  [x] Active    [x] Visible on Website    [ ] Featured                          ║
║                                                                                ║
║  ┌────────────────────────────────────────────────────────────────────────────╢
║  │                                                                            ║
║  │                                           [SAVE CHANGES]  [CANCEL]         ║
║  │                                                                            ║
║  └────────────────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════════════════╝

Screen 4: Employee Management

Purpose: Manage users, roles, permissions, and schedules.

Route: /employees/users

╔════════════════════════════════════════════════════════════════════════════════╗
║ EMPLOYEES > USERS                                            [+ Add Employee]  ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  [All Employees] [Active (24)] [Inactive (3)] [Pending (2)]                    ║
║  ═══════════════════════════════════════════════════════════════════════════   ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ [Search by name, email, employee ID...]         Location: [All ▼]       │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │       │ EMPLOYEE         │ ROLE          │ LOCATION │ STATUS  │ ACTIONS│  ║
║  ├───────┼──────────────────┼───────────────┼──────────┼─────────┼────────┤  ║
║  │       │                  │               │          │         │        │  ║
║  │ [img] │ Sarah Miller     │ Store Manager │ GM       │ Active  │ [Ed]   │  ║
║  │       │ sarah.m@nexus    │               │          │ Online  │ [...]  │  ║
║  │───────┼──────────────────┼───────────────┼──────────┼─────────┼────────│  ║
║  │ [img] │ James Wilson     │ Sales Assoc.  │ GM       │ Active  │ [Ed]   │  ║
║  │       │ james.w@nexus    │               │          │ Offline │ [...]  │  ║
║  │───────┼──────────────────┼───────────────┼──────────┼─────────┼────────│  ║
║  │ [img] │ Maria Garcia     │ Asst. Manager │ HM       │ Active  │ [Ed]   │  ║
║  │       │ maria.g@nexus    │               │          │ Online  │ [...]  │  ║
║  │───────┼──────────────────┼───────────────┼──────────┼─────────┼────────│  ║
║  │ [img] │ David Chen       │ Sales Assoc.  │ LM       │ Active  │ [Ed]   │  ║
║  │       │ david.c@nexus    │               │          │ Offline │ [...]  │  ║
║  │───────┼──────────────────┼───────────────┼──────────┼─────────┼────────│  ║
║  │ [img] │ Emma Johnson     │ Sales Assoc.  │ NM       │ Pending │ [Ed]   │  ║
║  │       │ emma.j@nexus     │               │          │ Invite  │ [...]  │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  Showing 1-25 of 29 employees                             << < 1 of 2 > >>    ║
║                                                                                ║
╚════════════════════════════════════════════════════════════════════════════════╝

Employee Detail Form:

╔════════════════════════════════════════════════════════════════════════════════╗
║ EDIT EMPLOYEE                                                            [X]   ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  [Profile] [Permissions] [Schedule] [Performance]                              ║
║  ═══════════════════════════════════════════════════════════════════════════   ║
║                                                                                ║
║  ┌────────────────────┐  PERSONAL INFORMATION                                  ║
║  │                    │                                                        ║
║  │   [Photo]          │  First Name *              Last Name *                 ║
║  │                    │  ┌───────────────────────┐ ┌───────────────────────┐  ║
║  │   [Change Photo]   │  │ Sarah                 │ │ Miller                │  ║
║  │                    │  └───────────────────────┘ └───────────────────────┘  ║
║  └────────────────────┘                                                        ║
║                          Email *                   Phone                       ║
║                          ┌───────────────────────┐ ┌───────────────────────┐  ║
║                          │ sarah.m@nexuscloth.com│ │ (555) 123-4567        │  ║
║                          └───────────────────────┘ └───────────────────────┘  ║
║                                                                                ║
║  EMPLOYMENT                                                                    ║
║                                                                                ║
║  Employee ID            Hire Date                 Status                       ║
║  ┌───────────────────┐  ┌───────────────────────┐ ┌───────────────────────┐  ║
║  │ EMP-00042         │  │ 03/15/2022            │ │ Active             ▼ │  ║
║  └───────────────────┘  └───────────────────────┘ └───────────────────────┘  ║
║                                                                                ║
║  Role *                 Primary Location *                                     ║
║  ┌───────────────────┐  ┌───────────────────────────────────────────────────┐ ║
║  │ Store Manager  ▼  │  │ GM - Greenbrier Mall                          ▼ │ ║
║  └───────────────────┘  └───────────────────────────────────────────────────┘ ║
║                                                                                ║
║  Additional Locations (can work at):                                           ║
║  [x] HQ - Headquarters   [x] HM - Peninsula   [ ] LM - Lynnhaven  [ ] NM      ║
║                                                                                ║
║  PIN: [****]  [Reset PIN]                                                      ║
║                                                                                ║
║  ┌────────────────────────────────────────────────────────────────────────────╢
║  │                                                                            ║
║  │                                           [SAVE CHANGES]  [CANCEL]         ║
║  │                                                                            ║
║  └────────────────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════════════════╝

Screen 5: Reporting

Purpose: Generate and view sales, inventory, and performance reports.

Route: /reports/sales

╔════════════════════════════════════════════════════════════════════════════════╗
║ REPORTS > SALES                                           [Schedule] [Export]  ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  [Sales Summary] [By Product] [By Location] [By Employee] [By Time]            ║
║  ═══════════════════════════════════════════════════════════════════════════   ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ Date Range: [12/01/2024] to [12/29/2024]      Location: [All ▼]         │  ║
║  │                                                                         │  ║
║  │ Compare to: [x] Previous Period  [ ] Same Period Last Year              │  ║
║  │                                                      [Generate Report]   │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌────────────────────────────────┐  ┌────────────────────────────────────┐   ║
║  │ TOTAL REVENUE                  │  │ TRANSACTIONS                       │   ║
║  │                                │  │                                    │   ║
║  │      $145,678.90               │  │        1,847                       │   ║
║  │      +12.3% vs prev            │  │        +8.2% vs prev               │   ║
║  └────────────────────────────────┘  └────────────────────────────────────┘   ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ REVENUE TREND                                                           │  ║
║  │                                                                         │  ║
║  │  $8K ┤                                          ╱╲                      │  ║
║  │      │                              ╱╲         ╱  ╲       ╱╲            │  ║
║  │  $6K ┤                   ╱╲        ╱  ╲       ╱    ╲     ╱  ╲           │  ║
║  │      │          ╱╲      ╱  ╲      ╱    ╲     ╱      ╲   ╱    ╲          │  ║
║  │  $4K ┤    ╱╲   ╱  ╲    ╱    ╲    ╱      ╲   ╱        ╲_╱      ╲_        │  ║
║  │      │   ╱  ╲_╱    ╲__╱      ╲__╱        ╲_╱                            │  ║
║  │  $2K ┤                                                                  │  ║
║  │      └──────────────────────────────────────────────────────────────    │  ║
║  │       Dec 1   Dec 5   Dec 10   Dec 15   Dec 20   Dec 25   Dec 29        │  ║
║  │                                                                         │  ║
║  │  ── Current Period     - - Previous Period                              │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ TOP SELLING PRODUCTS                          │ SALES BY LOCATION       │  ║
║  ├───────────────────────────────────────────────┼─────────────────────────┤  ║
║  │ 1. Galaxy V-Neck Tee        $12,450  (8.5%)   │ GM    ████████████ 35%  │  ║
║  │ 2. Slim Fit Chinos          $10,230  (7.0%)   │ HM    █████████   28%   │  ║
║  │ 3. Classic Jeans            $ 9,875  (6.8%)   │ LM    ███████     22%   │  ║
║  │ 4. Oxford Shirt             $ 8,920  (6.1%)   │ NM    █████       15%   │  ║
║  │ 5. Leather Belt             $ 7,560  (5.2%)   │                         │  ║
║  └───────────────────────────────────────────────┴─────────────────────────┘  ║
║                                                                                ║
╚════════════════════════════════════════════════════════════════════════════════╝

Screen 6: Settings

Purpose: Configure locations, integrations, and system parameters.

Route: /settings

╔════════════════════════════════════════════════════════════════════════════════╗
║ SETTINGS                                                                       ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  [Locations] [Integrations] [System] [Audit Log]                               ║
║  ═══════════════════════════════════════════════════════════════════════════   ║
║                                                                                ║
║  STORE LOCATIONS                                              [+ Add Location] ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │ CODE │ NAME                │ ADDRESS              │ STATUS  │ REGISTERS │  ║
║  ├──────┼─────────────────────┼──────────────────────┼─────────┼───────────┤  ║
║  │      │                     │                      │         │           │  ║
║  │ HQ   │ Headquarters        │ 123 Warehouse Blvd   │ Active  │    0      │  ║
║  │      │                     │ Chesapeake, VA       │         │           │  ║
║  │──────┼─────────────────────┼──────────────────────┼─────────┼───────────│  ║
║  │ GM   │ Greenbrier Mall     │ 1401 Greenbrier Pkwy │ Active  │    3      │  ║
║  │      │                     │ Chesapeake, VA       │ Online  │           │  ║
║  │──────┼─────────────────────┼──────────────────────┼─────────┼───────────│  ║
║  │ HM   │ Peninsula Town Ctr  │ 4410 E Claiborne Sq  │ Active  │    2      │  ║
║  │      │                     │ Hampton, VA          │ Online  │           │  ║
║  │──────┼─────────────────────┼──────────────────────┼─────────┼───────────│  ║
║  │ LM   │ Lynnhaven Mall      │ 701 Lynnhaven Pkwy   │ Active  │    2      │  ║
║  │      │                     │ Virginia Beach, VA   │ Online  │           │  ║
║  │──────┼─────────────────────┼──────────────────────┼─────────┼───────────│  ║
║  │ NM   │ Patrick Henry Mall  │ 12300 Jefferson Ave  │ Active  │    2      │  ║
║  │      │                     │ Newport News, VA     │ Offline │           │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  GENERAL SETTINGS                                                              ║
║                                                                                ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │                                                                         │  ║
║  │  Company Name:     [Nexus Clothing                             ]        │  ║
║  │  Tax Rate:         [6.00    ] %                                         │  ║
║  │  Currency:         [USD - US Dollar                         ▼ ]         │  ║
║  │  Timezone:         [America/New_York                        ▼ ]         │  ║
║  │                                                                         │  ║
║  │  Receipt Footer:   [Thank you for shopping at Nexus!           ]        │  ║
║  │                                                                         │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  ┌────────────────────────────────────────────────────────────────────────────╢
║  │ [!] You have unsaved changes                                              ║
║  │                                           [RESET]  [SAVE SETTINGS]        ║
║  │                                                                            ║
║  └────────────────────────────────────────────────────────────────────────────╢
╚════════════════════════════════════════════════════════════════════════════════╝

Integrations Tab:

╔════════════════════════════════════════════════════════════════════════════════╗
║ SETTINGS > INTEGRATIONS                                                        ║
╠════════════════════════════════════════════════════════════════════════════════╣
║                                                                                ║
║  SHOPIFY                                                    ● Connected        ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │                                                                         │  ║
║  │  Store Domain:    nexuspremier.myshopify.com                            │  ║
║  │  API Version:     2024-01                                               │  ║
║  │  Last Sync:       12/29/2024 2:45 PM                                    │  ║
║  │  Sync Status:     ✓ Products  ✓ Orders  ✓ Inventory                     │  ║
║  │                                                                         │  ║
║  │                              [Test Connection]  [Sync Now]  [Configure] │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  QUICKBOOKS DESKTOP                                         ● Connected        ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │                                                                         │  ║
║  │  Company File:    NexusClothing.qbw                                     │  ║
║  │  QB Version:      QuickBooks POS v19                                    │  ║
║  │  Bridges Online:  4 of 5                                                │  ║
║  │                                                                         │  ║
║  │                              [View Bridge Status]  [Refresh]            │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
║  PAYMENT PROCESSOR                                          ○ Not Connected    ║
║  ┌─────────────────────────────────────────────────────────────────────────┐  ║
║  │                                                                         │  ║
║  │  Provider:        [Select Payment Processor         ▼]                  │  ║
║  │                                                                         │  ║
║  │                              [Configure]                                │  ║
║  └─────────────────────────────────────────────────────────────────────────┘  ║
║                                                                                ║
╚════════════════════════════════════════════════════════════════════════════════╝

Responsive Design Considerations

Breakpoints

BreakpointWidthLayout Adaptation
Desktop XL1400px+Full 3-column layout
Desktop1024-1399px2-column with collapsible panels
Tablet768-1023pxHamburger menu, stacked cards
Mobile< 768pxSingle column, bottom nav
/* Desktop: Fixed sidebar */
@media (min-width: 1024px) {
    .sidebar {
        width: 240px;
        position: fixed;
        height: calc(100vh - 56px - 40px);
    }
}

/* Tablet: Overlay sidebar */
@media (max-width: 1023px) {
    .sidebar {
        position: fixed;
        z-index: 1000;
        transform: translateX(-100%);
        transition: transform 0.3s ease;
    }
    .sidebar.open {
        transform: translateX(0);
    }
}

/* Mobile: Hide sidebar, use bottom nav */
@media (max-width: 767px) {
    .sidebar { display: none; }
    .bottom-nav { display: flex; }
}

Table Responsiveness

/* Horizontal scroll for data tables on smaller screens */
@media (max-width: 1023px) {
    .data-table-container {
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
    }
}

/* Card layout for mobile */
@media (max-width: 767px) {
    .data-table tr {
        display: block;
        margin-bottom: 16px;
        border: 1px solid var(--color-border);
        border-radius: 8px;
    }
    .data-table td {
        display: flex;
        justify-content: space-between;
        padding: 8px 12px;
    }
    .data-table td::before {
        content: attr(data-label);
        font-weight: 600;
    }
}

Role-Based Access

FeatureAdminManagerSupervisorAssociate
DashboardFullFullLimitedView Only
Inventory - ViewYesYesYesYes
Inventory - TransferYesYesYesNo
Inventory - AdjustYesYesRequestNo
Products - ViewYesYesYesYes
Products - EditYesYesNoNo
Products - CreateYesYesNoNo
Employees - ViewYesYesOwn TeamSelf
Employees - EditYesOwn StoreNoNo
Reports - AllYesYesLimitedNo
Settings - ViewYesLimitedNoNo
Settings - EditYesNoNoNo

Real-Time Updates

The Admin Portal uses SignalR for live updates:

EventHub MethodUI Update
Sale CompletedReceiveSaleDashboard KPIs, Activity Feed
Inventory ChangedReceiveInventoryUpdateInventory grid, alerts
Bridge StatusReceiveBridgeStatusStatus indicators
New AlertReceiveAlertAlert panel, notification bell
Transfer CompleteReceiveTransferInventory grid, activity

Summary

The Admin Portal provides:

  1. Dashboard: Real-time KPIs, alerts, and activity monitoring
  2. Inventory: Multi-location stock management with transfers
  3. Products: Full catalog CRUD with variants and pricing
  4. Employees: User management, roles, schedules
  5. Reports: Comprehensive sales and performance analytics
  6. Settings: Location, integration, and system configuration

Implementation complete. Ready for engineer review.

Chapter 21: Mobile Raptag Application

RFID Inventory Management

The Raptag mobile application enables rapid inventory counting using RFID technology. Associates can scan entire racks of merchandise in seconds, dramatically reducing inventory count time and improving accuracy.


Technology Stack

ComponentTechnologyRationale
Framework.NET MAUICross-platform, native RFID SDK access
RFID SDKZebra RFID SDKEnterprise-grade, widely deployed
Local DatabaseSQLiteOffline-capable, lightweight
SyncREST API + Background ServiceReliable batch uploads
Tag PrintingZebra ZPLIndustry standard label format

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                    RAPTAG MOBILE APPLICATION                         │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │                         UI LAYER                                 ││
│  │  Login  │  Session  │  Scanning  │  Summary  │  Sync            ││
│  └─────────────────────────────────────────────────────────────────┘│
│                              │                                       │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │                      VIEW MODELS                                 ││
│  │  LoginVM  │  SessionVM  │  ScanVM  │  SummaryVM  │  SyncVM      ││
│  └─────────────────────────────────────────────────────────────────┘│
│                              │                                       │
│  ┌───────────────┬───────────────┬───────────────┬─────────────────┐│
│  │ RFID Service  │ Sync Service  │ Print Service │ Session Service ││
│  │ (Zebra SDK)   │ (HTTP/Queue)  │ (ZPL/BT)      │ (State Mgmt)    ││
│  └───────────────┴───────────────┴───────────────┴─────────────────┘│
│                              │                                       │
│  ┌─────────────────────────────────────────────────────────────────┐│
│  │                    LOCAL SQLITE DATABASE                         ││
│  │  - Sessions          - Tags                                      ││
│  │  - Scan Records      - Product Cache                             ││
│  │  - Sync Queue        - Settings                                  ││
│  └─────────────────────────────────────────────────────────────────┘│
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘
                                 │
                        ┌────────▼────────┐
                        │   Central API   │
                        │  (When Online)  │
                        └─────────────────┘

Network Architecture: How Raptag Connects

Important: Raptag connects directly to the Central Cloud API - NOT to the POS Client.

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                     RAPTAG NETWORK ARCHITECTURE                                      │
└─────────────────────────────────────────────────────────────────────────────────────┘

CORRECT UNDERSTANDING:

    ┌─────────────────────────────────────────────────────────────────┐
    │                   CENTRAL CLOUD API                              │
    │                 (Multi-Tenant SaaS)                              │
    │                                                                  │
    │  api.pos-platform.com                                            │
    │  └── /api/v1/inventory/*                                         │
    │  └── /api/v1/rfid/*                                              │
    │  └── /api/v1/auth/*                                              │
    └─────────────────────────────────────────────────────────────────┘
                    │                         │
         ┌──────────┴──────────┐   ┌──────────┴──────────┐
         │                     │   │                     │
         ▼                     ▼   ▼                     ▼
    ┌──────────┐         ┌──────────┐              ┌──────────┐
    │ Raptag   │         │   POS    │              │  Admin   │
    │ Mobile   │         │  Client  │              │  Portal  │
    │          │         │          │              │          │
    │ (RFID)   │         │ (Sales)  │              │  (Web)   │
    └──────────┘         └──────────┘              └──────────┘
         │                     │
      WiFi/LTE              LAN/WiFi
         │                     │
    Store Floor           Register


COMMON MISCONCEPTION (WRONG):

         ╔═══════════════════════════════╗
         ║  Raptag → POS Client → Cloud  ║    ← WRONG!
         ╚═══════════════════════════════╝

    Raptag does NOT connect through POS Client.
    They are SIBLINGS, not parent-child.

Why Direct Cloud Connection?

BenefitExplanation
IndependenceRaptag works even if POS Client is down
MobilityCan scan anywhere with WiFi (backroom, dock, sales floor)
SimplicityNo complex local networking between devices
ConsistencySame API endpoints as all other clients
ScalabilityMultiple Raptag devices don’t overload single POS

Connection Flow

STEP 1: DEVICE REGISTRATION
┌──────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│  1. Admin logs into Admin Portal                                             │
│  2. Navigate to Settings → RFID → Devices → Add Device                       │
│  3. Portal generates QR code with:                                           │
│     • Tenant ID                                                              │
│     • API endpoint URL                                                       │
│     • Registration token                                                     │
│  4. Scan QR code with Raptag app                                             │
│  5. Device registered to tenant                                              │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

STEP 2: DAILY OPERATION
┌──────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│  1. Associate enters PIN on Raptag                                           │
│     → POST /api/v1/auth/pin-login                                           │
│     ← JWT token returned                                                     │
│                                                                              │
│  2. Raptag syncs product catalog (if stale)                                  │
│     → GET /api/v1/products?since=2025-01-28                                 │
│     ← Updated products cached locally                                        │
│                                                                              │
│  3. Associate creates scan session                                           │
│     → POST /api/v1/rfid/sessions                                            │
│     ← Session ID returned                                                    │
│                                                                              │
│  4. Scans are stored locally during session                                  │
│     (Offline-capable - no API calls during scan)                             │
│                                                                              │
│  5. Session completed and submitted                                          │
│     → POST /api/v1/rfid/sessions/{id}/complete                              │
│       Body: { "tagReads": [...], "items": [...] }                           │
│     ← Confirmation returned                                                  │
│                                                                              │
│  6. Cloud updates inventory records                                          │
│     → Inventory adjustments broadcast to POS Clients via SignalR            │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘

Offline Mode

When WiFi is unavailable:

OFFLINE OPERATION:
┌────────────────────────────────────────────────────────────────────────────┐
│                                                                            │
│  Raptag App                           Local SQLite                         │
│      │                                    │                                │
│      │ 1. PIN login                       │                                │
│      ├───────────────────────────────────►│ Validate against cached users  │
│      │                                    │                                │
│      │ 2. Start session                   │                                │
│      ├───────────────────────────────────►│ Create local session record    │
│      │                                    │                                │
│      │ 3. Scan tags                       │                                │
│      ├───────────────────────────────────►│ Store in tag_reads table       │
│      │                                    │                                │
│      │ 4. Complete session                │                                │
│      ├───────────────────────────────────►│ Add to sync_queue              │
│      │                                    │                                │
│      │              ─── WiFi Restored ───                                  │
│      │                                    │                                │
│      │ 5. Background sync                 │                                │
│      │◄───────────────────────────────────┤ Flush sync_queue to Cloud      │
│      │                                    │                                │
└────────────────────────────────────────────────────────────────────────────┘

No network? No problem. Sessions queue and sync when WiFi returns.

RFID Configuration (Admin Portal)

RFID settings are configured in the Admin Portal (not a separate system):

ADMIN PORTAL → Settings → RFID / Raptag

┌─────────────────────────────────────────────────────────────────────────────┐
│  RFID SETTINGS                                                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  DEVICES                                                            [+ Add] │
│  ┌─────────────────────────────────────────────────────────────────────────┐
│  │ Device Name      │ Model     │ Location │ Last Seen    │ Status       │
│  ├──────────────────┼───────────┼──────────┼──────────────┼──────────────┤
│  │ Handheld-GM-01   │ MC3390R   │ GM       │ 2 min ago    │ ● Online     │
│  │ Handheld-GM-02   │ MC3390R   │ GM       │ 15 min ago   │ ○ Idle       │
│  │ Sled-HM-01       │ RFD40     │ HM       │ 3 hours ago  │ ○ Offline    │
│  └─────────────────────────────────────────────────────────────────────────┘
│                                                                             │
│  TAG MAPPINGS                                                               │
│  ┌─────────────────────────────────────────────────────────────────────────┐
│  │ EPC mappings are auto-generated when tags are printed.                  │
│  │ Use "Import Mappings" for pre-encoded tags from vendor.                 │
│  │                                                                         │
│  │ Total Mappings: 8,432         [Import Mappings] [Export Mappings]       │
│  └─────────────────────────────────────────────────────────────────────────┘
│                                                                             │
│  SCAN SETTINGS                                                              │
│  ┌─────────────────────────────────────────────────────────────────────────┐
│  │                                                                         │
│  │  Default Power Level:  [25 dBm      ▼]                                 │
│  │  Read Timeout:         [5 seconds   ▼]                                 │
│  │  Duplicate Filter:     [✓] Skip if read within 3 seconds               │
│  │  Unknown Tag Handling: [● Flag for review  ○ Ignore]                   │
│  │                                                                         │
│  └─────────────────────────────────────────────────────────────────────────┘
│                                                                             │
│  VARIANCE THRESHOLDS                                                        │
│  ┌─────────────────────────────────────────────────────────────────────────┐
│  │                                                                         │
│  │  Auto-approve if variance ≤  [2%  ▼]                                   │
│  │  Require recount if variance > [5%  ▼]                                 │
│  │                                                                         │
│  └─────────────────────────────────────────────────────────────────────────┘
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Screen Specifications

Screen 1: Login

Purpose: Authenticate user and select operational context.

Route: /login

╔════════════════════════════════════════════════════════════════════╗
║                                                                    ║
║                                                                    ║
║                    ┌──────────────────────────┐                    ║
║                    │                          │                    ║
║                    │    ████████████████      │                    ║
║                    │    ██  RAPTAG   ██       │                    ║
║                    │    ████████████████      │                    ║
║                    │                          │                    ║
║                    └──────────────────────────┘                    ║
║                                                                    ║
║                      RFID Inventory System                         ║
║                                                                    ║
║                                                                    ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │                                                            │ ║
║    │  Employee PIN                                              │ ║
║    │  ┌────────────────────────────────────────────────────┐   │ ║
║    │  │ ● ● ● ● ○ ○                                        │   │ ║
║    │  └────────────────────────────────────────────────────┘   │ ║
║    │                                                            │ ║
║    │  ┌───────┐  ┌───────┐  ┌───────┐                          │ ║
║    │  │   1   │  │   2   │  │   3   │                          │ ║
║    │  └───────┘  └───────┘  └───────┘                          │ ║
║    │  ┌───────┐  ┌───────┐  ┌───────┐                          │ ║
║    │  │   4   │  │   5   │  │   6   │                          │ ║
║    │  └───────┘  └───────┘  └───────┘                          │ ║
║    │  ┌───────┐  ┌───────┐  ┌───────┐                          │ ║
║    │  │   7   │  │   8   │  │   9   │                          │ ║
║    │  └───────┘  └───────┘  └───────┘                          │ ║
║    │  ┌───────┐  ┌───────┐  ┌───────┐                          │ ║
║    │  │  CLR  │  │   0   │  │  GO   │                          │ ║
║    │  └───────┘  └───────┘  └───────┘                          │ ║
║    │                                                            │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║                                                                    ║
║  ────────────────────────────────────────────────────────────────  ║
║  Reader: MC3390R  |  Battery: 85%  |  ● Offline                    ║
╚════════════════════════════════════════════════════════════════════╝

Components:

ComponentSpecification
LogoRaptag brand, centered
PIN Display6 digits with masked/filled indicators
NumpadLarge touch targets (64x64px min)
Clear (CLR)Reset PIN entry
GoSubmit PIN for validation
Status BarReader model, battery, connection

Behavior:

  • PIN validated against local cache (for offline)
  • Sync user list on startup when online
  • Auto-login from last session option
  • Lock screen after 5 minutes of inactivity

Screen 2: Session Start

Purpose: Configure a new inventory counting session.

Route: /session/new

╔════════════════════════════════════════════════════════════════════╗
║ NEW INVENTORY SESSION                                   [< Back]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║    Welcome, Sarah Miller                                           ║
║    Today: December 29, 2024                                        ║
║                                                                    ║
║  ──────────────────────────────────────────────────────────────    ║
║                                                                    ║
║    LOCATION *                                                      ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │ GM - Greenbrier Mall                                    ▼ │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║    COUNT TYPE *                                                    ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │  ○  Full Store Count                                       │ ║
║    │      Complete inventory of entire location                 │ ║
║    │                                                            │ ║
║    │  ●  Zone Count                                             │ ║
║    │      Count specific area/department                        │ ║
║    │                                                            │ ║
║    │  ○  Spot Check                                             │ ║
║    │      Quick verification of selected items                  │ ║
║    │                                                            │ ║
║    │  ○  Receiving                                              │ ║
║    │      Verify incoming shipment                              │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║    ZONE (Required for Zone Count)                                  ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │ Section A - Men's Tops                                  ▼ │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║    NOTES (Optional)                                                ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │ Pre-inventory count for Q4 audit                           │ ║
║    │                                                            │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║    ┌────────────────────────────────────────────────────────────┐ ║
║    │                                                            │ ║
║    │                   START SESSION                            │ ║
║    │                                                            │ ║
║    └────────────────────────────────────────────────────────────┘ ║
║                                                                    ║
║  ────────────────────────────────────────────────────────────────  ║
║  Reader: Ready  |  Battery: 85%  |  ● Online                       ║
╚════════════════════════════════════════════════════════════════════╝

Count Types:

TypeUse CaseExpected Items
Full StoreAnnual inventory2,000-10,000+
Zone CountSection audits200-1,000
Spot CheckDiscrepancy verification10-50
ReceivingShipment verification50-500

Zones (Configurable per location):

  • Section A - Men’s Tops
  • Section B - Men’s Bottoms
  • Section C - Women’s Tops
  • Section D - Women’s Bottoms
  • Section E - Accessories
  • Backroom
  • Display Window

Screen 3: Scanning (Main Interface)

Purpose: The primary RFID scanning interface during an active session.

Route: /session/scan

╔════════════════════════════════════════════════════════════════════╗
║ SCANNING - Zone Count                               [Pause] [End]  ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  Location: GM - Greenbrier Mall      Zone: Section A - Men's Tops  ║
║  Started: 2:45 PM                    Duration: 00:12:34            ║
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │                      LIVE SCAN                                 ║
║  │                                                                ║
║  │         ┌──────────────────────────────────────────┐          ║
║  │         │                                          │          ║
║  │         │            ████  SCANNING  ████          │          ║
║  │         │                                          │          ║
║  │         │             Tags Read: 847               │          ║
║  │         │             Unique Items: 312            │          ║
║  │         │             Read Rate: 42/sec            │          ║
║  │         │                                          │          ║
║  │         └──────────────────────────────────────────┘          ║
║  │                                                                ║
║  │                   [HOLD TRIGGER TO SCAN]                       ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  RECENT SCANS                                                      ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  ✓ NXJ1078-NAV-M    Galaxy V-Neck (M, Navy)         x3        ║
║  │  ✓ NXJ1078-NAV-L    Galaxy V-Neck (L, Navy)         x2        ║
║  │  ✓ NXP0892-KHK-32   Slim Fit Chinos (32, Khaki)     x1        ║
║  │  ! UNKNOWN TAG      E280116060000...                x1        ║
║  │  ✓ NXA0234-BLK-M    Leather Belt (M, Black)         x4        ║
║  │                                                                ║
║  │  [View All 312 Items]                                          ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐    ║
║  │  [MANUAL ADD]   │  │  [FIND ITEM]    │  │  [SETTINGS]     │    ║
║  └─────────────────┘  └─────────────────┘  └─────────────────┘    ║
║                                                                    ║
║  ────────────────────────────────────────────────────────────────  ║
║  Reader: Scanning  |  Battery: 82%  |  Signal: Strong              ║
╚════════════════════════════════════════════════════════════════════╝

Scanning States:

IDLE STATE                          SCANNING STATE
┌────────────────────────┐          ┌────────────────────────┐
│                        │          │                        │
│    ○ ○ ○ ○ ○ ○ ○ ○     │          │    ████████████████    │
│                        │          │    ████ ACTIVE ████    │
│    Ready to Scan       │          │    ████████████████    │
│                        │          │                        │
│    Tags: 0             │          │    Tags: 847           │
│                        │          │    Rate: 42/sec        │
│                        │          │                        │
└────────────────────────┘          └────────────────────────┘

PAUSED STATE                        COMPLETED STATE
┌────────────────────────┐          ┌────────────────────────┐
│                        │          │                        │
│    ║ ║  PAUSED  ║ ║    │          │    ✓ COMPLETE ✓        │
│                        │          │                        │
│    Session paused      │          │    Session ended       │
│    Tap to resume       │          │                        │
│                        │          │    Total: 847 tags     │
│    Tags: 312           │          │    312 unique items    │
│                        │          │                        │
└────────────────────────┘          └────────────────────────┘

Reader Signal Strength:

LevelIconRead Rate
Strong4 bars40+ tags/sec
Good3 bars20-40 tags/sec
Fair2 bars10-20 tags/sec
Weak1 bar< 10 tags/sec
NoneXNo connection

Quick Actions:

ActionPurpose
Manual AddBarcode scan for untagged items
Find ItemLocate specific SKU using reader
SettingsAdjust power, beep, vibration

Screen 4: Session Summary

Purpose: Review results and submit completed count session.

Route: /session/summary

╔════════════════════════════════════════════════════════════════════╗
║ SESSION SUMMARY                                         [< Back]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │  SESSION #GM-2024-1229-001                                     ║
║  │  Zone Count - Section A (Men's Tops)                           ║
║  │  Location: GM - Greenbrier Mall                                ║
║  │  Operator: Sarah Miller                                        ║
║  │  Date: December 29, 2024                                       ║
║  │  Duration: 00:23:45                                            ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  SCAN RESULTS                                                      ║
║  ┌─────────────────────────────────────────────────────────────┐  ║
║  │                                                             │  ║
║  │   ┌─────────────────────┐    ┌─────────────────────┐       │  ║
║  │   │  TOTAL TAGS         │    │  UNIQUE ITEMS       │       │  ║
║  │   │       847           │    │       312           │       │  ║
║  │   └─────────────────────┘    └─────────────────────┘       │  ║
║  │                                                             │  ║
║  │   ┌─────────────────────┐    ┌─────────────────────┐       │  ║
║  │   │  EXPECTED           │    │  VARIANCE           │       │  ║
║  │   │       305           │    │       +7 (2.3%)     │       │  ║
║  │   └─────────────────────┘    └─────────────────────┘       │  ║
║  │                                                             │  ║
║  └─────────────────────────────────────────────────────────────┘  ║
║                                                                    ║
║  DISCREPANCIES                                          View All   ║
║  ┌─────────────────────────────────────────────────────────────┐  ║
║  │                                                             │  ║
║  │  ▲ OVER (5 items)                                           │  ║
║  │    NXJ1078-NAV-M    Expected: 12   Counted: 15   (+3)       │  ║
║  │    NXP0892-KHK-32   Expected:  8   Counted: 10   (+2)       │  ║
║  │                                                             │  ║
║  │  ▼ SHORT (3 items)                                          │  ║
║  │    NXJ2156-WHT-L    Expected:  6   Counted:  4   (-2)       │  ║
║  │    NXA0234-BRN-M    Expected:  5   Counted:  4   (-1)       │  ║
║  │                                                             │  ║
║  │  ? UNKNOWN (2 tags)                                         │  ║
║  │    E280116060000207523456789                                │  ║
║  │    E280116060000207523456790                                │  ║
║  │                                                             │  ║
║  └─────────────────────────────────────────────────────────────┘  ║
║                                                                    ║
║  ┌─────────────────────┐  ┌─────────────────────────────────────┐ ║
║  │                     │  │                                     │ ║
║  │  [RECOUNT SECTION]  │  │         SUBMIT SESSION              │ ║
║  │                     │  │                                     │ ║
║  └─────────────────────┘  └─────────────────────────────────────┘ ║
║                                                                    ║
║  ────────────────────────────────────────────────────────────────  ║
║  Reader: Idle  |  Battery: 78%  |  ● Online                        ║
╚════════════════════════════════════════════════════════════════════╝

Variance Thresholds (Configurable):

VarianceColorAction
0%GreenAuto-approve
1-2%YellowReview recommended
3-5%OrangeManager review required
> 5%RedRecount required

Screen 5: Sync Status

Purpose: Monitor data synchronization with central server.

Route: /sync

╔════════════════════════════════════════════════════════════════════╗
║ SYNC STATUS                                             [< Menu]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  CONNECTION                                                        ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │    ●  Connected to Central Server                              ║
║  │       Server: api.nexuspos.com                                 ║
║  │       Latency: 45ms                                            ║
║  │       Last Sync: 2 minutes ago                                 ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  PENDING UPLOADS                                                   ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │    ┌─────────────────────────────────────────────────────────┐║
║  │    │ Session #GM-2024-1229-001                    Uploading  │║
║  │    │ 312 items, 847 tag reads                                │║
║  │    │ Progress: ████████████░░░░░░░░  62%                     │║
║  │    └─────────────────────────────────────────────────────────┘║
║  │                                                                ║
║  │    ┌─────────────────────────────────────────────────────────┐║
║  │    │ Session #GM-2024-1228-003                    Pending    │║
║  │    │ 156 items, 423 tag reads                                │║
║  │    │ Waiting...                                              │║
║  │    └─────────────────────────────────────────────────────────┘║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  RECENT SYNCS                                                      ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │    ✓  Product Catalog     Updated 10 min ago     1,245 items  ║
║  │    ✓  Tag Mappings        Updated 10 min ago     8,432 tags   ║
║  │    ✓  User List           Updated 1 hour ago     24 users     ║
║  │    ✓  Location Config     Updated 1 hour ago     5 locations  ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  ┌─────────────────────────────────────────────────────────────┐  ║
║  │                                                             │  ║
║  │                     [SYNC NOW]                              │  ║
║  │                                                             │  ║
║  └─────────────────────────────────────────────────────────────┘  ║
║                                                                    ║
║  Storage: 245 MB used of 2 GB  |  Last Full Sync: 12/29 9:00 AM   ║
║                                                                    ║
║  ────────────────────────────────────────────────────────────────  ║
║  Reader: Idle  |  Battery: 78%  |  ● Online                        ║
╚════════════════════════════════════════════════════════════════════╝

Zebra RFID Reader Integration

Supported Devices

ModelForm FactorRangeUse Case
MC3390RHandheld20 ftStore counts
RFD40Sled12 ftAttaches to phone
FX9600Fixed30 ftDock door receiving

SDK Integration

public interface IRfidService
{
    event EventHandler<TagReadEventArgs> TagRead;
    event EventHandler<BatteryEventArgs> BatteryChanged;
    event EventHandler<ReaderEventArgs> ReaderConnected;
    event EventHandler<ReaderEventArgs> ReaderDisconnected;

    Task<bool> ConnectAsync();
    Task DisconnectAsync();
    Task StartInventoryAsync();
    Task StopInventoryAsync();
    Task<ReaderStatus> GetStatusAsync();
    Task SetPowerLevelAsync(int dbm);
}

public class ZebraRfidService : IRfidService
{
    private readonly RFIDReader _reader;
    private readonly EventHandler _eventHandler;

    public async Task<bool> ConnectAsync()
    {
        var readers = RFIDReader.GetAvailableReaders();
        if (readers.Count == 0) return false;

        _reader = readers[0];
        _reader.Events.TagReadEvent += OnTagRead;
        _reader.Events.ReaderAppearEvent += OnReaderAppear;
        _reader.Events.ReaderDisappearEvent += OnReaderDisappear;
        _reader.Events.BatteryEvent += OnBatteryChanged;

        return await _reader.ConnectAsync();
    }

    public async Task StartInventoryAsync()
    {
        var config = new InventoryConfig
        {
            MemoryBank = MEMORY_BANK.MEMORY_BANK_EPC,
            ReportUnique = true,
            StopTrigger = new StopTrigger
            {
                StopTriggerType = STOP_TRIGGER_TYPE.STOP_TRIGGER_TYPE_TAG_OBSERVATION
            }
        };

        await _reader.Inventory.PerformAsync(config);
    }

    private void OnTagRead(object sender, TagDataEventArgs e)
    {
        foreach (var tag in e.ReadEventData.TagData)
        {
            var epc = tag.TagID;
            var rssi = tag.PeakRSSI;

            TagRead?.Invoke(this, new TagReadEventArgs(epc, rssi));
        }
    }
}

Power Level Settings

Power (dBm)RangeBattery ImpactUse Case
30 (Max)20+ ftHighFull store
2515 ftMediumZone count
2010 ftLowSpot check
155 ftMinimalSingle rack

Tag Printing Workflow

Encoding New Tags

╔════════════════════════════════════════════════════════════════════╗
║ PRINT RFID TAGS                                         [< Back]   ║
╠════════════════════════════════════════════════════════════════════╣
║                                                                    ║
║  PRODUCT                                                           ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │  NXJ1078-NAV-M                                                 ║
║  │  Galaxy V-Neck Tee - Navy, Medium                              ║
║  │  Price: $29.00                                                 ║
║  │  Current Stock: 15 (GM)                                        ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  PRINT SETTINGS                                                    ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │                                                                ║
║  │  Quantity:    [    5    ]  tags                                ║
║  │                                                                ║
║  │  Tag Type:    ○ Hang Tag (Apparel)                             ║
║  │               ● Price Tag (Standard)                           ║
║  │               ○ Label (Adhesive)                               ║
║  │                                                                ║
║  │  Printer:     [Zebra ZD621R                               ▼]  ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  TAG PREVIEW                                                       ║
║  ┌────────────────────────────────────────────────────────────────╢
║  │  ┌─────────────────────────┐                                   ║
║  │  │ NEXUS CLOTHING          │                                   ║
║  │  │                         │                                   ║
║  │  │ Galaxy V-Neck Tee       │                                   ║
║  │  │ Navy / Medium           │                                   ║
║  │  │                         │                                   ║
║  │  │        $29.00           │                                   ║
║  │  │                         │                                   ║
║  │  │ |||||||||||||||||||     │  <- Barcode                       ║
║  │  │ NXJ1078-NAV-M           │                                   ║
║  │  │                         │                                   ║
║  │  │ [RFID ENCODED]          │  <- Chip indicator                ║
║  │  └─────────────────────────┘                                   ║
║  │                                                                ║
║  └────────────────────────────────────────────────────────────────╢
║                                                                    ║
║  ┌─────────────────────────────────────────────────────────────┐  ║
║  │                                                             │  ║
║  │                    [PRINT 5 TAGS]                           │  ║
║  │                                                             │  ║
║  └─────────────────────────────────────────────────────────────┘  ║
║                                                                    ║
╚════════════════════════════════════════════════════════════════════╝

ZPL Template

^XA
^FO50,50^A0N,30,30^FDNexus Clothing^FS
^FO50,100^A0N,40,40^FD%PRODUCT_NAME%^FS
^FO50,150^A0N,25,25^FD%VARIANT%^FS
^FO50,200^A0N,50,50^FD$%PRICE%^FS
^FO50,280^BY2^BCN,80,Y,N,N^FD%SKU%^FS
^RFW,H,2,4,1^FD%EPC%^FS
^RFR,H,0,12,1^FN0^FS
^XZ

Template Variables:

VariableSourceExample
%PRODUCT_NAME%Product.NameGalaxy V-Neck Tee
%VARIANT%Size/ColorNavy / Medium
%PRICE%Product.Price29.00
%SKU%Product.SKUNXJ1078-NAV-M
%EPC%GeneratedE28011606000020752345

Local Database Schema

-- Sessions
CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    location_code TEXT NOT NULL,
    count_type TEXT NOT NULL,
    zone TEXT,
    operator_id TEXT NOT NULL,
    started_at TEXT NOT NULL,
    ended_at TEXT,
    status TEXT DEFAULT 'active',
    notes TEXT,
    synced_at TEXT
);

-- Tag Reads (raw data)
CREATE TABLE tag_reads (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL,
    epc TEXT NOT NULL,
    rssi INTEGER,
    read_at TEXT NOT NULL,
    FOREIGN KEY (session_id) REFERENCES sessions(id)
);

-- Session Items (aggregated)
CREATE TABLE session_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT NOT NULL,
    sku TEXT,
    epc TEXT NOT NULL,
    product_name TEXT,
    quantity INTEGER DEFAULT 1,
    expected_qty INTEGER,
    status TEXT DEFAULT 'matched',  -- matched, over, short, unknown
    FOREIGN KEY (session_id) REFERENCES sessions(id)
);

-- Product Cache
CREATE TABLE products (
    sku TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    barcode TEXT,
    price REAL,
    category TEXT,
    last_synced TEXT NOT NULL
);

-- Tag Mappings (SKU to EPC prefix)
CREATE TABLE tag_mappings (
    epc_prefix TEXT PRIMARY KEY,
    sku TEXT NOT NULL,
    last_synced TEXT NOT NULL
);

-- Sync Queue
CREATE TABLE sync_queue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    entity_type TEXT NOT NULL,
    entity_id TEXT NOT NULL,
    action TEXT NOT NULL,
    payload TEXT NOT NULL,
    retry_count INTEGER DEFAULT 0,
    created_at TEXT NOT NULL,
    status TEXT DEFAULT 'pending'
);

Offline Capabilities

FeatureOffline Behavior
LoginUses cached credentials
Session StartCreates local session ID
ScanningFull functionality
Product LookupUses cached catalog
Session SummaryCalculates from local data
SubmitQueues for later sync
Sync StatusShows pending items

Sync Priority

PriorityData TypeFrequency
1 (Critical)Completed sessionsImmediate when online
2 (High)Tag readsBackground sync
3 (Medium)Product updatesPull on app launch
4 (Low)User listDaily refresh

Summary

The Raptag mobile application provides:

  1. Fast Authentication: PIN-based login with offline support
  2. Flexible Counting: Multiple session types for different needs
  3. Real-time Scanning: Live tag counts with signal feedback
  4. Accuracy Tracking: Variance calculation and discrepancy flagging
  5. Reliable Sync: Queue-based upload with retry logic
  6. Tag Printing: Integrated label printing with encoding

Implementation complete. Ready for engineer review.

Chapter 22: UI Component Library

The Shared Design System

This chapter defines the complete UI component library shared across all POS Platform applications. These specifications ensure visual consistency, reduce development time, and enable rapid prototyping.


Design Tokens

Color Palette

Primary Colors

TokenHexRGBUsage
--color-primary#1976D225, 118, 210Main brand, primary buttons, links
--color-primary-dark#1565C021, 101, 192Hover states, headers
--color-primary-light#BBDEFB187, 222, 251Selected backgrounds, info panels
--color-primary-50#E3F2FD227, 242, 253Subtle backgrounds

Secondary Colors

TokenHexRGBUsage
--color-secondary#42424266, 66, 66Secondary buttons, icons
--color-secondary-dark#21212133, 33, 33Text, headings
--color-secondary-light#757575117, 117, 117Secondary text, labels

Status Colors

TokenHexRGBUsage
--color-success#4CAF5076, 175, 80Success states, positive
--color-success-light#E8F5E9232, 245, 233Success backgrounds
--color-success-dark#2E7D3246, 125, 50Success text on light bg
--color-warning#FF9800255, 152, 0Warning states, caution
--color-warning-light#FFF3E0255, 243, 224Warning backgrounds
--color-warning-dark#E65100230, 81, 0Warning text on light bg
--color-error#F44336244, 67, 54Error states, destructive
--color-error-light#FFEBEE255, 235, 238Error backgrounds
--color-error-dark#C62828198, 40, 40Error text on light bg
--color-info#2196F333, 150, 243Informational states
--color-info-light#E3F2FD227, 242, 253Info backgrounds
--color-info-dark#1565C021, 101, 192Info text on light bg

Neutral Colors

TokenHexUsage
--color-white#FFFFFFCard backgrounds, content areas
--color-gray-50#FAFAFAAlternating row backgrounds
--color-gray-100#F5F5F5Page backgrounds, disabled
--color-gray-200#EEEEEELight borders, dividers
--color-gray-300#E0E0E0Standard borders
--color-gray-400#BDBDBDInput borders, icons
--color-gray-500#9E9E9EDisabled text, placeholders
--color-gray-600#757575Secondary text
--color-gray-700#616161Icons, labels
--color-gray-800#424242Body text
--color-gray-900#212121Headings, primary text
--color-black#000000Maximum contrast

Typography Scale

Font Families

--font-family-base: 'Segoe UI', -apple-system, BlinkMacSystemFont,
                    'Roboto', 'Helvetica Neue', Arial, sans-serif;

--font-family-mono: 'Cascadia Code', 'Fira Code', 'Consolas',
                    'Monaco', 'Courier New', monospace;

Font Sizes

TokenSizeLine HeightUsage
--font-size-xs11px1.4Captions, badges
--font-size-sm12px1.4Secondary text, timestamps
--font-size-base14px1.5Body text, inputs
--font-size-md16px1.5Emphasized body
--font-size-lg18px1.4Section headers
--font-size-xl20px1.3Card titles
--font-size-2xl24px1.3Page titles
--font-size-3xl30px1.2Dashboard stats
--font-size-4xl36px1.1Large numbers

Font Weights

TokenWeightUsage
--font-weight-light300Large titles
--font-weight-normal400Body text
--font-weight-medium500Buttons, emphasized
--font-weight-semibold600Headers, labels
--font-weight-bold700Stats, strong emphasis

Spacing System

TokenValueUsage
--space-00No spacing
--space-14pxTight, inline elements
--space-28pxComponent padding, gaps
--space-312pxCard padding
--space-416pxSection margins
--space-520pxLarger gaps
--space-624pxPanel padding
--space-832pxSection spacing
--space-1040pxLarge separations
--space-1248pxPage margins

Border Radius

TokenValueUsage
--radius-none0Sharp corners
--radius-sm2pxSubtle rounding
--radius-base4pxInputs, buttons
--radius-md6pxCards
--radius-lg8pxPanels, modals
--radius-xl12pxLarge cards
--radius-full9999pxPills, circles

Shadows

TokenValueUsage
--shadow-sm0 1px 2px rgba(0,0,0,0.05)Subtle lift
--shadow-base0 2px 4px rgba(0,0,0,0.1)Standard cards
--shadow-md0 4px 8px rgba(0,0,0,0.12)Elevated cards
--shadow-lg0 8px 16px rgba(0,0,0,0.15)Dropdowns, popovers
--shadow-xl0 12px 24px rgba(0,0,0,0.2)Modals

Component Specifications

1. StatCard

Purpose: Display key metrics with trend indicators on dashboards.

ASCII Wireframe:

┌────────────────────────────────────┐
│  [icon]                            │
│                                    │
│  LABEL                             │
│  12,450                            │
│  +12.3% vs previous                │
│                                    │
└────────────────────────────────────┘

Variants:

STANDARD                    COMPACT                     INLINE
┌──────────────────┐       ┌──────────────────┐       ┌──────────────────────────┐
│ [icon]           │       │ Orders    1,234  │       │ [icon] Orders: 1,234 +5% │
│ Orders           │       │ +12% ▲           │       └──────────────────────────┘
│ 1,234            │       └──────────────────┘
│ +12% ▲           │
└──────────────────┘

Props:

PropTypeDefaultDescription
TitlestringrequiredMetric label
ValuestringrequiredPrimary value
IconIconTypenullOptional icon
ChangestringnullChange indicator (e.g., “+12%”)
IsPositivebooltrueTrend direction
Colorstring“primary”primary, success, warning, error
Sizestring“standard”standard, compact, inline

Blazor Usage:

<StatCard Title="Today's Sales"
          Value="$12,450"
          Icon="IconType.DollarSign"
          Change="+12.3%"
          IsPositive="true"
          Color="success" />

2. DataGrid

Purpose: Display tabular data with sorting, filtering, and pagination.

ASCII Wireframe:

┌─────────────────────────────────────────────────────────────────────┐
│ [x] │ ORDER #  ▼ │ DATE       │ CUSTOMER      │ AMOUNT ▼ │ STATUS  │
├─────┼────────────┼────────────┼───────────────┼──────────┼─────────┤
│ [ ] │ #1234      │ 12/29/2024 │ John Smith    │   $99.00 │ ● New   │
│ [x] │ #1235      │ 12/29/2024 │ Jane Doe      │  $149.00 │ ● Done  │
│ [ ] │ #1236      │ 12/28/2024 │ Bob Johnson   │   $75.50 │ ! Error │
├─────┴────────────┴────────────┴───────────────┴──────────┴─────────┤
│ Showing 1-50 of 256                        << < Page 1 of 6 > >>   │
└─────────────────────────────────────────────────────────────────────┘

Column Types:

TEXT COLUMN         NUMBER COLUMN       STATUS COLUMN       ACTION COLUMN
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│ John Smith  │    │     $99.00  │    │ ● Completed │    │ [Ed] [Del]  │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
     Left               Right              Center            Center

Props:

PropTypeDefaultDescription
ItemsIEnumerablerequiredData source
ColumnsListrequiredColumn definitions
SelectableboolfalseEnable row selection
SortablebooltrueEnable column sorting
PaginatebooltrueEnable pagination
PageSizeint25Items per page
OnRowClickEventCallbacknullRow click handler
OnSelectionChangeEventCallbacknullSelection handler

Column Definition:

public class Column<T>
{
    public string Header { get; set; }
    public Func<T, object> ValueFunc { get; set; }
    public string Align { get; set; } = "left";  // left, center, right
    public bool Sortable { get; set; } = true;
    public string Width { get; set; } = "auto";
    public Func<T, RenderFragment> Template { get; set; }
}

Blazor Usage:

<DataGrid Items="@orders" Selectable="true" OnRowClick="ViewOrder">
    <Column Header="Order #" ValueFunc="@(o => o.OrderNumber)" />
    <Column Header="Date" ValueFunc="@(o => o.Date.ToShortDateString())" />
    <Column Header="Amount" ValueFunc="@(o => o.Total)" Align="right" />
    <Column Header="Status">
        <Template>
            <StatusBadge Status="@context.Status" />
        </Template>
    </Column>
</DataGrid>

3. StatusBadge

Purpose: Display color-coded status indicators.

ASCII Wireframe:

SUCCESS           WARNING           ERROR             INFO              NEUTRAL
┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐      ┌─────────┐
│● Active │      │● Pending│      │● Failed │      │● Syncing│      │● Draft  │
└─────────┘      └─────────┘      └─────────┘      └─────────┘      └─────────┘
 Green bg         Orange bg        Red bg           Blue bg          Gray bg

Size Variants:

SMALL                 MEDIUM (Default)           LARGE
┌──────────┐         ┌─────────────┐            ┌────────────────┐
│ ● Active │         │  ● Active   │            │   ● Active     │
└──────────┘         └─────────────┘            └────────────────┘
  11px font            13px font                   15px font

Props:

PropTypeDefaultDescription
StatusstringrequiredStatus text
Variantstring“info”success, warning, error, info, neutral
Sizestring“medium”small, medium, large
ShowDotbooltrueShow status dot

CSS Classes:

.status-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 8px;
    border-radius: var(--radius-base);
    font-size: var(--font-size-sm);
    font-weight: var(--font-weight-medium);
}

.status-badge--success {
    background: var(--color-success-light);
    color: var(--color-success-dark);
}

.status-badge--warning {
    background: var(--color-warning-light);
    color: var(--color-warning-dark);
}

.status-badge--error {
    background: var(--color-error-light);
    color: var(--color-error-dark);
}

.status-badge--info {
    background: var(--color-info-light);
    color: var(--color-info-dark);
}

.status-badge--neutral {
    background: var(--color-gray-100);
    color: var(--color-gray-700);
}

.status-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: currentColor;
}

Blazor Usage:

<StatusBadge Status="Active" Variant="success" />
<StatusBadge Status="Pending" Variant="warning" />
<StatusBadge Status="Failed" Variant="error" ShowDot="false" />

4. SearchInput

Purpose: Debounced search input with autocomplete support.

ASCII Wireframe:

EMPTY STATE                          WITH VALUE
┌────────────────────────────────┐  ┌────────────────────────────────┐
│ [O] Search products...         │  │ [O] galaxy v-neck          [X] │
└────────────────────────────────┘  └────────────────────────────────┘

WITH AUTOCOMPLETE                    LOADING STATE
┌────────────────────────────────┐  ┌────────────────────────────────┐
│ [O] galaxy v                   │  │ [O] galaxy v-neck      [...]   │
├────────────────────────────────┤  └────────────────────────────────┘
│ Galaxy V-Neck Tee              │
│ Galaxy V-Neck Tank             │
│ Galaxy Vintage Wash            │
└────────────────────────────────┘

Props:

PropTypeDefaultDescription
Valuestring“”Current value
Placeholderstring“Search…”Placeholder text
DebounceMsint300Debounce delay
AutoCompleteboolfalseEnable autocomplete
ItemsIEnumerablenullAutocomplete items
OnSearchEventCallbacknullSearch handler
OnSelectEventCallbacknullSelection handler
DisabledboolfalseDisable input

Blazor Usage:

<SearchInput @bind-Value="searchTerm"
             Placeholder="Search products..."
             DebounceMs="300"
             OnSearch="HandleSearch" />

<SearchInput @bind-Value="productSearch"
             AutoComplete="true"
             Items="@productSuggestions"
             OnSelect="SelectProduct" />

5. Modal

Purpose: Overlay dialog for forms, confirmations, and detail views.

ASCII Wireframe:

STANDARD MODAL
┌────────────────────────────────────────────────────────────┐
│ Modal Title                                           [X]  │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  Modal content goes here.                                  │
│                                                            │
│  This can include forms, text, images, or any other        │
│  content that needs to be displayed in an overlay.         │
│                                                            │
├────────────────────────────────────────────────────────────┤
│                                     [Cancel]  [Confirm]    │
└────────────────────────────────────────────────────────────┘

CONFIRMATION MODAL (Compact)
┌─────────────────────────────────────────────┐
│ [!] Delete Item?                       [X]  │
├─────────────────────────────────────────────┤
│                                             │
│ Are you sure you want to delete this item?  │
│ This action cannot be undone.               │
│                                             │
├─────────────────────────────────────────────┤
│                    [Cancel]  [Delete]       │
└─────────────────────────────────────────────┘

FULLSCREEN MODAL (Mobile)
╔═════════════════════════════════════════════╗
║ [<] Modal Title                             ║
╠═════════════════════════════════════════════╣
║                                             ║
║  Full content area                          ║
║  (scrollable)                               ║
║                                             ║
╠═════════════════════════════════════════════╣
║            [Primary Action]                 ║
╚═════════════════════════════════════════════╝

Size Variants:

SizeWidthUse Case
small400pxConfirmations, alerts
medium600pxForms, details
large800pxComplex forms, tables
fullscreen100%Mobile, immersive

Props:

PropTypeDefaultDescription
TitlestringnullModal title
IsOpenboolfalseVisibility state
Sizestring“medium”small, medium, large, fullscreen
ShowClosebooltrueShow close button
CloseOnOverlaybooltrueClose on backdrop click
OnCloseEventCallbacknullClose handler
ChildContentRenderFragmentrequiredModal body
FooterRenderFragmentnullFooter actions

Blazor Usage:

<Modal Title="Edit Product"
       IsOpen="@showModal"
       Size="medium"
       OnClose="CloseModal">
    <ChildContent>
        <EditForm Model="@product">
            <!-- Form fields -->
        </EditForm>
    </ChildContent>
    <Footer>
        <Button Variant="secondary" OnClick="CloseModal">Cancel</Button>
        <Button Variant="primary" OnClick="SaveProduct">Save</Button>
    </Footer>
</Modal>

6. Toast

Purpose: Non-blocking notifications that auto-dismiss.

ASCII Wireframe:

SUCCESS TOAST                    ERROR TOAST
┌──────────────────────────┐    ┌──────────────────────────┐
│ [check] Product saved    │    │ [X] Failed to save       │
│         successfully     │    │     Please try again     │
│                     [X]  │    │                     [X]  │
└──────────────────────────┘    └──────────────────────────┘

WARNING TOAST                    INFO TOAST
┌──────────────────────────┐    ┌──────────────────────────┐
│ [!] Low inventory        │    │ [i] Sync completed       │
│     Check stock levels   │    │     245 items updated    │
│                     [X]  │    │                     [X]  │
└──────────────────────────┘    └──────────────────────────┘

TOAST WITH ACTION
┌──────────────────────────────────────────┐
│ [!] Order requires attention             │
│     Missing shipping address             │
│                          [View] [Dismiss]│
└──────────────────────────────────────────┘

Position Options:

TOP-RIGHT (Default)             TOP-CENTER              BOTTOM-RIGHT
┌─────────────────┐            ┌─────────────────┐
│                 │            │                 │
│            [T]  │            │       [T]       │
│            [T]  │            │       [T]       │     ┌─────────────────┐
│                 │            │                 │     │                 │
│                 │            │                 │     │            [T]  │
└─────────────────┘            └─────────────────┘     └─────────────────┘

Props:

PropTypeDefaultDescription
MessagestringrequiredToast message
TitlestringnullOptional title
Variantstring“info”success, warning, error, info
Durationint5000Auto-dismiss (ms), 0 = persist
Positionstring“top-right”Toast position
ShowClosebooltrueShow dismiss button
ActionRenderFragmentnullAction buttons

Toast Service:

public interface IToastService
{
    void ShowSuccess(string message, string title = null);
    void ShowError(string message, string title = null);
    void ShowWarning(string message, string title = null);
    void ShowInfo(string message, string title = null);
    void Show(ToastOptions options);
    void DismissAll();
}

Blazor Usage:

@inject IToastService Toast

<button @onclick="SaveProduct">Save</button>

@code {
    async Task SaveProduct()
    {
        try
        {
            await productService.SaveAsync(product);
            Toast.ShowSuccess("Product saved successfully");
        }
        catch
        {
            Toast.ShowError("Failed to save product", "Error");
        }
    }
}

7. LoadingSpinner

Purpose: Indicate loading states.

ASCII Wireframe:

SPINNER ONLY           WITH TEXT              OVERLAY
    ◐                    ◐                 ┌─────────────────┐
   ╱ ╲                 Loading...          │    ░░░░░░░░░    │
  ◜   ◝                                    │    ░  ◐   ░    │
                                           │    ░Loading░    │
                                           │    ░░░░░░░░░    │
                                           └─────────────────┘

Size Variants:

SizeDiameterUse Case
small16pxInline, buttons
medium24pxCards, sections
large48pxPage, full overlay

Props:

PropTypeDefaultDescription
Sizestring“medium”small, medium, large
TextstringnullLoading text
OverlayboolfalseFull overlay mode
Colorstring“primary”Spinner color

Blazor Usage:

<!-- Inline spinner -->
<LoadingSpinner Size="small" />

<!-- With text -->
<LoadingSpinner Text="Saving..." />

<!-- Full overlay -->
<LoadingSpinner Overlay="true" Text="Processing order..." />

<!-- In button -->
<Button Disabled="@isSaving">
    @if (isSaving)
    {
        <LoadingSpinner Size="small" Color="white" />
        <span>Saving...</span>
    }
    else
    {
        <span>Save</span>
    }
</Button>

8. EmptyState

Purpose: Display meaningful placeholder when no data is available.

ASCII Wireframe:

STANDARD EMPTY STATE
┌─────────────────────────────────────────────────────┐
│                                                     │
│                    [  ICON  ]                       │
│                                                     │
│              No products found                      │
│                                                     │
│     Try adjusting your search or filters to         │
│     find what you're looking for.                   │
│                                                     │
│              [Clear Filters]                        │
│                                                     │
└─────────────────────────────────────────────────────┘

COMPACT EMPTY STATE                 WITH ACTION
┌─────────────────────────┐        ┌─────────────────────────┐
│     [icon]              │        │       [icon]            │
│   No items found        │        │   No orders yet         │
└─────────────────────────┘        │                         │
                                   │   [Create Order]        │
                                   └─────────────────────────┘

Props:

PropTypeDefaultDescription
IconIconTypenullIllustration icon
TitlestringrequiredEmpty state title
DescriptionstringnullExplanatory text
ActionRenderFragmentnullAction button(s)
Sizestring“medium”compact, medium, large

Blazor Usage:

<EmptyState Icon="IconType.Box"
            Title="No products found"
            Description="Try adjusting your search or filters.">
    <Action>
        <Button Variant="secondary" OnClick="ClearFilters">Clear Filters</Button>
    </Action>
</EmptyState>

Button Component

Purpose: Primary interactive element for triggering actions.

ASCII Wireframe:

PRIMARY                 SECONDARY              TERTIARY/TEXT
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│    Save         │    │    Cancel       │    │    Learn More   │
└─────────────────┘    └─────────────────┘    └─────────────────┘
 Solid background       Outlined              No border

DANGER                  WITH ICON              LOADING
┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│    Delete       │    │  [+] Add Item   │    │  [o] Saving...  │
└─────────────────┘    └─────────────────┘    └─────────────────┘
 Red background         Icon + text           Spinner + text

Size Variants:

SizeHeightPaddingFont Size
small28px8px 12px12px
medium36px10px 16px14px
large44px12px 20px16px

Props:

PropTypeDefaultDescription
Variantstring“primary”primary, secondary, tertiary, danger
Sizestring“medium”small, medium, large
IconIconTypenullLeading icon
IconPositionstring“left”left, right
LoadingboolfalseShow loading state
DisabledboolfalseDisable button
FullWidthboolfalse100% width
OnClickEventCallbacknullClick handler

Form Components

TextInput

LABEL WITH INPUT                 ERROR STATE
┌────────────────────────────┐  ┌────────────────────────────┐
│ Email Address              │  │ Email Address              │
│ ┌────────────────────────┐ │  │ ┌────────────────────────┐ │
│ │ user@example.com       │ │  │ │ invalid-email          │ │
│ └────────────────────────┘ │  │ └────────────────────────┘ │
└────────────────────────────┘  │ Please enter a valid email │
                                └────────────────────────────┘

Select/Dropdown

CLOSED                          OPEN
┌────────────────────────────┐  ┌────────────────────────────┐
│ Select option          [v] │  │ Option One             [^] │
└────────────────────────────┘  ├────────────────────────────┤
                                │ Option One       [check]   │
                                │ Option Two                 │
                                │ Option Three               │
                                └────────────────────────────┘

Checkbox

UNCHECKED           CHECKED             INDETERMINATE
[ ] Option One      [x] Option Two      [-] Select All

Radio Button

UNSELECTED          SELECTED
( ) Option One      (o) Option Two

Dark Mode Considerations

Color Mapping

Light ModeDark Mode
#FFFFFF (white)#1E1E1E (dark surface)
#F5F5F5 (gray-100)#2D2D2D (elevated surface)
#212121 (text)#FFFFFF (text)
#757575 (secondary)#B0B0B0 (secondary)
#1976D2 (primary)#64B5F6 (lighter primary)

Dark Mode Tokens

:root[data-theme="dark"] {
    --color-background: #121212;
    --color-surface: #1E1E1E;
    --color-surface-elevated: #2D2D2D;
    --color-text-primary: #FFFFFF;
    --color-text-secondary: #B0B0B0;
    --color-text-disabled: #6B6B6B;
    --color-border: #3D3D3D;
    --color-primary: #64B5F6;
    --color-primary-dark: #90CAF9;
}

Component Adjustments

ComponentLightDark
CardsWhite bg, shadowDark surface, border
InputsWhite bg, gray borderDark bg, light border
BadgesColored bgReduced opacity bg
ButtonsStandardSlightly elevated

Accessibility Guidelines

Focus States

*:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

/* High contrast mode */
@media (prefers-contrast: high) {
    *:focus-visible {
        outline-width: 3px;
    }
}

Color Contrast

RequirementRatioUsage
AA Normal4.5:1Body text
AA Large3:118px+ text
AAA Normal7:1Enhanced
AAA Large4.5:1Enhanced large

ARIA Labels

<!-- Button with icon only -->
<Button Icon="IconType.Search" aria-label="Search products" />

<!-- Loading state -->
<LoadingSpinner aria-label="Loading content" role="status" />

<!-- Badge with context -->
<StatusBadge Status="Error"
             aria-label="Order status: Error - requires attention" />

Summary

The Component Library provides:

  1. StatCard: Dashboard metrics with trends
  2. DataGrid: Sortable, filterable data tables
  3. StatusBadge: Color-coded status indicators
  4. SearchInput: Debounced search with autocomplete
  5. Modal: Overlay dialogs for forms and confirmations
  6. Toast: Non-blocking notifications
  7. LoadingSpinner: Loading state indicators
  8. EmptyState: Meaningful placeholders

All components follow:

  • Consistent design tokens
  • Responsive sizing
  • Dark mode support
  • Accessibility standards

Implementation complete. Ready for engineer review.

Chapter 23: Development Environment Setup

Overview

This chapter provides complete, step-by-step instructions for setting up your development environment for the POS platform. By the end, you will have a fully functional local development stack.


Prerequisites

Required Software

SoftwareVersionPurpose
.NET SDK8.0+Backend development
PostgreSQL16+Primary database
Docker24.0+Containerization
Docker Compose2.20+Multi-container orchestration
Node.js20 LTSFrontend tooling
Git2.40+Version control

Hardware Requirements

ComponentMinimumRecommended
RAM8 GB16 GB
Storage20 GB free50 GB SSD
CPU4 cores8 cores

Project Structure

/volume1/docker/pos-platform/
├── CLAUDE.md                          # AI assistant guidance
├── README.md                          # Quick start guide
├── .gitignore                         # Git ignore patterns
├── .env.example                       # Environment template
├── pos-platform.sln                   # .NET solution file
│
├── docker/
│   ├── docker-compose.yml             # Development stack
│   ├── docker-compose.prod.yml        # Production overrides
│   ├── Dockerfile                     # API container build
│   ├── Dockerfile.web                 # Web container build
│   └── .env                           # Docker environment (gitignored)
│
├── src/
│   ├── PosPlatform.Core/              # Domain layer
│   │   ├── Entities/                  # Domain entities
│   │   ├── ValueObjects/              # Immutable value objects
│   │   ├── Events/                    # Domain events
│   │   ├── Exceptions/                # Domain exceptions
│   │   ├── Interfaces/                # Repository interfaces
│   │   └── Services/                  # Domain services
│   │
│   ├── PosPlatform.Infrastructure/    # Infrastructure layer
│   │   ├── Data/                      # EF Core contexts
│   │   ├── Repositories/              # Repository implementations
│   │   ├── Services/                  # External service integrations
│   │   ├── Messaging/                 # Event bus, queues
│   │   └── MultiTenant/               # Tenant resolution
│   │
│   ├── PosPlatform.Api/               # API layer
│   │   ├── Controllers/               # REST endpoints
│   │   ├── Middleware/                # Request pipeline
│   │   ├── Filters/                   # Action filters
│   │   ├── DTOs/                      # Data transfer objects
│   │   └── Program.cs                 # Application entry
│   │
│   └── PosPlatform.Web/               # Blazor frontend
│       ├── Components/                # Blazor components
│       ├── Pages/                     # Routable pages
│       ├── Services/                  # Frontend services
│       └── wwwroot/                   # Static assets
│
├── tests/
│   ├── PosPlatform.Core.Tests/        # Unit tests
│   ├── PosPlatform.Api.Tests/         # API integration tests
│   └── PosPlatform.E2E.Tests/         # End-to-end tests
│
└── database/
    ├── migrations/                    # EF Core migrations
    ├── seed/                          # Seed data scripts
    └── init.sql                       # Database initialization

Step 1: Install Prerequisites

Linux (Ubuntu/Debian)

# Update package manager
sudo apt update && sudo apt upgrade -y

# Install .NET 8 SDK
wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
sudo apt update
sudo apt install -y dotnet-sdk-8.0

# Verify .NET installation
dotnet --version

# Install Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in for group changes

# Verify Docker
docker --version
docker compose version

# Install Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

# Verify Node.js
node --version
npm --version

# Install Git
sudo apt install -y git
git --version

macOS

# Install Homebrew (if not installed)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Install .NET 8 SDK
brew install dotnet-sdk

# Install Docker Desktop
brew install --cask docker

# Install Node.js
brew install node@20

# Install Git
brew install git

Windows

# Install with winget (Windows Package Manager)
winget install Microsoft.DotNet.SDK.8
winget install Docker.DockerDesktop
winget install OpenJS.NodeJS.LTS
winget install Git.Git

# Alternatively, download installers from:
# - https://dotnet.microsoft.com/download
# - https://docker.com/products/docker-desktop
# - https://nodejs.org/
# - https://git-scm.com/

Step 2: Create Project Structure

Initialize Repository

# Create project directory
mkdir -p /volume1/docker/pos-platform
cd /volume1/docker/pos-platform

# Initialize Git repository
git init
git branch -M main

# Create initial structure
mkdir -p docker src tests database/migrations database/seed

Create .gitignore

cat > .gitignore << 'EOF'
# Build outputs
bin/
obj/
publish/

# IDE
.vs/
.vscode/
.idea/
*.user
*.suo

# Environment
.env
*.env.local
appsettings.*.json
!appsettings.json
!appsettings.Development.json

# Logs
logs/
*.log

# Docker
docker/.env

# Node
node_modules/
dist/

# Database
*.db
*.sqlite

# OS
.DS_Store
Thumbs.db

# Secrets
*.pem
*.key
secrets/
EOF

Create Solution File

# Create .NET solution
dotnet new sln -n pos-platform

# Create projects
dotnet new classlib -n PosPlatform.Core -o src/PosPlatform.Core
dotnet new classlib -n PosPlatform.Infrastructure -o src/PosPlatform.Infrastructure
dotnet new webapi -n PosPlatform.Api -o src/PosPlatform.Api
dotnet new blazorserver -n PosPlatform.Web -o src/PosPlatform.Web

# Create test projects
dotnet new xunit -n PosPlatform.Core.Tests -o tests/PosPlatform.Core.Tests
dotnet new xunit -n PosPlatform.Api.Tests -o tests/PosPlatform.Api.Tests

# Add projects to solution
dotnet sln add src/PosPlatform.Core/PosPlatform.Core.csproj
dotnet sln add src/PosPlatform.Infrastructure/PosPlatform.Infrastructure.csproj
dotnet sln add src/PosPlatform.Api/PosPlatform.Api.csproj
dotnet sln add src/PosPlatform.Web/PosPlatform.Web.csproj
dotnet sln add tests/PosPlatform.Core.Tests/PosPlatform.Core.Tests.csproj
dotnet sln add tests/PosPlatform.Api.Tests/PosPlatform.Api.Tests.csproj

# Add project references
dotnet add src/PosPlatform.Infrastructure/PosPlatform.Infrastructure.csproj reference src/PosPlatform.Core/PosPlatform.Core.csproj
dotnet add src/PosPlatform.Api/PosPlatform.Api.csproj reference src/PosPlatform.Infrastructure/PosPlatform.Infrastructure.csproj
dotnet add src/PosPlatform.Api/PosPlatform.Api.csproj reference src/PosPlatform.Core/PosPlatform.Core.csproj
dotnet add src/PosPlatform.Web/PosPlatform.Web.csproj reference src/PosPlatform.Core/PosPlatform.Core.csproj
dotnet add tests/PosPlatform.Core.Tests/PosPlatform.Core.Tests.csproj reference src/PosPlatform.Core/PosPlatform.Core.csproj
dotnet add tests/PosPlatform.Api.Tests/PosPlatform.Api.Tests.csproj reference src/PosPlatform.Api/PosPlatform.Api.csproj

Step 3: Docker Configuration

docker-compose.yml

# /volume1/docker/pos-platform/docker/docker-compose.yml
version: '3.8'

services:
  # PostgreSQL Database
  postgres:
    image: postgres:16-alpine
    container_name: pos-postgres
    environment:
      POSTGRES_USER: ${DB_USER:-pos_admin}
      POSTGRES_PASSWORD: ${DB_PASSWORD:-PosDevPass2025!}
      POSTGRES_DB: ${DB_NAME:-pos_platform}
    ports:
      - "5434:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ../database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-pos_admin} -d ${DB_NAME:-pos_platform}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - pos-network

  # Redis for Caching and Sessions
  redis:
    image: redis:7-alpine
    container_name: pos-redis
    ports:
      - "6380:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - pos-network

  # RabbitMQ for Event Bus
  rabbitmq:
    image: rabbitmq:3-management-alpine
    container_name: pos-rabbitmq
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-pos_user}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-PosRabbit2025!}
    ports:
      - "5673:5672"   # AMQP
      - "15673:15672" # Management UI
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "check_running"]
      interval: 30s
      timeout: 10s
      retries: 5
    networks:
      - pos-network

  # POS API (Development)
  api:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    container_name: pos-api
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://+:8080
      - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=${DB_NAME:-pos_platform};Username=${DB_USER:-pos_admin};Password=${DB_PASSWORD:-PosDevPass2025!}
      - Redis__ConnectionString=redis:6379
      - RabbitMQ__Host=rabbitmq
      - RabbitMQ__Username=${RABBITMQ_USER:-pos_user}
      - RabbitMQ__Password=${RABBITMQ_PASS:-PosRabbit2025!}
    ports:
      - "5100:8080"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    volumes:
      - ../src:/app/src:ro
      - api_logs:/app/logs
    networks:
      - pos-network

  # POS Web (Development)
  web:
    build:
      context: ..
      dockerfile: docker/Dockerfile.web
    container_name: pos-web
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=http://+:8080
      - ApiBaseUrl=http://api:8080
    ports:
      - "5101:8080"
    depends_on:
      - api
    networks:
      - pos-network

volumes:
  postgres_data:
  redis_data:
  rabbitmq_data:
  api_logs:

networks:
  pos-network:
    driver: bridge

Dockerfile for API

# /volume1/docker/pos-platform/docker/Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src

# Copy solution and project files
COPY *.sln ./
COPY src/PosPlatform.Core/*.csproj ./src/PosPlatform.Core/
COPY src/PosPlatform.Infrastructure/*.csproj ./src/PosPlatform.Infrastructure/
COPY src/PosPlatform.Api/*.csproj ./src/PosPlatform.Api/

# Restore dependencies
RUN dotnet restore src/PosPlatform.Api/PosPlatform.Api.csproj

# Copy source code
COPY src/ ./src/

# Build and publish
WORKDIR /src/src/PosPlatform.Api
RUN dotnet publish -c Release -o /app/publish --no-restore

# Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app

# Install culture support
RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

# Copy published app
COPY --from=build /app/publish .

# Create non-root user
RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8080
ENTRYPOINT ["dotnet", "PosPlatform.Api.dll"]

Dockerfile for Web

# /volume1/docker/pos-platform/docker/Dockerfile.web
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
WORKDIR /src

# Copy solution and project files
COPY *.sln ./
COPY src/PosPlatform.Core/*.csproj ./src/PosPlatform.Core/
COPY src/PosPlatform.Web/*.csproj ./src/PosPlatform.Web/

# Restore dependencies
RUN dotnet restore src/PosPlatform.Web/PosPlatform.Web.csproj

# Copy source code
COPY src/ ./src/

# Build and publish
WORKDIR /src/src/PosPlatform.Web
RUN dotnet publish -c Release -o /app/publish --no-restore

# Runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app

RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

COPY --from=build /app/publish .

RUN adduser -D -u 1000 appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8080
ENTRYPOINT ["dotnet", "PosPlatform.Web.dll"]

Environment Template

# /volume1/docker/pos-platform/docker/.env.example
# Database
DB_USER=pos_admin
DB_PASSWORD=PosDevPass2025!
DB_NAME=pos_platform

# RabbitMQ
RABBITMQ_USER=pos_user
RABBITMQ_PASS=PosRabbit2025!

# API Keys (development)
JWT_SECRET=dev-jwt-secret-key-min-32-characters-long
ENCRYPTION_KEY=dev-encryption-key-32-chars-long

Step 4: Database Initialization

init.sql

-- /volume1/docker/pos-platform/database/init.sql

-- Create extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";

-- Create shared schema for platform-wide data
CREATE SCHEMA IF NOT EXISTS shared;

-- Tenants table (platform-wide)
CREATE TABLE shared.tenants (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    code VARCHAR(10) NOT NULL UNIQUE,
    name VARCHAR(100) NOT NULL,
    domain VARCHAR(255),
    status VARCHAR(20) NOT NULL DEFAULT 'active',
    settings JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ
);

-- Platform users (super admins)
CREATE TABLE shared.platform_users (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    full_name VARCHAR(100) NOT NULL,
    role VARCHAR(50) NOT NULL DEFAULT 'admin',
    is_active BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Function to create tenant schema
CREATE OR REPLACE FUNCTION shared.create_tenant_schema(tenant_code VARCHAR)
RETURNS VOID AS $$
BEGIN
    EXECUTE format('CREATE SCHEMA IF NOT EXISTS tenant_%s', tenant_code);

    -- Create tenant-specific tables
    EXECUTE format('
        CREATE TABLE tenant_%s.locations (
            id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
            code VARCHAR(10) NOT NULL UNIQUE,
            name VARCHAR(100) NOT NULL,
            address JSONB,
            is_active BOOLEAN DEFAULT true,
            created_at TIMESTAMPTZ DEFAULT NOW()
        )', tenant_code);

    EXECUTE format('
        CREATE TABLE tenant_%s.users (
            id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
            employee_id VARCHAR(20) UNIQUE,
            full_name VARCHAR(100) NOT NULL,
            email VARCHAR(255),
            pin_hash VARCHAR(255),
            role VARCHAR(50) NOT NULL,
            location_id UUID REFERENCES tenant_%s.locations(id),
            is_active BOOLEAN DEFAULT true,
            created_at TIMESTAMPTZ DEFAULT NOW()
        )', tenant_code, tenant_code);

    EXECUTE format('
        CREATE TABLE tenant_%s.products (
            id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
            sku VARCHAR(50) NOT NULL UNIQUE,
            name VARCHAR(255) NOT NULL,
            description TEXT,
            category_id UUID,
            base_price DECIMAL(10,2) NOT NULL,
            cost DECIMAL(10,2),
            is_active BOOLEAN DEFAULT true,
            created_at TIMESTAMPTZ DEFAULT NOW(),
            updated_at TIMESTAMPTZ
        )', tenant_code);
END;
$$ LANGUAGE plpgsql;

-- Insert default platform admin
INSERT INTO shared.platform_users (email, password_hash, full_name, role)
VALUES (
    'admin@posplatform.local',
    '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.vttYqBZq.kxVQ6', -- "admin123"
    'Platform Administrator',
    'super_admin'
);

-- Insert demo tenant
INSERT INTO shared.tenants (code, name, domain, status, settings)
VALUES (
    'DEMO',
    'Demo Retail Store',
    'demo.posplatform.local',
    'active',
    '{"timezone": "America/New_York", "currency": "USD", "taxRate": 0.07}'
);

-- Create demo tenant schema
SELECT shared.create_tenant_schema('demo');

COMMENT ON SCHEMA shared IS 'Platform-wide shared data';

Step 5: IDE Setup

VS Code Configuration

// /volume1/docker/pos-platform/.vscode/settings.json
{
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "ms-dotnettools.csharp",
    "omnisharp.enableRoslynAnalyzers": true,
    "omnisharp.enableEditorConfigSupport": true,
    "dotnet.defaultSolution": "pos-platform.sln",
    "files.exclude": {
        "**/bin": true,
        "**/obj": true,
        "**/node_modules": true
    },
    "[csharp]": {
        "editor.defaultFormatter": "ms-dotnettools.csharp"
    }
}
// /volume1/docker/pos-platform/.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch API",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build-api",
            "program": "${workspaceFolder}/src/PosPlatform.Api/bin/Debug/net8.0/PosPlatform.Api.dll",
            "args": [],
            "cwd": "${workspaceFolder}/src/PosPlatform.Api",
            "console": "internalConsole",
            "stopAtEntry": false,
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            }
        },
        {
            "name": "Launch Web",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build-web",
            "program": "${workspaceFolder}/src/PosPlatform.Web/bin/Debug/net8.0/PosPlatform.Web.dll",
            "args": [],
            "cwd": "${workspaceFolder}/src/PosPlatform.Web",
            "console": "internalConsole",
            "stopAtEntry": false
        }
    ]
}
// /volume1/docker/pos-platform/.vscode/extensions.json
{
    "recommendations": [
        "ms-dotnettools.csharp",
        "ms-dotnettools.csdevkit",
        "ms-azuretools.vscode-docker",
        "eamodio.gitlens",
        "streetsidesoftware.code-spell-checker",
        "editorconfig.editorconfig",
        "humao.rest-client",
        "mtxr.sqltools",
        "mtxr.sqltools-driver-pg"
    ]
}

Step 6: Git Workflow

Branch Strategy

main                    # Production-ready code
  |
  +-- develop           # Integration branch
       |
       +-- feature/*    # New features
       +-- bugfix/*     # Bug fixes
       +-- hotfix/*     # Urgent production fixes

Initial Commit

cd /volume1/docker/pos-platform

# Stage all files
git add .

# Initial commit
git commit -m "Initial project structure with Docker development stack

- Created .NET 8 solution with 4 projects (Core, Infrastructure, Api, Web)
- Added docker-compose with PostgreSQL 16, Redis, RabbitMQ
- Configured multi-tenant database initialization
- Set up VS Code development environment

Generated with Claude Code"

# Create develop branch
git checkout -b develop

Quick Reference Commands

Start Development Stack

cd /volume1/docker/pos-platform/docker

# Copy environment file
cp .env.example .env

# Start all services
docker compose up -d

# View logs
docker compose logs -f

# Check status
docker compose ps

Database Access

# Connect to PostgreSQL
docker exec -it pos-postgres psql -U pos_admin -d pos_platform

# List schemas
\dn

# List tables in shared schema
\dt shared.*

# List tables in tenant schema
\dt tenant_demo.*

Build and Run Locally

cd /volume1/docker/pos-platform

# Restore dependencies
dotnet restore

# Build solution
dotnet build

# Run API (from project directory)
cd src/PosPlatform.Api
dotnet run

# Run tests
cd /volume1/docker/pos-platform
dotnet test

Stop and Clean

cd /volume1/docker/pos-platform/docker

# Stop services
docker compose down

# Stop and remove volumes (WARNING: deletes data)
docker compose down -v

# Remove unused images
docker image prune -f

Verification Checklist

After completing setup, verify each component:

  • dotnet --version shows 8.0.x
  • docker compose ps shows all containers healthy
  • PostgreSQL accepts connections on port 5434
  • Redis responds to ping on port 6380
  • RabbitMQ management UI accessible at http://localhost:15673
  • Solution builds without errors: dotnet build
  • All tests pass: dotnet test

Next Steps

With your development environment ready:

  1. Proceed to Chapter 24: Implementation Roadmap for the full build plan
  2. Begin Phase 1: Foundation in Chapter 25
  3. Reference Chapter 23 when adding new developers to the project

Chapter 23 Complete - Development Environment Setup

Chapter 24: Implementation Roadmap

Overview

This chapter presents the complete implementation roadmap for the POS platform, organized into 4 phases spanning 16 weeks. Each phase builds upon the previous, with clear milestones and dependencies.


Phase Summary

PhaseNameDurationKey Deliverables
1FoundationWeeks 1-4Multi-tenant, Auth, Catalog
2CoreWeeks 5-10Inventory, Sales, Payments, Cash
3SupportWeeks 11-14Customers, Offline, RFID
4ProductionWeeks 15-16Monitoring, Security, Deployment

Gantt Chart

Week:     1    2    3    4    5    6    7    8    9   10   11   12   13   14   15   16
          |----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|

PHASE 1 - FOUNDATION
Multi-Tenant   [====|====]
Authentication      [====|====]
Catalog                  [====|====]

PHASE 2 - CORE
Inventory                      [====|====]
Sales Domain                        [====|====]
Payments                                 [====|====]
Cash Drawer                                   [====|====]

PHASE 3 - SUPPORT
Customer/Loyalty                                    [====|====]
Offline Sync                                             [====|====]
RFID Module                                                   [====|====]

PHASE 4 - PRODUCTION
Monitoring                                                          [====]
Security                                                             [====]
Deployment                                                                [====]

MILESTONES
    M1: Tenant Demo        *
    M2: Auth Complete           *
    M3: Catalog API                  *
    M4: Inventory Sync                    *
    M5: First Sale                             *
    M6: Payment Complete                            *
    M7: Offline Ready                                         *
    M8: Go-Live                                                          *

Phase 1: Foundation (Weeks 1-4)

Week 1-2: Multi-Tenant Infrastructure

Objective: Establish schema-per-tenant database isolation with automatic provisioning.

DayTaskDeliverable
1-2Tenant entity and repositoryTenant CRUD operations
3-4Schema provisioning serviceAutomatic schema creation
5Tenant resolution middlewareRequest-scoped tenant context
6-7Connection string routingDynamic connection per tenant
8-9Tenant management APIREST endpoints for tenants
10Integration testsTenant isolation verified

Claude Commands:

/dev-team implement tenant entity with repository pattern
/architect-review multi-tenant database isolation strategy
/dev-team create tenant provisioning service
/dev-team implement tenant resolution middleware
/qa-team write tenant isolation integration tests

Success Criteria:

  • New tenant creates isolated schema in < 5 seconds
  • Tenant data completely isolated (cross-tenant queries blocked)
  • Tenant context available in all service layers
  • 100% test coverage on tenant resolution

Week 2-3: Authentication System

Objective: Implement JWT-based authentication with PIN support for POS terminals.

DayTaskDeliverable
1-2User entity with password hashingBCrypt password storage
3-4JWT token serviceAccess + refresh token generation
5-6PIN-based authentication4-6 digit PIN for terminals
7-8RBAC permission systemRole-based access control
9-10Auth middlewareToken validation, user context

Claude Commands:

/dev-team implement user entity with bcrypt password hashing
/dev-team create JWT token service with refresh token support
/dev-team implement PIN authentication for POS terminals
/security-review authentication implementation
/dev-team create authorization middleware with RBAC

Success Criteria:

  • JWT tokens expire and refresh correctly
  • PIN login works for cashier terminals
  • Roles enforce API access restrictions
  • Password reset flow functional
  • Failed login attempts are rate-limited

Week 3-4: Catalog Domain

Objective: Build product catalog with variants, categories, and pricing.

DayTaskDeliverable
1-2Product and Category entitiesDomain models
3-4Product variant supportSize, color, style variations
5-6Pricing rules engineBase price, markups, promotions
7-8Product repositoryCRUD with search, filtering
9-10Catalog API endpointsREST API for products

Claude Commands:

/dev-team create product entity with variant support
/dev-team implement category hierarchy with nested sets
/dev-team create pricing rules engine
/dev-team implement product repository with full-text search
/dev-team create catalog API endpoints with pagination

Success Criteria:

  • Products support unlimited variants
  • Categories support infinite nesting
  • Full-text search returns results in < 100ms
  • Bulk import handles 10,000 products
  • API returns paginated results

Phase 2: Core (Weeks 5-10)

Week 5-6: Inventory Domain

Objective: Implement multi-location inventory with real-time tracking.

DayTaskDeliverable
1-2Inventory item entityStock levels per location
3-4Stock movement trackingAudit trail of all changes
5-6Inventory adjustment serviceManual adjustments with reasons
7-8Inter-store transfersTransfer request workflow
9-10Low stock alertsConfigurable thresholds

Claude Commands:

/dev-team create inventory item entity with location quantities
/dev-team implement stock movement event sourcing
/dev-team create inventory adjustment service
/dev-team implement inter-store transfer workflow
/dev-team create low stock alert notification system

Dependencies: Catalog (products), Multi-tenant (locations)

Success Criteria:

  • Stock levels accurate across all locations
  • Every inventory change has audit record
  • Transfers update both source and destination
  • Alerts fire when stock below threshold
  • Concurrent updates handled correctly

Week 6-7: Sales Domain (Event Sourcing)

Objective: Build sale transaction processing with event-sourced state.

DayTaskDeliverable
1-2Sale aggregate rootEvent-sourced sale entity
3-4Sale eventsItemAdded, ItemRemoved, DiscountApplied
5-6Sale projectionsCurrent cart state, totals
7-8Sale completionFinalization workflow
9-10Receipt generationDigital and print receipts

Claude Commands:

/dev-team create sale aggregate with event sourcing
/dev-team implement sale events (add, remove, discount)
/dev-team create sale projection service
/dev-team implement sale completion workflow
/dev-team create receipt generation service

Dependencies: Inventory (stock deduction), Catalog (product lookup)

Success Criteria:

  • Sales can be reconstructed from events
  • Cart updates in < 50ms
  • Tax calculations accurate to penny
  • Concurrent cart modifications handled
  • Receipts generated in < 1 second

Week 8-9: Payment Processing

Objective: Implement multi-tender payment with gateway integration.

DayTaskDeliverable
1-2Payment entityMulti-tender support
3-4Cash payment handlerExact, over, change calculation
5-6Card payment abstractionPayment gateway interface
7-8Split tender supportMultiple payment methods
9-10Void and refundTransaction reversal

Claude Commands:

/dev-team create payment entity with multi-tender support
/dev-team implement cash payment handler with change calculation
/dev-team create payment gateway abstraction (Stripe/Square)
/dev-team implement split tender payment processing
/dev-team create void and refund transaction handlers

Dependencies: Sales (total calculation)

Success Criteria:

  • Cash, card, and mixed payments work
  • Change calculated correctly
  • Failed payments don’t affect inventory
  • Refunds trace to original sale
  • Gateway timeouts handled gracefully

Week 9-10: Cash Drawer Operations

Objective: Manage physical cash with drawer sessions and blind counts.

DayTaskDeliverable
1-2Drawer session entityOpen, active, closed states
3-4Cash in/out trackingExpected vs actual
5-6Blind count supportCashier cannot see expected
7-8Drawer reconciliationVariance calculation
9-10Shift handoffMid-shift cash pickup

Claude Commands:

/dev-team create drawer session entity with state machine
/dev-team implement cash transaction tracking
/dev-team create blind count entry service
/dev-team implement drawer reconciliation with variance alerts
/dev-team create shift handoff workflow

Dependencies: Authentication (cashier identity), Sales (cash payments)

Success Criteria:

  • Drawer opens with starting balance
  • All cash movements tracked
  • Blind count mode prevents cheating
  • Variances flagged for review
  • Shift reports accurate

Phase 3: Support (Weeks 11-14)

Week 11-12: Customer Domain with Loyalty

Objective: Customer profiles, purchase history, and loyalty points.

DayTaskDeliverable
1-2Customer entityProfile, contact info
3-4Customer lookupPhone, email, loyalty ID
5-6Purchase historyOrders linked to customer
7-8Loyalty programPoints earning and redemption
9-10Customer APICRUD and search endpoints

Claude Commands:

/dev-team create customer entity with contact information
/dev-team implement customer lookup by phone, email, ID
/dev-team create purchase history tracking
/dev-team implement loyalty points system
/dev-team create customer API with search

Dependencies: Sales (purchase linkage)

Success Criteria:

  • Customer lookup in < 200ms
  • Points calculated on every purchase
  • Points redemption decreases balance
  • Purchase history complete
  • GDPR data export works

Week 12-13: Offline Sync Infrastructure

Objective: Enable POS operation during network outages.

DayTaskDeliverable
1-2Local SQLite databaseOffline storage
3-4Queue serviceOffline transaction queue
5-6Sync protocolConflict resolution
7-8Connectivity detectionOnline/offline mode
9-10Background syncAutomatic upload when online

Claude Commands:

/dev-team implement local SQLite storage for offline mode
/dev-team create offline transaction queue service
/dev-team implement sync protocol with conflict resolution
/dev-team create connectivity detection service
/dev-team implement background sync with retry logic

Dependencies: Sales, Payments, Inventory

Success Criteria:

  • POS operates fully offline
  • Transactions queue locally
  • Sync completes within 30 seconds online
  • Conflicts resolved with last-write-wins
  • No data loss during sync

Week 13-14: RFID Module (Optional)

Objective: RFID tag reading for inventory and sales.

DayTaskDeliverable
1-2RFID reader abstractionDevice interface
3-4Tag inventory scanningBulk inventory count
5-6POS tag readingAdd items by RFID
7-8Anti-theft detectionUnpaid item alerts
9-10Tag encodingWrite product info to tags

Claude Commands:

/dev-team create RFID reader abstraction interface
/dev-team implement bulk inventory scanning with RFID
/dev-team create POS RFID tag reading for sales
/dev-team implement anti-theft detection at exit
/dev-team create RFID tag encoding service

Dependencies: Inventory, Catalog

Success Criteria:

  • Reader connects and reads tags
  • Bulk scan counts 1000 items in < 60 seconds
  • POS adds items by RFID instantly
  • Alerts fire for unpaid items
  • Tags written with product data

Phase 4: Production (Weeks 15-16)

Week 15: Monitoring and Alerting

Objective: Production observability with metrics, logs, and alerts.

DayTaskDeliverable
1Structured loggingSerilog with context
2Metrics collectionPrometheus endpoints
3Health checksLiveness and readiness
4Grafana dashboardsKey metrics visualization
5Alert rulesPagerDuty/Slack integration

Claude Commands:

/dev-team implement structured logging with Serilog
/dev-team add Prometheus metrics endpoints
/dev-team create health check endpoints
/devops-team create Grafana dashboards
/devops-team configure alerting rules

Success Criteria:

  • Logs include correlation IDs
  • Key metrics exposed (latency, errors, saturation)
  • Health checks report component status
  • Dashboards show real-time data
  • Alerts notify on-call team

Week 15: Security Hardening

Objective: Production security controls and compliance.

DayTaskDeliverable
1Input validationAll endpoints validated
2Rate limitingAPI throttling
3Secrets managementVault integration
4Security headersCSP, HSTS, etc.
5Penetration testingVulnerability scan

Claude Commands:

/security-team review input validation coverage
/dev-team implement rate limiting middleware
/devops-team configure secrets management with Vault
/dev-team add security headers middleware
/security-team run penetration test scan

Success Criteria:

  • No SQL injection vulnerabilities
  • Rate limiting prevents abuse
  • No secrets in code or logs
  • Security headers configured
  • Pen test findings remediated

Week 16: Production Deployment

Objective: Deploy to production with zero-downtime release.

DayTaskDeliverable
1-2Production infrastructureKubernetes/Docker Swarm
3Database migrationSchema applied
4Blue-green deploymentZero-downtime release
5Go-liveProduction traffic

Claude Commands:

/devops-team provision production infrastructure
/devops-team run database migrations
/devops-team execute blue-green deployment
/qa-team run production smoke tests
/team go-live celebration

Success Criteria:

  • Infrastructure provisioned and tested
  • Database migrated without data loss
  • Deployment completes in < 10 minutes
  • Zero downtime during release
  • Production accepting traffic

Module Dependencies

                    ┌─────────────┐
                    │ Multi-Tenant│
                    └──────┬──────┘
                           │
           ┌───────────────┼───────────────┐
           │               │               │
    ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
    │    Auth     │ │   Catalog   │ │  Locations  │
    └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
           │               │               │
           │        ┌──────▼──────┐        │
           │        │  Inventory  │◄───────┘
           │        └──────┬──────┘
           │               │
    ┌──────▼───────────────▼──────┐
    │           Sales             │
    └──────┬───────────────┬──────┘
           │               │
    ┌──────▼──────┐ ┌──────▼──────┐
    │  Payments   │ │  Customer   │
    └──────┬──────┘ └─────────────┘
           │
    ┌──────▼──────┐
    │ Cash Drawer │
    └─────────────┘

    ┌─────────────┐
    │    RFID     │ (Optional, independent)
    └─────────────┘

    ┌─────────────┐
    │ Offline Sync│ (Wraps: Sales, Payments, Inventory)
    └─────────────┘

Risk Assessment

High Risk

RiskImpactMitigation
Multi-tenant data leakCriticalExtensive testing, schema isolation
Payment processing failureHighRetry logic, fallback methods
Offline sync data lossHighLocal backup, conflict resolution

Medium Risk

RiskImpactMitigation
Performance degradationMediumLoad testing, caching
RFID reader compatibilityMediumAbstraction layer
Third-party API outagesMediumCircuit breakers, fallbacks

Low Risk

RiskImpactMitigation
UI complexityLowUser testing, iteration
Documentation gapsLowContinuous documentation

Resource Requirements

Team Composition

RoleCountPhase Focus
Senior Backend Developer2All phases
Frontend Developer1Phase 2-3
DevOps Engineer1Phase 1, 4
QA Engineer1All phases
Project Manager1All phases

Infrastructure

ResourceDevelopmentProduction
API Servers13 (min)
DatabaseSharedDedicated cluster
CacheSharedDedicated Redis
Message QueueSharedDedicated RabbitMQ

Milestone Checklist

M1: Tenant Demo (Week 2)

  • Tenant CRUD API working
  • Schema provisioning automated
  • Tenant isolation verified

M2: Auth Complete (Week 3)

  • User registration and login
  • JWT tokens functioning
  • PIN login for terminals

M3: Catalog API (Week 4)

  • Product CRUD complete
  • Variant support working
  • Search and filtering

M4: Inventory Sync (Week 6)

  • Stock levels tracked
  • Movements audited
  • Transfers working

M5: First Sale (Week 7)

  • Cart operations complete
  • Sale finalization working
  • Inventory decremented

M6: Payment Complete (Week 9)

  • Multi-tender payments
  • Card processing
  • Refunds working

M7: Offline Ready (Week 13)

  • Offline mode functional
  • Sync protocol tested
  • Conflict resolution verified

M8: Go-Live (Week 16)

  • Production deployed
  • Monitoring active
  • Team trained

Next Steps

  1. Begin Chapter 25: Phase 1 Foundation for detailed week-by-week implementation
  2. Set up project tracking in GitHub Projects or Jira
  3. Schedule weekly demo sessions for stakeholder feedback
  4. Establish on-call rotation for Phase 4

Chapter 24 Complete - Implementation Roadmap

Chapter 25: Phase 1 - Foundation Implementation

Overview

Phase 1 establishes the foundational infrastructure: multi-tenant isolation, authentication, and catalog management. This 4-week phase creates the base upon which all other modules build.


Week 1-2: Multi-Tenant Infrastructure

Day 1-2: Tenant Entity and Repository

Objective: Create the tenant domain model with repository pattern.

Claude Command:

/dev-team implement tenant entity with repository pattern

Implementation:

// src/PosPlatform.Core/Entities/Tenant.cs
using System;
using System.Collections.Generic;

namespace PosPlatform.Core.Entities;

public class Tenant
{
    public Guid Id { get; private set; }
    public string Code { get; private set; } = string.Empty;
    public string Name { get; private set; } = string.Empty;
    public string? Domain { get; private set; }
    public TenantStatus Status { get; private set; }
    public TenantSettings Settings { get; private set; } = new();
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private Tenant() { } // EF Core

    public static Tenant Create(string code, string name, string? domain = null)
    {
        if (string.IsNullOrWhiteSpace(code) || code.Length > 10)
            throw new ArgumentException("Code must be 1-10 characters", nameof(code));

        return new Tenant
        {
            Id = Guid.NewGuid(),
            Code = code.ToUpperInvariant(),
            Name = name,
            Domain = domain,
            Status = TenantStatus.Active,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void UpdateSettings(TenantSettings settings)
    {
        Settings = settings ?? throw new ArgumentNullException(nameof(settings));
        UpdatedAt = DateTime.UtcNow;
    }

    public void Suspend() => Status = TenantStatus.Suspended;
    public void Activate() => Status = TenantStatus.Active;
}

public enum TenantStatus
{
    Active,
    Suspended,
    Pending
}

public class TenantSettings
{
    public string Timezone { get; set; } = "UTC";
    public string Currency { get; set; } = "USD";
    public decimal TaxRate { get; set; } = 0.0m;
    public string? LogoUrl { get; set; }
    public Dictionary<string, string> Custom { get; set; } = new();
}
// src/PosPlatform.Core/Interfaces/ITenantRepository.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantRepository
{
    Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default);
    Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default);
    Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default);
    Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default);
    Task UpdateAsync(Tenant tenant, CancellationToken ct = default);
    Task<bool> ExistsAsync(string code, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/Repositories/TenantRepository.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Infrastructure.Repositories;

public class TenantRepository : ITenantRepository
{
    private readonly PlatformDbContext _context;

    public TenantRepository(PlatformDbContext context)
    {
        _context = context;
    }

    public async Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Tenants.FindAsync(new object[] { id }, ct);

    public async Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Code == code.ToUpperInvariant(), ct);

    public async Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Domain == domain.ToLowerInvariant(), ct);

    public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default)
        => await _context.Tenants.ToListAsync(ct);

    public async Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default)
    {
        await _context.Tenants.AddAsync(tenant, ct);
        await _context.SaveChangesAsync(ct);
        return tenant;
    }

    public async Task UpdateAsync(Tenant tenant, CancellationToken ct = default)
    {
        _context.Tenants.Update(tenant);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<bool> ExistsAsync(string code, CancellationToken ct = default)
        => await _context.Tenants.AnyAsync(t => t.Code == code.ToUpperInvariant(), ct);
}

Test Command:

# Run unit tests for tenant entity
dotnet test --filter "FullyQualifiedName~TenantTests"

Day 3-4: Schema Provisioning Service

Objective: Automatically create tenant-specific database schemas.

Claude Command:

/dev-team create tenant provisioning service with schema isolation

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantProvisioningService.cs
namespace PosPlatform.Core.Interfaces;

public interface ITenantProvisioningService
{
    Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantProvisioningService.cs
using Microsoft.Extensions.Logging;
using Npgsql;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantProvisioningService : ITenantProvisioningService
{
    private readonly string _connectionString;
    private readonly ILogger<TenantProvisioningService> _logger;

    public TenantProvisioningService(
        string connectionString,
        ILogger<TenantProvisioningService> logger)
    {
        _connectionString = connectionString;
        _logger = logger;
    }

    public async Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogInformation("Provisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var transaction = await conn.BeginTransactionAsync(ct);

        try
        {
            // Create schema
            await ExecuteAsync(conn, $"CREATE SCHEMA IF NOT EXISTS {schemaName}", ct);

            // Create locations table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.locations (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    code VARCHAR(10) NOT NULL,
                    name VARCHAR(100) NOT NULL,
                    address JSONB,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_locations_code UNIQUE (code)
                )", ct);

            // Create users table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.users (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    employee_id VARCHAR(20),
                    full_name VARCHAR(100) NOT NULL,
                    email VARCHAR(255),
                    password_hash VARCHAR(255),
                    pin_hash VARCHAR(255),
                    role VARCHAR(50) NOT NULL,
                    location_id UUID REFERENCES {schemaName}.locations(id),
                    is_active BOOLEAN DEFAULT true,
                    last_login_at TIMESTAMPTZ,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_users_email UNIQUE (email),
                    CONSTRAINT uk_{schemaName}_users_employee_id UNIQUE (employee_id)
                )", ct);

            // Create categories table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.categories (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    name VARCHAR(100) NOT NULL,
                    parent_id UUID REFERENCES {schemaName}.categories(id),
                    sort_order INT DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW()
                )", ct);

            // Create products table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.products (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    description TEXT,
                    category_id UUID REFERENCES {schemaName}.categories(id),
                    base_price DECIMAL(10,2) NOT NULL,
                    cost DECIMAL(10,2),
                    tax_rate DECIMAL(5,4) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    updated_at TIMESTAMPTZ,
                    CONSTRAINT uk_{schemaName}_products_sku UNIQUE (sku)
                )", ct);

            // Create product_variants table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.product_variants (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    product_id UUID NOT NULL REFERENCES {schemaName}.products(id),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    attributes JSONB NOT NULL DEFAULT '{{}}',
                    price_adjustment DECIMAL(10,2) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_variants_sku UNIQUE (sku)
                )", ct);

            // Create indexes
            await ExecuteAsync(conn, $@"
                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_category
                ON {schemaName}.products(category_id);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_name
                ON {schemaName}.products USING gin(name gin_trgm_ops);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_variants_product
                ON {schemaName}.product_variants(product_id);
            ", ct);

            await transaction.CommitAsync(ct);
            _logger.LogInformation("Tenant schema provisioned: {Schema}", schemaName);
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync(ct);
            _logger.LogError(ex, "Failed to provision tenant schema: {Schema}", schemaName);
            throw;
        }
    }

    public async Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogWarning("Deprovisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await ExecuteAsync(conn, $"DROP SCHEMA IF EXISTS {schemaName} CASCADE", ct);
    }

    public async Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var cmd = new NpgsqlCommand(
            "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @schema)",
            conn);
        cmd.Parameters.AddWithValue("schema", schemaName);

        var result = await cmd.ExecuteScalarAsync(ct);
        return result is true;
    }

    private static string GetSchemaName(string tenantCode)
        => $"tenant_{tenantCode.ToLowerInvariant()}";

    private static async Task ExecuteAsync(NpgsqlConnection conn, string sql, CancellationToken ct)
    {
        await using var cmd = new NpgsqlCommand(sql, conn);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

Test Command:

# Test schema provisioning
curl -X POST http://localhost:5100/api/admin/tenants \
  -H "Content-Type: application/json" \
  -d '{"code": "TEST", "name": "Test Store"}'

# Verify schema exists
docker exec -it pos-postgres psql -U pos_admin -d pos_platform -c "\dn"

Day 5: Tenant Resolution Middleware

Objective: Resolve tenant from request and establish context.

Claude Command:

/dev-team implement tenant resolution middleware with request context

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantContext.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantContext
{
    Tenant? CurrentTenant { get; }
    string? TenantCode { get; }
    bool HasTenant { get; }
}

public interface ITenantContextSetter
{
    void SetTenant(Tenant tenant);
    void ClearTenant();
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantContext.cs
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantContext : ITenantContext, ITenantContextSetter
{
    private Tenant? _tenant;

    public Tenant? CurrentTenant => _tenant;
    public string? TenantCode => _tenant?.Code;
    public bool HasTenant => _tenant != null;

    public void SetTenant(Tenant tenant)
    {
        _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
    }

    public void ClearTenant()
    {
        _tenant = null;
    }
}
// src/PosPlatform.Api/Middleware/TenantResolutionMiddleware.cs
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Middleware;

public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<TenantResolutionMiddleware> _logger;

    public TenantResolutionMiddleware(
        RequestDelegate next,
        ILogger<TenantResolutionMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(
        HttpContext context,
        ITenantRepository tenantRepository,
        ITenantContextSetter tenantContext)
    {
        // Skip tenant resolution for platform endpoints
        if (context.Request.Path.StartsWithSegments("/api/admin") ||
            context.Request.Path.StartsWithSegments("/health"))
        {
            await _next(context);
            return;
        }

        var tenantCode = ResolveTenantCode(context);

        if (string.IsNullOrEmpty(tenantCode))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not specified" });
            return;
        }

        var tenant = await tenantRepository.GetByCodeAsync(tenantCode);

        if (tenant == null)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not found" });
            return;
        }

        if (tenant.Status != TenantStatus.Active)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant is not active" });
            return;
        }

        tenantContext.SetTenant(tenant);
        _logger.LogDebug("Tenant resolved: {TenantCode}", tenant.Code);

        try
        {
            await _next(context);
        }
        finally
        {
            tenantContext.ClearTenant();
        }
    }

    private static string? ResolveTenantCode(HttpContext context)
    {
        // Priority 1: Header
        if (context.Request.Headers.TryGetValue("X-Tenant-Code", out var headerValue))
            return headerValue.ToString();

        // Priority 2: Query string
        if (context.Request.Query.TryGetValue("tenant", out var queryValue))
            return queryValue.ToString();

        // Priority 3: Subdomain (e.g., tenant1.posplatform.com)
        var host = context.Request.Host.Host;
        var parts = host.Split('.');
        if (parts.Length >= 3)
            return parts[0];

        // Priority 4: JWT claim (if authenticated)
        var tenantClaim = context.User?.FindFirst("tenant_code");
        if (tenantClaim != null)
            return tenantClaim.Value;

        return null;
    }
}

// Extension method for registration
public static class TenantMiddlewareExtensions
{
    public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
    {
        return app.UseMiddleware<TenantResolutionMiddleware>();
    }
}

Registration in Program.cs:

// Add to Program.cs
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<TenantContext>());
builder.Services.AddScoped<ITenantContextSetter>(sp => sp.GetRequiredService<TenantContext>());

// In middleware pipeline (after authentication, before controllers)
app.UseAuthentication();
app.UseTenantResolution();
app.UseAuthorization();

Day 6-7: Dynamic Connection Routing

Objective: Route database connections to tenant-specific schemas.

Claude Command:

/dev-team implement dynamic connection string routing per tenant

Implementation:

// src/PosPlatform.Infrastructure/Data/TenantDbContext.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.Data;

public class TenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;
    private readonly string _schemaName;

    public TenantDbContext(
        DbContextOptions<TenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
        _schemaName = tenantContext.HasTenant
            ? $"tenant_{tenantContext.TenantCode!.ToLowerInvariant()}"
            : "public";
    }

    public DbSet<Location> Locations => Set<Location>();
    public DbSet<User> Users => Set<User>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<ProductVariant> ProductVariants => Set<ProductVariant>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Set default schema for all entities
        modelBuilder.HasDefaultSchema(_schemaName);

        // Configure entities
        modelBuilder.Entity<Location>(entity =>
        {
            entity.ToTable("locations");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Code).HasMaxLength(10).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasIndex(e => e.Code).IsUnique();
        });

        modelBuilder.Entity<User>(entity =>
        {
            entity.ToTable("users");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.FullName).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Email).HasMaxLength(255);
            entity.HasIndex(e => e.Email).IsUnique();
            entity.HasIndex(e => e.EmployeeId).IsUnique();
            entity.HasOne(e => e.Location)
                  .WithMany()
                  .HasForeignKey(e => e.LocationId);
        });

        modelBuilder.Entity<Category>(entity =>
        {
            entity.ToTable("categories");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasOne(e => e.Parent)
                  .WithMany(e => e.Children)
                  .HasForeignKey(e => e.ParentId)
                  .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<Product>(entity =>
        {
            entity.ToTable("products");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(255).IsRequired();
            entity.Property(e => e.BasePrice).HasPrecision(10, 2);
            entity.Property(e => e.Cost).HasPrecision(10, 2);
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Category)
                  .WithMany()
                  .HasForeignKey(e => e.CategoryId);
        });

        modelBuilder.Entity<ProductVariant>(entity =>
        {
            entity.ToTable("product_variants");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Product)
                  .WithMany(p => p.Variants)
                  .HasForeignKey(e => e.ProductId);
        });
    }
}
// DI Registration in Program.cs
builder.Services.AddDbContext<TenantDbContext>((sp, options) =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    options.UseNpgsql(connectionString);
});

Day 8-9: Tenant Management API

Objective: Create REST API for tenant CRUD operations.

Claude Command:

/dev-team create tenant management API with CRUD endpoints

Implementation:

// src/PosPlatform.Api/Controllers/Admin/TenantsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Controllers.Admin;

[ApiController]
[Route("api/admin/tenants")]
[Authorize(Roles = "super_admin")]
public class TenantsController : ControllerBase
{
    private readonly ITenantRepository _tenantRepository;
    private readonly ITenantProvisioningService _provisioningService;
    private readonly ILogger<TenantsController> _logger;

    public TenantsController(
        ITenantRepository tenantRepository,
        ITenantProvisioningService provisioningService,
        ILogger<TenantsController> logger)
    {
        _tenantRepository = tenantRepository;
        _provisioningService = provisioningService;
        _logger = logger;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<TenantDto>>> GetAll(CancellationToken ct)
    {
        var tenants = await _tenantRepository.GetAllAsync(ct);
        return Ok(tenants.Select(TenantDto.FromEntity));
    }

    [HttpGet("{code}")]
    public async Task<ActionResult<TenantDto>> GetByCode(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        return Ok(TenantDto.FromEntity(tenant));
    }

    [HttpPost]
    public async Task<ActionResult<TenantDto>> Create(
        [FromBody] CreateTenantRequest request,
        CancellationToken ct)
    {
        if (await _tenantRepository.ExistsAsync(request.Code, ct))
            return Conflict(new { error = "Tenant code already exists" });

        var tenant = Tenant.Create(request.Code, request.Name, request.Domain);

        if (request.Settings != null)
            tenant.UpdateSettings(request.Settings);

        await _tenantRepository.AddAsync(tenant, ct);

        // Provision database schema
        await _provisioningService.ProvisionTenantAsync(tenant.Code, ct);

        _logger.LogInformation("Tenant created: {Code}", tenant.Code);

        return CreatedAtAction(
            nameof(GetByCode),
            new { code = tenant.Code },
            TenantDto.FromEntity(tenant));
    }

    [HttpPut("{code}/settings")]
    public async Task<IActionResult> UpdateSettings(
        string code,
        [FromBody] TenantSettings settings,
        CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.UpdateSettings(settings);
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }

    [HttpPost("{code}/suspend")]
    public async Task<IActionResult> Suspend(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Suspend();
        await _tenantRepository.UpdateAsync(tenant, ct);

        _logger.LogWarning("Tenant suspended: {Code}", code);
        return NoContent();
    }

    [HttpPost("{code}/activate")]
    public async Task<IActionResult> Activate(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Activate();
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }
}

// DTOs
public record CreateTenantRequest(
    string Code,
    string Name,
    string? Domain,
    TenantSettings? Settings);

public record TenantDto(
    Guid Id,
    string Code,
    string Name,
    string? Domain,
    string Status,
    TenantSettings Settings,
    DateTime CreatedAt)
{
    public static TenantDto FromEntity(Tenant tenant) => new(
        tenant.Id,
        tenant.Code,
        tenant.Name,
        tenant.Domain,
        tenant.Status.ToString(),
        tenant.Settings,
        tenant.CreatedAt);
}

Day 10: Integration Tests

Objective: Verify tenant isolation through integration tests.

Claude Command:

/qa-team write tenant isolation integration tests

Implementation:

// tests/PosPlatform.Api.Tests/TenantIsolationTests.cs
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace PosPlatform.Api.Tests;

public class TenantIsolationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TenantIsolationTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task Request_WithoutTenant_Returns400()
    {
        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task Request_WithInvalidTenant_Returns404()
    {
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "INVALID");

        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Products_FromDifferentTenants_AreIsolated()
    {
        // Create product in Tenant A
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var productA = new { Sku = "SKU-A", Name = "Product A", BasePrice = 10.00m };
        await _client.PostAsJsonAsync("/api/products", productA);

        // Create product in Tenant B
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var productB = new { Sku = "SKU-B", Name = "Product B", BasePrice = 20.00m };
        await _client.PostAsJsonAsync("/api/products", productB);

        // Verify Tenant A only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var responseA = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseA!.Items);
        Assert.Equal("SKU-A", responseA.Items[0].Sku);

        // Verify Tenant B only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var responseB = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseB!.Items);
        Assert.Equal("SKU-B", responseB.Items[0].Sku);
    }
}

public record ProductListResponse(List<ProductItem> Items);
public record ProductItem(string Sku, string Name, decimal BasePrice);

Test Command:

# Run integration tests
dotnet test tests/PosPlatform.Api.Tests --filter "FullyQualifiedName~TenantIsolation"

Week 2-3: Authentication System

Day 1-2: User Entity with Password Hashing

Claude Command:

/dev-team implement user entity with bcrypt password hashing

Implementation:

// src/PosPlatform.Core/Entities/User.cs
using System.Security.Cryptography;
using BCrypt.Net;

namespace PosPlatform.Core.Entities;

public class User
{
    public Guid Id { get; private set; }
    public string? EmployeeId { get; private set; }
    public string FullName { get; private set; } = string.Empty;
    public string? Email { get; private set; }
    public string? PasswordHash { get; private set; }
    public string? PinHash { get; private set; }
    public UserRole Role { get; private set; }
    public Guid? LocationId { get; private set; }
    public Location? Location { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime? LastLoginAt { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private User() { }

    public static User Create(
        string fullName,
        UserRole role,
        string? email = null,
        string? employeeId = null)
    {
        return new User
        {
            Id = Guid.NewGuid(),
            FullName = fullName,
            Email = email?.ToLowerInvariant(),
            EmployeeId = employeeId,
            Role = role,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void SetPassword(string password)
    {
        if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters");

        PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, 12);
    }

    public bool VerifyPassword(string password)
    {
        if (string.IsNullOrEmpty(PasswordHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(password, PasswordHash);
    }

    public void SetPin(string pin)
    {
        if (string.IsNullOrWhiteSpace(pin) || !pin.All(char.IsDigit) || pin.Length < 4 || pin.Length > 6)
            throw new ArgumentException("PIN must be 4-6 digits");

        PinHash = BCrypt.Net.BCrypt.HashPassword(pin, 10);
    }

    public bool VerifyPin(string pin)
    {
        if (string.IsNullOrEmpty(PinHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(pin, PinHash);
    }

    public void AssignLocation(Guid locationId) => LocationId = locationId;
    public void RecordLogin() => LastLoginAt = DateTime.UtcNow;
    public void Deactivate() => IsActive = false;
    public void Activate() => IsActive = true;
}

public enum UserRole
{
    Cashier,
    Supervisor,
    Manager,
    Admin
}

Day 3-4: JWT Token Service

Claude Command:

/dev-team create JWT token service with refresh token support

Implementation:

// src/PosPlatform.Infrastructure/Services/JwtTokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using PosPlatform.Core.Entities;

namespace PosPlatform.Infrastructure.Services;

public interface IJwtTokenService
{
    TokenResult GenerateTokens(User user, string tenantCode);
    ClaimsPrincipal? ValidateToken(string token);
    string GenerateRefreshToken();
}

public record TokenResult(
    string AccessToken,
    string RefreshToken,
    DateTime ExpiresAt);

public class JwtTokenService : IJwtTokenService
{
    private readonly JwtSettings _settings;
    private readonly byte[] _key;

    public JwtTokenService(IOptions<JwtSettings> settings)
    {
        _settings = settings.Value;
        _key = Encoding.UTF8.GetBytes(_settings.SecretKey);
    }

    public TokenResult GenerateTokens(User user, string tenantCode)
    {
        var expiresAt = DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes);

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Name, user.FullName),
            new(ClaimTypes.Role, user.Role.ToString()),
            new("tenant_code", tenantCode),
            new("jti", Guid.NewGuid().ToString())
        };

        if (!string.IsNullOrEmpty(user.Email))
            claims.Add(new Claim(ClaimTypes.Email, user.Email));

        if (user.LocationId.HasValue)
            claims.Add(new Claim("location_id", user.LocationId.Value.ToString()));

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = expiresAt,
            Issuer = _settings.Issuer,
            Audience = _settings.Audience,
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(_key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);

        return new TokenResult(
            tokenHandler.WriteToken(token),
            GenerateRefreshToken(),
            expiresAt);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(_key),
                ValidateIssuer = true,
                ValidIssuer = _settings.Issuer,
                ValidateAudience = true,
                ValidAudience = _settings.Audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            }, out _);

            return principal;
        }
        catch
        {
            return null;
        }
    }

    public string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = "PosPlatform";
    public string Audience { get; set; } = "PosPlatform";
    public int AccessTokenExpirationMinutes { get; set; } = 60;
    public int RefreshTokenExpirationDays { get; set; } = 7;
}

Day 5-6: PIN Authentication

Claude Command:

/dev-team implement PIN authentication for POS terminals

Implementation:

// src/PosPlatform.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Services;

namespace PosPlatform.Api.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtTokenService _tokenService;
    private readonly ITenantContext _tenantContext;

    public AuthController(
        IUserRepository userRepository,
        IJwtTokenService tokenService,
        ITenantContext tenantContext)
    {
        _userRepository = userRepository;
        _tokenService = tokenService;
        _tenantContext = tenantContext;
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmailAsync(request.Email, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPassword(request.Password))
            return Unauthorized(new { error = "Invalid credentials" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("login/pin")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> LoginWithPin(
        [FromBody] PinLoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmployeeIdAsync(request.EmployeeId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPin(request.Pin))
            return Unauthorized(new { error = "Invalid PIN" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<ActionResult<TokenResponse>> Refresh(
        [FromBody] RefreshRequest request,
        CancellationToken ct)
    {
        // In production, validate refresh token from database
        var principal = _tokenService.ValidateToken(request.AccessToken);
        if (principal == null)
            return Unauthorized();

        var userId = Guid.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value);
        var user = await _userRepository.GetByIdAsync(userId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized();

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new TokenResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt));
    }

    [HttpPost("logout")]
    [Authorize]
    public IActionResult Logout()
    {
        // In production, invalidate refresh token in database
        return NoContent();
    }
}

// DTOs
public record LoginRequest(string Email, string Password);
public record PinLoginRequest(string EmployeeId, string Pin);
public record RefreshRequest(string AccessToken, string RefreshToken);
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, UserDto User);
public record TokenResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);

public record UserDto(Guid Id, string FullName, string? Email, string Role, Guid? LocationId)
{
    public static UserDto FromEntity(User user) => new(
        user.Id, user.FullName, user.Email, user.Role.ToString(), user.LocationId);
}

Week 3-4: Catalog Domain

Day 1-2: Product and Category Entities

Claude Command:

/dev-team create product entity with variant support

See entities defined in TenantDbContext above.

Day 5-6: Pricing Rules Engine

Claude Command:

/dev-team create pricing rules engine

Implementation:

// src/PosPlatform.Core/Services/PricingService.cs
namespace PosPlatform.Core.Services;

public interface IPricingService
{
    decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context);
}

public class PricingService : IPricingService
{
    public decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context)
    {
        var basePrice = product.BasePrice;

        // Apply variant adjustment
        if (variant != null)
            basePrice += variant.PriceAdjustment;

        // Apply promotions
        foreach (var promo in context.ActivePromotions)
        {
            if (promo.AppliesTo(product))
                basePrice = promo.Apply(basePrice);
        }

        // Apply customer discount
        if (context.CustomerDiscount > 0)
            basePrice *= (1 - context.CustomerDiscount);

        return Math.Round(basePrice, 2);
    }
}

public class PricingContext
{
    public List<Promotion> ActivePromotions { get; set; } = new();
    public decimal CustomerDiscount { get; set; }
    public DateTime PriceDate { get; set; } = DateTime.UtcNow;
}

public abstract class Promotion
{
    public abstract bool AppliesTo(Product product);
    public abstract decimal Apply(decimal price);
}

public class PercentagePromotion : Promotion
{
    public decimal DiscountPercent { get; set; }
    public Guid? CategoryId { get; set; }

    public override bool AppliesTo(Product product)
        => !CategoryId.HasValue || product.CategoryId == CategoryId;

    public override decimal Apply(decimal price)
        => price * (1 - DiscountPercent / 100);
}

Week 1-2 Review Checkpoint

Claude Command:

/architect-review multi-tenant isolation and authentication implementation

Checklist:

  • Tenant CRUD API functional
  • Schema provisioning creates tables correctly
  • Tenant middleware resolves from header/subdomain
  • JWT authentication working
  • PIN login functional for cashiers
  • Integration tests pass

Next Steps

Proceed to Chapter 26: Phase 2 - Core Implementation for:

  • Inventory domain with stock tracking
  • Sales domain with event sourcing
  • Payment processing
  • Cash drawer operations

Chapter 25 Complete - Phase 1 Foundation Implementation

Chapter 26: Phase 2 - Core Implementation

Overview

Phase 2 builds the core transactional capabilities: inventory management, sales processing with event sourcing, payment handling, and cash drawer operations. This 6-week phase (Weeks 5-10) delivers the heart of the POS system.


Week 5-6: Inventory Domain

Day 1-2: Inventory Item Entity

Objective: Create inventory entity with multi-location stock tracking.

Claude Command:

/dev-team create inventory item entity with location quantities

Implementation:

// src/PosPlatform.Core/Entities/Inventory/InventoryItem.cs
namespace PosPlatform.Core.Entities.Inventory;

public class InventoryItem
{
    public Guid Id { get; private set; }
    public Guid ProductId { get; private set; }
    public Guid? VariantId { get; private set; }
    public string Sku { get; private set; } = string.Empty;
    public Guid LocationId { get; private set; }

    // Stock levels
    public int QuantityOnHand { get; private set; }
    public int QuantityReserved { get; private set; }
    public int QuantityAvailable => QuantityOnHand - QuantityReserved;

    // Thresholds
    public int ReorderPoint { get; private set; }
    public int ReorderQuantity { get; private set; }
    public int MaxQuantity { get; private set; }

    // Tracking
    public DateTime LastCountedAt { get; private set; }
    public DateTime LastReceivedAt { get; private set; }
    public DateTime LastSoldAt { get; private set; }
    public DateTime UpdatedAt { get; private set; }

    private readonly List<StockMovement> _movements = new();
    public IReadOnlyList<StockMovement> Movements => _movements.AsReadOnly();

    private InventoryItem() { }

    public static InventoryItem Create(
        Guid productId,
        Guid locationId,
        string sku,
        Guid? variantId = null)
    {
        return new InventoryItem
        {
            Id = Guid.NewGuid(),
            ProductId = productId,
            VariantId = variantId,
            Sku = sku,
            LocationId = locationId,
            QuantityOnHand = 0,
            QuantityReserved = 0,
            ReorderPoint = 10,
            ReorderQuantity = 50,
            MaxQuantity = 200,
            LastCountedAt = DateTime.MinValue,
            LastReceivedAt = DateTime.MinValue,
            LastSoldAt = DateTime.MinValue,
            UpdatedAt = DateTime.UtcNow
        };
    }

    public void ReceiveStock(int quantity, string reference, Guid userId)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));

        var movement = StockMovement.Create(
            Id, MovementType.Receipt, quantity, QuantityOnHand, reference, userId);

        QuantityOnHand += quantity;
        LastReceivedAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void SellStock(int quantity, string saleReference, Guid userId)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));

        if (quantity > QuantityAvailable)
            throw new InvalidOperationException($"Insufficient stock. Available: {QuantityAvailable}");

        var movement = StockMovement.Create(
            Id, MovementType.Sale, -quantity, QuantityOnHand, saleReference, userId);

        QuantityOnHand -= quantity;
        QuantityReserved = Math.Max(0, QuantityReserved - quantity);
        LastSoldAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void ReserveStock(int quantity)
    {
        if (quantity > QuantityAvailable)
            throw new InvalidOperationException($"Cannot reserve {quantity}. Available: {QuantityAvailable}");

        QuantityReserved += quantity;
        UpdatedAt = DateTime.UtcNow;
    }

    public void ReleaseReservation(int quantity)
    {
        QuantityReserved = Math.Max(0, QuantityReserved - quantity);
        UpdatedAt = DateTime.UtcNow;
    }

    public void Adjust(int newQuantity, string reason, Guid userId)
    {
        var difference = newQuantity - QuantityOnHand;

        var movement = StockMovement.Create(
            Id, MovementType.Adjustment, difference, QuantityOnHand, reason, userId);

        QuantityOnHand = newQuantity;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void RecordCount(int countedQuantity, Guid userId)
    {
        if (countedQuantity != QuantityOnHand)
        {
            var variance = countedQuantity - QuantityOnHand;
            var movement = StockMovement.Create(
                Id, MovementType.Count, variance, QuantityOnHand,
                $"Physical count variance: {variance}", userId);

            QuantityOnHand = countedQuantity;
            _movements.Add(movement);
        }

        LastCountedAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;
    }

    public bool NeedsReorder => QuantityOnHand <= ReorderPoint;
    public bool IsOverstocked => QuantityOnHand > MaxQuantity;
}
// src/PosPlatform.Core/Entities/Inventory/StockMovement.cs
namespace PosPlatform.Core.Entities.Inventory;

public class StockMovement
{
    public Guid Id { get; private set; }
    public Guid InventoryItemId { get; private set; }
    public MovementType Type { get; private set; }
    public int Quantity { get; private set; }
    public int QuantityBefore { get; private set; }
    public int QuantityAfter { get; private set; }
    public string Reference { get; private set; } = string.Empty;
    public Guid UserId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private StockMovement() { }

    public static StockMovement Create(
        Guid inventoryItemId,
        MovementType type,
        int quantity,
        int quantityBefore,
        string reference,
        Guid userId)
    {
        return new StockMovement
        {
            Id = Guid.NewGuid(),
            InventoryItemId = inventoryItemId,
            Type = type,
            Quantity = quantity,
            QuantityBefore = quantityBefore,
            QuantityAfter = quantityBefore + quantity,
            Reference = reference,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };
    }
}

public enum MovementType
{
    Receipt,      // Stock received from vendor
    Sale,         // Stock sold to customer
    Return,       // Customer return
    Adjustment,   // Manual adjustment
    Transfer,     // Inter-store transfer
    Count,        // Physical count variance
    Damage,       // Damaged/written off
    Reserved      // Reserved for order
}

Day 3-4: Stock Movement Event Sourcing

Objective: Implement event-sourced stock movements for complete audit trail.

Claude Command:

/dev-team implement stock movement event sourcing

Implementation:

// src/PosPlatform.Core/Events/Inventory/InventoryEvents.cs
namespace PosPlatform.Core.Events.Inventory;

public abstract record InventoryEvent(
    Guid InventoryItemId,
    Guid UserId,
    DateTime OccurredAt);

public record StockReceivedEvent(
    Guid InventoryItemId,
    int Quantity,
    string ReceiptReference,
    decimal UnitCost,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockSoldEvent(
    Guid InventoryItemId,
    int Quantity,
    Guid SaleId,
    decimal UnitPrice,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockAdjustedEvent(
    Guid InventoryItemId,
    int QuantityChange,
    int NewQuantity,
    string Reason,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockTransferredEvent(
    Guid SourceInventoryItemId,
    Guid DestinationInventoryItemId,
    int Quantity,
    string TransferReference,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(SourceInventoryItemId, UserId, OccurredAt);

public record StockCountedEvent(
    Guid InventoryItemId,
    int CountedQuantity,
    int SystemQuantity,
    int Variance,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);
// src/PosPlatform.Infrastructure/Data/InventoryEventStore.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Events.Inventory;
using System.Text.Json;

namespace PosPlatform.Infrastructure.Data;

public interface IInventoryEventStore
{
    Task AppendAsync(InventoryEvent @event, CancellationToken ct = default);
    Task<IReadOnlyList<InventoryEvent>> GetEventsAsync(
        Guid inventoryItemId,
        DateTime? fromDate = null,
        CancellationToken ct = default);
}

public class InventoryEventStore : IInventoryEventStore
{
    private readonly TenantDbContext _context;

    public InventoryEventStore(TenantDbContext context)
    {
        _context = context;
    }

    public async Task AppendAsync(InventoryEvent @event, CancellationToken ct = default)
    {
        var storedEvent = new StoredInventoryEvent
        {
            Id = Guid.NewGuid(),
            InventoryItemId = @event.InventoryItemId,
            EventType = @event.GetType().Name,
            EventData = JsonSerializer.Serialize(@event, @event.GetType()),
            UserId = @event.UserId,
            CreatedAt = @event.OccurredAt
        };

        _context.Set<StoredInventoryEvent>().Add(storedEvent);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<IReadOnlyList<InventoryEvent>> GetEventsAsync(
        Guid inventoryItemId,
        DateTime? fromDate = null,
        CancellationToken ct = default)
    {
        var query = _context.Set<StoredInventoryEvent>()
            .Where(e => e.InventoryItemId == inventoryItemId);

        if (fromDate.HasValue)
            query = query.Where(e => e.CreatedAt >= fromDate.Value);

        var stored = await query
            .OrderBy(e => e.CreatedAt)
            .ToListAsync(ct);

        return stored
            .Select(DeserializeEvent)
            .Where(e => e != null)
            .Cast<InventoryEvent>()
            .ToList();
    }

    private static InventoryEvent? DeserializeEvent(StoredInventoryEvent stored)
    {
        var type = stored.EventType switch
        {
            nameof(StockReceivedEvent) => typeof(StockReceivedEvent),
            nameof(StockSoldEvent) => typeof(StockSoldEvent),
            nameof(StockAdjustedEvent) => typeof(StockAdjustedEvent),
            nameof(StockTransferredEvent) => typeof(StockTransferredEvent),
            nameof(StockCountedEvent) => typeof(StockCountedEvent),
            _ => null
        };

        if (type == null) return null;

        return JsonSerializer.Deserialize(stored.EventData, type) as InventoryEvent;
    }
}

public class StoredInventoryEvent
{
    public Guid Id { get; set; }
    public Guid InventoryItemId { get; set; }
    public string EventType { get; set; } = string.Empty;
    public string EventData { get; set; } = string.Empty;
    public Guid UserId { get; set; }
    public DateTime CreatedAt { get; set; }
}

Day 5-6: Inventory Adjustment Service

Claude Command:

/dev-team create inventory adjustment service with reasons

Implementation:

// src/PosPlatform.Core/Services/InventoryAdjustmentService.cs
using PosPlatform.Core.Entities.Inventory;
using PosPlatform.Core.Events.Inventory;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Core.Services;

public interface IInventoryAdjustmentService
{
    Task<InventoryItem> AdjustQuantityAsync(
        Guid inventoryItemId,
        int newQuantity,
        AdjustmentReason reason,
        string? notes,
        Guid userId,
        CancellationToken ct = default);

    Task<InventoryItem> RecordCountAsync(
        Guid inventoryItemId,
        int countedQuantity,
        Guid userId,
        CancellationToken ct = default);
}

public class InventoryAdjustmentService : IInventoryAdjustmentService
{
    private readonly IInventoryRepository _repository;
    private readonly IInventoryEventStore _eventStore;

    public InventoryAdjustmentService(
        IInventoryRepository repository,
        IInventoryEventStore eventStore)
    {
        _repository = repository;
        _eventStore = eventStore;
    }

    public async Task<InventoryItem> AdjustQuantityAsync(
        Guid inventoryItemId,
        int newQuantity,
        AdjustmentReason reason,
        string? notes,
        Guid userId,
        CancellationToken ct = default)
    {
        var item = await _repository.GetByIdAsync(inventoryItemId, ct)
            ?? throw new InvalidOperationException("Inventory item not found");

        var oldQuantity = item.QuantityOnHand;
        var reasonText = FormatReason(reason, notes);

        item.Adjust(newQuantity, reasonText, userId);
        await _repository.UpdateAsync(item, ct);

        var @event = new StockAdjustedEvent(
            inventoryItemId,
            newQuantity - oldQuantity,
            newQuantity,
            reasonText,
            userId,
            DateTime.UtcNow);

        await _eventStore.AppendAsync(@event, ct);

        return item;
    }

    public async Task<InventoryItem> RecordCountAsync(
        Guid inventoryItemId,
        int countedQuantity,
        Guid userId,
        CancellationToken ct = default)
    {
        var item = await _repository.GetByIdAsync(inventoryItemId, ct)
            ?? throw new InvalidOperationException("Inventory item not found");

        var systemQuantity = item.QuantityOnHand;
        var variance = countedQuantity - systemQuantity;

        item.RecordCount(countedQuantity, userId);
        await _repository.UpdateAsync(item, ct);

        var @event = new StockCountedEvent(
            inventoryItemId,
            countedQuantity,
            systemQuantity,
            variance,
            userId,
            DateTime.UtcNow);

        await _eventStore.AppendAsync(@event, ct);

        return item;
    }

    private static string FormatReason(AdjustmentReason reason, string? notes)
    {
        var reasonText = reason switch
        {
            AdjustmentReason.Damaged => "Damaged merchandise",
            AdjustmentReason.Theft => "Theft/shrinkage",
            AdjustmentReason.Expired => "Expired product",
            AdjustmentReason.DataCorrection => "Data entry correction",
            AdjustmentReason.VendorReturn => "Returned to vendor",
            AdjustmentReason.Found => "Found stock",
            AdjustmentReason.Other => "Other adjustment",
            _ => "Unknown reason"
        };

        return string.IsNullOrWhiteSpace(notes)
            ? reasonText
            : $"{reasonText}: {notes}";
    }
}

public enum AdjustmentReason
{
    Damaged,
    Theft,
    Expired,
    DataCorrection,
    VendorReturn,
    Found,
    Other
}

Day 7-8: Inter-Store Transfers

Claude Command:

/dev-team implement inter-store transfer workflow

Implementation:

// src/PosPlatform.Core/Entities/Inventory/TransferRequest.cs
namespace PosPlatform.Core.Entities.Inventory;

public class TransferRequest
{
    public Guid Id { get; private set; }
    public string TransferNumber { get; private set; } = string.Empty;
    public Guid SourceLocationId { get; private set; }
    public Guid DestinationLocationId { get; private set; }
    public TransferStatus Status { get; private set; }
    public Guid RequestedByUserId { get; private set; }
    public DateTime RequestedAt { get; private set; }
    public Guid? ApprovedByUserId { get; private set; }
    public DateTime? ApprovedAt { get; private set; }
    public Guid? ShippedByUserId { get; private set; }
    public DateTime? ShippedAt { get; private set; }
    public Guid? ReceivedByUserId { get; private set; }
    public DateTime? ReceivedAt { get; private set; }
    public string? Notes { get; private set; }

    private readonly List<TransferItem> _items = new();
    public IReadOnlyList<TransferItem> Items => _items.AsReadOnly();

    private TransferRequest() { }

    public static TransferRequest Create(
        Guid sourceLocationId,
        Guid destinationLocationId,
        Guid requestedByUserId,
        string? notes = null)
    {
        return new TransferRequest
        {
            Id = Guid.NewGuid(),
            TransferNumber = GenerateTransferNumber(),
            SourceLocationId = sourceLocationId,
            DestinationLocationId = destinationLocationId,
            Status = TransferStatus.Pending,
            RequestedByUserId = requestedByUserId,
            RequestedAt = DateTime.UtcNow,
            Notes = notes
        };
    }

    public void AddItem(Guid productId, Guid? variantId, string sku, int quantity)
    {
        if (Status != TransferStatus.Pending)
            throw new InvalidOperationException("Cannot modify non-pending transfer");

        var existing = _items.FirstOrDefault(i => i.Sku == sku);
        if (existing != null)
        {
            existing.UpdateQuantity(existing.Quantity + quantity);
        }
        else
        {
            _items.Add(new TransferItem(Id, productId, variantId, sku, quantity));
        }
    }

    public void Approve(Guid userId)
    {
        if (Status != TransferStatus.Pending)
            throw new InvalidOperationException("Can only approve pending transfers");

        Status = TransferStatus.Approved;
        ApprovedByUserId = userId;
        ApprovedAt = DateTime.UtcNow;
    }

    public void Ship(Guid userId)
    {
        if (Status != TransferStatus.Approved)
            throw new InvalidOperationException("Can only ship approved transfers");

        Status = TransferStatus.InTransit;
        ShippedByUserId = userId;
        ShippedAt = DateTime.UtcNow;
    }

    public void Receive(Guid userId, IEnumerable<ReceivedQuantity> receivedQuantities)
    {
        if (Status != TransferStatus.InTransit)
            throw new InvalidOperationException("Can only receive in-transit transfers");

        foreach (var received in receivedQuantities)
        {
            var item = _items.FirstOrDefault(i => i.Sku == received.Sku);
            item?.RecordReceived(received.Quantity);
        }

        Status = TransferStatus.Completed;
        ReceivedByUserId = userId;
        ReceivedAt = DateTime.UtcNow;
    }

    public void Cancel(Guid userId, string reason)
    {
        if (Status == TransferStatus.Completed)
            throw new InvalidOperationException("Cannot cancel completed transfer");

        Status = TransferStatus.Cancelled;
        Notes = $"{Notes}\nCancelled: {reason}";
    }

    private static string GenerateTransferNumber()
        => $"TRF-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString()[..8].ToUpper()}";
}

public class TransferItem
{
    public Guid Id { get; private set; }
    public Guid TransferRequestId { get; private set; }
    public Guid ProductId { get; private set; }
    public Guid? VariantId { get; private set; }
    public string Sku { get; private set; } = string.Empty;
    public int Quantity { get; private set; }
    public int QuantityReceived { get; private set; }
    public int Variance => QuantityReceived - Quantity;

    internal TransferItem(Guid transferId, Guid productId, Guid? variantId, string sku, int quantity)
    {
        Id = Guid.NewGuid();
        TransferRequestId = transferId;
        ProductId = productId;
        VariantId = variantId;
        Sku = sku;
        Quantity = quantity;
    }

    internal void UpdateQuantity(int quantity) => Quantity = quantity;
    internal void RecordReceived(int quantity) => QuantityReceived = quantity;
}

public record ReceivedQuantity(string Sku, int Quantity);

public enum TransferStatus
{
    Pending,
    Approved,
    InTransit,
    Completed,
    Cancelled
}

Day 9-10: Low Stock Alerts

Claude Command:

/dev-team create low stock alert notification system

Implementation:

// src/PosPlatform.Core/Services/LowStockAlertService.cs
using PosPlatform.Core.Entities.Inventory;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ILowStockAlertService
{
    Task<IReadOnlyList<LowStockAlert>> GetAlertsAsync(
        Guid? locationId = null,
        CancellationToken ct = default);

    Task ProcessAlertsAsync(CancellationToken ct = default);
}

public class LowStockAlertService : ILowStockAlertService
{
    private readonly IInventoryRepository _repository;
    private readonly INotificationService _notificationService;

    public LowStockAlertService(
        IInventoryRepository repository,
        INotificationService notificationService)
    {
        _repository = repository;
        _notificationService = notificationService;
    }

    public async Task<IReadOnlyList<LowStockAlert>> GetAlertsAsync(
        Guid? locationId = null,
        CancellationToken ct = default)
    {
        var lowStockItems = await _repository.GetBelowReorderPointAsync(locationId, ct);

        return lowStockItems.Select(item => new LowStockAlert(
            item.Id,
            item.Sku,
            item.LocationId,
            item.QuantityOnHand,
            item.ReorderPoint,
            item.ReorderQuantity,
            item.QuantityOnHand == 0 ? AlertSeverity.Critical : AlertSeverity.Warning
        )).ToList();
    }

    public async Task ProcessAlertsAsync(CancellationToken ct = default)
    {
        var alerts = await GetAlertsAsync(ct: ct);

        var criticalAlerts = alerts.Where(a => a.Severity == AlertSeverity.Critical);
        foreach (var alert in criticalAlerts)
        {
            await _notificationService.SendAsync(new Notification
            {
                Type = NotificationType.LowStock,
                Priority = NotificationPriority.High,
                Title = $"Out of Stock: {alert.Sku}",
                Message = $"SKU {alert.Sku} is out of stock at location. Reorder quantity: {alert.ReorderQuantity}",
                Data = new { alert.InventoryItemId, alert.LocationId }
            }, ct);
        }
    }
}

public record LowStockAlert(
    Guid InventoryItemId,
    string Sku,
    Guid LocationId,
    int CurrentQuantity,
    int ReorderPoint,
    int SuggestedOrderQuantity,
    AlertSeverity Severity);

public enum AlertSeverity
{
    Info,
    Warning,
    Critical
}

Week 6-7: Sales Domain (Event Sourcing)

Day 1-2: Sale Aggregate Root

Objective: Create event-sourced sale entity.

Claude Command:

/dev-team create sale aggregate with event sourcing

Implementation:

// src/PosPlatform.Core/Entities/Sales/Sale.cs
using PosPlatform.Core.Events.Sales;

namespace PosPlatform.Core.Entities.Sales;

public class Sale
{
    public Guid Id { get; private set; }
    public string SaleNumber { get; private set; } = string.Empty;
    public Guid LocationId { get; private set; }
    public Guid CashierId { get; private set; }
    public Guid? CustomerId { get; private set; }
    public SaleStatus Status { get; private set; }

    // Calculated totals
    public decimal Subtotal { get; private set; }
    public decimal TotalDiscount { get; private set; }
    public decimal TaxAmount { get; private set; }
    public decimal Total { get; private set; }

    // Metadata
    public DateTime StartedAt { get; private set; }
    public DateTime? CompletedAt { get; private set; }
    public DateTime? VoidedAt { get; private set; }
    public Guid? VoidedByUserId { get; private set; }
    public string? VoidReason { get; private set; }

    private readonly List<SaleLineItem> _items = new();
    public IReadOnlyList<SaleLineItem> Items => _items.AsReadOnly();

    private readonly List<SalePayment> _payments = new();
    public IReadOnlyList<SalePayment> Payments => _payments.AsReadOnly();

    private readonly List<SaleEvent> _events = new();
    public IReadOnlyList<SaleEvent> Events => _events.AsReadOnly();

    private Sale() { }

    public static Sale Start(Guid locationId, Guid cashierId, Guid? customerId = null)
    {
        var sale = new Sale
        {
            Id = Guid.NewGuid(),
            SaleNumber = GenerateSaleNumber(),
            LocationId = locationId,
            CashierId = cashierId,
            CustomerId = customerId,
            Status = SaleStatus.InProgress,
            StartedAt = DateTime.UtcNow
        };

        sale.Apply(new SaleStartedEvent(sale.Id, locationId, cashierId, DateTime.UtcNow));

        return sale;
    }

    public void AddItem(
        Guid productId,
        Guid? variantId,
        string sku,
        string name,
        int quantity,
        decimal unitPrice,
        decimal taxRate)
    {
        EnsureInProgress();

        var existing = _items.FirstOrDefault(i => i.Sku == sku);
        if (existing != null)
        {
            existing.UpdateQuantity(existing.Quantity + quantity);
        }
        else
        {
            var item = new SaleLineItem(Id, productId, variantId, sku, name, quantity, unitPrice, taxRate);
            _items.Add(item);
        }

        RecalculateTotals();

        Apply(new ItemAddedEvent(Id, sku, name, quantity, unitPrice, DateTime.UtcNow));
    }

    public void RemoveItem(string sku)
    {
        EnsureInProgress();

        var item = _items.FirstOrDefault(i => i.Sku == sku);
        if (item != null)
        {
            _items.Remove(item);
            RecalculateTotals();

            Apply(new ItemRemovedEvent(Id, sku, DateTime.UtcNow));
        }
    }

    public void UpdateItemQuantity(string sku, int newQuantity)
    {
        EnsureInProgress();

        var item = _items.FirstOrDefault(i => i.Sku == sku)
            ?? throw new InvalidOperationException($"Item {sku} not found");

        if (newQuantity <= 0)
        {
            RemoveItem(sku);
            return;
        }

        item.UpdateQuantity(newQuantity);
        RecalculateTotals();

        Apply(new ItemQuantityChangedEvent(Id, sku, newQuantity, DateTime.UtcNow));
    }

    public void ApplyDiscount(decimal discountAmount, string discountCode)
    {
        EnsureInProgress();

        TotalDiscount = discountAmount;
        RecalculateTotals();

        Apply(new DiscountAppliedEvent(Id, discountAmount, discountCode, DateTime.UtcNow));
    }

    public void AddPayment(PaymentMethod method, decimal amount, string? reference = null)
    {
        EnsureInProgress();

        var payment = new SalePayment(Id, method, amount, reference);
        _payments.Add(payment);

        Apply(new PaymentReceivedEvent(Id, method, amount, DateTime.UtcNow));

        if (TotalPaid >= Total)
        {
            Complete();
        }
    }

    public void Complete()
    {
        if (Status != SaleStatus.InProgress)
            throw new InvalidOperationException("Sale is not in progress");

        if (TotalPaid < Total)
            throw new InvalidOperationException($"Payment incomplete. Due: {Total - TotalPaid:C}");

        Status = SaleStatus.Completed;
        CompletedAt = DateTime.UtcNow;

        Apply(new SaleCompletedEvent(Id, Total, DateTime.UtcNow));
    }

    public void Void(Guid userId, string reason)
    {
        if (Status == SaleStatus.Voided)
            throw new InvalidOperationException("Sale already voided");

        Status = SaleStatus.Voided;
        VoidedAt = DateTime.UtcNow;
        VoidedByUserId = userId;
        VoidReason = reason;

        Apply(new SaleVoidedEvent(Id, userId, reason, DateTime.UtcNow));
    }

    public decimal TotalPaid => _payments.Sum(p => p.Amount);
    public decimal BalanceDue => Total - TotalPaid;
    public decimal ChangeDue => TotalPaid > Total ? TotalPaid - Total : 0;

    private void RecalculateTotals()
    {
        Subtotal = _items.Sum(i => i.ExtendedPrice);
        TaxAmount = _items.Sum(i => i.TaxAmount);
        Total = Subtotal - TotalDiscount + TaxAmount;
    }

    private void EnsureInProgress()
    {
        if (Status != SaleStatus.InProgress)
            throw new InvalidOperationException("Sale is not in progress");
    }

    private void Apply(SaleEvent @event)
    {
        _events.Add(@event);
    }

    private static string GenerateSaleNumber()
        => $"S-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString()[..4].ToUpper()}";
}

public enum SaleStatus
{
    InProgress,
    Completed,
    Voided,
    Suspended
}

Day 3-4: Sale Events

Claude Command:

/dev-team implement sale events (add, remove, discount, payment)

Implementation:

// src/PosPlatform.Core/Events/Sales/SaleEvents.cs
namespace PosPlatform.Core.Events.Sales;

public abstract record SaleEvent(Guid SaleId, DateTime OccurredAt);

public record SaleStartedEvent(
    Guid SaleId,
    Guid LocationId,
    Guid CashierId,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemAddedEvent(
    Guid SaleId,
    string Sku,
    string Name,
    int Quantity,
    decimal UnitPrice,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemRemovedEvent(
    Guid SaleId,
    string Sku,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemQuantityChangedEvent(
    Guid SaleId,
    string Sku,
    int NewQuantity,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record DiscountAppliedEvent(
    Guid SaleId,
    decimal DiscountAmount,
    string DiscountCode,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record PaymentReceivedEvent(
    Guid SaleId,
    PaymentMethod Method,
    decimal Amount,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record SaleCompletedEvent(
    Guid SaleId,
    decimal TotalAmount,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record SaleVoidedEvent(
    Guid SaleId,
    Guid VoidedByUserId,
    string Reason,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

Day 7-8: Sale Completion Workflow

Claude Command:

/dev-team implement sale completion workflow

Implementation:

// src/PosPlatform.Core/Services/SaleCompletionService.cs
using PosPlatform.Core.Entities.Sales;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ISaleCompletionService
{
    Task<SaleCompletionResult> CompleteSaleAsync(
        Guid saleId,
        CancellationToken ct = default);
}

public class SaleCompletionService : ISaleCompletionService
{
    private readonly ISaleRepository _saleRepository;
    private readonly IInventoryService _inventoryService;
    private readonly IReceiptService _receiptService;
    private readonly IEventPublisher _eventPublisher;

    public SaleCompletionService(
        ISaleRepository saleRepository,
        IInventoryService inventoryService,
        IReceiptService receiptService,
        IEventPublisher eventPublisher)
    {
        _saleRepository = saleRepository;
        _inventoryService = inventoryService;
        _receiptService = receiptService;
        _eventPublisher = eventPublisher;
    }

    public async Task<SaleCompletionResult> CompleteSaleAsync(
        Guid saleId,
        CancellationToken ct = default)
    {
        var sale = await _saleRepository.GetByIdAsync(saleId, ct)
            ?? throw new InvalidOperationException("Sale not found");

        // Validate payment
        if (sale.BalanceDue > 0)
        {
            return SaleCompletionResult.Failed($"Balance due: {sale.BalanceDue:C}");
        }

        // Deduct inventory
        foreach (var item in sale.Items)
        {
            await _inventoryService.DeductStockAsync(
                item.Sku,
                sale.LocationId,
                item.Quantity,
                sale.SaleNumber,
                sale.CashierId,
                ct);
        }

        // Complete the sale
        sale.Complete();
        await _saleRepository.UpdateAsync(sale, ct);

        // Generate receipt
        var receipt = await _receiptService.GenerateAsync(sale, ct);

        // Publish events
        foreach (var @event in sale.Events)
        {
            await _eventPublisher.PublishAsync(@event, ct);
        }

        return SaleCompletionResult.Success(receipt.ReceiptNumber, sale.ChangeDue);
    }
}

public record SaleCompletionResult(
    bool IsSuccess,
    string? ReceiptNumber,
    decimal ChangeDue,
    string? ErrorMessage)
{
    public static SaleCompletionResult Success(string receiptNumber, decimal change)
        => new(true, receiptNumber, change, null);

    public static SaleCompletionResult Failed(string error)
        => new(false, null, 0, error);
}

Week 8-9: Payment Processing

Day 1-2: Multi-Tender Payment Entity

Claude Command:

/dev-team create payment entity with multi-tender support

Implementation:

// src/PosPlatform.Core/Entities/Sales/SalePayment.cs
namespace PosPlatform.Core.Entities.Sales;

public class SalePayment
{
    public Guid Id { get; private set; }
    public Guid SaleId { get; private set; }
    public PaymentMethod Method { get; private set; }
    public decimal Amount { get; private set; }
    public string? Reference { get; private set; }
    public PaymentStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public string? ProcessorResponse { get; private set; }

    internal SalePayment(
        Guid saleId,
        PaymentMethod method,
        decimal amount,
        string? reference = null)
    {
        Id = Guid.NewGuid();
        SaleId = saleId;
        Method = method;
        Amount = amount;
        Reference = reference;
        Status = PaymentStatus.Pending;
        CreatedAt = DateTime.UtcNow;
    }

    public void MarkApproved(string? processorResponse = null)
    {
        Status = PaymentStatus.Approved;
        ProcessorResponse = processorResponse;
    }

    public void MarkDeclined(string? reason = null)
    {
        Status = PaymentStatus.Declined;
        ProcessorResponse = reason;
    }

    public void MarkRefunded()
    {
        Status = PaymentStatus.Refunded;
    }
}

public enum PaymentMethod
{
    Cash,
    CreditCard,
    DebitCard,
    GiftCard,
    StoreCredit,
    Check,
    Other
}

public enum PaymentStatus
{
    Pending,
    Approved,
    Declined,
    Refunded,
    Voided
}

Day 3-4: Cash Payment Handler

Claude Command:

/dev-team implement cash payment handler with change calculation

Implementation:

// src/PosPlatform.Core/Services/Payments/CashPaymentHandler.cs
namespace PosPlatform.Core.Services.Payments;

public interface ICashPaymentHandler
{
    CashPaymentResult ProcessPayment(decimal amountDue, decimal amountTendered);
    IReadOnlyList<CashDenomination> CalculateOptimalChange(decimal changeAmount);
}

public class CashPaymentHandler : ICashPaymentHandler
{
    private static readonly decimal[] Denominations =
    {
        100.00m, 50.00m, 20.00m, 10.00m, 5.00m, 2.00m, 1.00m,
        0.25m, 0.10m, 0.05m, 0.01m
    };

    public CashPaymentResult ProcessPayment(decimal amountDue, decimal amountTendered)
    {
        if (amountTendered < 0)
            throw new ArgumentException("Amount tendered cannot be negative");

        if (amountTendered < amountDue)
        {
            return new CashPaymentResult(
                false,
                amountTendered,
                0,
                amountDue - amountTendered,
                Array.Empty<CashDenomination>());
        }

        var change = amountTendered - amountDue;
        var changeDenominations = CalculateOptimalChange(change);

        return new CashPaymentResult(
            true,
            amountTendered,
            change,
            0,
            changeDenominations);
    }

    public IReadOnlyList<CashDenomination> CalculateOptimalChange(decimal changeAmount)
    {
        var result = new List<CashDenomination>();
        var remaining = changeAmount;

        foreach (var denom in Denominations)
        {
            if (remaining <= 0) break;

            var count = (int)(remaining / denom);
            if (count > 0)
            {
                result.Add(new CashDenomination(denom, count));
                remaining -= count * denom;
            }
        }

        // Handle any remaining due to floating point
        remaining = Math.Round(remaining, 2);
        if (remaining > 0)
        {
            result.Add(new CashDenomination(0.01m, (int)(remaining / 0.01m)));
        }

        return result;
    }
}

public record CashPaymentResult(
    bool IsFullPayment,
    decimal AmountTendered,
    decimal ChangeAmount,
    decimal RemainingBalance,
    IReadOnlyList<CashDenomination> ChangeDenominations);

public record CashDenomination(decimal Value, int Count)
{
    public decimal Total => Value * Count;
}

Week 9-10: Cash Drawer Operations

Day 1-2: Drawer Session Entity

Claude Command:

/dev-team create drawer session entity with state machine

Implementation:

// src/PosPlatform.Core/Entities/CashDrawer/DrawerSession.cs
namespace PosPlatform.Core.Entities.CashDrawer;

public class DrawerSession
{
    public Guid Id { get; private set; }
    public Guid LocationId { get; private set; }
    public Guid TerminalId { get; private set; }
    public Guid OpenedByUserId { get; private set; }
    public Guid? ClosedByUserId { get; private set; }

    public DrawerSessionStatus Status { get; private set; }

    public decimal OpeningBalance { get; private set; }
    public decimal ExpectedBalance { get; private set; }
    public decimal? CountedBalance { get; private set; }
    public decimal? Variance { get; private set; }

    public DateTime OpenedAt { get; private set; }
    public DateTime? ClosedAt { get; private set; }

    private readonly List<DrawerTransaction> _transactions = new();
    public IReadOnlyList<DrawerTransaction> Transactions => _transactions.AsReadOnly();

    private DrawerSession() { }

    public static DrawerSession Open(
        Guid locationId,
        Guid terminalId,
        Guid userId,
        decimal openingBalance)
    {
        return new DrawerSession
        {
            Id = Guid.NewGuid(),
            LocationId = locationId,
            TerminalId = terminalId,
            OpenedByUserId = userId,
            Status = DrawerSessionStatus.Open,
            OpeningBalance = openingBalance,
            ExpectedBalance = openingBalance,
            OpenedAt = DateTime.UtcNow
        };
    }

    public void RecordCashSale(decimal amount, string saleReference)
    {
        EnsureOpen();
        var txn = DrawerTransaction.CashIn(Id, amount, saleReference);
        _transactions.Add(txn);
        ExpectedBalance += amount;
    }

    public void RecordCashRefund(decimal amount, string refundReference)
    {
        EnsureOpen();
        var txn = DrawerTransaction.CashOut(Id, amount, refundReference, "Cash Refund");
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void RecordPaidOut(decimal amount, string description, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.PaidOut(Id, amount, description, userId);
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void RecordPaidIn(decimal amount, string description, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.PaidIn(Id, amount, description, userId);
        _transactions.Add(txn);
        ExpectedBalance += amount;
    }

    public void RecordPickup(decimal amount, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.Pickup(Id, amount, userId);
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void SubmitBlindCount(decimal countedAmount, Guid userId)
    {
        EnsureOpen();
        CountedBalance = countedAmount;
        Variance = countedAmount - ExpectedBalance;
        Status = DrawerSessionStatus.Counted;
        ClosedByUserId = userId;
    }

    public void Close(Guid userId)
    {
        if (Status == DrawerSessionStatus.Open)
            throw new InvalidOperationException("Must submit count before closing");

        if (Status == DrawerSessionStatus.Closed)
            throw new InvalidOperationException("Drawer already closed");

        Status = DrawerSessionStatus.Closed;
        ClosedByUserId = userId;
        ClosedAt = DateTime.UtcNow;
    }

    public decimal TotalCashIn => _transactions
        .Where(t => t.Type == DrawerTransactionType.CashIn || t.Type == DrawerTransactionType.PaidIn)
        .Sum(t => t.Amount);

    public decimal TotalCashOut => _transactions
        .Where(t => t.Type == DrawerTransactionType.CashOut ||
                    t.Type == DrawerTransactionType.PaidOut ||
                    t.Type == DrawerTransactionType.Pickup)
        .Sum(t => t.Amount);

    private void EnsureOpen()
    {
        if (Status != DrawerSessionStatus.Open)
            throw new InvalidOperationException("Drawer is not open");
    }
}

public enum DrawerSessionStatus
{
    Open,
    Counted,
    Closed
}
// src/PosPlatform.Core/Entities/CashDrawer/DrawerTransaction.cs
namespace PosPlatform.Core.Entities.CashDrawer;

public class DrawerTransaction
{
    public Guid Id { get; private set; }
    public Guid SessionId { get; private set; }
    public DrawerTransactionType Type { get; private set; }
    public decimal Amount { get; private set; }
    public string Reference { get; private set; } = string.Empty;
    public string? Description { get; private set; }
    public Guid? UserId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private DrawerTransaction() { }

    public static DrawerTransaction CashIn(Guid sessionId, decimal amount, string reference)
        => Create(sessionId, DrawerTransactionType.CashIn, amount, reference);

    public static DrawerTransaction CashOut(Guid sessionId, decimal amount, string reference, string description)
        => Create(sessionId, DrawerTransactionType.CashOut, amount, reference, description);

    public static DrawerTransaction PaidOut(Guid sessionId, decimal amount, string description, Guid userId)
        => Create(sessionId, DrawerTransactionType.PaidOut, amount, Guid.NewGuid().ToString(), description, userId);

    public static DrawerTransaction PaidIn(Guid sessionId, decimal amount, string description, Guid userId)
        => Create(sessionId, DrawerTransactionType.PaidIn, amount, Guid.NewGuid().ToString(), description, userId);

    public static DrawerTransaction Pickup(Guid sessionId, decimal amount, Guid userId)
        => Create(sessionId, DrawerTransactionType.Pickup, amount, $"PICKUP-{DateTime.UtcNow:yyyyMMddHHmmss}", "Cash Pickup", userId);

    private static DrawerTransaction Create(
        Guid sessionId,
        DrawerTransactionType type,
        decimal amount,
        string reference,
        string? description = null,
        Guid? userId = null)
    {
        return new DrawerTransaction
        {
            Id = Guid.NewGuid(),
            SessionId = sessionId,
            Type = type,
            Amount = Math.Abs(amount),
            Reference = reference,
            Description = description,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };
    }
}

public enum DrawerTransactionType
{
    CashIn,      // Cash received from sale
    CashOut,     // Cash returned (refund, change)
    PaidIn,      // Manual cash deposit
    PaidOut,     // Manual cash withdrawal
    Pickup       // Cash pickup during shift
}

Testing Checkpoints

Week 6 Checkpoint: Inventory

# Run inventory tests
dotnet test --filter "FullyQualifiedName~Inventory"

# Manual verification
curl -X POST http://localhost:5100/api/inventory/receive \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Code: DEMO" \
  -d '{"sku": "TEST-001", "quantity": 100, "reference": "PO-001"}'

Week 7 Checkpoint: Sales

# Create and complete a sale
curl -X POST http://localhost:5100/api/sales \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Code: DEMO" \
  -d '{"locationId": "...", "cashierId": "..."}'

# Add item
curl -X POST http://localhost:5100/api/sales/{saleId}/items \
  -d '{"sku": "TEST-001", "quantity": 1}'

# Add payment
curl -X POST http://localhost:5100/api/sales/{saleId}/payments \
  -d '{"method": "Cash", "amount": 50.00}'

Next Steps

Proceed to Chapter 27: Phase 3 - Support Implementation for:

  • Customer domain with loyalty
  • Offline sync infrastructure
  • RFID module (optional)

Chapter 26 Complete - Phase 2 Core Implementation

Chapter 27: Phase 3 - Support Implementation

Overview

Phase 3 adds support capabilities that enhance the core POS system: customer management with loyalty programs, offline operation support, and optional RFID integration. This 4-week phase (Weeks 11-14) builds features that differentiate the platform.


Week 11-12: Customer Domain with Loyalty

Day 1-2: Customer Entity

Objective: Create customer entity with profile and contact information.

Claude Command:

/dev-team create customer entity with contact information and profile

Implementation:

// src/PosPlatform.Core/Entities/Customers/Customer.cs
namespace PosPlatform.Core.Entities.Customers;

public class Customer
{
    public Guid Id { get; private set; }
    public string? CustomerNumber { get; private set; }
    public string FirstName { get; private set; } = string.Empty;
    public string LastName { get; private set; } = string.Empty;
    public string FullName => $"{FirstName} {LastName}".Trim();

    // Contact info
    public string? Email { get; private set; }
    public string? Phone { get; private set; }
    public CustomerAddress? Address { get; private set; }

    // Loyalty
    public string? LoyaltyId { get; private set; }
    public int LoyaltyPoints { get; private set; }
    public CustomerTier Tier { get; private set; }

    // Marketing
    public bool EmailOptIn { get; private set; }
    public bool SmsOptIn { get; private set; }

    // Stats
    public int TotalOrders { get; private set; }
    public decimal TotalSpent { get; private set; }
    public DateTime? LastPurchaseAt { get; private set; }

    // Metadata
    public bool IsActive { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private readonly List<CustomerNote> _notes = new();
    public IReadOnlyList<CustomerNote> Notes => _notes.AsReadOnly();

    private Customer() { }

    public static Customer Create(
        string firstName,
        string lastName,
        string? email = null,
        string? phone = null)
    {
        var customer = new Customer
        {
            Id = Guid.NewGuid(),
            CustomerNumber = GenerateCustomerNumber(),
            FirstName = firstName,
            LastName = lastName,
            Email = email?.ToLowerInvariant(),
            Phone = NormalizePhone(phone),
            Tier = CustomerTier.Bronze,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };

        // Auto-generate loyalty ID
        customer.LoyaltyId = GenerateLoyaltyId();

        return customer;
    }

    public void UpdateContact(string? email, string? phone)
    {
        Email = email?.ToLowerInvariant();
        Phone = NormalizePhone(phone);
        UpdatedAt = DateTime.UtcNow;
    }

    public void UpdateAddress(CustomerAddress address)
    {
        Address = address;
        UpdatedAt = DateTime.UtcNow;
    }

    public void SetMarketingPreferences(bool emailOptIn, bool smsOptIn)
    {
        EmailOptIn = emailOptIn;
        SmsOptIn = smsOptIn;
        UpdatedAt = DateTime.UtcNow;
    }

    public void RecordPurchase(decimal amount, int pointsEarned)
    {
        TotalOrders++;
        TotalSpent += amount;
        LoyaltyPoints += pointsEarned;
        LastPurchaseAt = DateTime.UtcNow;

        // Update tier based on total spent
        Tier = TotalSpent switch
        {
            >= 10000 => CustomerTier.Platinum,
            >= 5000 => CustomerTier.Gold,
            >= 1000 => CustomerTier.Silver,
            _ => CustomerTier.Bronze
        };

        UpdatedAt = DateTime.UtcNow;
    }

    public bool RedeemPoints(int points)
    {
        if (points > LoyaltyPoints)
            return false;

        LoyaltyPoints -= points;
        UpdatedAt = DateTime.UtcNow;
        return true;
    }

    public void AddNote(string content, Guid userId)
    {
        _notes.Add(new CustomerNote(Id, content, userId));
        UpdatedAt = DateTime.UtcNow;
    }

    public void Deactivate() => IsActive = false;
    public void Reactivate() => IsActive = true;

    private static string GenerateCustomerNumber()
        => $"C{DateTime.UtcNow:yyMMdd}{new Random().Next(1000, 9999)}";

    private static string GenerateLoyaltyId()
        => $"LYL{Guid.NewGuid().ToString()[..8].ToUpper()}";

    private static string? NormalizePhone(string? phone)
    {
        if (string.IsNullOrWhiteSpace(phone))
            return null;

        // Remove non-digits
        var digits = new string(phone.Where(char.IsDigit).ToArray());

        // Format as (XXX) XXX-XXXX for US numbers
        if (digits.Length == 10)
            return $"({digits[..3]}) {digits[3..6]}-{digits[6..]}";

        if (digits.Length == 11 && digits[0] == '1')
            return $"({digits[1..4]}) {digits[4..7]}-{digits[7..]}";

        return digits;
    }
}

public class CustomerAddress
{
    public string Street1 { get; set; } = string.Empty;
    public string? Street2 { get; set; }
    public string City { get; set; } = string.Empty;
    public string State { get; set; } = string.Empty;
    public string PostalCode { get; set; } = string.Empty;
    public string Country { get; set; } = "US";
}

public class CustomerNote
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public string Content { get; private set; }
    public Guid CreatedByUserId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    public CustomerNote(Guid customerId, string content, Guid userId)
    {
        Id = Guid.NewGuid();
        CustomerId = customerId;
        Content = content;
        CreatedByUserId = userId;
        CreatedAt = DateTime.UtcNow;
    }
}

public enum CustomerTier
{
    Bronze,
    Silver,
    Gold,
    Platinum
}

Day 3-4: Customer Lookup Service

Objective: Fast customer lookup by multiple identifiers.

Claude Command:

/dev-team implement customer lookup by phone, email, and loyalty ID

Implementation:

// src/PosPlatform.Core/Interfaces/ICustomerRepository.cs
using PosPlatform.Core.Entities.Customers;

namespace PosPlatform.Core.Interfaces;

public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default);
    Task<Customer?> GetByPhoneAsync(string phone, CancellationToken ct = default);
    Task<Customer?> GetByLoyaltyIdAsync(string loyaltyId, CancellationToken ct = default);
    Task<Customer?> GetByCustomerNumberAsync(string customerNumber, CancellationToken ct = default);

    Task<IReadOnlyList<Customer>> SearchAsync(
        string searchTerm,
        int limit = 20,
        CancellationToken ct = default);

    Task<Customer> AddAsync(Customer customer, CancellationToken ct = default);
    Task UpdateAsync(Customer customer, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/Repositories/CustomerRepository.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Infrastructure.Repositories;

public class CustomerRepository : ICustomerRepository
{
    private readonly TenantDbContext _context;

    public CustomerRepository(TenantDbContext context)
    {
        _context = context;
    }

    public async Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Customers
            .Include(c => c.Notes)
            .FirstOrDefaultAsync(c => c.Id == id, ct);

    public async Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default)
        => await _context.Customers
            .FirstOrDefaultAsync(c => c.Email == email.ToLowerInvariant(), ct);

    public async Task<Customer?> GetByPhoneAsync(string phone, CancellationToken ct = default)
    {
        // Normalize phone for comparison
        var normalizedPhone = NormalizePhoneForSearch(phone);

        return await _context.Customers
            .FirstOrDefaultAsync(c => c.Phone != null &&
                EF.Functions.Like(c.Phone, $"%{normalizedPhone}%"), ct);
    }

    public async Task<Customer?> GetByLoyaltyIdAsync(string loyaltyId, CancellationToken ct = default)
        => await _context.Customers
            .FirstOrDefaultAsync(c => c.LoyaltyId == loyaltyId.ToUpperInvariant(), ct);

    public async Task<Customer?> GetByCustomerNumberAsync(string customerNumber, CancellationToken ct = default)
        => await _context.Customers
            .FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber, ct);

    public async Task<IReadOnlyList<Customer>> SearchAsync(
        string searchTerm,
        int limit = 20,
        CancellationToken ct = default)
    {
        var term = searchTerm.ToLowerInvariant();

        return await _context.Customers
            .Where(c => c.IsActive &&
                (c.FirstName.ToLower().Contains(term) ||
                 c.LastName.ToLower().Contains(term) ||
                 (c.Email != null && c.Email.Contains(term)) ||
                 (c.Phone != null && c.Phone.Contains(term)) ||
                 (c.LoyaltyId != null && c.LoyaltyId.Contains(term.ToUpper())) ||
                 (c.CustomerNumber != null && c.CustomerNumber.Contains(term))))
            .OrderBy(c => c.LastName)
            .ThenBy(c => c.FirstName)
            .Take(limit)
            .ToListAsync(ct);
    }

    public async Task<Customer> AddAsync(Customer customer, CancellationToken ct = default)
    {
        await _context.Customers.AddAsync(customer, ct);
        await _context.SaveChangesAsync(ct);
        return customer;
    }

    public async Task UpdateAsync(Customer customer, CancellationToken ct = default)
    {
        _context.Customers.Update(customer);
        await _context.SaveChangesAsync(ct);
    }

    private static string NormalizePhoneForSearch(string phone)
        => new string(phone.Where(char.IsDigit).ToArray());
}
// src/PosPlatform.Core/Services/CustomerLookupService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ICustomerLookupService
{
    Task<Customer?> LookupAsync(string identifier, CancellationToken ct = default);
    Task<IReadOnlyList<Customer>> QuickSearchAsync(string term, CancellationToken ct = default);
}

public class CustomerLookupService : ICustomerLookupService
{
    private readonly ICustomerRepository _repository;

    public CustomerLookupService(ICustomerRepository repository)
    {
        _repository = repository;
    }

    public async Task<Customer?> LookupAsync(string identifier, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(identifier))
            return null;

        identifier = identifier.Trim();

        // Try loyalty ID first (fast, unique)
        if (identifier.StartsWith("LYL", StringComparison.OrdinalIgnoreCase))
        {
            return await _repository.GetByLoyaltyIdAsync(identifier, ct);
        }

        // Try customer number
        if (identifier.StartsWith("C", StringComparison.OrdinalIgnoreCase) &&
            identifier.Length == 11)
        {
            return await _repository.GetByCustomerNumberAsync(identifier, ct);
        }

        // Try email
        if (identifier.Contains('@'))
        {
            return await _repository.GetByEmailAsync(identifier, ct);
        }

        // Try phone (if mostly digits)
        var digits = identifier.Count(char.IsDigit);
        if (digits >= 7)
        {
            return await _repository.GetByPhoneAsync(identifier, ct);
        }

        // Fall back to search
        var results = await _repository.SearchAsync(identifier, 1, ct);
        return results.FirstOrDefault();
    }

    public async Task<IReadOnlyList<Customer>> QuickSearchAsync(
        string term,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
            return Array.Empty<Customer>();

        return await _repository.SearchAsync(term, 10, ct);
    }
}

Day 5-6: Purchase History

Objective: Track and query customer purchase history.

Claude Command:

/dev-team create purchase history tracking and queries

Implementation:

// src/PosPlatform.Core/Entities/Customers/CustomerPurchase.cs
namespace PosPlatform.Core.Entities.Customers;

public class CustomerPurchase
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public Guid SaleId { get; private set; }
    public string SaleNumber { get; private set; } = string.Empty;
    public Guid LocationId { get; private set; }
    public decimal TotalAmount { get; private set; }
    public int ItemCount { get; private set; }
    public int PointsEarned { get; private set; }
    public int PointsRedeemed { get; private set; }
    public DateTime PurchasedAt { get; private set; }

    private readonly List<CustomerPurchaseItem> _items = new();
    public IReadOnlyList<CustomerPurchaseItem> Items => _items.AsReadOnly();

    private CustomerPurchase() { }

    public static CustomerPurchase Create(
        Guid customerId,
        Guid saleId,
        string saleNumber,
        Guid locationId,
        decimal totalAmount,
        int pointsEarned,
        int pointsRedeemed,
        IEnumerable<CustomerPurchaseItem> items)
    {
        var purchase = new CustomerPurchase
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            SaleId = saleId,
            SaleNumber = saleNumber,
            LocationId = locationId,
            TotalAmount = totalAmount,
            PointsEarned = pointsEarned,
            PointsRedeemed = pointsRedeemed,
            PurchasedAt = DateTime.UtcNow
        };

        foreach (var item in items)
        {
            purchase._items.Add(item);
        }

        purchase.ItemCount = purchase._items.Sum(i => i.Quantity);

        return purchase;
    }
}

public class CustomerPurchaseItem
{
    public Guid Id { get; set; }
    public Guid PurchaseId { get; set; }
    public string Sku { get; set; } = string.Empty;
    public string ProductName { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal TotalPrice { get; set; }
}
// src/PosPlatform.Core/Services/PurchaseHistoryService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Entities.Sales;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface IPurchaseHistoryService
{
    Task RecordPurchaseAsync(Guid customerId, Sale sale, CancellationToken ct = default);
    Task<IReadOnlyList<CustomerPurchase>> GetHistoryAsync(
        Guid customerId,
        DateTime? fromDate = null,
        DateTime? toDate = null,
        int limit = 50,
        CancellationToken ct = default);
    Task<CustomerPurchaseStats> GetStatsAsync(Guid customerId, CancellationToken ct = default);
}

public class PurchaseHistoryService : IPurchaseHistoryService
{
    private readonly IPurchaseHistoryRepository _repository;
    private readonly ICustomerRepository _customerRepository;
    private readonly ILoyaltyService _loyaltyService;

    public PurchaseHistoryService(
        IPurchaseHistoryRepository repository,
        ICustomerRepository customerRepository,
        ILoyaltyService loyaltyService)
    {
        _repository = repository;
        _customerRepository = customerRepository;
        _loyaltyService = loyaltyService;
    }

    public async Task RecordPurchaseAsync(
        Guid customerId,
        Sale sale,
        CancellationToken ct = default)
    {
        var customer = await _customerRepository.GetByIdAsync(customerId, ct)
            ?? throw new InvalidOperationException("Customer not found");

        var pointsEarned = _loyaltyService.CalculatePoints(sale.Total, customer.Tier);

        var items = sale.Items.Select(i => new CustomerPurchaseItem
        {
            Id = Guid.NewGuid(),
            Sku = i.Sku,
            ProductName = i.Name,
            Quantity = i.Quantity,
            UnitPrice = i.UnitPrice,
            TotalPrice = i.ExtendedPrice
        });

        var purchase = CustomerPurchase.Create(
            customerId,
            sale.Id,
            sale.SaleNumber,
            sale.LocationId,
            sale.Total,
            pointsEarned,
            0, // Points redeemed tracked separately
            items);

        await _repository.AddAsync(purchase, ct);

        customer.RecordPurchase(sale.Total, pointsEarned);
        await _customerRepository.UpdateAsync(customer, ct);
    }

    public async Task<IReadOnlyList<CustomerPurchase>> GetHistoryAsync(
        Guid customerId,
        DateTime? fromDate = null,
        DateTime? toDate = null,
        int limit = 50,
        CancellationToken ct = default)
    {
        return await _repository.GetByCustomerAsync(customerId, fromDate, toDate, limit, ct);
    }

    public async Task<CustomerPurchaseStats> GetStatsAsync(
        Guid customerId,
        CancellationToken ct = default)
    {
        return await _repository.GetStatsAsync(customerId, ct);
    }
}

public record CustomerPurchaseStats(
    int TotalOrders,
    decimal TotalSpent,
    decimal AverageOrderValue,
    int TotalItems,
    string? TopCategory,
    string? TopProduct,
    DateTime? FirstPurchase,
    DateTime? LastPurchase);

Day 7-8: Loyalty Points System

Objective: Implement point earning and redemption.

Claude Command:

/dev-team implement loyalty points earning and redemption system

Implementation:

// src/PosPlatform.Core/Services/LoyaltyService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ILoyaltyService
{
    int CalculatePoints(decimal purchaseAmount, CustomerTier tier);
    decimal CalculateRedemptionValue(int points);
    Task<PointsRedemptionResult> RedeemPointsAsync(
        Guid customerId,
        int points,
        Guid saleId,
        CancellationToken ct = default);
    LoyaltyTierBenefits GetTierBenefits(CustomerTier tier);
}

public class LoyaltyService : ILoyaltyService
{
    private readonly ICustomerRepository _customerRepository;
    private readonly ILoyaltyTransactionRepository _transactionRepository;

    // Configuration (in production, load from settings)
    private const decimal BasePointsPerDollar = 1.0m;
    private const decimal PointValue = 0.01m; // Each point = $0.01

    private static readonly Dictionary<CustomerTier, decimal> TierMultipliers = new()
    {
        { CustomerTier.Bronze, 1.0m },
        { CustomerTier.Silver, 1.25m },
        { CustomerTier.Gold, 1.5m },
        { CustomerTier.Platinum, 2.0m }
    };

    public LoyaltyService(
        ICustomerRepository customerRepository,
        ILoyaltyTransactionRepository transactionRepository)
    {
        _customerRepository = customerRepository;
        _transactionRepository = transactionRepository;
    }

    public int CalculatePoints(decimal purchaseAmount, CustomerTier tier)
    {
        var multiplier = TierMultipliers.GetValueOrDefault(tier, 1.0m);
        var points = purchaseAmount * BasePointsPerDollar * multiplier;
        return (int)Math.Floor(points);
    }

    public decimal CalculateRedemptionValue(int points)
    {
        return points * PointValue;
    }

    public async Task<PointsRedemptionResult> RedeemPointsAsync(
        Guid customerId,
        int points,
        Guid saleId,
        CancellationToken ct = default)
    {
        var customer = await _customerRepository.GetByIdAsync(customerId, ct)
            ?? throw new InvalidOperationException("Customer not found");

        if (points > customer.LoyaltyPoints)
        {
            return PointsRedemptionResult.Failed(
                $"Insufficient points. Available: {customer.LoyaltyPoints}");
        }

        var value = CalculateRedemptionValue(points);

        if (!customer.RedeemPoints(points))
        {
            return PointsRedemptionResult.Failed("Failed to redeem points");
        }

        await _customerRepository.UpdateAsync(customer, ct);

        // Record transaction
        var transaction = new LoyaltyTransaction
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            Type = LoyaltyTransactionType.Redemption,
            Points = -points,
            BalanceAfter = customer.LoyaltyPoints,
            Reference = saleId.ToString(),
            CreatedAt = DateTime.UtcNow
        };

        await _transactionRepository.AddAsync(transaction, ct);

        return PointsRedemptionResult.Success(points, value);
    }

    public LoyaltyTierBenefits GetTierBenefits(CustomerTier tier)
    {
        return tier switch
        {
            CustomerTier.Bronze => new LoyaltyTierBenefits(
                "Bronze", 1.0m, 0, false, false),
            CustomerTier.Silver => new LoyaltyTierBenefits(
                "Silver", 1.25m, 5, true, false),
            CustomerTier.Gold => new LoyaltyTierBenefits(
                "Gold", 1.5m, 10, true, true),
            CustomerTier.Platinum => new LoyaltyTierBenefits(
                "Platinum", 2.0m, 15, true, true),
            _ => new LoyaltyTierBenefits("Unknown", 1.0m, 0, false, false)
        };
    }
}

public record PointsRedemptionResult(
    bool IsSuccess,
    int PointsRedeemed,
    decimal DiscountValue,
    string? ErrorMessage)
{
    public static PointsRedemptionResult Success(int points, decimal value)
        => new(true, points, value, null);

    public static PointsRedemptionResult Failed(string error)
        => new(false, 0, 0, error);
}

public record LoyaltyTierBenefits(
    string TierName,
    decimal PointsMultiplier,
    int DiscountPercentage,
    bool FreeShipping,
    bool EarlyAccess);

public class LoyaltyTransaction
{
    public Guid Id { get; set; }
    public Guid CustomerId { get; set; }
    public LoyaltyTransactionType Type { get; set; }
    public int Points { get; set; }
    public int BalanceAfter { get; set; }
    public string Reference { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

public enum LoyaltyTransactionType
{
    Earn,
    Redemption,
    Adjustment,
    Expiration
}

Day 9-10: Customer API

Claude Command:

/dev-team create customer API endpoints with search and CRUD

Implementation:

// src/PosPlatform.Api/Controllers/CustomersController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
using PosPlatform.Core.Services;

namespace PosPlatform.Api.Controllers;

[ApiController]
[Route("api/customers")]
[Authorize]
public class CustomersController : ControllerBase
{
    private readonly ICustomerRepository _repository;
    private readonly ICustomerLookupService _lookupService;
    private readonly IPurchaseHistoryService _historyService;
    private readonly ILoyaltyService _loyaltyService;

    public CustomersController(
        ICustomerRepository repository,
        ICustomerLookupService lookupService,
        IPurchaseHistoryService historyService,
        ILoyaltyService loyaltyService)
    {
        _repository = repository;
        _lookupService = lookupService;
        _historyService = historyService;
        _loyaltyService = loyaltyService;
    }

    [HttpGet("search")]
    public async Task<ActionResult<IEnumerable<CustomerSummaryDto>>> Search(
        [FromQuery] string q,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(q))
            return BadRequest("Search term required");

        var customers = await _lookupService.QuickSearchAsync(q, ct);

        return Ok(customers.Select(CustomerSummaryDto.FromEntity));
    }

    [HttpGet("lookup")]
    public async Task<ActionResult<CustomerDto>> Lookup(
        [FromQuery] string identifier,
        CancellationToken ct)
    {
        var customer = await _lookupService.LookupAsync(identifier, ct);

        if (customer == null)
            return NotFound();

        var benefits = _loyaltyService.GetTierBenefits(customer.Tier);
        return Ok(CustomerDto.FromEntity(customer, benefits));
    }

    [HttpGet("{id:guid}")]
    public async Task<ActionResult<CustomerDto>> GetById(Guid id, CancellationToken ct)
    {
        var customer = await _repository.GetByIdAsync(id, ct);

        if (customer == null)
            return NotFound();

        var benefits = _loyaltyService.GetTierBenefits(customer.Tier);
        return Ok(CustomerDto.FromEntity(customer, benefits));
    }

    [HttpPost]
    public async Task<ActionResult<CustomerDto>> Create(
        [FromBody] CreateCustomerRequest request,
        CancellationToken ct)
    {
        // Check for existing customer
        if (!string.IsNullOrEmpty(request.Email))
        {
            var existing = await _repository.GetByEmailAsync(request.Email, ct);
            if (existing != null)
                return Conflict(new { error = "Email already registered" });
        }

        var customer = Customer.Create(
            request.FirstName,
            request.LastName,
            request.Email,
            request.Phone);

        if (request.Address != null)
            customer.UpdateAddress(request.Address);

        customer.SetMarketingPreferences(
            request.EmailOptIn ?? false,
            request.SmsOptIn ?? false);

        await _repository.AddAsync(customer, ct);

        var benefits = _loyaltyService.GetTierBenefits(customer.Tier);

        return CreatedAtAction(
            nameof(GetById),
            new { id = customer.Id },
            CustomerDto.FromEntity(customer, benefits));
    }

    [HttpPut("{id:guid}")]
    public async Task<IActionResult> Update(
        Guid id,
        [FromBody] UpdateCustomerRequest request,
        CancellationToken ct)
    {
        var customer = await _repository.GetByIdAsync(id, ct);
        if (customer == null)
            return NotFound();

        if (request.Email != null || request.Phone != null)
            customer.UpdateContact(request.Email ?? customer.Email, request.Phone ?? customer.Phone);

        if (request.Address != null)
            customer.UpdateAddress(request.Address);

        if (request.EmailOptIn.HasValue || request.SmsOptIn.HasValue)
            customer.SetMarketingPreferences(
                request.EmailOptIn ?? customer.EmailOptIn,
                request.SmsOptIn ?? customer.SmsOptIn);

        await _repository.UpdateAsync(customer, ct);

        return NoContent();
    }

    [HttpGet("{id:guid}/purchases")]
    public async Task<ActionResult<IEnumerable<PurchaseDto>>> GetPurchases(
        Guid id,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] int limit = 50,
        CancellationToken ct)
    {
        var purchases = await _historyService.GetHistoryAsync(id, from, to, limit, ct);
        return Ok(purchases.Select(PurchaseDto.FromEntity));
    }

    [HttpGet("{id:guid}/stats")]
    public async Task<ActionResult<CustomerPurchaseStats>> GetStats(
        Guid id,
        CancellationToken ct)
    {
        var stats = await _historyService.GetStatsAsync(id, ct);
        return Ok(stats);
    }

    [HttpPost("{id:guid}/redeem-points")]
    public async Task<ActionResult<PointsRedemptionResult>> RedeemPoints(
        Guid id,
        [FromBody] RedeemPointsRequest request,
        CancellationToken ct)
    {
        var result = await _loyaltyService.RedeemPointsAsync(
            id, request.Points, request.SaleId, ct);

        if (!result.IsSuccess)
            return BadRequest(result);

        return Ok(result);
    }
}

// DTOs
public record CreateCustomerRequest(
    string FirstName,
    string LastName,
    string? Email,
    string? Phone,
    CustomerAddress? Address,
    bool? EmailOptIn,
    bool? SmsOptIn);

public record UpdateCustomerRequest(
    string? Email,
    string? Phone,
    CustomerAddress? Address,
    bool? EmailOptIn,
    bool? SmsOptIn);

public record RedeemPointsRequest(int Points, Guid SaleId);

public record CustomerSummaryDto(
    Guid Id,
    string FullName,
    string? Email,
    string? Phone,
    string? LoyaltyId,
    int LoyaltyPoints,
    string Tier);

public record CustomerDto(
    Guid Id,
    string CustomerNumber,
    string FirstName,
    string LastName,
    string FullName,
    string? Email,
    string? Phone,
    CustomerAddress? Address,
    string? LoyaltyId,
    int LoyaltyPoints,
    string Tier,
    LoyaltyTierBenefits TierBenefits,
    int TotalOrders,
    decimal TotalSpent,
    DateTime? LastPurchaseAt)
{
    public static CustomerDto FromEntity(Customer c, LoyaltyTierBenefits benefits) => new(
        c.Id, c.CustomerNumber ?? "", c.FirstName, c.LastName, c.FullName,
        c.Email, c.Phone, c.Address, c.LoyaltyId, c.LoyaltyPoints,
        c.Tier.ToString(), benefits, c.TotalOrders, c.TotalSpent, c.LastPurchaseAt);
}

public record PurchaseDto(
    Guid Id,
    string SaleNumber,
    decimal TotalAmount,
    int ItemCount,
    int PointsEarned,
    DateTime PurchasedAt);

Week 12-13: Offline Sync Infrastructure

Day 1-2: Local SQLite Storage

Objective: Implement local storage for offline operation.

Claude Command:

/dev-team implement local SQLite storage for offline mode

Implementation:

// src/PosPlatform.Core/Offline/OfflineStorage.cs
using Microsoft.Data.Sqlite;
using System.Text.Json;

namespace PosPlatform.Core.Offline;

public interface IOfflineStorage
{
    Task InitializeAsync(CancellationToken ct = default);
    Task StoreTransactionAsync(OfflineTransaction transaction, CancellationToken ct = default);
    Task<IReadOnlyList<OfflineTransaction>> GetPendingTransactionsAsync(CancellationToken ct = default);
    Task MarkSyncedAsync(Guid transactionId, CancellationToken ct = default);
    Task DeleteSyncedAsync(CancellationToken ct = default);
}

public class SqliteOfflineStorage : IOfflineStorage
{
    private readonly string _connectionString;

    public SqliteOfflineStorage(string databasePath)
    {
        _connectionString = $"Data Source={databasePath}";
    }

    public async Task InitializeAsync(CancellationToken ct = default)
    {
        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);

        var sql = @"
            CREATE TABLE IF NOT EXISTS offline_transactions (
                id TEXT PRIMARY KEY,
                transaction_type TEXT NOT NULL,
                payload TEXT NOT NULL,
                created_at TEXT NOT NULL,
                synced_at TEXT,
                retry_count INTEGER DEFAULT 0,
                last_error TEXT
            );

            CREATE INDEX IF NOT EXISTS idx_offline_synced
            ON offline_transactions(synced_at);
        ";

        await using var cmd = new SqliteCommand(sql, conn);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async Task StoreTransactionAsync(
        OfflineTransaction transaction,
        CancellationToken ct = default)
    {
        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);

        var sql = @"
            INSERT INTO offline_transactions (id, transaction_type, payload, created_at)
            VALUES (@id, @type, @payload, @created)
        ";

        await using var cmd = new SqliteCommand(sql, conn);
        cmd.Parameters.AddWithValue("@id", transaction.Id.ToString());
        cmd.Parameters.AddWithValue("@type", transaction.Type.ToString());
        cmd.Parameters.AddWithValue("@payload", transaction.PayloadJson);
        cmd.Parameters.AddWithValue("@created", transaction.CreatedAt.ToString("O"));

        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async Task<IReadOnlyList<OfflineTransaction>> GetPendingTransactionsAsync(
        CancellationToken ct = default)
    {
        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);

        var sql = @"
            SELECT id, transaction_type, payload, created_at, retry_count, last_error
            FROM offline_transactions
            WHERE synced_at IS NULL
            ORDER BY created_at ASC
        ";

        await using var cmd = new SqliteCommand(sql, conn);
        await using var reader = await cmd.ExecuteReaderAsync(ct);

        var transactions = new List<OfflineTransaction>();

        while (await reader.ReadAsync(ct))
        {
            transactions.Add(new OfflineTransaction
            {
                Id = Guid.Parse(reader.GetString(0)),
                Type = Enum.Parse<OfflineTransactionType>(reader.GetString(1)),
                PayloadJson = reader.GetString(2),
                CreatedAt = DateTime.Parse(reader.GetString(3)),
                RetryCount = reader.GetInt32(4),
                LastError = reader.IsDBNull(5) ? null : reader.GetString(5)
            });
        }

        return transactions;
    }

    public async Task MarkSyncedAsync(Guid transactionId, CancellationToken ct = default)
    {
        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);

        var sql = "UPDATE offline_transactions SET synced_at = @synced WHERE id = @id";

        await using var cmd = new SqliteCommand(sql, conn);
        cmd.Parameters.AddWithValue("@id", transactionId.ToString());
        cmd.Parameters.AddWithValue("@synced", DateTime.UtcNow.ToString("O"));

        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async Task DeleteSyncedAsync(CancellationToken ct = default)
    {
        await using var conn = new SqliteConnection(_connectionString);
        await conn.OpenAsync(ct);

        var sql = "DELETE FROM offline_transactions WHERE synced_at IS NOT NULL";

        await using var cmd = new SqliteCommand(sql, conn);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

public class OfflineTransaction
{
    public Guid Id { get; set; }
    public OfflineTransactionType Type { get; set; }
    public string PayloadJson { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? SyncedAt { get; set; }
    public int RetryCount { get; set; }
    public string? LastError { get; set; }

    public T? GetPayload<T>() where T : class
    {
        return JsonSerializer.Deserialize<T>(PayloadJson);
    }
}

public enum OfflineTransactionType
{
    Sale,
    Payment,
    InventoryAdjustment,
    CustomerCreate,
    DrawerTransaction
}

Day 3-4: Offline Queue Service

Claude Command:

/dev-team create offline transaction queue service

Implementation:

// src/PosPlatform.Core/Offline/OfflineQueueService.cs
using System.Text.Json;

namespace PosPlatform.Core.Offline;

public interface IOfflineQueueService
{
    Task<bool> IsOnlineAsync(CancellationToken ct = default);
    Task EnqueueAsync<T>(OfflineTransactionType type, T payload, CancellationToken ct = default);
    Task<int> GetPendingCountAsync(CancellationToken ct = default);
    Task ProcessQueueAsync(CancellationToken ct = default);
}

public class OfflineQueueService : IOfflineQueueService
{
    private readonly IOfflineStorage _storage;
    private readonly IConnectivityService _connectivity;
    private readonly ISyncProcessor _syncProcessor;
    private readonly ILogger<OfflineQueueService> _logger;

    public OfflineQueueService(
        IOfflineStorage storage,
        IConnectivityService connectivity,
        ISyncProcessor syncProcessor,
        ILogger<OfflineQueueService> logger)
    {
        _storage = storage;
        _connectivity = connectivity;
        _syncProcessor = syncProcessor;
        _logger = logger;
    }

    public async Task<bool> IsOnlineAsync(CancellationToken ct = default)
    {
        return await _connectivity.CheckConnectionAsync(ct);
    }

    public async Task EnqueueAsync<T>(
        OfflineTransactionType type,
        T payload,
        CancellationToken ct = default)
    {
        var transaction = new OfflineTransaction
        {
            Id = Guid.NewGuid(),
            Type = type,
            PayloadJson = JsonSerializer.Serialize(payload),
            CreatedAt = DateTime.UtcNow
        };

        await _storage.StoreTransactionAsync(transaction, ct);

        _logger.LogInformation(
            "Transaction queued for offline sync: {Type} {Id}",
            type, transaction.Id);
    }

    public async Task<int> GetPendingCountAsync(CancellationToken ct = default)
    {
        var pending = await _storage.GetPendingTransactionsAsync(ct);
        return pending.Count;
    }

    public async Task ProcessQueueAsync(CancellationToken ct = default)
    {
        if (!await IsOnlineAsync(ct))
        {
            _logger.LogDebug("Cannot process queue - offline");
            return;
        }

        var pending = await _storage.GetPendingTransactionsAsync(ct);

        if (pending.Count == 0)
            return;

        _logger.LogInformation("Processing {Count} pending transactions", pending.Count);

        foreach (var transaction in pending)
        {
            try
            {
                await _syncProcessor.ProcessAsync(transaction, ct);
                await _storage.MarkSyncedAsync(transaction.Id, ct);

                _logger.LogInformation(
                    "Transaction synced: {Type} {Id}",
                    transaction.Type, transaction.Id);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to sync transaction {Id}. Retry count: {RetryCount}",
                    transaction.Id, transaction.RetryCount);

                // Will retry on next sync cycle
            }
        }

        // Clean up synced transactions older than 24 hours
        await _storage.DeleteSyncedAsync(ct);
    }
}

Day 5-6: Sync Protocol with Conflict Resolution

Claude Command:

/dev-team implement sync protocol with conflict resolution

Implementation:

// src/PosPlatform.Core/Offline/SyncProcessor.cs
namespace PosPlatform.Core.Offline;

public interface ISyncProcessor
{
    Task ProcessAsync(OfflineTransaction transaction, CancellationToken ct = default);
}

public class SyncProcessor : ISyncProcessor
{
    private readonly ISaleRepository _saleRepository;
    private readonly IInventoryService _inventoryService;
    private readonly ICustomerRepository _customerRepository;
    private readonly IConflictResolver _conflictResolver;
    private readonly ILogger<SyncProcessor> _logger;

    public SyncProcessor(
        ISaleRepository saleRepository,
        IInventoryService inventoryService,
        ICustomerRepository customerRepository,
        IConflictResolver conflictResolver,
        ILogger<SyncProcessor> logger)
    {
        _saleRepository = saleRepository;
        _inventoryService = inventoryService;
        _customerRepository = customerRepository;
        _conflictResolver = conflictResolver;
        _logger = logger;
    }

    public async Task ProcessAsync(
        OfflineTransaction transaction,
        CancellationToken ct = default)
    {
        switch (transaction.Type)
        {
            case OfflineTransactionType.Sale:
                await ProcessSaleAsync(transaction, ct);
                break;

            case OfflineTransactionType.InventoryAdjustment:
                await ProcessInventoryAsync(transaction, ct);
                break;

            case OfflineTransactionType.CustomerCreate:
                await ProcessCustomerAsync(transaction, ct);
                break;

            default:
                _logger.LogWarning("Unknown transaction type: {Type}", transaction.Type);
                break;
        }
    }

    private async Task ProcessSaleAsync(
        OfflineTransaction transaction,
        CancellationToken ct)
    {
        var payload = transaction.GetPayload<OfflineSalePayload>();
        if (payload == null) return;

        // Check if sale already exists (idempotency)
        var existing = await _saleRepository.GetByIdAsync(payload.SaleId, ct);
        if (existing != null)
        {
            _logger.LogInformation("Sale {Id} already synced, skipping", payload.SaleId);
            return;
        }

        // Validate inventory availability
        foreach (var item in payload.Items)
        {
            var available = await _inventoryService.GetAvailableAsync(
                item.Sku, payload.LocationId, ct);

            if (available < item.Quantity)
            {
                // Conflict: inventory no longer available
                var resolution = await _conflictResolver.ResolveInventoryConflictAsync(
                    item.Sku, item.Quantity, available, ct);

                if (resolution.Action == ConflictAction.Reject)
                {
                    throw new SyncConflictException(
                        $"Insufficient inventory for {item.Sku}");
                }

                // Adjust quantity if partial fulfillment allowed
                item.Quantity = resolution.AdjustedQuantity;
            }
        }

        // Create the sale
        await _saleRepository.AddFromOfflineAsync(payload, ct);
    }

    private async Task ProcessInventoryAsync(
        OfflineTransaction transaction,
        CancellationToken ct)
    {
        var payload = transaction.GetPayload<OfflineInventoryPayload>();
        if (payload == null) return;

        // Get current server state
        var currentQuantity = await _inventoryService.GetQuantityAsync(
            payload.Sku, payload.LocationId, ct);

        // Apply delta (relative adjustment)
        var newQuantity = currentQuantity + payload.QuantityDelta;

        if (newQuantity < 0)
        {
            // Last-write-wins for negative inventory
            _logger.LogWarning(
                "Inventory for {Sku} would go negative, clamping to 0", payload.Sku);
            newQuantity = 0;
        }

        await _inventoryService.SetQuantityAsync(
            payload.Sku, payload.LocationId, newQuantity, payload.Reason, payload.UserId, ct);
    }

    private async Task ProcessCustomerAsync(
        OfflineTransaction transaction,
        CancellationToken ct)
    {
        var payload = transaction.GetPayload<OfflineCustomerPayload>();
        if (payload == null) return;

        // Check for duplicate by email
        if (!string.IsNullOrEmpty(payload.Email))
        {
            var existing = await _customerRepository.GetByEmailAsync(payload.Email, ct);
            if (existing != null)
            {
                _logger.LogInformation(
                    "Customer with email {Email} already exists, merging",
                    payload.Email);

                // Merge: update existing customer
                existing.UpdateContact(payload.Email, payload.Phone);
                await _customerRepository.UpdateAsync(existing, ct);
                return;
            }
        }

        // Create new customer
        var customer = Customer.Create(
            payload.FirstName,
            payload.LastName,
            payload.Email,
            payload.Phone);

        await _customerRepository.AddAsync(customer, ct);
    }
}

public interface IConflictResolver
{
    Task<ConflictResolution> ResolveInventoryConflictAsync(
        string sku,
        int requested,
        int available,
        CancellationToken ct = default);
}

public class ConflictResolver : IConflictResolver
{
    public Task<ConflictResolution> ResolveInventoryConflictAsync(
        string sku,
        int requested,
        int available,
        CancellationToken ct = default)
    {
        // Strategy: Partial fulfillment if any stock available
        if (available > 0)
        {
            return Task.FromResult(new ConflictResolution(
                ConflictAction.AdjustAndContinue,
                available));
        }

        // No stock: reject the item
        return Task.FromResult(new ConflictResolution(
            ConflictAction.Reject,
            0));
    }
}

public record ConflictResolution(ConflictAction Action, int AdjustedQuantity);

public enum ConflictAction
{
    Continue,
    AdjustAndContinue,
    Reject
}

public class SyncConflictException : Exception
{
    public SyncConflictException(string message) : base(message) { }
}

// Payload models
public class OfflineSalePayload
{
    public Guid SaleId { get; set; }
    public Guid LocationId { get; set; }
    public Guid CashierId { get; set; }
    public List<OfflineSaleItem> Items { get; set; } = new();
    public List<OfflinePayment> Payments { get; set; } = new();
    public DateTime CreatedAt { get; set; }
}

public class OfflineSaleItem
{
    public string Sku { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

public class OfflinePayment
{
    public string Method { get; set; } = string.Empty;
    public decimal Amount { get; set; }
}

public class OfflineInventoryPayload
{
    public string Sku { get; set; } = string.Empty;
    public Guid LocationId { get; set; }
    public int QuantityDelta { get; set; }
    public string Reason { get; set; } = string.Empty;
    public Guid UserId { get; set; }
}

public class OfflineCustomerPayload
{
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? Email { get; set; }
    public string? Phone { get; set; }
}

Day 7-8: Connectivity Detection

Claude Command:

/dev-team create connectivity detection service

Implementation:

// src/PosPlatform.Core/Offline/ConnectivityService.cs
namespace PosPlatform.Core.Offline;

public interface IConnectivityService
{
    event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;
    bool IsOnline { get; }
    Task<bool> CheckConnectionAsync(CancellationToken ct = default);
    void StartMonitoring();
    void StopMonitoring();
}

public class ConnectivityService : IConnectivityService, IDisposable
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<ConnectivityService> _logger;
    private readonly string _healthCheckUrl;
    private readonly TimeSpan _checkInterval;

    private Timer? _timer;
    private bool _isOnline = true;

    public event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;

    public bool IsOnline => _isOnline;

    public ConnectivityService(
        HttpClient httpClient,
        IConfiguration configuration,
        ILogger<ConnectivityService> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
        _healthCheckUrl = configuration["Api:HealthCheckUrl"] ?? "/health";
        _checkInterval = TimeSpan.FromSeconds(
            configuration.GetValue<int>("Connectivity:CheckIntervalSeconds", 30));
    }

    public async Task<bool> CheckConnectionAsync(CancellationToken ct = default)
    {
        try
        {
            var response = await _httpClient.GetAsync(_healthCheckUrl, ct);
            var isOnline = response.IsSuccessStatusCode;

            if (isOnline != _isOnline)
            {
                var previousState = _isOnline;
                _isOnline = isOnline;

                _logger.LogInformation(
                    "Connectivity changed: {Previous} -> {Current}",
                    previousState ? "Online" : "Offline",
                    isOnline ? "Online" : "Offline");

                ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(isOnline));
            }

            return isOnline;
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Connectivity check failed");

            if (_isOnline)
            {
                _isOnline = false;
                ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(false));
            }

            return false;
        }
    }

    public void StartMonitoring()
    {
        _timer = new Timer(
            async _ => await CheckConnectionAsync(),
            null,
            TimeSpan.Zero,
            _checkInterval);

        _logger.LogInformation(
            "Connectivity monitoring started. Check interval: {Interval}s",
            _checkInterval.TotalSeconds);
    }

    public void StopMonitoring()
    {
        _timer?.Dispose();
        _timer = null;

        _logger.LogInformation("Connectivity monitoring stopped");
    }

    public void Dispose()
    {
        StopMonitoring();
    }
}

public class ConnectivityChangedEventArgs : EventArgs
{
    public bool IsOnline { get; }

    public ConnectivityChangedEventArgs(bool isOnline)
    {
        IsOnline = isOnline;
    }
}

Day 9-10: Background Sync Service

Claude Command:

/dev-team implement background sync with retry logic

Implementation:

// src/PosPlatform.Infrastructure/Services/BackgroundSyncService.cs
using Microsoft.Extensions.Hosting;

namespace PosPlatform.Infrastructure.Services;

public class BackgroundSyncService : BackgroundService
{
    private readonly IOfflineQueueService _queueService;
    private readonly IConnectivityService _connectivity;
    private readonly ILogger<BackgroundSyncService> _logger;
    private readonly TimeSpan _syncInterval;

    public BackgroundSyncService(
        IOfflineQueueService queueService,
        IConnectivityService connectivity,
        IConfiguration configuration,
        ILogger<BackgroundSyncService> logger)
    {
        _queueService = queueService;
        _connectivity = connectivity;
        _logger = logger;
        _syncInterval = TimeSpan.FromSeconds(
            configuration.GetValue<int>("Sync:IntervalSeconds", 60));
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("Background sync service started");

        // Subscribe to connectivity changes for immediate sync
        _connectivity.ConnectivityChanged += OnConnectivityChanged;
        _connectivity.StartMonitoring();

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await _queueService.ProcessQueueAsync(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error processing sync queue");
            }

            await Task.Delay(_syncInterval, stoppingToken);
        }

        _connectivity.StopMonitoring();
        _connectivity.ConnectivityChanged -= OnConnectivityChanged;

        _logger.LogInformation("Background sync service stopped");
    }

    private async void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
    {
        if (e.IsOnline)
        {
            _logger.LogInformation("Connection restored, triggering immediate sync");

            try
            {
                await _queueService.ProcessQueueAsync(CancellationToken.None);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error during immediate sync after reconnection");
            }
        }
    }
}

Week 13-14: RFID Module (Optional)

Day 1-2: RFID Reader Abstraction

Claude Command:

/dev-team create RFID reader abstraction interface

Implementation:

// src/PosPlatform.Core/RFID/IRfidReader.cs
namespace PosPlatform.Core.RFID;

public interface IRfidReader : IDisposable
{
    event EventHandler<TagReadEventArgs>? TagRead;
    event EventHandler<ReaderStatusEventArgs>? StatusChanged;

    string ReaderId { get; }
    ReaderStatus Status { get; }

    Task ConnectAsync(CancellationToken ct = default);
    Task DisconnectAsync(CancellationToken ct = default);
    Task StartInventoryAsync(CancellationToken ct = default);
    Task StopInventoryAsync(CancellationToken ct = default);
    Task<IReadOnlyList<RfidTag>> ReadTagsAsync(TimeSpan timeout, CancellationToken ct = default);
    Task<bool> WriteTagAsync(string epc, byte[] data, CancellationToken ct = default);
}

public class TagReadEventArgs : EventArgs
{
    public RfidTag Tag { get; }
    public DateTime ReadAt { get; }

    public TagReadEventArgs(RfidTag tag)
    {
        Tag = tag;
        ReadAt = DateTime.UtcNow;
    }
}

public class ReaderStatusEventArgs : EventArgs
{
    public ReaderStatus Status { get; }
    public string? Message { get; }

    public ReaderStatusEventArgs(ReaderStatus status, string? message = null)
    {
        Status = status;
        Message = message;
    }
}

public class RfidTag
{
    public string Epc { get; set; } = string.Empty;
    public string? Tid { get; set; }
    public int Rssi { get; set; }
    public int ReadCount { get; set; }
    public byte[]? UserData { get; set; }
    public DateTime FirstSeen { get; set; }
    public DateTime LastSeen { get; set; }

    // Parsed product info (if encoded)
    public string? Sku { get; set; }
    public string? SerialNumber { get; set; }
}

public enum ReaderStatus
{
    Disconnected,
    Connecting,
    Connected,
    Reading,
    Error
}

Day 3-4: Bulk Inventory Scanning

Claude Command:

/dev-team implement bulk inventory scanning with RFID

Implementation:

// src/PosPlatform.Core/RFID/RfidInventoryService.cs
namespace PosPlatform.Core.RFID;

public interface IRfidInventoryService
{
    Task<InventoryScanResult> ScanInventoryAsync(
        Guid locationId,
        TimeSpan scanDuration,
        CancellationToken ct = default);

    Task<InventoryComparisonResult> CompareWithSystemAsync(
        Guid locationId,
        IReadOnlyList<RfidTag> scannedTags,
        CancellationToken ct = default);
}

public class RfidInventoryService : IRfidInventoryService
{
    private readonly IRfidReader _reader;
    private readonly IInventoryRepository _inventoryRepository;
    private readonly IRfidTagDecoder _tagDecoder;
    private readonly ILogger<RfidInventoryService> _logger;

    public RfidInventoryService(
        IRfidReader reader,
        IInventoryRepository inventoryRepository,
        IRfidTagDecoder tagDecoder,
        ILogger<RfidInventoryService> logger)
    {
        _reader = reader;
        _inventoryRepository = inventoryRepository;
        _tagDecoder = tagDecoder;
        _logger = logger;
    }

    public async Task<InventoryScanResult> ScanInventoryAsync(
        Guid locationId,
        TimeSpan scanDuration,
        CancellationToken ct = default)
    {
        var startTime = DateTime.UtcNow;
        var allTags = new Dictionary<string, RfidTag>();

        _logger.LogInformation(
            "Starting RFID inventory scan at location {Location} for {Duration}s",
            locationId, scanDuration.TotalSeconds);

        await _reader.StartInventoryAsync(ct);

        var endTime = DateTime.UtcNow.Add(scanDuration);

        while (DateTime.UtcNow < endTime && !ct.IsCancellationRequested)
        {
            var tags = await _reader.ReadTagsAsync(TimeSpan.FromSeconds(1), ct);

            foreach (var tag in tags)
            {
                if (allTags.TryGetValue(tag.Epc, out var existing))
                {
                    existing.ReadCount += tag.ReadCount;
                    existing.LastSeen = tag.LastSeen;
                    existing.Rssi = Math.Max(existing.Rssi, tag.Rssi);
                }
                else
                {
                    // Decode SKU from tag
                    tag.Sku = await _tagDecoder.DecodeSkuAsync(tag.Epc, ct);
                    allTags[tag.Epc] = tag;
                }
            }
        }

        await _reader.StopInventoryAsync(ct);

        var elapsed = DateTime.UtcNow - startTime;

        _logger.LogInformation(
            "RFID scan complete. Found {Count} unique tags in {Elapsed}s",
            allTags.Count, elapsed.TotalSeconds);

        return new InventoryScanResult(
            locationId,
            allTags.Values.ToList(),
            startTime,
            elapsed);
    }

    public async Task<InventoryComparisonResult> CompareWithSystemAsync(
        Guid locationId,
        IReadOnlyList<RfidTag> scannedTags,
        CancellationToken ct = default)
    {
        // Group scanned tags by SKU
        var scannedBySku = scannedTags
            .Where(t => !string.IsNullOrEmpty(t.Sku))
            .GroupBy(t => t.Sku!)
            .ToDictionary(g => g.Key, g => g.Count());

        // Get system inventory
        var systemInventory = await _inventoryRepository
            .GetByLocationAsync(locationId, ct);

        var systemBySku = systemInventory
            .ToDictionary(i => i.Sku, i => i.QuantityOnHand);

        var discrepancies = new List<InventoryDiscrepancy>();

        // Find discrepancies
        var allSkus = scannedBySku.Keys.Union(systemBySku.Keys);

        foreach (var sku in allSkus)
        {
            var scanned = scannedBySku.GetValueOrDefault(sku, 0);
            var system = systemBySku.GetValueOrDefault(sku, 0);

            if (scanned != system)
            {
                discrepancies.Add(new InventoryDiscrepancy(
                    sku,
                    system,
                    scanned,
                    scanned - system));
            }
        }

        return new InventoryComparisonResult(
            locationId,
            scannedTags.Count,
            systemInventory.Sum(i => i.QuantityOnHand),
            discrepancies);
    }
}

public record InventoryScanResult(
    Guid LocationId,
    IReadOnlyList<RfidTag> Tags,
    DateTime StartedAt,
    TimeSpan Duration)
{
    public int TotalTags => Tags.Count;
    public int UniqueSkus => Tags.Where(t => t.Sku != null).Select(t => t.Sku).Distinct().Count();
}

public record InventoryComparisonResult(
    Guid LocationId,
    int ScannedCount,
    int SystemCount,
    IReadOnlyList<InventoryDiscrepancy> Discrepancies)
{
    public int MatchCount => ScannedCount - Discrepancies.Sum(d => Math.Abs(d.Variance));
    public decimal AccuracyPercent => SystemCount > 0
        ? (decimal)MatchCount / SystemCount * 100
        : 100;
}

public record InventoryDiscrepancy(
    string Sku,
    int SystemQuantity,
    int ScannedQuantity,
    int Variance);

Integration Testing

Customer Domain Tests

# Run customer tests
dotnet test --filter "FullyQualifiedName~Customer"

# Manual API test
curl -X POST http://localhost:5100/api/customers \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Code: DEMO" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "firstName": "John",
    "lastName": "Doe",
    "email": "john@example.com",
    "phone": "555-123-4567"
  }'

Offline Sync Tests

# Simulate offline mode
# 1. Create sale while "offline"
# 2. Check pending queue
# 3. Restore connection
# 4. Verify sync completed

dotnet test --filter "FullyQualifiedName~OfflineSync"

Performance Testing

Customer Lookup Performance

// tests/PosPlatform.Api.Tests/CustomerLookupPerformanceTests.cs
[Fact]
public async Task CustomerLookup_ShouldCompleteIn200ms()
{
    var stopwatch = Stopwatch.StartNew();

    var result = await _lookupService.LookupAsync("555-123-4567");

    stopwatch.Stop();

    Assert.NotNull(result);
    Assert.True(stopwatch.ElapsedMilliseconds < 200,
        $"Lookup took {stopwatch.ElapsedMilliseconds}ms, expected < 200ms");
}

Next Steps

Proceed to Chapter 28: Phase 4 - Production Implementation for:

  • Monitoring and alerting setup
  • Security hardening
  • Production deployment
  • Go-live procedures

Chapter 27 Complete - Phase 3 Support Implementation

Chapter 28: Phase 4 - Production Implementation

Overview

Phase 4 prepares the POS platform for production deployment. This 2-week phase (Weeks 15-16) covers monitoring, security hardening, deployment procedures, and go-live operations. Every step is critical for a successful production launch.


Week 15: Monitoring and Alerting

Day 1: Structured Logging with Serilog

Objective: Implement structured, queryable logging with correlation IDs.

Claude Command:

/dev-team implement structured logging with Serilog and correlation IDs

Implementation:

// Install packages
// dotnet add package Serilog.AspNetCore
// dotnet add package Serilog.Sinks.Console
// dotnet add package Serilog.Sinks.File
// dotnet add package Serilog.Enrichers.Environment
// dotnet add package Serilog.Enrichers.Thread

// src/PosPlatform.Api/Program.cs - Logging configuration
using Serilog;
using Serilog.Events;

var builder = WebApplication.CreateBuilder(args);

// Configure Serilog
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Information()
    .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
    .MinimumLevel.Override("Microsoft.EntityFrameworkCore", LogEventLevel.Warning)
    .Enrich.FromLogContext()
    .Enrich.WithEnvironmentName()
    .Enrich.WithMachineName()
    .Enrich.WithThreadId()
    .Enrich.WithProperty("Application", "PosPlatform.Api")
    .WriteTo.Console(
        outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {CorrelationId} {Message:lj}{NewLine}{Exception}")
    .WriteTo.File(
        path: "logs/pos-api-.log",
        rollingInterval: RollingInterval.Day,
        retainedFileCountLimit: 30,
        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {CorrelationId} {TenantCode} {UserId} {Message:lj}{NewLine}{Exception}")
    .CreateLogger();

builder.Host.UseSerilog();

// ... rest of configuration
// src/PosPlatform.Api/Middleware/CorrelationIdMiddleware.cs
using Serilog.Context;

namespace PosPlatform.Api.Middleware;

public class CorrelationIdMiddleware
{
    private const string CorrelationIdHeader = "X-Correlation-Id";
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[CorrelationIdHeader].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Response.Headers[CorrelationIdHeader] = correlationId;

        using (LogContext.PushProperty("CorrelationId", correlationId))
        {
            await _next(context);
        }
    }
}
// src/PosPlatform.Api/Middleware/RequestLoggingMiddleware.cs
using System.Diagnostics;

namespace PosPlatform.Api.Middleware;

public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await _next(context);
            stopwatch.Stop();

            _logger.LogInformation(
                "HTTP {Method} {Path} responded {StatusCode} in {ElapsedMs}ms",
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                stopwatch.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            stopwatch.Stop();

            _logger.LogError(ex,
                "HTTP {Method} {Path} failed after {ElapsedMs}ms",
                context.Request.Method,
                context.Request.Path,
                stopwatch.ElapsedMilliseconds);

            throw;
        }
    }
}

Day 2: Prometheus Metrics

Objective: Expose application metrics for Prometheus scraping.

Claude Command:

/dev-team add Prometheus metrics endpoints for key operations

Implementation:

// Install packages
// dotnet add package prometheus-net.AspNetCore

// src/PosPlatform.Api/Metrics/PosMetrics.cs
using Prometheus;

namespace PosPlatform.Api.Metrics;

public static class PosMetrics
{
    // HTTP request metrics (auto-collected by prometheus-net)
    public static readonly Counter HttpRequestsTotal = Prometheus.Metrics
        .CreateCounter("pos_http_requests_total", "Total HTTP requests",
            new CounterConfiguration
            {
                LabelNames = new[] { "method", "endpoint", "status_code" }
            });

    // Business metrics
    public static readonly Counter SalesTotal = Prometheus.Metrics
        .CreateCounter("pos_sales_total", "Total sales completed",
            new CounterConfiguration
            {
                LabelNames = new[] { "tenant", "location" }
            });

    public static readonly Counter SalesAmount = Prometheus.Metrics
        .CreateCounter("pos_sales_amount_total", "Total sales amount in cents",
            new CounterConfiguration
            {
                LabelNames = new[] { "tenant", "location", "payment_method" }
            });

    public static readonly Histogram SaleProcessingDuration = Prometheus.Metrics
        .CreateHistogram("pos_sale_processing_seconds", "Sale processing duration",
            new HistogramConfiguration
            {
                LabelNames = new[] { "tenant" },
                Buckets = new[] { 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0 }
            });

    public static readonly Gauge ActiveSales = Prometheus.Metrics
        .CreateGauge("pos_active_sales", "Currently active sales",
            new GaugeConfiguration
            {
                LabelNames = new[] { "tenant", "location" }
            });

    // Inventory metrics
    public static readonly Gauge InventoryLowStock = Prometheus.Metrics
        .CreateGauge("pos_inventory_low_stock_items", "Items below reorder point",
            new GaugeConfiguration
            {
                LabelNames = new[] { "tenant", "location" }
            });

    public static readonly Counter InventoryAdjustments = Prometheus.Metrics
        .CreateCounter("pos_inventory_adjustments_total", "Inventory adjustments",
            new CounterConfiguration
            {
                LabelNames = new[] { "tenant", "location", "reason" }
            });

    // System metrics
    public static readonly Gauge DatabaseConnections = Prometheus.Metrics
        .CreateGauge("pos_database_connections", "Active database connections");

    public static readonly Counter OfflineTransactions = Prometheus.Metrics
        .CreateCounter("pos_offline_transactions_total", "Transactions processed offline",
            new CounterConfiguration
            {
                LabelNames = new[] { "tenant", "type" }
            });

    public static readonly Gauge SyncQueueDepth = Prometheus.Metrics
        .CreateGauge("pos_sync_queue_depth", "Pending sync queue depth",
            new GaugeConfiguration
            {
                LabelNames = new[] { "tenant" }
            });
}
// Program.cs additions
using Prometheus;

var builder = WebApplication.CreateBuilder(args);

// Add metrics
builder.Services.AddSingleton<IMetricServer>(sp =>
    new MetricServer(port: 9090));

var app = builder.Build();

// Enable metrics endpoint
app.UseHttpMetrics();

// Metrics endpoint for Prometheus scraping
app.MapMetrics("/metrics");
// src/PosPlatform.Core/Services/SaleCompletionService.cs - Metrics integration
public async Task<SaleCompletionResult> CompleteSaleAsync(
    Guid saleId,
    CancellationToken ct = default)
{
    using var timer = PosMetrics.SaleProcessingDuration
        .WithLabels(_tenantContext.TenantCode!)
        .NewTimer();

    var sale = await _saleRepository.GetByIdAsync(saleId, ct);
    // ... processing

    // Record metrics on success
    PosMetrics.SalesTotal
        .WithLabels(_tenantContext.TenantCode!, sale.LocationId.ToString())
        .Inc();

    foreach (var payment in sale.Payments)
    {
        PosMetrics.SalesAmount
            .WithLabels(
                _tenantContext.TenantCode!,
                sale.LocationId.ToString(),
                payment.Method.ToString())
            .Inc((long)(payment.Amount * 100)); // Store as cents
    }

    return result;
}

Day 3: Health Checks

Objective: Implement comprehensive health checks for all dependencies.

Claude Command:

/dev-team create health check endpoints for database, Redis, and RabbitMQ

Implementation:

// Install packages
// dotnet add package AspNetCore.HealthChecks.NpgSql
// dotnet add package AspNetCore.HealthChecks.Redis
// dotnet add package AspNetCore.HealthChecks.RabbitMQ

// src/PosPlatform.Api/HealthChecks/ServiceHealthCheck.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace PosPlatform.Api.HealthChecks;

public class ServiceHealthCheck : IHealthCheck
{
    private readonly IServiceProvider _serviceProvider;

    public ServiceHealthCheck(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        try
        {
            // Check critical services are resolvable
            using var scope = _serviceProvider.CreateScope();

            var saleService = scope.ServiceProvider.GetService<ISaleRepository>();
            if (saleService == null)
                return HealthCheckResult.Degraded("Sale service unavailable");

            var inventoryService = scope.ServiceProvider.GetService<IInventoryRepository>();
            if (inventoryService == null)
                return HealthCheckResult.Degraded("Inventory service unavailable");

            return HealthCheckResult.Healthy("All services operational");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Service check failed", ex);
        }
    }
}
// Program.cs - Health check configuration
builder.Services.AddHealthChecks()
    // Database
    .AddNpgSql(
        builder.Configuration.GetConnectionString("DefaultConnection")!,
        name: "postgresql",
        tags: new[] { "db", "critical" })

    // Redis
    .AddRedis(
        builder.Configuration["Redis:ConnectionString"]!,
        name: "redis",
        tags: new[] { "cache", "critical" })

    // RabbitMQ
    .AddRabbitMQ(
        builder.Configuration["RabbitMQ:ConnectionString"]!,
        name: "rabbitmq",
        tags: new[] { "messaging" })

    // Custom service check
    .AddCheck<ServiceHealthCheck>(
        "services",
        tags: new[] { "services" });

// Health endpoints
app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = WriteHealthResponse,
    Predicate = _ => true
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    ResponseWriter = WriteHealthResponse,
    Predicate = check => check.Tags.Contains("critical")
});

app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // Just returns 200 if app is running
});

static Task WriteHealthResponse(HttpContext context, HealthReport report)
{
    context.Response.ContentType = "application/json";

    var result = new
    {
        status = report.Status.ToString(),
        duration = report.TotalDuration.TotalMilliseconds,
        checks = report.Entries.Select(e => new
        {
            name = e.Key,
            status = e.Value.Status.ToString(),
            duration = e.Value.Duration.TotalMilliseconds,
            description = e.Value.Description,
            error = e.Value.Exception?.Message
        })
    };

    return context.Response.WriteAsJsonAsync(result);
}

Day 4: Grafana Dashboards

Claude Command:

/devops-team create Grafana dashboards for POS metrics

Dashboard Configuration:

// grafana/dashboards/pos-overview.json
{
  "title": "POS Platform Overview",
  "uid": "pos-overview",
  "panels": [
    {
      "title": "Sales Per Minute",
      "type": "graph",
      "targets": [
        {
          "expr": "rate(pos_sales_total[1m])",
          "legendFormat": "{{tenant}} - {{location}}"
        }
      ],
      "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }
    },
    {
      "title": "Revenue (Last Hour)",
      "type": "stat",
      "targets": [
        {
          "expr": "sum(increase(pos_sales_amount_total[1h])) / 100",
          "legendFormat": "Revenue"
        }
      ],
      "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }
    },
    {
      "title": "Active Sales",
      "type": "gauge",
      "targets": [
        {
          "expr": "sum(pos_active_sales)"
        }
      ],
      "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }
    },
    {
      "title": "Sale Processing Time (p95)",
      "type": "graph",
      "targets": [
        {
          "expr": "histogram_quantile(0.95, rate(pos_sale_processing_seconds_bucket[5m]))",
          "legendFormat": "{{tenant}}"
        }
      ],
      "gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 }
    },
    {
      "title": "Low Stock Alerts",
      "type": "stat",
      "targets": [
        {
          "expr": "sum(pos_inventory_low_stock_items)"
        }
      ],
      "gridPos": { "x": 12, "y": 4, "w": 6, "h": 4 }
    },
    {
      "title": "Sync Queue Depth",
      "type": "gauge",
      "targets": [
        {
          "expr": "sum(pos_sync_queue_depth)"
        }
      ],
      "thresholds": {
        "mode": "absolute",
        "steps": [
          { "color": "green", "value": null },
          { "color": "yellow", "value": 10 },
          { "color": "red", "value": 50 }
        ]
      },
      "gridPos": { "x": 18, "y": 4, "w": 6, "h": 4 }
    },
    {
      "title": "HTTP Request Rate",
      "type": "graph",
      "targets": [
        {
          "expr": "rate(http_requests_total[1m])",
          "legendFormat": "{{method}} {{status}}"
        }
      ],
      "gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 }
    },
    {
      "title": "Database Connections",
      "type": "gauge",
      "targets": [
        {
          "expr": "pos_database_connections"
        }
      ],
      "gridPos": { "x": 12, "y": 8, "w": 6, "h": 4 }
    }
  ]
}

Day 5: Alerting Rules

Claude Command:

/devops-team configure alerting rules for critical conditions

Prometheus Alert Rules:

# prometheus/alerts/pos-alerts.yml
groups:
  - name: pos-critical
    rules:
      - alert: POSSalesDown
        expr: rate(pos_sales_total[5m]) == 0 and hour() >= 9 and hour() <= 21
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "No sales in last 5 minutes during business hours"
          description: "{{ $labels.tenant }} at {{ $labels.location }} has no sales"

      - alert: POSHighLatency
        expr: histogram_quantile(0.95, rate(pos_sale_processing_seconds_bucket[5m])) > 5
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Sale processing latency is high"
          description: "95th percentile latency is {{ $value }}s for {{ $labels.tenant }}"

      - alert: POSSyncQueueBacklog
        expr: pos_sync_queue_depth > 100
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "Offline sync queue is backed up"
          description: "{{ $value }} transactions pending sync for {{ $labels.tenant }}"

      - alert: POSLowStockCritical
        expr: pos_inventory_low_stock_items > 50
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "Many items below reorder point"
          description: "{{ $value }} items need reorder at {{ $labels.location }}"

  - name: pos-infrastructure
    rules:
      - alert: POSDatabaseDown
        expr: up{job="postgresql"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "PostgreSQL database is down"
          description: "Database connection failed"

      - alert: POSRedisDown
        expr: up{job="redis"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Redis cache is down"
          description: "Redis connection failed"

      - alert: POSHighMemory
        expr: process_resident_memory_bytes / 1024 / 1024 > 500
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High memory usage"
          description: "Memory usage is {{ $value }}MB"

Alertmanager Configuration:

# alertmanager/config.yml
global:
  smtp_smarthost: 'smtp.example.com:587'
  smtp_from: 'pos-alerts@example.com'
  slack_api_url: 'https://hooks.slack.com/services/xxx/xxx/xxx'

route:
  group_by: ['alertname', 'tenant']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'default'
  routes:
    - match:
        severity: critical
      receiver: 'pagerduty-critical'
    - match:
        severity: warning
      receiver: 'slack-warnings'

receivers:
  - name: 'default'
    email_configs:
      - to: 'ops@example.com'

  - name: 'pagerduty-critical'
    pagerduty_configs:
      - service_key: 'your-pagerduty-key'
        severity: critical

  - name: 'slack-warnings'
    slack_configs:
      - channel: '#pos-alerts'
        send_resolved: true
        title: '{{ .Status | toUpper }}: {{ .CommonLabels.alertname }}'
        text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'

Week 15: Security Hardening

Day 1: Input Validation

Claude Command:

/security-team review and enhance input validation coverage

Implementation:

// src/PosPlatform.Api/Validation/ValidatorExtensions.cs
using FluentValidation;

namespace PosPlatform.Api.Validation;

public static class ValidatorExtensions
{
    public static IRuleBuilderOptions<T, string> SafeString<T>(
        this IRuleBuilder<T, string> ruleBuilder,
        int maxLength = 255)
    {
        return ruleBuilder
            .MaximumLength(maxLength)
            .Must(s => s == null || !ContainsDangerousCharacters(s))
            .WithMessage("Input contains invalid characters");
    }

    public static IRuleBuilderOptions<T, string> Sku<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .NotEmpty()
            .MaximumLength(50)
            .Matches(@"^[A-Za-z0-9\-_]+$")
            .WithMessage("SKU must contain only alphanumeric characters, hyphens, and underscores");
    }

    public static IRuleBuilderOptions<T, string> Email<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .EmailAddress()
            .MaximumLength(255)
            .Must(e => e == null || !e.Contains(".."))
            .WithMessage("Invalid email format");
    }

    public static IRuleBuilderOptions<T, decimal> MoneyAmount<T>(
        this IRuleBuilder<T, decimal> ruleBuilder)
    {
        return ruleBuilder
            .GreaterThanOrEqualTo(0)
            .LessThanOrEqualTo(999999.99m)
            .PrecisionScale(10, 2, true)
            .WithMessage("Invalid monetary amount");
    }

    private static bool ContainsDangerousCharacters(string input)
    {
        var dangerous = new[] { "<script", "javascript:", "onclick", "onerror", "--", "/*", "*/" };
        return dangerous.Any(d => input.Contains(d, StringComparison.OrdinalIgnoreCase));
    }
}

// Validators for key DTOs
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Sku).Sku();
        RuleFor(x => x.Name).SafeString(255).NotEmpty();
        RuleFor(x => x.Description).SafeString(2000);
        RuleFor(x => x.BasePrice).MoneyAmount();
        RuleFor(x => x.Cost).MoneyAmount();
    }
}

public class CreateSaleRequestValidator : AbstractValidator<CreateSaleRequest>
{
    public CreateSaleRequestValidator()
    {
        RuleFor(x => x.LocationId).NotEmpty();
        RuleFor(x => x.CashierId).NotEmpty();
        RuleForEach(x => x.Items).SetValidator(new SaleItemValidator());
    }
}

public class SaleItemValidator : AbstractValidator<SaleItemRequest>
{
    public SaleItemValidator()
    {
        RuleFor(x => x.Sku).Sku();
        RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(1000);
        RuleFor(x => x.UnitPrice).MoneyAmount();
    }
}
// Program.cs - Register validators
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();

Day 2: Rate Limiting

Claude Command:

/dev-team implement rate limiting middleware

Implementation:

// Install package
// dotnet add package AspNetCoreRateLimit

// Program.cs
using AspNetCoreRateLimit;

builder.Services.AddMemoryCache();
builder.Services.Configure<IpRateLimitOptions>(options =>
{
    options.EnableEndpointRateLimiting = true;
    options.StackBlockedRequests = false;
    options.HttpStatusCode = 429;
    options.RealIpHeader = "X-Real-IP";
    options.GeneralRules = new List<RateLimitRule>
    {
        // General API limit
        new RateLimitRule
        {
            Endpoint = "*",
            Period = "1m",
            Limit = 100
        },
        // Login endpoints - stricter
        new RateLimitRule
        {
            Endpoint = "*:/api/auth/*",
            Period = "1m",
            Limit = 10
        },
        // Sales endpoints
        new RateLimitRule
        {
            Endpoint = "POST:/api/sales",
            Period = "1s",
            Limit = 5
        }
    };
});

builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
builder.Services.AddInMemoryRateLimiting();

// In pipeline
app.UseIpRateLimiting();
// src/PosPlatform.Api/Middleware/LoginRateLimitMiddleware.cs
public class LoginRateLimitMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IMemoryCache _cache;
    private readonly ILogger<LoginRateLimitMiddleware> _logger;
    private const int MaxAttempts = 5;
    private const int LockoutMinutes = 15;

    public LoginRateLimitMiddleware(
        RequestDelegate next,
        IMemoryCache cache,
        ILogger<LoginRateLimitMiddleware> logger)
    {
        _next = next;
        _cache = cache;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (!IsLoginEndpoint(context))
        {
            await _next(context);
            return;
        }

        var key = GetRateLimitKey(context);

        if (IsLockedOut(key))
        {
            _logger.LogWarning("Login attempt blocked due to rate limiting: {Key}", key);
            context.Response.StatusCode = 429;
            await context.Response.WriteAsJsonAsync(new
            {
                error = "Too many login attempts. Please try again later.",
                retryAfter = LockoutMinutes * 60
            });
            return;
        }

        await _next(context);

        // Check if login failed (401 response)
        if (context.Response.StatusCode == 401)
        {
            IncrementFailedAttempts(key);
        }
        else if (context.Response.StatusCode == 200)
        {
            ClearFailedAttempts(key);
        }
    }

    private bool IsLoginEndpoint(HttpContext context)
        => context.Request.Path.StartsWithSegments("/api/auth/login") &&
           context.Request.Method == "POST";

    private string GetRateLimitKey(HttpContext context)
    {
        var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return $"login_attempts:{ip}";
    }

    private bool IsLockedOut(string key)
    {
        var attempts = _cache.Get<int>(key);
        return attempts >= MaxAttempts;
    }

    private void IncrementFailedAttempts(string key)
    {
        var attempts = _cache.Get<int>(key) + 1;
        _cache.Set(key, attempts, TimeSpan.FromMinutes(LockoutMinutes));
    }

    private void ClearFailedAttempts(string key)
    {
        _cache.Remove(key);
    }
}

Day 3: Secrets Management

Claude Command:

/devops-team configure secrets management with HashiCorp Vault

Implementation:

// Install package
// dotnet add package VaultSharp

// src/PosPlatform.Infrastructure/Secrets/VaultSecretProvider.cs
using VaultSharp;
using VaultSharp.V1.AuthMethods.Token;

namespace PosPlatform.Infrastructure.Secrets;

public interface ISecretProvider
{
    Task<string> GetSecretAsync(string path, string key);
    Task<Dictionary<string, string>> GetSecretsAsync(string path);
}

public class VaultSecretProvider : ISecretProvider
{
    private readonly IVaultClient _client;
    private readonly ILogger<VaultSecretProvider> _logger;

    public VaultSecretProvider(
        IConfiguration configuration,
        ILogger<VaultSecretProvider> logger)
    {
        _logger = logger;

        var vaultAddr = configuration["Vault:Address"];
        var vaultToken = configuration["Vault:Token"];

        var authMethod = new TokenAuthMethodInfo(vaultToken);
        var settings = new VaultClientSettings(vaultAddr, authMethod);

        _client = new VaultClient(settings);
    }

    public async Task<string> GetSecretAsync(string path, string key)
    {
        var secrets = await GetSecretsAsync(path);
        return secrets.TryGetValue(key, out var value) ? value : string.Empty;
    }

    public async Task<Dictionary<string, string>> GetSecretsAsync(string path)
    {
        try
        {
            var secret = await _client.V1.Secrets.KeyValue.V2.ReadSecretAsync(
                path: path,
                mountPoint: "secret");

            return secret.Data.Data
                .ToDictionary(
                    kv => kv.Key,
                    kv => kv.Value?.ToString() ?? string.Empty);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to retrieve secrets from path: {Path}", path);
            throw;
        }
    }
}
// Startup configuration for secrets
public static class SecretConfigurationExtensions
{
    public static IHostBuilder ConfigureSecrets(this IHostBuilder hostBuilder)
    {
        return hostBuilder.ConfigureAppConfiguration((context, config) =>
        {
            var settings = config.Build();
            var vaultEnabled = settings.GetValue<bool>("Vault:Enabled");

            if (vaultEnabled)
            {
                var vaultAddress = settings["Vault:Address"];
                var vaultToken = Environment.GetEnvironmentVariable("VAULT_TOKEN");

                config.AddVaultConfiguration(vaultAddress, vaultToken, new[]
                {
                    "secret/data/pos/database",
                    "secret/data/pos/jwt",
                    "secret/data/pos/payment-gateway"
                });
            }
        });
    }
}

Day 4: Security Headers

Claude Command:

/dev-team add security headers middleware

Implementation:

// src/PosPlatform.Api/Middleware/SecurityHeadersMiddleware.cs
namespace PosPlatform.Api.Middleware;

public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public SecurityHeadersMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // Prevent clickjacking
        context.Response.Headers["X-Frame-Options"] = "DENY";

        // Prevent MIME type sniffing
        context.Response.Headers["X-Content-Type-Options"] = "nosniff";

        // Enable XSS protection
        context.Response.Headers["X-XSS-Protection"] = "1; mode=block";

        // Content Security Policy
        context.Response.Headers["Content-Security-Policy"] =
            "default-src 'self'; " +
            "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data: https:; " +
            "font-src 'self'; " +
            "connect-src 'self' wss: https:; " +
            "frame-ancestors 'none'";

        // Strict Transport Security (HTTPS only)
        context.Response.Headers["Strict-Transport-Security"] =
            "max-age=31536000; includeSubDomains";

        // Referrer Policy
        context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";

        // Permissions Policy
        context.Response.Headers["Permissions-Policy"] =
            "accelerometer=(), camera=(), geolocation=(), gyroscope=(), " +
            "magnetometer=(), microphone=(), payment=(), usb=()";

        await _next(context);
    }
}

// Extension method
public static class SecurityHeadersExtensions
{
    public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app)
    {
        return app.UseMiddleware<SecurityHeadersMiddleware>();
    }
}

Day 5: Security Scanning

Claude Command:

/security-team run security vulnerability scan and remediate findings

Security Scan Configuration:

# .github/workflows/security-scan.yml
name: Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1' # Weekly on Monday

jobs:
  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - name: Restore dependencies
        run: dotnet restore

      - name: Run security audit
        run: dotnet list package --vulnerable --include-transitive

  code-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: csharp

      - name: Build
        run: dotnet build

      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3

  container-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t pos-api:scan -f docker/Dockerfile .

      - name: Run Trivy scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'pos-api:scan'
          format: 'sarif'
          output: 'trivy-results.sarif'

      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

Week 16: Production Deployment

Day 1-2: Production Infrastructure

Claude Command:

/devops-team provision production infrastructure with Kubernetes

Kubernetes Manifests:

# k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: pos-platform
  labels:
    name: pos-platform

---
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pos-api
  namespace: pos-platform
spec:
  replicas: 3
  selector:
    matchLabels:
      app: pos-api
  template:
    metadata:
      labels:
        app: pos-api
    spec:
      containers:
        - name: pos-api
          image: pos-platform/api:latest
          ports:
            - containerPort: 8080
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: ConnectionStrings__DefaultConnection
              valueFrom:
                secretKeyRef:
                  name: pos-secrets
                  key: database-connection
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
      imagePullSecrets:
        - name: registry-credentials

---
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: pos-api
  namespace: pos-platform
spec:
  selector:
    app: pos-api
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: ClusterIP

---
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: pos-api
  namespace: pos-platform
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
    - hosts:
        - api.posplatform.com
      secretName: pos-api-tls
  rules:
    - host: api.posplatform.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: pos-api
                port:
                  number: 80

Day 3: Database Migration

Claude Command:

/devops-team run database migrations for production

Migration Script:

#!/bin/bash
# scripts/deploy-database.sh

set -e

echo "=== POS Platform Database Deployment ==="

# Configuration
DB_HOST=${DB_HOST:-"localhost"}
DB_PORT=${DB_PORT:-"5432"}
DB_NAME=${DB_NAME:-"pos_platform"}
DB_USER=${DB_USER:-"pos_admin"}
BACKUP_DIR="/backups/$(date +%Y%m%d_%H%M%S)"

# Create backup directory
mkdir -p "$BACKUP_DIR"

echo "1. Creating pre-migration backup..."
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
  -F c -f "$BACKUP_DIR/pre-migration.dump"

echo "   Backup saved to: $BACKUP_DIR/pre-migration.dump"

echo "2. Running EF Core migrations..."
cd /app
dotnet ef database update --connection "Host=$DB_HOST;Port=$DB_PORT;Database=$DB_NAME;Username=$DB_USER;Password=$DB_PASSWORD"

echo "3. Verifying migration..."
TABLES=$(psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c \
  "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'shared'")

if [ "$TABLES" -gt 0 ]; then
  echo "   Migration verified: $TABLES tables in shared schema"
else
  echo "   ERROR: No tables found in shared schema!"
  echo "   Rolling back..."
  pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
    -c "$BACKUP_DIR/pre-migration.dump"
  exit 1
fi

echo "4. Creating post-migration backup..."
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
  -F c -f "$BACKUP_DIR/post-migration.dump"

echo "=== Database deployment complete ==="

Day 4: Blue-Green Deployment

Claude Command:

/devops-team execute blue-green deployment for zero downtime

Deployment Script:

#!/bin/bash
# scripts/blue-green-deploy.sh

set -e

echo "=== POS Platform Blue-Green Deployment ==="

NAMESPACE="pos-platform"
NEW_VERSION=$1
CURRENT_COLOR=$(kubectl get svc pos-api -n $NAMESPACE -o jsonpath='{.spec.selector.color}')

if [ "$CURRENT_COLOR" == "blue" ]; then
  NEW_COLOR="green"
else
  NEW_COLOR="blue"
fi

echo "Current: $CURRENT_COLOR -> New: $NEW_COLOR"
echo "Deploying version: $NEW_VERSION"

# Step 1: Deploy new version
echo "1. Deploying $NEW_COLOR environment..."
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pos-api-$NEW_COLOR
  namespace: $NAMESPACE
spec:
  replicas: 3
  selector:
    matchLabels:
      app: pos-api
      color: $NEW_COLOR
  template:
    metadata:
      labels:
        app: pos-api
        color: $NEW_COLOR
        version: $NEW_VERSION
    spec:
      containers:
        - name: pos-api
          image: pos-platform/api:$NEW_VERSION
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
EOF

# Step 2: Wait for rollout
echo "2. Waiting for $NEW_COLOR rollout..."
kubectl rollout status deployment/pos-api-$NEW_COLOR -n $NAMESPACE --timeout=5m

# Step 3: Run smoke tests
echo "3. Running smoke tests against $NEW_COLOR..."
NEW_POD=$(kubectl get pod -n $NAMESPACE -l color=$NEW_COLOR -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n $NAMESPACE $NEW_POD -- curl -s http://localhost:8080/health

# Step 4: Switch traffic
echo "4. Switching traffic to $NEW_COLOR..."
kubectl patch svc pos-api -n $NAMESPACE -p "{\"spec\":{\"selector\":{\"color\":\"$NEW_COLOR\"}}}"

# Step 5: Monitor
echo "5. Monitoring new deployment for 2 minutes..."
sleep 120

# Check error rate
ERROR_RATE=$(kubectl logs -n $NAMESPACE -l color=$NEW_COLOR --since=2m | grep -c "ERROR" || true)
if [ "$ERROR_RATE" -gt 10 ]; then
  echo "ERROR: High error rate detected. Rolling back..."
  kubectl patch svc pos-api -n $NAMESPACE -p "{\"spec\":{\"selector\":{\"color\":\"$CURRENT_COLOR\"}}}"
  exit 1
fi

# Step 6: Scale down old
echo "6. Scaling down $CURRENT_COLOR..."
kubectl scale deployment pos-api-$CURRENT_COLOR -n $NAMESPACE --replicas=0

echo "=== Deployment complete ==="
echo "Version $NEW_VERSION is now live on $NEW_COLOR"

Day 5: Go-Live

Go-Live Checklist:

# POS Platform Go-Live Checklist

## Pre-Deployment (D-1)
- [ ] All features tested in staging environment
- [ ] Load testing completed (target: 100 concurrent users)
- [ ] Security scan passed with no critical issues
- [ ] Database backup verified and tested
- [ ] Rollback procedure documented and tested
- [ ] On-call rotation confirmed for launch weekend
- [ ] Customer support team briefed on new system
- [ ] DNS TTL reduced to 5 minutes

## Deployment Day (D-Day)
### Morning (Before Store Opens)
- [ ] Team standup at 6:00 AM
- [ ] Final database backup
- [ ] Deploy to production (blue-green)
- [ ] Verify health checks passing
- [ ] Run smoke tests
- [ ] Check monitoring dashboards

### Store Opening
- [ ] Process first test transaction
- [ ] Verify receipt printing
- [ ] Confirm payment processing
- [ ] Check inventory updates

### Throughout Day
- [ ] Monitor error rates (target: <0.1%)
- [ ] Monitor latency (target: p95 <500ms)
- [ ] Check offline sync queue
- [ ] Respond to support tickets within 15 minutes

## Post-Deployment (D+1)
- [ ] Review overnight logs
- [ ] Check daily sales reports
- [ ] Verify nightly backups ran
- [ ] Team retrospective meeting
- [ ] Document any issues encountered

## Success Criteria
- [ ] Zero data loss
- [ ] No customer-facing downtime during deployment
- [ ] All stores operational within 30 minutes of go-live
- [ ] Error rate below 0.1%
- [ ] All payments processed successfully

Rollback Procedures

Immediate Rollback

#!/bin/bash
# scripts/rollback.sh

set -e

NAMESPACE="pos-platform"
CURRENT_COLOR=$(kubectl get svc pos-api -n $NAMESPACE -o jsonpath='{.spec.selector.color}')

if [ "$CURRENT_COLOR" == "blue" ]; then
  ROLLBACK_COLOR="green"
else
  ROLLBACK_COLOR="blue"
fi

echo "!!! ROLLBACK INITIATED !!!"
echo "Switching from $CURRENT_COLOR to $ROLLBACK_COLOR"

# Scale up rollback environment
kubectl scale deployment pos-api-$ROLLBACK_COLOR -n $NAMESPACE --replicas=3

# Wait for pods
kubectl rollout status deployment/pos-api-$ROLLBACK_COLOR -n $NAMESPACE --timeout=2m

# Switch traffic
kubectl patch svc pos-api -n $NAMESPACE -p "{\"spec\":{\"selector\":{\"color\":\"$ROLLBACK_COLOR\"}}}"

echo "Rollback complete. Traffic now on $ROLLBACK_COLOR"

# Scale down failed deployment
kubectl scale deployment pos-api-$CURRENT_COLOR -n $NAMESPACE --replicas=0

Database Rollback

#!/bin/bash
# scripts/rollback-database.sh

BACKUP_FILE=$1

if [ -z "$BACKUP_FILE" ]; then
  echo "Usage: rollback-database.sh <backup-file>"
  exit 1
fi

echo "!!! DATABASE ROLLBACK !!!"
echo "Restoring from: $BACKUP_FILE"

# Stop application
kubectl scale deployment --all -n pos-platform --replicas=0

# Restore database
pg_restore -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
  --clean --if-exists "$BACKUP_FILE"

# Restart application
kubectl scale deployment pos-api-blue -n pos-platform --replicas=3

echo "Database rollback complete"

Post Go-Live Operations

Daily Operations Checklist

## Daily POS Operations Checklist

### Morning (Before Store Opens)
- [ ] Check overnight error logs
- [ ] Verify backup completed successfully
- [ ] Review offline sync queue (should be empty)
- [ ] Check low stock alerts
- [ ] Verify all terminals connected

### Throughout Day
- [ ] Monitor sales dashboard
- [ ] Check response times
- [ ] Review any support tickets
- [ ] Monitor cash drawer sessions

### Evening (After Store Closes)
- [ ] Review daily sales summary
- [ ] Check inventory discrepancies
- [ ] Verify cash drawer reconciliations
- [ ] Confirm backup initiated

Next Steps

With production deployment complete:

  1. Review Appendix A for API reference documentation
  2. Consult Appendix B for troubleshooting guides
  3. Reference Appendix C for operational procedures
  4. Plan ongoing maintenance and feature development

Chapter 28 Complete - Phase 4 Production Implementation


Congratulations!

You have completed the POS Platform Implementation Guide. The system is now:

  • Multi-tenant with complete data isolation
  • Fully authenticated with JWT and PIN support
  • Processing sales with event-sourced transactions
  • Managing inventory across multiple locations
  • Supporting customer loyalty programs
  • Operating offline with sync capabilities
  • Monitored with comprehensive observability
  • Secured with defense-in-depth measures
  • Deployed with zero-downtime capability

Total Implementation Time: 16 weeks Lines of Code: ~15,000+ Database Tables: 30+ API Endpoints: 50+

Welcome to production!

Chapter 29: Deployment Guide

Overview

This chapter provides complete deployment procedures for the POS Platform, including Docker containerization, environment configuration, deployment strategies, and rollback procedures.


Deployment Architecture

                                    PRODUCTION ENVIRONMENT
+-----------------------------------------------------------------------------------+
|                              Load Balancer (Nginx/HAProxy)                        |
|                                    Port 443 (HTTPS)                               |
+-----------------------------------------------------------------------------------+
           |                              |                              |
           v                              v                              v
+-------------------+        +-------------------+        +-------------------+
|   POS-API-01      |        |   POS-API-02      |        |   POS-API-03      |
|   (Container)     |        |   (Container)     |        |   (Container)     |
|   Port 8080       |        |   Port 8080       |        |   Port 8080       |
+-------------------+        +-------------------+        +-------------------+
           |                              |                              |
           +------------------------------+------------------------------+
                                          |
                                          v
+-----------------------------------------------------------------------------------+
|                              PostgreSQL Cluster                                   |
|                         Primary (Write) + Replica (Read)                          |
|                                    Port 5432                                      |
+-----------------------------------------------------------------------------------+
           |                              |                              |
           v                              v                              v
+-------------------+        +-------------------+        +-------------------+
|    Redis          |        |    RabbitMQ       |        |   Prometheus      |
|   (Cache/Session) |        |   (Event Bus)     |        |   (Metrics)       |
|    Port 6379      |        |    Port 5672      |        |    Port 9090      |
+-------------------+        +-------------------+        +-------------------+

Container Images

Complete Dockerfile for API

# File: /pos-platform/docker/api/Dockerfile
# Multi-stage build for ASP.NET Core POS API

#=============================================
# Stage 1: Build Environment
#=============================================
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build

WORKDIR /src

# Copy solution and project files for layer caching
COPY ["POS.sln", "./"]
COPY ["src/POS.Api/POS.Api.csproj", "src/POS.Api/"]
COPY ["src/POS.Core/POS.Core.csproj", "src/POS.Core/"]
COPY ["src/POS.Infrastructure/POS.Infrastructure.csproj", "src/POS.Infrastructure/"]
COPY ["src/POS.Application/POS.Application.csproj", "src/POS.Application/"]

# Restore dependencies (cached unless .csproj changes)
RUN dotnet restore "POS.sln"

# Copy remaining source code
COPY . .

# Build release version
WORKDIR "/src/src/POS.Api"
RUN dotnet build "POS.Api.csproj" -c Release -o /app/build

#=============================================
# Stage 2: Publish
#=============================================
FROM build AS publish

RUN dotnet publish "POS.Api.csproj" \
    -c Release \
    -o /app/publish \
    --no-restore \
    /p:UseAppHost=false \
    /p:PublishTrimmed=false

#=============================================
# Stage 3: Runtime Environment
#=============================================
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime

# Security: Run as non-root user
RUN addgroup -S posgroup && adduser -S posuser -G posgroup

WORKDIR /app

# Install health check dependencies
RUN apk add --no-cache curl

# Copy published application
COPY --from=publish /app/publish .

# Set ownership
RUN chown -R posuser:posgroup /app

# Switch to non-root user
USER posuser

# Expose port
EXPOSE 8080

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8080/health || exit 1

# Set environment
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV DOTNET_RUNNING_IN_CONTAINER=true

# Entry point
ENTRYPOINT ["dotnet", "POS.Api.dll"]

Dockerfile for Frontend (Blazor WASM)

# File: /pos-platform/docker/frontend/Dockerfile
# Multi-stage build for Blazor WebAssembly

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/POS.Client/POS.Client.csproj", "src/POS.Client/"]
RUN dotnet restore "src/POS.Client/POS.Client.csproj"
COPY . .
WORKDIR "/src/src/POS.Client"
RUN dotnet publish "POS.Client.csproj" -c Release -o /app/publish

FROM nginx:alpine AS runtime
COPY --from=build /app/publish/wwwroot /usr/share/nginx/html
COPY docker/frontend/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker Compose Configuration

Complete Production docker-compose.yml

# File: /pos-platform/docker/docker-compose.yml
# Production deployment configuration

version: '3.8'

services:
  #=========================================
  # POS API Service (Scalable)
  #=========================================
  pos-api:
    build:
      context: ..
      dockerfile: docker/api/Dockerfile
    image: pos-api:${TAG:-latest}
    container_name: pos-api-${INSTANCE:-1}
    restart: unless-stopped
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: '2'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M
      update_config:
        parallelism: 1
        delay: 30s
        failure_action: rollback
        order: start-first
      rollback_config:
        parallelism: 1
        delay: 10s
    environment:
      - ASPNETCORE_ENVIRONMENT=Production
      - ConnectionStrings__DefaultConnection=${DB_CONNECTION_STRING}
      - ConnectionStrings__ReadReplicaConnection=${DB_READ_CONNECTION_STRING}
      - Redis__ConnectionString=${REDIS_CONNECTION_STRING}
      - RabbitMQ__Host=${RABBITMQ_HOST}
      - RabbitMQ__Username=${RABBITMQ_USER}
      - RabbitMQ__Password=${RABBITMQ_PASSWORD}
      - Jwt__Secret=${JWT_SECRET}
      - Jwt__Issuer=${JWT_ISSUER}
      - Jwt__Audience=${JWT_AUDIENCE}
      - Payment__StripeApiKey=${STRIPE_API_KEY}
      - Payment__StripeWebhookSecret=${STRIPE_WEBHOOK_SECRET}
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://prometheus:9090
    ports:
      - "${API_PORT:-8080}:8080"
    networks:
      - pos-network
    depends_on:
      postgres-primary:
        condition: service_healthy
      redis:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    logging:
      driver: "json-file"
      options:
        max-size: "50m"
        max-file: "5"
    volumes:
      - pos-data-protection:/app/keys
      - pos-logs:/app/logs

  #=========================================
  # PostgreSQL Primary (Write)
  #=========================================
  postgres-primary:
    image: postgres:16-alpine
    container_name: pos-postgres-primary
    restart: unless-stopped
    environment:
      - POSTGRES_DB=pos_db
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - PGDATA=/var/lib/postgresql/data/pgdata
    ports:
      - "${DB_PORT:-5432}:5432"
    networks:
      - pos-network
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
      - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
    command: postgres -c config_file=/etc/postgresql/postgresql.conf
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d pos_db"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    deploy:
      resources:
        limits:
          cpus: '4'
          memory: 8G
        reservations:
          cpus: '1'
          memory: 2G

  #=========================================
  # PostgreSQL Replica (Read)
  #=========================================
  postgres-replica:
    image: postgres:16-alpine
    container_name: pos-postgres-replica
    restart: unless-stopped
    environment:
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - PGDATA=/var/lib/postgresql/data/pgdata
    networks:
      - pos-network
    volumes:
      - postgres-replica-data:/var/lib/postgresql/data
    command: postgres -c hot_standby=on
    depends_on:
      postgres-primary:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 4G

  #=========================================
  # Redis Cache
  #=========================================
  redis:
    image: redis:7-alpine
    container_name: pos-redis
    restart: unless-stopped
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    ports:
      - "${REDIS_PORT:-6379}:6379"
    networks:
      - pos-network
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1G

  #=========================================
  # RabbitMQ Message Broker
  #=========================================
  rabbitmq:
    image: rabbitmq:3-management-alpine
    container_name: pos-rabbitmq
    restart: unless-stopped
    environment:
      - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
      - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
      - RABBITMQ_DEFAULT_VHOST=pos
    ports:
      - "${RABBITMQ_PORT:-5672}:5672"
      - "${RABBITMQ_MGMT_PORT:-15672}:15672"
    networks:
      - pos-network
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 1G

  #=========================================
  # Nginx Load Balancer
  #=========================================
  nginx:
    image: nginx:alpine
    container_name: pos-nginx
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    networks:
      - pos-network
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - nginx-cache:/var/cache/nginx
    depends_on:
      - pos-api
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 10s
      retries: 3

#=========================================
# Networks
#=========================================
networks:
  pos-network:
    driver: bridge
    ipam:
      config:
        - subnet: 172.28.0.0/16

#=========================================
# Volumes
#=========================================
volumes:
  postgres-data:
    driver: local
  postgres-replica-data:
    driver: local
  redis-data:
    driver: local
  rabbitmq-data:
    driver: local
  nginx-cache:
    driver: local
  pos-data-protection:
    driver: local
  pos-logs:
    driver: local

Environment Variables Reference

Complete .env Template

# File: /pos-platform/docker/.env.template

#=============================================
# ENVIRONMENT
#=============================================
ENVIRONMENT=Production
TAG=latest

#=============================================
# DATABASE - PostgreSQL
#=============================================
DB_HOST=postgres-primary
DB_PORT=5432
DB_NAME=pos_db
DB_USER=pos_admin
DB_PASSWORD=<GENERATE_STRONG_PASSWORD>
DB_CONNECTION_STRING=Host=postgres-primary;Port=5432;Database=pos_db;Username=pos_admin;Password=${DB_PASSWORD};Pooling=true;MinPoolSize=5;MaxPoolSize=100
DB_READ_CONNECTION_STRING=Host=postgres-replica;Port=5432;Database=pos_db;Username=pos_admin;Password=${DB_PASSWORD};Pooling=true

#=============================================
# CACHE - Redis
#=============================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=<GENERATE_STRONG_PASSWORD>
REDIS_CONNECTION_STRING=redis:6379,password=${REDIS_PASSWORD},abortConnect=false

#=============================================
# MESSAGE BROKER - RabbitMQ
#=============================================
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=pos_admin
RABBITMQ_PASSWORD=<GENERATE_STRONG_PASSWORD>
RABBITMQ_VHOST=pos

#=============================================
# SECURITY - JWT
#=============================================
JWT_SECRET=<GENERATE_256_BIT_SECRET>
JWT_ISSUER=pos-platform
JWT_AUDIENCE=pos-clients
JWT_EXPIRY_MINUTES=60
JWT_REFRESH_EXPIRY_DAYS=7

#=============================================
# PAYMENT PROCESSING
#=============================================
STRIPE_API_KEY=sk_live_<YOUR_KEY>
STRIPE_WEBHOOK_SECRET=whsec_<YOUR_SECRET>
STRIPE_PUBLIC_KEY=pk_live_<YOUR_KEY>

#=============================================
# EXTERNAL SERVICES
#=============================================
SHOPIFY_API_KEY=<YOUR_KEY>
SHOPIFY_API_SECRET=<YOUR_SECRET>
QUICKBOOKS_CLIENT_ID=<YOUR_ID>
QUICKBOOKS_CLIENT_SECRET=<YOUR_SECRET>

#=============================================
# MONITORING
#=============================================
PROMETHEUS_PORT=9090
GRAFANA_PORT=3000
GRAFANA_ADMIN_PASSWORD=<GENERATE_STRONG_PASSWORD>

#=============================================
# LOGGING
#=============================================
LOG_LEVEL=Information
LOG_PATH=/app/logs
SERILOG_SEQ_URL=http://seq:5341

#=============================================
# API CONFIGURATION
#=============================================
API_PORT=8080
API_RATE_LIMIT_PER_MINUTE=100
API_CORS_ORIGINS=https://pos.yourcompany.com

Deployment Checklist

Pre-Deployment Checklist

## Pre-Deployment Verification

### 1. Code Readiness
- [ ] All tests passing (unit, integration, e2e)
- [ ] Code review approved
- [ ] Security scan completed (no critical/high vulnerabilities)
- [ ] Version number updated in csproj
- [ ] CHANGELOG.md updated
- [ ] Database migrations tested

### 2. Infrastructure Readiness
- [ ] Target environment accessible
- [ ] SSL certificates valid (> 30 days)
- [ ] Database backup completed (< 1 hour old)
- [ ] Sufficient disk space (> 20% free)
- [ ] Load balancer health checks configured
- [ ] DNS pointing to correct servers

### 3. Configuration Verification
- [ ] .env file populated with production values
- [ ] Secrets stored in secure vault
- [ ] Connection strings validated
- [ ] External API keys verified

### 4. Rollback Preparation
- [ ] Previous version image tagged and available
- [ ] Rollback script tested
- [ ] Database rollback script prepared (if schema changes)
- [ ] Rollback communication template ready

### 5. Team Readiness
- [ ] Deployment window communicated
- [ ] On-call engineer identified
- [ ] Customer support notified
- [ ] Monitoring dashboard accessible

Deployment Script

#!/bin/bash
# File: /pos-platform/scripts/deploy.sh
# Production deployment script

set -e  # Exit on error

#=============================================
# CONFIGURATION
#=============================================
DEPLOY_DIR="/opt/pos-platform"
DOCKER_COMPOSE="docker compose"
TAG=${1:-latest}
BACKUP_DIR="/backups/pos"
LOG_FILE="/var/log/pos-deploy.log"

#=============================================
# FUNCTIONS
#=============================================
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

check_prerequisites() {
    log "Checking prerequisites..."

    # Check Docker
    if ! command -v docker &> /dev/null; then
        log "ERROR: Docker not installed"
        exit 1
    fi

    # Check disk space (require 20% free)
    FREE_SPACE=$(df / | tail -1 | awk '{print $5}' | sed 's/%//')
    if [ "$FREE_SPACE" -gt 80 ]; then
        log "ERROR: Insufficient disk space (${FREE_SPACE}% used)"
        exit 1
    fi

    log "Prerequisites check passed"
}

backup_database() {
    log "Creating database backup..."
    BACKUP_FILE="${BACKUP_DIR}/pos_db_$(date +%Y%m%d_%H%M%S).sql.gz"

    $DOCKER_COMPOSE exec -T postgres-primary pg_dump -U pos_admin pos_db | gzip > "$BACKUP_FILE"

    if [ $? -eq 0 ]; then
        log "Database backup created: $BACKUP_FILE"
    else
        log "ERROR: Database backup failed"
        exit 1
    fi
}

pull_images() {
    log "Pulling new images (tag: $TAG)..."

    $DOCKER_COMPOSE pull

    log "Images pulled successfully"
}

deploy_with_zero_downtime() {
    log "Starting zero-downtime deployment..."

    # Scale up new containers first
    $DOCKER_COMPOSE up -d --scale pos-api=4 --no-recreate

    # Wait for new containers to be healthy
    log "Waiting for health checks..."
    sleep 60

    # Verify new containers are healthy
    HEALTHY_COUNT=$($DOCKER_COMPOSE ps | grep "healthy" | wc -l)
    if [ "$HEALTHY_COUNT" -lt 3 ]; then
        log "ERROR: Not enough healthy containers"
        rollback
        exit 1
    fi

    # Rolling update
    $DOCKER_COMPOSE up -d --force-recreate

    # Scale back to normal
    $DOCKER_COMPOSE up -d --scale pos-api=3

    log "Deployment completed successfully"
}

verify_deployment() {
    log "Verifying deployment..."

    # Check health endpoint
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)

    if [ "$HTTP_CODE" -eq 200 ]; then
        log "Health check passed (HTTP $HTTP_CODE)"
    else
        log "ERROR: Health check failed (HTTP $HTTP_CODE)"
        rollback
        exit 1
    fi

    # Check version
    VERSION=$(curl -s http://localhost:8080/health | jq -r '.version')
    log "Deployed version: $VERSION"
}

rollback() {
    log "ROLLBACK: Initiating rollback..."

    # Get previous image tag
    PREVIOUS_TAG=$(docker images pos-api --format "{{.Tag}}" | sed -n '2p')

    if [ -z "$PREVIOUS_TAG" ]; then
        log "ERROR: No previous version found for rollback"
        exit 1
    fi

    log "Rolling back to version: $PREVIOUS_TAG"

    TAG=$PREVIOUS_TAG $DOCKER_COMPOSE up -d --force-recreate

    log "Rollback completed"
}

#=============================================
# MAIN EXECUTION
#=============================================
main() {
    log "=========================================="
    log "POS Platform Deployment - Started"
    log "Tag: $TAG"
    log "=========================================="

    cd "$DEPLOY_DIR"

    check_prerequisites
    backup_database
    pull_images
    deploy_with_zero_downtime
    verify_deployment

    log "=========================================="
    log "Deployment completed successfully!"
    log "=========================================="
}

# Run main function
main "$@"

Zero-Downtime Deployment Strategy

Rolling Update Process

┌─────────────────────────────────────────────────────────────────────────────┐
│                         ZERO-DOWNTIME DEPLOYMENT                            │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 1: Initial State
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├──────► API-1 (v1.0) [HEALTHY] ◄── Receiving traffic               │
│       ├──────► API-2 (v1.0) [HEALTHY] ◄── Receiving traffic               │
│       └──────► API-3 (v1.0) [HEALTHY] ◄── Receiving traffic               │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 2: Add New Container
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├──────► API-1 (v1.0) [HEALTHY]                                      │
│       ├──────► API-2 (v1.0) [HEALTHY]                                      │
│       ├──────► API-3 (v1.0) [HEALTHY]                                      │
│       └─ - - ► API-4 (v2.0) [STARTING] ◄── Not yet in rotation            │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 3: New Container Healthy
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├──────► API-1 (v1.0) [HEALTHY]                                      │
│       ├──────► API-2 (v1.0) [HEALTHY]                                      │
│       ├──────► API-3 (v1.0) [HEALTHY]                                      │
│       └──────► API-4 (v2.0) [HEALTHY] ◄── Now receiving traffic           │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 4: Drain Old Container
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├─ X ─► API-1 (v1.0) [DRAINING] ◄── Finishing existing requests     │
│       ├──────► API-2 (v1.0) [HEALTHY]                                      │
│       ├──────► API-3 (v1.0) [HEALTHY]                                      │
│       └──────► API-4 (v2.0) [HEALTHY]                                      │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 5: Replace Old Container
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├──────► API-1 (v2.0) [HEALTHY] ◄── Replaced                         │
│       ├──────► API-2 (v1.0) [DRAINING]                                     │
│       ├──────► API-3 (v1.0) [HEALTHY]                                      │
│       └──────► API-4 (v2.0) [HEALTHY]                                      │
└─────────────────────────────────────────────────────────────────────────────┘

STEP 6: Complete (Scale back to 3)
┌─────────────────────────────────────────────────────────────────────────────┐
│  Load Balancer                                                              │
│       │                                                                     │
│       ├──────► API-1 (v2.0) [HEALTHY]                                      │
│       ├──────► API-2 (v2.0) [HEALTHY]                                      │
│       └──────► API-3 (v2.0) [HEALTHY]                                      │
│                                                                             │
│  Result: Zero downtime, all traffic served continuously                     │
└─────────────────────────────────────────────────────────────────────────────┘

Rollback Procedures

Automated Rollback Script

#!/bin/bash
# File: /pos-platform/scripts/rollback.sh
# Emergency rollback script

set -e

DEPLOY_DIR="/opt/pos-platform"
DOCKER_COMPOSE="docker compose"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ROLLBACK: $1"
}

#=============================================
# ROLLBACK TO PREVIOUS VERSION
#=============================================
rollback_containers() {
    log "Starting container rollback..."

    # Get previous image
    PREVIOUS_TAG=$(docker images pos-api --format "table {{.Tag}}\t{{.CreatedAt}}" | \
                   grep -v latest | head -2 | tail -1 | awk '{print $1}')

    if [ -z "$PREVIOUS_TAG" ]; then
        log "ERROR: No previous version available"
        exit 1
    fi

    log "Rolling back to: $PREVIOUS_TAG"

    cd "$DEPLOY_DIR"
    export TAG=$PREVIOUS_TAG

    # Force recreate with previous version
    $DOCKER_COMPOSE up -d --force-recreate pos-api

    log "Containers rolled back to $PREVIOUS_TAG"
}

#=============================================
# ROLLBACK DATABASE (IF NEEDED)
#=============================================
rollback_database() {
    BACKUP_FILE=$1

    if [ -z "$BACKUP_FILE" ]; then
        log "No database backup specified, skipping DB rollback"
        return
    fi

    log "Rolling back database from: $BACKUP_FILE"

    # Restore from backup
    zcat "$BACKUP_FILE" | $DOCKER_COMPOSE exec -T postgres-primary psql -U pos_admin pos_db

    log "Database rolled back"
}

#=============================================
# VERIFY ROLLBACK
#=============================================
verify_rollback() {
    log "Verifying rollback..."

    sleep 30  # Wait for containers to stabilize

    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)

    if [ "$HTTP_CODE" -eq 200 ]; then
        log "Rollback verified successfully (HTTP $HTTP_CODE)"
    else
        log "ERROR: Rollback verification failed (HTTP $HTTP_CODE)"
        log "CRITICAL: Manual intervention required!"
        exit 1
    fi
}

#=============================================
# MAIN
#=============================================
main() {
    log "=========================================="
    log "EMERGENCY ROLLBACK INITIATED"
    log "=========================================="

    rollback_containers
    rollback_database "$1"
    verify_rollback

    log "=========================================="
    log "ROLLBACK COMPLETED"
    log "=========================================="
}

main "$@"

Health Check Endpoints

Health Check Implementation

// File: /src/POS.Api/Health/HealthCheckEndpoints.cs

public static class HealthCheckEndpoints
{
    public static void MapHealthChecks(this WebApplication app)
    {
        // Basic liveness probe (is the app running?)
        app.MapHealthChecks("/health/live", new HealthCheckOptions
        {
            Predicate = _ => false,  // No checks, just confirms app is running
            ResponseWriter = WriteResponse
        });

        // Readiness probe (is the app ready to serve traffic?)
        app.MapHealthChecks("/health/ready", new HealthCheckOptions
        {
            Predicate = check => check.Tags.Contains("ready"),
            ResponseWriter = WriteResponse
        });

        // Full health check (all dependencies)
        app.MapHealthChecks("/health", new HealthCheckOptions
        {
            ResponseWriter = WriteResponse
        });
    }

    private static async Task WriteResponse(
        HttpContext context,
        HealthReport report)
    {
        context.Response.ContentType = "application/json";

        var response = new
        {
            status = report.Status.ToString(),
            version = Assembly.GetExecutingAssembly()
                .GetCustomAttribute<AssemblyInformationalVersionAttribute>()
                ?.InformationalVersion ?? "unknown",
            timestamp = DateTime.UtcNow,
            checks = report.Entries.Select(e => new
            {
                name = e.Key,
                status = e.Value.Status.ToString(),
                duration = e.Value.Duration.TotalMilliseconds,
                description = e.Value.Description,
                data = e.Value.Data
            })
        };

        await context.Response.WriteAsJsonAsync(response);
    }
}

Health Check Response Example

{
  "status": "Healthy",
  "version": "2.1.0",
  "timestamp": "2025-12-29T10:30:00Z",
  "checks": [
    {
      "name": "database",
      "status": "Healthy",
      "duration": 12.5,
      "description": "PostgreSQL connection is healthy"
    },
    {
      "name": "redis",
      "status": "Healthy",
      "duration": 3.2,
      "description": "Redis cache is accessible"
    },
    {
      "name": "rabbitmq",
      "status": "Healthy",
      "duration": 8.1,
      "description": "RabbitMQ broker is connected"
    },
    {
      "name": "disk",
      "status": "Healthy",
      "duration": 1.0,
      "description": "Disk space: 45% used"
    }
  ]
}

Summary

This chapter provides complete deployment procedures including:

  1. Docker Configuration: Multi-stage Dockerfile and production docker-compose.yml
  2. Environment Variables: Complete reference for all configuration
  3. Deployment Checklist: Pre-deployment verification steps
  4. Zero-Downtime Strategy: Rolling update process diagram
  5. Rollback Procedures: Automated rollback scripts
  6. Health Checks: Implementation and response format

Next Chapter: Chapter 30: Monitoring and Alerting


“Deploy with confidence. Rollback without fear.”

Chapter 30: Monitoring and Alerting

Overview

This chapter defines the complete monitoring architecture for the POS Platform, including metrics collection, dashboards, alerting rules, and incident response procedures.


Monitoring Architecture

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                              MONITORING STACK                                        │
└─────────────────────────────────────────────────────────────────────────────────────┘

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   POS-API-1     │     │   POS-API-2     │     │   POS-API-3     │
│                 │     │                 │     │                 │
│ /metrics:8080   │     │ /metrics:8080   │     │ /metrics:8080   │
└────────┬────────┘     └────────┬────────┘     └────────┬────────┘
         │                       │                       │
         └───────────────────────┼───────────────────────┘
                                 │
                                 ▼
              ┌──────────────────────────────────────┐
              │           PROMETHEUS                 │
              │          (Metrics Store)             │
              │                                      │
              │  - Scrape interval: 15s              │
              │  - Retention: 15 days                │
              │  - Port: 9090                        │
              └──────────────────┬───────────────────┘
                                 │
              ┌──────────────────┼──────────────────┐
              │                  │                  │
              ▼                  ▼                  ▼
┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│    GRAFANA      │  │  ALERTMANAGER   │  │   LOKI          │
│  (Dashboards)   │  │    (Alerts)     │  │   (Logs)        │
│                 │  │                 │  │                 │
│  Port: 3000     │  │  Port: 9093     │  │  Port: 3100     │
└─────────────────┘  └────────┬────────┘  └─────────────────┘
                              │
              ┌───────────────┼───────────────┐
              │               │               │
              ▼               ▼               ▼
         ┌────────┐     ┌────────┐     ┌────────┐
         │ Slack  │     │ Email  │     │ PagerDuty│
         └────────┘     └────────┘     └────────┘

Key Metrics

Business SLIs (Service Level Indicators)

MetricDescriptionTargetAlert Threshold
Transaction Success Rate% of transactions completed successfully> 99.9%< 99.5%
Avg Transaction TimeEnd-to-end transaction processing< 2s> 5s
Payment Success Rate% of payments processed successfully> 99.5%< 99%
Order Fulfillment RateOrders fulfilled within SLA> 98%< 95%
API AvailabilityUptime of API endpoints> 99.9%< 99.5%

Infrastructure Metrics

CategoryMetricWarningCritical
CPUUsage %> 70%> 90%
MemoryUsage %> 75%> 90%
DiskUsage %> 70%> 85%
DiskI/O Wait> 20%> 40%
NetworkPacket Loss> 0.1%> 1%
NetworkLatency (ms)> 100ms> 500ms

Application Metrics

MetricDescriptionWarningCritical
Error Rate5xx errors per minute> 1%> 5%
Response Time (p99)99th percentile latency> 500ms> 2000ms
Response Time (p50)Median latency> 100ms> 500ms
Request RateRequests per secondN/A (baseline)> 200% of baseline
Queue DepthMessages waiting in RabbitMQ> 1000> 5000
Active ConnectionsDB connections in use> 80% of pool> 95% of pool
Cache Hit RateRedis cache effectiveness< 80%< 60%

Prometheus Configuration

Complete prometheus.yml

# File: /pos-platform/monitoring/prometheus/prometheus.yml

global:
  scrape_interval: 15s
  evaluation_interval: 15s
  external_labels:
    cluster: 'pos-production'
    environment: 'production'

#=============================================
# ALERTING CONFIGURATION
#=============================================
alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - alertmanager:9093

#=============================================
# RULE FILES
#=============================================
rule_files:
  - "/etc/prometheus/rules/*.yml"

#=============================================
# SCRAPE CONFIGURATIONS
#=============================================
scrape_configs:
  #-----------------------------------------
  # Prometheus Self-Monitoring
  #-----------------------------------------
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  #-----------------------------------------
  # POS API Instances
  #-----------------------------------------
  - job_name: 'pos-api'
    metrics_path: '/metrics'
    static_configs:
      - targets:
          - 'pos-api-1:8080'
          - 'pos-api-2:8080'
          - 'pos-api-3:8080'
        labels:
          app: 'pos-api'
          tier: 'backend'
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
        regex: '([^:]+):\d+'
        replacement: '${1}'

  #-----------------------------------------
  # PostgreSQL Exporter
  #-----------------------------------------
  - job_name: 'postgres'
    static_configs:
      - targets: ['postgres-exporter:9187']
        labels:
          app: 'postgres'
          tier: 'database'

  #-----------------------------------------
  # Redis Exporter
  #-----------------------------------------
  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']
        labels:
          app: 'redis'
          tier: 'cache'

  #-----------------------------------------
  # RabbitMQ Exporter
  #-----------------------------------------
  - job_name: 'rabbitmq'
    static_configs:
      - targets: ['rabbitmq:15692']
        labels:
          app: 'rabbitmq'
          tier: 'messaging'

  #-----------------------------------------
  # Nginx Exporter
  #-----------------------------------------
  - job_name: 'nginx'
    static_configs:
      - targets: ['nginx-exporter:9113']
        labels:
          app: 'nginx'
          tier: 'ingress'

  #-----------------------------------------
  # Node Exporter (Host Metrics)
  #-----------------------------------------
  - job_name: 'node'
    static_configs:
      - targets:
          - 'node-exporter:9100'
        labels:
          tier: 'infrastructure'

  #-----------------------------------------
  # Docker Container Metrics
  #-----------------------------------------
  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']
        labels:
          tier: 'containers'

Alert Rules

Complete Alert Rules Configuration

# File: /pos-platform/monitoring/prometheus/rules/alerts.yml

groups:
  #=============================================
  # P1 - CRITICAL (Page immediately)
  #=============================================
  - name: critical_alerts
    rules:
      #-----------------------------------------
      # API Down
      #-----------------------------------------
      - alert: APIDown
        expr: up{job="pos-api"} == 0
        for: 1m
        labels:
          severity: P1
          team: platform
        annotations:
          summary: "POS API instance {{ $labels.instance }} is down"
          description: "API instance has been unreachable for more than 1 minute"
          runbook_url: "https://wiki.internal/runbooks/api-down"

      #-----------------------------------------
      # Database Down
      #-----------------------------------------
      - alert: DatabaseDown
        expr: pg_up == 0
        for: 30s
        labels:
          severity: P1
          team: platform
        annotations:
          summary: "PostgreSQL database is down"
          description: "Database connection failed for 30 seconds"
          runbook_url: "https://wiki.internal/runbooks/db-down"

      #-----------------------------------------
      # High Error Rate
      #-----------------------------------------
      - alert: HighErrorRate
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total[5m]))
          ) * 100 > 5
        for: 2m
        labels:
          severity: P1
          team: platform
        annotations:
          summary: "High error rate detected: {{ $value | printf \"%.2f\" }}%"
          description: "Error rate exceeds 5% for more than 2 minutes"
          runbook_url: "https://wiki.internal/runbooks/high-error-rate"

      #-----------------------------------------
      # Transaction Failure Spike
      #-----------------------------------------
      - alert: TransactionFailureSpike
        expr: |
          (
            sum(rate(pos_transactions_failed_total[5m]))
            /
            sum(rate(pos_transactions_total[5m]))
          ) * 100 > 1
        for: 5m
        labels:
          severity: P1
          team: platform
        annotations:
          summary: "Transaction failure rate: {{ $value | printf \"%.2f\" }}%"
          description: "More than 1% of transactions are failing"
          runbook_url: "https://wiki.internal/runbooks/transaction-failures"

  #=============================================
  # P2 - HIGH (Page during business hours)
  #=============================================
  - name: high_alerts
    rules:
      #-----------------------------------------
      # High Response Time
      #-----------------------------------------
      - alert: HighResponseTime
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
          ) > 2
        for: 5m
        labels:
          severity: P2
          team: platform
        annotations:
          summary: "P99 response time is {{ $value | printf \"%.2f\" }}s"
          description: "99th percentile latency exceeds 2 seconds"
          runbook_url: "https://wiki.internal/runbooks/high-latency"

      #-----------------------------------------
      # Database Connection Pool Exhaustion
      #-----------------------------------------
      - alert: DBConnectionPoolLow
        expr: |
          pg_stat_activity_count / pg_settings_max_connections * 100 > 80
        for: 5m
        labels:
          severity: P2
          team: platform
        annotations:
          summary: "DB connection pool at {{ $value | printf \"%.0f\" }}%"
          description: "Database connections nearly exhausted"
          runbook_url: "https://wiki.internal/runbooks/db-connections"

      #-----------------------------------------
      # Queue Backlog
      #-----------------------------------------
      - alert: QueueBacklog
        expr: rabbitmq_queue_messages > 5000
        for: 10m
        labels:
          severity: P2
          team: platform
        annotations:
          summary: "Message queue backlog: {{ $value }} messages"
          description: "RabbitMQ queue has significant backlog"
          runbook_url: "https://wiki.internal/runbooks/queue-backlog"

      #-----------------------------------------
      # Memory Pressure
      #-----------------------------------------
      - alert: HighMemoryUsage
        expr: |
          (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 90
        for: 5m
        labels:
          severity: P2
          team: infrastructure
        annotations:
          summary: "Memory usage at {{ $value | printf \"%.0f\" }}%"
          description: "System memory is critically low"
          runbook_url: "https://wiki.internal/runbooks/memory-pressure"

  #=============================================
  # P3 - MEDIUM (Email/Slack notification)
  #=============================================
  - name: medium_alerts
    rules:
      #-----------------------------------------
      # CPU Warning
      #-----------------------------------------
      - alert: HighCPUUsage
        expr: |
          100 - (avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 70
        for: 15m
        labels:
          severity: P3
          team: infrastructure
        annotations:
          summary: "CPU usage at {{ $value | printf \"%.0f\" }}%"
          description: "CPU usage elevated for extended period"

      #-----------------------------------------
      # Disk Space Warning
      #-----------------------------------------
      - alert: DiskSpaceLow
        expr: |
          (1 - (node_filesystem_avail_bytes / node_filesystem_size_bytes)) * 100 > 70
        for: 30m
        labels:
          severity: P3
          team: infrastructure
        annotations:
          summary: "Disk usage at {{ $value | printf \"%.0f\" }}% on {{ $labels.mountpoint }}"
          description: "Disk space running low"

      #-----------------------------------------
      # Cache Hit Rate Low
      #-----------------------------------------
      - alert: CacheHitRateLow
        expr: |
          redis_keyspace_hits_total /
          (redis_keyspace_hits_total + redis_keyspace_misses_total) * 100 < 80
        for: 30m
        labels:
          severity: P3
          team: platform
        annotations:
          summary: "Cache hit rate: {{ $value | printf \"%.0f\" }}%"
          description: "Redis cache effectiveness is low"

  #=============================================
  # P4 - LOW (Log/Dashboard only)
  #=============================================
  - name: low_alerts
    rules:
      #-----------------------------------------
      # SSL Certificate Expiry
      #-----------------------------------------
      - alert: SSLCertExpiringSoon
        expr: |
          (probe_ssl_earliest_cert_expiry - time()) / 86400 < 30
        for: 1h
        labels:
          severity: P4
          team: platform
        annotations:
          summary: "SSL cert expires in {{ $value | printf \"%.0f\" }} days"
          description: "Certificate renewal needed soon"

      #-----------------------------------------
      # Container Restarts
      #-----------------------------------------
      - alert: ContainerRestarts
        expr: |
          increase(kube_pod_container_status_restarts_total[1h]) > 3
        for: 1h
        labels:
          severity: P4
          team: platform
        annotations:
          summary: "Container {{ $labels.container }} restarted {{ $value }} times"
          description: "Container may be unstable"

AlertManager Configuration

# File: /pos-platform/monitoring/alertmanager/alertmanager.yml

global:
  smtp_smarthost: 'smtp.company.com:587'
  smtp_from: 'alerts@pos-platform.com'
  smtp_auth_username: 'alerts@pos-platform.com'
  smtp_auth_password: '${SMTP_PASSWORD}'

  slack_api_url: '${SLACK_WEBHOOK_URL}'

  pagerduty_url: 'https://events.pagerduty.com/v2/enqueue'

#=============================================
# ROUTING
#=============================================
route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'default-receiver'

  routes:
    #-----------------------------------------
    # P1 - Critical: Page immediately
    #-----------------------------------------
    - match:
        severity: P1
      receiver: 'pagerduty-critical'
      continue: true
    - match:
        severity: P1
      receiver: 'slack-critical'
      continue: true

    #-----------------------------------------
    # P2 - High: Page during business hours
    #-----------------------------------------
    - match:
        severity: P2
      receiver: 'pagerduty-high'
      active_time_intervals:
        - business-hours
      continue: true
    - match:
        severity: P2
      receiver: 'slack-high'

    #-----------------------------------------
    # P3 - Medium: Slack + Email
    #-----------------------------------------
    - match:
        severity: P3
      receiver: 'slack-medium'
      continue: true
    - match:
        severity: P3
      receiver: 'email-team'

    #-----------------------------------------
    # P4 - Low: Slack only
    #-----------------------------------------
    - match:
        severity: P4
      receiver: 'slack-low'

#=============================================
# TIME INTERVALS
#=============================================
time_intervals:
  - name: business-hours
    time_intervals:
      - weekdays: ['monday:friday']
        times:
          - start_time: '09:00'
            end_time: '18:00'

#=============================================
# RECEIVERS
#=============================================
receivers:
  - name: 'default-receiver'
    slack_configs:
      - channel: '#pos-alerts'
        send_resolved: true

  - name: 'pagerduty-critical'
    pagerduty_configs:
      - service_key: '${PAGERDUTY_SERVICE_KEY}'
        severity: critical

  - name: 'pagerduty-high'
    pagerduty_configs:
      - service_key: '${PAGERDUTY_SERVICE_KEY}'
        severity: error

  - name: 'slack-critical'
    slack_configs:
      - channel: '#pos-critical'
        send_resolved: true
        color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}'
        title: '{{ .Status | toUpper }}: {{ .CommonAnnotations.summary }}'
        text: '{{ .CommonAnnotations.description }}'
        actions:
          - type: button
            text: 'Runbook'
            url: '{{ .CommonAnnotations.runbook_url }}'
          - type: button
            text: 'Dashboard'
            url: 'https://grafana.internal/d/pos-overview'

  - name: 'slack-high'
    slack_configs:
      - channel: '#pos-alerts'
        send_resolved: true
        color: 'warning'

  - name: 'slack-medium'
    slack_configs:
      - channel: '#pos-alerts'
        send_resolved: true

  - name: 'slack-low'
    slack_configs:
      - channel: '#pos-info'
        send_resolved: false

  - name: 'email-team'
    email_configs:
      - to: 'platform-team@company.com'
        send_resolved: true

Grafana Dashboard

POS Platform Overview Dashboard (JSON)

{
  "dashboard": {
    "id": null,
    "uid": "pos-overview",
    "title": "POS Platform Overview",
    "tags": ["pos", "production"],
    "timezone": "browser",
    "refresh": "30s",
    "time": {
      "from": "now-1h",
      "to": "now"
    },
    "panels": [
      {
        "id": 1,
        "title": "Transaction Success Rate",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 0, "y": 0},
        "targets": [
          {
            "expr": "(sum(rate(pos_transactions_success_total[5m])) / sum(rate(pos_transactions_total[5m]))) * 100",
            "legendFormat": "Success Rate"
          }
        ],
        "options": {
          "colorMode": "value",
          "graphMode": "area"
        },
        "fieldConfig": {
          "defaults": {
            "unit": "percent",
            "thresholds": {
              "mode": "absolute",
              "steps": [
                {"color": "red", "value": null},
                {"color": "yellow", "value": 99},
                {"color": "green", "value": 99.5}
              ]
            }
          }
        }
      },
      {
        "id": 2,
        "title": "Requests per Second",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 4, "y": 0},
        "targets": [
          {
            "expr": "sum(rate(http_requests_total[1m]))",
            "legendFormat": "RPS"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "unit": "reqps"
          }
        }
      },
      {
        "id": 3,
        "title": "P99 Response Time",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 8, "y": 0},
        "targets": [
          {
            "expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
            "legendFormat": "P99"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "unit": "s",
            "thresholds": {
              "mode": "absolute",
              "steps": [
                {"color": "green", "value": null},
                {"color": "yellow", "value": 0.5},
                {"color": "red", "value": 2}
              ]
            }
          }
        }
      },
      {
        "id": 4,
        "title": "Error Rate",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 12, "y": 0},
        "targets": [
          {
            "expr": "(sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m]))) * 100",
            "legendFormat": "Errors"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "unit": "percent",
            "thresholds": {
              "mode": "absolute",
              "steps": [
                {"color": "green", "value": null},
                {"color": "yellow", "value": 1},
                {"color": "red", "value": 5}
              ]
            }
          }
        }
      },
      {
        "id": 5,
        "title": "Active Transactions",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 16, "y": 0},
        "targets": [
          {
            "expr": "pos_transactions_in_progress",
            "legendFormat": "Active"
          }
        ]
      },
      {
        "id": 6,
        "title": "API Health",
        "type": "stat",
        "gridPos": {"h": 4, "w": 4, "x": 20, "y": 0},
        "targets": [
          {
            "expr": "count(up{job=\"pos-api\"} == 1)",
            "legendFormat": "Healthy Instances"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "thresholds": {
              "mode": "absolute",
              "steps": [
                {"color": "red", "value": null},
                {"color": "yellow", "value": 2},
                {"color": "green", "value": 3}
              ]
            }
          }
        }
      },
      {
        "id": 10,
        "title": "Request Rate by Endpoint",
        "type": "timeseries",
        "gridPos": {"h": 8, "w": 12, "x": 0, "y": 4},
        "targets": [
          {
            "expr": "sum(rate(http_requests_total[5m])) by (endpoint)",
            "legendFormat": "{{endpoint}}"
          }
        ]
      },
      {
        "id": 11,
        "title": "Response Time Distribution",
        "type": "heatmap",
        "gridPos": {"h": 8, "w": 12, "x": 12, "y": 4},
        "targets": [
          {
            "expr": "sum(increase(http_request_duration_seconds_bucket[1m])) by (le)",
            "legendFormat": "{{le}}"
          }
        ]
      },
      {
        "id": 20,
        "title": "Database Connections",
        "type": "timeseries",
        "gridPos": {"h": 6, "w": 8, "x": 0, "y": 12},
        "targets": [
          {
            "expr": "pg_stat_activity_count",
            "legendFormat": "Active"
          },
          {
            "expr": "pg_settings_max_connections",
            "legendFormat": "Max"
          }
        ]
      },
      {
        "id": 21,
        "title": "Redis Operations",
        "type": "timeseries",
        "gridPos": {"h": 6, "w": 8, "x": 8, "y": 12},
        "targets": [
          {
            "expr": "rate(redis_commands_processed_total[1m])",
            "legendFormat": "Commands/sec"
          }
        ]
      },
      {
        "id": 22,
        "title": "Queue Depth",
        "type": "timeseries",
        "gridPos": {"h": 6, "w": 8, "x": 16, "y": 12},
        "targets": [
          {
            "expr": "rabbitmq_queue_messages",
            "legendFormat": "{{queue}}"
          }
        ]
      },
      {
        "id": 30,
        "title": "CPU Usage by Container",
        "type": "timeseries",
        "gridPos": {"h": 6, "w": 12, "x": 0, "y": 18},
        "targets": [
          {
            "expr": "rate(container_cpu_usage_seconds_total{container!=\"\"}[5m]) * 100",
            "legendFormat": "{{container}}"
          }
        ],
        "fieldConfig": {
          "defaults": {"unit": "percent"}
        }
      },
      {
        "id": 31,
        "title": "Memory Usage by Container",
        "type": "timeseries",
        "gridPos": {"h": 6, "w": 12, "x": 12, "y": 18},
        "targets": [
          {
            "expr": "container_memory_usage_bytes{container!=\"\"} / 1024 / 1024",
            "legendFormat": "{{container}}"
          }
        ],
        "fieldConfig": {
          "defaults": {"unit": "decmbytes"}
        }
      }
    ]
  }
}

Incident Response Runbooks

Runbook: API Down (P1)

# Runbook: API Down

**Alert**: APIDown
**Severity**: P1 (Critical)
**Impact**: Customers cannot complete transactions

## Symptoms
- Health check endpoint returning non-200
- Load balancer showing unhealthy targets
- Transaction error rate spike

## Immediate Actions (First 5 minutes)

1. **Verify the alert**
   ```bash
   curl -s http://pos-api:8080/health | jq
   docker ps | grep pos-api
  1. Check container logs

    docker logs pos-api-1 --tail 100
    docker logs pos-api-2 --tail 100
    docker logs pos-api-3 --tail 100
    
  2. Check resource usage

    docker stats --no-stream
    
  3. Restart unhealthy containers

    docker restart pos-api-1  # Replace with affected container
    

Escalation

  • If all containers down: Page Infrastructure Lead
  • If database issue: Page Database Team
  • If network issue: Page Network Team

Resolution Checklist

  • Identify root cause
  • Apply fix (restart, rollback, config change)
  • Verify health checks passing
  • Monitor for 15 minutes
  • Update incident ticket
  • Schedule postmortem if major outage

Common Causes

CauseSolution
OOM (Out of Memory)Restart, investigate memory leak
Database connection failureCheck DB health, restart connections
Deployment failureRollback to previous version
Network partitionCheck network, restart networking

### Runbook: High Error Rate (P1)

```markdown
# Runbook: High Error Rate

**Alert**: HighErrorRate
**Severity**: P1 (Critical)
**Impact**: Significant portion of requests failing

## Symptoms
- 5xx error rate > 5%
- Customer complaints about failures
- Transaction success rate dropping

## Immediate Actions

1. **Identify error patterns**
   ```bash
   # Check recent errors in logs
   docker logs pos-api-1 2>&1 | grep -i error | tail -50

   # Query Loki for error patterns
   {job="pos-api"} |= "error" | json | line_format "{{.message}}"
  1. Check which endpoints are failing

    # In Grafana/Prometheus
    sum(rate(http_requests_total{status=~"5.."}[5m])) by (endpoint, status)
    
  2. Check dependent services

    # Database
    docker exec pos-postgres-primary pg_isready
    
    # Redis
    docker exec pos-redis redis-cli ping
    
    # RabbitMQ
    curl -u admin:password http://localhost:15672/api/healthchecks/node
    

Root Cause Investigation

Error PatternLikely CauseSolution
500 on /api/transactionsDatabase timeoutCheck DB connections
503 across all endpointsOverloadScale up or rate limit
502 from nginxContainer crashRestart containers
Timeout errorsSlow DB queriesKill long queries, add indexes

Recovery Steps

  1. If DB issue: Restart connection pool
  2. If overload: Enable aggressive rate limiting
  3. If code bug: Rollback deployment
  4. If external dependency: Enable circuit breaker

---

## Summary

This chapter provides complete monitoring coverage:

1. **Architecture**: Prometheus + Grafana + AlertManager stack
2. **Metrics**: Business SLIs and infrastructure metrics with thresholds
3. **Prometheus Config**: Complete scrape configuration
4. **Alert Rules**: P1-P4 severity levels with escalation
5. **Grafana Dashboard**: Production-ready JSON dashboard
6. **Runbooks**: Step-by-step incident response procedures

**Next Chapter**: [Chapter 31: Security Compliance](./Chapter-31-Security-Compliance.md)

---

*"You cannot improve what you do not measure."*

Chapter 31: Security and Compliance

Overview

This chapter covers security architecture, PCI-DSS compliance requirements, data protection strategies, and security audit procedures for the POS Platform.


Security Architecture

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                              SECURITY LAYERS                                         │
└─────────────────────────────────────────────────────────────────────────────────────┘

                              INTERNET
                                 │
                    ┌────────────┴────────────┐
                    │      WAF / DDoS         │  Layer 1: Edge Security
                    │    (Cloudflare/AWS)     │  - Rate limiting
                    └────────────┬────────────┘  - Bot protection
                                 │               - Geo-blocking
                    ┌────────────┴────────────┐
                    │     Load Balancer       │  Layer 2: TLS Termination
                    │   (TLS 1.3 only)        │  - Certificate management
                    └────────────┬────────────┘  - HSTS enforcement
                                 │
         ┌───────────────────────┼───────────────────────┐
         │                       │                       │
┌────────┴────────┐    ┌────────┴────────┐    ┌────────┴────────┐
│   POS API       │    │   POS API       │    │   POS API       │
│   (Container)   │    │   (Container)   │    │   (Container)   │
│                 │    │                 │    │                 │
│ Layer 3:        │    │ - JWT Auth      │    │ - Input Valid.  │
│ Application     │    │ - RBAC          │    │ - Output Encod. │
└────────┬────────┘    └────────┬────────┘    └────────┬────────┘
         │                       │                       │
         └───────────────────────┼───────────────────────┘
                                 │
                    ┌────────────┴────────────┐
                    │    Network Firewall     │  Layer 4: Network
                    │   (Docker Network)      │  - Microsegmentation
                    └────────────┬────────────┘  - No direct DB access
                                 │
         ┌───────────────────────┼───────────────────────┐
         │                       │                       │
┌────────┴────────┐    ┌────────┴────────┐    ┌────────┴────────┐
│   PostgreSQL    │    │     Redis       │    │   RabbitMQ      │
│                 │    │                 │    │                 │
│ Layer 5:        │    │ - Encrypted     │    │ - TLS enabled   │
│ Data Layer      │    │ - Auth required │    │ - Auth required │
│                 │    │                 │    │                 │
│ - Encryption    │    │                 │    │                 │
│ - Row-level sec │    │                 │    │                 │
└─────────────────┘    └─────────────────┘    └─────────────────┘

PCI-DSS Compliance Checklist

Complete 12 Requirements

# PCI-DSS v4.0 Compliance Checklist for POS Platform

## REQUIREMENT 1: Install and Maintain Network Security Controls

### 1.1 Network Security Policies
- [x] Firewall rules documented
- [x] Network diagram maintained
- [x] All connections reviewed quarterly
- [x] Traffic restrictions enforced

### 1.2 Network Configuration Standards
- [x] Default passwords changed on all devices
- [x] Unnecessary services disabled
- [x] Security patches applied within 30 days
- [x] Anti-spoofing measures implemented

### Implementation
```bash
# Docker network isolation
docker network create --driver bridge \
  --subnet=172.28.0.0/16 \
  --opt com.docker.network.bridge.enable_ip_masquerade=true \
  pos-secure-network

# Firewall rules (iptables)
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT
iptables -A INPUT -j DROP

REQUIREMENT 2: Apply Secure Configurations

2.1 System Configuration Standards

  • Hardened container images (Alpine-based)
  • Non-root container execution
  • Minimal installed packages
  • Security benchmarks applied (CIS)

2.2 Secure Defaults

  • Default accounts disabled/removed
  • Vendor defaults changed
  • Unnecessary functionality removed

Implementation

# Secure Dockerfile practices
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine

# Remove unnecessary packages
RUN apk del --purge wget curl || true

# Non-root user
RUN addgroup -S posgroup && adduser -S posuser -G posgroup
USER posuser

# Read-only filesystem where possible
RUN chmod -R 555 /app

REQUIREMENT 3: Protect Stored Account Data

3.1 Data Retention Policy

  • Card data retention minimized
  • PAN stored only when necessary (we don’t store)
  • Quarterly purge of unnecessary data
  • Documented retention periods

3.2 Sensitive Authentication Data

  • Full track data NOT stored ✓
  • CVV/CVC NOT stored ✓
  • PIN/PIN block NOT stored ✓

3.3 PAN Display Masking

  • PAN masked on display (show last 4 only)
  • Full PAN not logged

3.4 PAN Rendering Unreadable

  • We use tokenization (no PAN stored)
  • Stripe tokens reference only

What We Store vs. Don’t Store

Data TypeStored?MethodLocation
Full PANNOTokenizedStripe
Last 4 digitsYESMaskedLocal DB
CVV/CVCNONever capturedN/A
Expiry DateYESEncryptedLocal DB
Cardholder NameYESEncryptedLocal DB
Track DataNONever capturedN/A
PINNONever capturedN/A
Payment TokenYESAs-isLocal DB

REQUIREMENT 4: Protect Data in Transit

4.1 Encryption Standards

  • TLS 1.2+ for all transmissions
  • TLS 1.3 preferred
  • Strong cipher suites only
  • Certificate validation enforced

4.2 Wireless Security

  • WPA3 for wireless POS terminals
  • No open wireless networks
  • Wireless IDS monitoring

Implementation

# Nginx TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;

# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

REQUIREMENT 5: Protect from Malicious Software

5.1 Anti-Malware Deployment

  • Container scanning in CI/CD
  • Runtime malware detection
  • Automatic signature updates

5.2 Anti-Phishing

  • Email filtering enabled
  • User awareness training
  • SPF/DKIM/DMARC configured

Implementation

# CI/CD container scanning (GitHub Actions)
- name: Container Security Scan
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: pos-api:${{ github.sha }}
    severity: 'CRITICAL,HIGH'
    exit-code: '1'

REQUIREMENT 6: Develop and Maintain Secure Systems

6.1 Secure Development Lifecycle

  • Security requirements in design phase
  • Code review mandatory
  • SAST (Static Analysis) in CI
  • DAST (Dynamic Analysis) pre-release

6.2 Change Control

  • All changes documented
  • Security impact assessment
  • Rollback procedures defined
  • Separation of dev/test/prod

6.3 Vulnerability Management

  • Known vulnerabilities addressed
  • Security patches within 30 days (critical)
  • Dependency scanning automated

REQUIREMENT 7: Restrict Access to System Components

7.1 Access Control Model

  • Role-based access control (RBAC)
  • Least privilege principle
  • Access reviews quarterly
  • Default deny policy

7.2 Access Control System

  • Unique user IDs
  • MFA for admin access
  • Session timeout enforced

Access Control Matrix

RoleTransactionsInventoryReportsUsersSettings
CashierCreateViewNoneNoneNone
SupervisorAllAllStoreNoneStore
Store ManagerAllAllStoreStoreStore
Regional ManagerViewViewRegionViewView
AdminAllAllAllAllAll
SystemAPI OnlyAPI OnlyNoneNoneNone

REQUIREMENT 8: Identify Users and Authenticate Access

8.1 User Identification

  • Unique user IDs for all users
  • Shared accounts prohibited
  • User ID policy documented

8.2 Authentication Management

  • Password complexity enforced
  • Password history (12 passwords)
  • Account lockout (5 failures)
  • Session timeout (15 minutes inactive)

8.3 Multi-Factor Authentication

  • MFA for remote access
  • MFA for admin consoles
  • MFA for cardholder data access

Implementation

// Password policy configuration
services.Configure<IdentityOptions>(options =>
{
    options.Password.RequiredLength = 12;
    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireUppercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequiredUniqueChars = 4;

    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30);
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.AllowedForNewUsers = true;
});

REQUIREMENT 9: Restrict Physical Access

9.1 Physical Security

  • Data center access controlled
  • Visitor logs maintained
  • Badge access for sensitive areas

9.2 Media Protection

  • Media inventory maintained
  • Secure media destruction
  • Media transport security

9.3 POS Device Security

  • Device inventory maintained
  • Tamper-evident labels
  • Regular device inspection

REQUIREMENT 10: Log and Monitor All Access

10.1 Audit Logging

  • All access logged
  • Log integrity protected
  • Logs retained 1 year (3 months online)

10.2 Log Content

  • User identification
  • Event type
  • Date/time
  • Success/failure
  • Affected resource

10.3 Log Review

  • Daily log review
  • Automated anomaly detection
  • Incident correlation

Implementation

// Audit logging configuration
public class AuditLogEntry
{
    public Guid Id { get; set; }
    public DateTime Timestamp { get; set; }
    public string UserId { get; set; }
    public string UserName { get; set; }
    public string EventType { get; set; }  // Login, Access, Modify, Delete
    public string Resource { get; set; }
    public string ResourceId { get; set; }
    public bool Success { get; set; }
    public string IpAddress { get; set; }
    public string UserAgent { get; set; }
    public string Details { get; set; }  // JSON of changes
}

REQUIREMENT 11: Test Security Regularly

11.1 Vulnerability Scanning

  • Internal scans quarterly
  • External scans quarterly
  • Rescans after changes

11.2 Penetration Testing

  • Annual penetration test
  • Test after significant changes
  • Remediation verified

11.3 Change Detection

  • File integrity monitoring
  • Configuration drift detection
  • Unauthorized change alerts

Vulnerability Scanning Schedule

Scan TypeFrequencyToolRemediation SLA
Container scanEvery buildTrivyBlock if Critical
Dependency scanDailyDependabot7 days
SASTEvery PRSonarQubeBlock if High
DASTWeeklyOWASP ZAP14 days
External ASVQuarterlyQualys30 days
Internal NetworkQuarterlyNessus30 days

REQUIREMENT 12: Support Security with Policies

12.1 Security Policy

  • Information security policy documented
  • Annual policy review
  • Policy accessible to all staff

12.2 Risk Assessment

  • Annual risk assessment
  • Risk register maintained
  • Risk treatment plans

12.3 Security Awareness

  • Security training for all staff
  • Annual refresher training
  • Role-specific training

12.4 Incident Response

  • Incident response plan
  • Annual plan testing
  • Breach notification procedures

---

## Tokenization Flow

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ PAYMENT TOKENIZATION FLOW │ └─────────────────────────────────────────────────────────────────────────────────────┘

STEP 1: Customer Enters Card ┌───────────────┐ │ POS Client │ Customer swipes/taps/enters card │ │ Card data NEVER touches our servers └───────┬───────┘ │ Card data (encrypted) ▼ ┌───────────────┐ │ Stripe.js │ Client-side SDK handles card data │ (Browser) │ Tokenization happens in secure iframe └───────┬───────┘ │ HTTPS (TLS 1.3) ▼ ┌───────────────┐ │ Stripe │ PCI Level 1 certified │ Servers │ Card data stored securely └───────┬───────┘ │ Payment Token (tok_xxx) ▼ ┌───────────────┐ │ POS Client │ Receives token, NOT card data │ │ └───────┬───────┘ │ Token + amount ▼ ┌───────────────┐ │ POS API │ Our server sees ONLY token │ Server │ Never handles raw card data └───────┬───────┘ │ Charge request with token ▼ ┌───────────────┐ │ Stripe │ Processes payment │ Servers │ Returns charge ID └───────┬───────┘ │ Charge result ▼ ┌───────────────┐ │ POS API │ Stores transaction record │ Server │ Stores: token, last4, amount └───────────────┘ Does NOT store: full PAN, CVV

WHAT WE STORE: ┌─────────────────────────────────────────────────────┐ │ Transaction Record │ ├─────────────────────────────────────────────────────┤ │ transaction_id: “txn_abc123” │ │ stripe_charge_id: “ch_xyz789” │ │ stripe_token: “tok_xxx” (reference only) │ │ card_last4: “4242” (masked) │ │ card_brand: “Visa” │ │ amount: 99.99 │ │ status: “completed” │ │ created_at: “2025-12-29T10:30:00Z” │ └─────────────────────────────────────────────────────┘

WHAT WE NEVER STORE: ┌─────────────────────────────────────────────────────┐ │ ❌ Full card number (PAN) │ │ ❌ CVV/CVC │ │ ❌ PIN │ │ ❌ Track data │ │ ❌ Expiration date (optional, encrypted if stored) │ └─────────────────────────────────────────────────────┘


---

## Network Segmentation

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ NETWORK SEGMENTATION │ └─────────────────────────────────────────────────────────────────────────────────────┘

                     ┌─────────────────────────────┐
                     │      INTERNET (Untrusted)   │
                     └──────────────┬──────────────┘
                                    │
                     ┌──────────────┴──────────────┐
                     │         DMZ ZONE            │
                     │    (172.28.1.0/24)          │
                     │                             │
                     │  ┌──────────┐ ┌──────────┐  │
                     │  │  Nginx   │ │   WAF    │  │
                     │  │  (LB)    │ │          │  │
                     │  └────┬─────┘ └────┬─────┘  │
                     └───────┼────────────┼────────┘
                             │            │

══════════════════════════════════════════════════════════ Firewall │ │ ┌───────┴────────────┴────────┐ │ APPLICATION ZONE │ │ (172.28.2.0/24) │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ POS-API │ │ POS-API │ │ │ │ 1 │ │ 2 │ │ │ └────┬─────┘ └────┬─────┘ │ └───────┼────────────┼────────┘ │ │ ══════════════════════════════════════════════════════════ Firewall │ │ ┌───────┴────────────┴────────┐ │ DATA ZONE │ │ (172.28.3.0/24) │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Postgres │ │ Redis │ │ │ │ │ │ │ │ │ └──────────┘ └──────────┘ │ │ │ │ ┌──────────┐ │ │ │ RabbitMQ │ │ │ │ │ │ │ └──────────┘ │ └─────────────────────────────┘ │ ══════════════════════════════════════════════════════════ Firewall │ ┌──────────────┴──────────────┐ │ MANAGEMENT ZONE │ │ (172.28.4.0/24) │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Grafana │ │Prometheus│ │ │ │ │ │ │ │ │ └──────────┘ └──────────┘ │ └─────────────────────────────┘

FIREWALL RULES:

DMZ → Application: ALLOW: TCP 8080 (API) from Nginx only DENY: All other traffic

Application → Data: ALLOW: TCP 5432 (Postgres) from API containers ALLOW: TCP 6379 (Redis) from API containers ALLOW: TCP 5672 (RabbitMQ) from API containers DENY: All other traffic

Data → External: DENY: All outbound traffic

Management → All: ALLOW: TCP 9090 (metrics scrape) ALLOW: SSH from jump host only


---

## Breach Response Procedures

### Incident Response Plan

```markdown
# Security Incident Response Plan

## Phase 1: Detection & Identification (0-15 minutes)

### Indicators of Compromise
- Unusual database queries
- Spike in failed authentication
- Unexpected outbound traffic
- Data exfiltration alerts
- Customer reports of fraud

### Initial Assessment
1. Confirm incident is real (not false positive)
2. Classify severity:
   - P1: Active breach, data exfiltration
   - P2: Attempted breach, no data loss
   - P3: Vulnerability discovered, no exploitation

### Notification Matrix

| Severity | Notify Immediately |
|----------|-------------------|
| P1 | CISO, CTO, Legal, CEO, Payment Processor |
| P2 | CISO, Security Team Lead, Engineering Lead |
| P3 | Security Team Lead |

---

## Phase 2: Containment (15-60 minutes)

### Immediate Actions (P1)
1. **Isolate affected systems**
   ```bash
   # Block external traffic
   iptables -I INPUT -j DROP

   # Preserve evidence
   docker pause <container>
  1. Revoke compromised credentials

    -- Revoke all API keys
    UPDATE api_keys SET revoked = true WHERE tenant_id = <affected>;
    
    -- Force password reset
    UPDATE users SET must_reset_password = true WHERE tenant_id = <affected>;
    
  2. Notify payment processor

    • Call Stripe incident hotline
    • Provide transaction date range
    • Request card replacement if needed

Phase 3: Eradication (1-24 hours)

Evidence Collection

  1. Capture memory dump
  2. Export all logs (past 90 days)
  3. Capture network traffic
  4. Preserve container images

Root Cause Analysis

  1. How did attacker gain access?
  2. What systems were accessed?
  3. What data was accessed/exfiltrated?
  4. How long was attacker present?

Remediation

  1. Patch vulnerability
  2. Remove backdoors
  3. Reset all credentials
  4. Update security controls

Phase 4: Recovery (24-72 hours)

System Restoration

  1. Deploy from known-good images
  2. Restore data from clean backup
  3. Implement additional monitoring
  4. Gradual traffic restoration

Verification

  1. Security scan of restored systems
  2. Penetration test of fixed vulnerability
  3. Log analysis for lingering threats

Phase 5: Lessons Learned (1-2 weeks)

Post-Incident Review

  • Timeline of events
  • What worked well
  • What needs improvement
  • Action items with owners

Regulatory Notifications

RegulationNotification PeriodAuthority
PCI-DSSImmediatelyPayment brands, acquiring bank
GDPR72 hoursSupervisory authority
State LawsVaries (30-90 days)State AG, affected individuals

Communication Templates

Customer Notification (Email)

Subject: Important Security Notice

Dear [Customer Name],

We are writing to inform you of a security incident that may have
affected your information...

[Describe incident without technical details]

What We Are Doing:
- [Actions taken]

What You Should Do:
- Monitor your accounts
- Report suspicious activity

[Contact information]
[Credit monitoring offer if applicable]

---

## Security Audit Checklist

```markdown
# Quarterly Security Audit Checklist

## 1. Access Control Review

### User Accounts
- [ ] Review all user accounts for necessity
- [ ] Verify MFA enabled for all admin accounts
- [ ] Check for dormant accounts (no login > 90 days)
- [ ] Verify terminated employee access removed
- [ ] Review service account permissions

### API Keys & Tokens
- [ ] Rotate API keys > 90 days old
- [ ] Review API key permissions
- [ ] Check for exposed keys in code/logs
- [ ] Verify webhook secrets rotated

## 2. System Configuration

### Containers
- [ ] Scan all images for vulnerabilities
- [ ] Verify base images up to date
- [ ] Check for containers running as root
- [ ] Review exposed ports

### Database
- [ ] Verify encryption at rest enabled
- [ ] Check backup encryption
- [ ] Review database user permissions
- [ ] Test backup restoration

### Network
- [ ] Review firewall rules
- [ ] Check for unnecessary open ports
- [ ] Verify TLS configuration (SSL Labs A+)
- [ ] Test network segmentation

## 3. Logging & Monitoring

### Audit Logs
- [ ] Verify all security events logged
- [ ] Check log integrity (no gaps)
- [ ] Test log alerting
- [ ] Verify log retention (1 year)

### Monitoring
- [ ] Review alert thresholds
- [ ] Test incident response workflow
- [ ] Verify on-call rotation
- [ ] Check monitoring coverage

## 4. Vulnerability Management

### Scanning
- [ ] Review latest vulnerability scan results
- [ ] Verify critical findings remediated
- [ ] Check dependency vulnerabilities
- [ ] Review code analysis findings

### Patching
- [ ] Verify OS patches current
- [ ] Check application dependencies
- [ ] Review security advisories
- [ ] Test patch deployment process

## 5. Compliance

### PCI-DSS
- [ ] Review SAQ completion
- [ ] Verify ASV scan passing
- [ ] Check penetration test findings
- [ ] Update network diagram

### Data Protection
- [ ] Review data retention
- [ ] Verify data classification
- [ ] Check encryption standards
- [ ] Test data deletion process

## Sign-off

| Role | Name | Date | Signature |
|------|------|------|-----------|
| Security Lead | | | |
| CTO | | | |
| Compliance Officer | | | |

Summary

This chapter provides comprehensive security coverage:

  1. Security Architecture: Defense-in-depth layers
  2. PCI-DSS Compliance: Complete 12-requirement checklist
  3. Tokenization: Payment data flow and storage policies
  4. Network Segmentation: Zone-based security architecture
  5. Breach Response: Step-by-step incident procedures
  6. Audit Checklist: Quarterly security review process

Next Chapter: Chapter 32: Disaster Recovery


“Security is not a product, but a process.”

Chapter 32: Disaster Recovery

Overview

This chapter defines the disaster recovery strategy, backup procedures, failover architecture, and recovery processes for the POS Platform.


Recovery Objectives

RTO/RPO Requirements by Data Type

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                    RECOVERY TIME OBJECTIVE (RTO) / RECOVERY POINT OBJECTIVE (RPO)   │
└─────────────────────────────────────────────────────────────────────────────────────┘

┌───────────────────┬─────────────┬─────────────┬──────────────────────────────────────┐
│ Data Category     │ RTO         │ RPO         │ Justification                        │
├───────────────────┼─────────────┼─────────────┼──────────────────────────────────────┤
│ Transaction Data  │ < 1 hour    │ 0 (no loss) │ Revenue-critical, legal requirements │
│ Inventory Data    │ < 4 hours   │ < 1 hour    │ Business operations                  │
│ Customer Data     │ < 4 hours   │ < 1 hour    │ Order fulfillment                    │
│ Product Catalog   │ < 8 hours   │ < 24 hours  │ Can rebuild from source              │
│ Audit Logs        │ < 24 hours  │ < 1 hour    │ Compliance requirements              │
│ Analytics Data    │ < 72 hours  │ < 24 hours  │ Non-critical, can rebuild            │
│ Configuration     │ Immediate   │ 0 (no loss) │ Stored in Git                        │
└───────────────────┴─────────────┴─────────────┴──────────────────────────────────────┘


Recovery Tier Definitions:

┌─────────┬─────────────────────────────────────────────────────────────────────────────┐
│ TIER 1  │  MISSION CRITICAL                                                          │
│         │  RTO: < 1 hour | RPO: 0                                                     │
│         │  - Active transactions                                                      │
│         │  - Payment processing                                                       │
│         │  - Real-time inventory                                                      │
│         │  Strategy: Synchronous replication, hot standby                            │
├─────────┼─────────────────────────────────────────────────────────────────────────────┤
│ TIER 2  │  BUSINESS CRITICAL                                                         │
│         │  RTO: < 4 hours | RPO: < 1 hour                                            │
│         │  - Customer data                                                            │
│         │  - Order history                                                            │
│         │  - Inventory levels                                                         │
│         │  Strategy: Asynchronous replication, warm standby                          │
├─────────┼─────────────────────────────────────────────────────────────────────────────┤
│ TIER 3  │  IMPORTANT                                                                 │
│         │  RTO: < 24 hours | RPO: < 24 hours                                         │
│         │  - Product catalog                                                          │
│         │  - Reports                                                                  │
│         │  - Historical analytics                                                     │
│         │  Strategy: Daily backups, cold standby                                     │
├─────────┼─────────────────────────────────────────────────────────────────────────────┤
│ TIER 4  │  NON-CRITICAL                                                              │
│         │  RTO: < 72 hours | RPO: < 72 hours                                         │
│         │  - Archived data                                                            │
│         │  - Legacy exports                                                           │
│         │  Strategy: Weekly backups, rebuild if needed                               │
└─────────┴─────────────────────────────────────────────────────────────────────────────┘

Backup Strategy

Database Backup Architecture

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           DATABASE BACKUP STRATEGY                                   │
└─────────────────────────────────────────────────────────────────────────────────────┘

                    PostgreSQL Primary
                          │
         ┌────────────────┼────────────────┐
         │                │                │
         ▼                ▼                ▼
┌─────────────────┐ ┌──────────┐ ┌─────────────────┐
│  Streaming      │ │   WAL    │ │   pg_dump       │
│  Replication    │ │ Archiving│ │   (Daily)       │
│  (Real-time)    │ │ (PITR)   │ │                 │
└────────┬────────┘ └────┬─────┘ └────────┬────────┘
         │               │                │
         ▼               ▼                ▼
┌─────────────────┐ ┌──────────┐ ┌─────────────────┐
│  Hot Standby    │ │   WAL    │ │  Backup Storage │
│  (Same Region)  │ │ Archive  │ │  (Encrypted)    │
│                 │ │ (S3/NFS) │ │                 │
└─────────────────┘ └──────────┘ └─────────────────┘
         │               │                │
         │               │                │
         └───────────────┼────────────────┘
                         │
                         ▼
              ┌─────────────────────┐
              │   Offsite Backup    │
              │   (Different DC)    │
              │   S3 Cross-Region   │
              └─────────────────────┘


BACKUP SCHEDULE:

┌──────────────────┬───────────────┬─────────────────┬────────────────────────────────┐
│ Backup Type      │ Frequency     │ Retention       │ Storage Location               │
├──────────────────┼───────────────┼─────────────────┼────────────────────────────────┤
│ WAL Archiving    │ Continuous    │ 7 days          │ Local NFS + S3                 │
│ pg_dump (Full)   │ Daily 2AM     │ 30 days         │ S3 (encrypted)                 │
│ pg_dump (Weekly) │ Sunday 3AM    │ 90 days         │ S3 + Glacier                   │
│ Monthly Archive  │ 1st of month  │ 1 year          │ Glacier                        │
│ Yearly Archive   │ Jan 1st       │ 7 years         │ Glacier Deep Archive           │
└──────────────────┴───────────────┴─────────────────┴────────────────────────────────┘

Backup Scripts

#!/bin/bash
# File: /pos-platform/scripts/backup/daily-backup.sh
# Daily database backup script

set -e

#=============================================
# CONFIGURATION
#=============================================
BACKUP_DIR="/backups/postgres/daily"
S3_BUCKET="s3://pos-backups/postgres"
RETENTION_DAYS=30
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="pos_db_${DATE}.sql.gz"
LOG_FILE="/var/log/pos-backup.log"

#=============================================
# FUNCTIONS
#=============================================
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

send_alert() {
    # Send to Slack on failure
    curl -X POST "$SLACK_WEBHOOK_URL" \
        -H 'Content-type: application/json' \
        -d "{\"text\": \"BACKUP ALERT: $1\"}"
}

#=============================================
# BACKUP PROCESS
#=============================================
backup_database() {
    log "Starting database backup..."

    # Create backup with compression
    docker exec postgres-primary pg_dump \
        -U pos_admin \
        -d pos_db \
        --format=custom \
        --compress=9 \
        --file="/tmp/${BACKUP_FILE}"

    # Copy from container
    docker cp "postgres-primary:/tmp/${BACKUP_FILE}" "${BACKUP_DIR}/${BACKUP_FILE}"

    # Verify backup integrity
    docker exec postgres-primary pg_restore \
        --list "/tmp/${BACKUP_FILE}" > /dev/null 2>&1

    if [ $? -eq 0 ]; then
        log "Backup verified successfully"
    else
        log "ERROR: Backup verification failed"
        send_alert "Backup verification failed for ${BACKUP_FILE}"
        exit 1
    fi

    log "Backup completed: ${BACKUP_FILE}"
}

upload_to_s3() {
    log "Uploading to S3..."

    # Encrypt and upload
    aws s3 cp \
        "${BACKUP_DIR}/${BACKUP_FILE}" \
        "${S3_BUCKET}/daily/${BACKUP_FILE}" \
        --sse aws:kms \
        --sse-kms-key-id "$KMS_KEY_ID"

    log "Upload completed"
}

cleanup_old_backups() {
    log "Cleaning up old backups..."

    # Local cleanup
    find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$RETENTION_DAYS -delete

    # S3 cleanup (handled by lifecycle policy)

    log "Cleanup completed"
}

#=============================================
# PER-TENANT BACKUP
#=============================================
backup_tenant_data() {
    log "Starting per-tenant backups..."

    # Get all active tenants
    TENANTS=$(docker exec postgres-primary psql -U pos_admin -d pos_db -t -c \
        "SELECT schema_name FROM tenants WHERE status = 'active';")

    for TENANT in $TENANTS; do
        TENANT=$(echo "$TENANT" | tr -d ' ')
        TENANT_BACKUP="${BACKUP_DIR}/tenants/${TENANT}_${DATE}.sql.gz"

        log "Backing up tenant: $TENANT"

        docker exec postgres-primary pg_dump \
            -U pos_admin \
            -d pos_db \
            --schema="${TENANT}" \
            --format=custom \
            --compress=9 \
            --file="/tmp/tenant_${TENANT}.sql"

        docker cp "postgres-primary:/tmp/tenant_${TENANT}.sql" "$TENANT_BACKUP"

        # Upload tenant backup
        aws s3 cp "$TENANT_BACKUP" \
            "${S3_BUCKET}/tenants/${TENANT}/${TENANT}_${DATE}.sql.gz" \
            --sse aws:kms

        log "Tenant backup completed: $TENANT"
    done
}

#=============================================
# MAIN
#=============================================
main() {
    log "=========================================="
    log "Daily Backup Started"
    log "=========================================="

    mkdir -p "$BACKUP_DIR/tenants"

    backup_database
    backup_tenant_data
    upload_to_s3
    cleanup_old_backups

    log "=========================================="
    log "Daily Backup Completed Successfully"
    log "=========================================="
}

main "$@"

WAL Archiving Configuration

# File: /pos-platform/docker/postgres/postgresql.conf (excerpt)

# WAL Settings
wal_level = replica
archive_mode = on
archive_command = 'aws s3 cp %p s3://pos-backups/wal/%f --sse aws:kms'
archive_timeout = 60

# Replication Settings
max_wal_senders = 5
wal_keep_size = 1GB
hot_standby = on

# Recovery Settings (for standby)
restore_command = 'aws s3 cp s3://pos-backups/wal/%f %p'
recovery_target_timeline = 'latest'

Failover Architecture

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           MULTI-REGION FAILOVER ARCHITECTURE                         │
└─────────────────────────────────────────────────────────────────────────────────────┘

                              ┌─────────────────┐
                              │   DNS (Route53) │
                              │   Health-based  │
                              │   Failover      │
                              └────────┬────────┘
                                       │
                    ┌──────────────────┼──────────────────┐
                    │                  │                  │
                    ▼                  │                  ▼
        ┌───────────────────┐          │      ┌───────────────────┐
        │   PRIMARY REGION  │          │      │  SECONDARY REGION │
        │   (US-East-1)     │          │      │  (US-West-2)      │
        │                   │          │      │                   │
        │  ┌─────────────┐  │          │      │  ┌─────────────┐  │
        │  │ Load        │  │          │      │  │ Load        │  │
        │  │ Balancer    │  │          │      │  │ Balancer    │  │
        │  └──────┬──────┘  │          │      │  └──────┬──────┘  │
        │         │         │          │      │         │         │
        │  ┌──────┴──────┐  │          │      │  ┌──────┴──────┐  │
        │  │  API (x3)   │  │          │      │  │  API (x2)   │  │
        │  │  Active     │  │          │      │  │  Standby    │  │
        │  └──────┬──────┘  │          │      │  └──────┬──────┘  │
        │         │         │          │      │         │         │
        │  ┌──────┴──────┐  │   Sync   │      │  ┌──────┴──────┐  │
        │  │  PostgreSQL │  │◄─────────┼──────│  │  PostgreSQL │  │
        │  │  PRIMARY    │  │  (Async) │      │  │  REPLICA    │  │
        │  └─────────────┘  │          │      │  └─────────────┘  │
        │                   │          │      │                   │
        │  ┌─────────────┐  │   Sync   │      │  ┌─────────────┐  │
        │  │   Redis     │  │◄─────────┼──────│  │   Redis     │  │
        │  │  PRIMARY    │  │          │      │  │  REPLICA    │  │
        │  └─────────────┘  │          │      │  └─────────────┘  │
        └───────────────────┘          │      └───────────────────┘
                                       │
                              NORMAL OPERATION:
                              100% traffic → Primary

                              FAILOVER STATE:
                              100% traffic → Secondary


FAILOVER TRIGGERS:
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Trigger                          │ Detection Time │ Failover Time │ Auto/Manual    │
├──────────────────────────────────┼────────────────┼───────────────┼────────────────┤
│ Load balancer health check fail  │ 30 seconds     │ 1 minute      │ Automatic      │
│ Database connection failure      │ 1 minute       │ 5 minutes     │ Automatic      │
│ Region-wide outage (AWS)         │ 5 minutes      │ 10 minutes    │ Automatic      │
│ Planned maintenance              │ N/A            │ 0 (graceful)  │ Manual         │
│ Security incident                │ Immediate      │ 5 minutes     │ Manual         │
└─────────────────────────────────────────────────────────────────────────────────────┘

Recovery Procedures

Complete Database Recovery

#!/bin/bash
# File: /pos-platform/scripts/recovery/full-db-recovery.sh
# Complete database recovery from backup

set -e

#=============================================
# RECOVERY MODES
#=============================================
# 1. full    - Restore to latest available state
# 2. pitr    - Point-in-time recovery to specific timestamp
# 3. tenant  - Restore specific tenant only

RECOVERY_MODE=${1:-full}
TARGET_TIME=${2:-}
TENANT_ID=${3:-}

#=============================================
# CONFIGURATION
#=============================================
S3_BUCKET="s3://pos-backups"
WORK_DIR="/tmp/recovery_$(date +%s)"
LOG_FILE="/var/log/pos-recovery.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] RECOVERY: $1" | tee -a "$LOG_FILE"
}

#=============================================
# STEP 1: STOP SERVICES
#=============================================
stop_services() {
    log "Stopping API services..."

    docker-compose stop pos-api

    log "Services stopped"
}

#=============================================
# STEP 2: DOWNLOAD BACKUP
#=============================================
download_backup() {
    log "Downloading backup files..."

    mkdir -p "$WORK_DIR"

    # Get latest backup
    LATEST_BACKUP=$(aws s3 ls "${S3_BUCKET}/postgres/daily/" | \
                    sort | tail -1 | awk '{print $4}')

    aws s3 cp "${S3_BUCKET}/postgres/daily/${LATEST_BACKUP}" \
        "${WORK_DIR}/backup.sql.gz"

    log "Downloaded: ${LATEST_BACKUP}"
}

#=============================================
# STEP 3: VERIFY BACKUP INTEGRITY
#=============================================
verify_backup() {
    log "Verifying backup integrity..."

    # Check file is valid
    gunzip -t "${WORK_DIR}/backup.sql.gz"

    if [ $? -ne 0 ]; then
        log "ERROR: Backup file is corrupted"
        exit 1
    fi

    log "Backup verified"
}

#=============================================
# STEP 4: PREPARE DATABASE
#=============================================
prepare_database() {
    log "Preparing database for recovery..."

    # Create recovery database
    docker exec postgres-primary psql -U postgres -c \
        "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'pos_db';"

    docker exec postgres-primary psql -U postgres -c \
        "DROP DATABASE IF EXISTS pos_db_recovery;"

    docker exec postgres-primary psql -U postgres -c \
        "CREATE DATABASE pos_db_recovery;"

    log "Recovery database prepared"
}

#=============================================
# STEP 5: RESTORE DATA
#=============================================
restore_data() {
    log "Restoring data..."

    # Copy backup to container
    docker cp "${WORK_DIR}/backup.sql.gz" postgres-primary:/tmp/

    # Decompress and restore
    docker exec postgres-primary bash -c \
        "gunzip -c /tmp/backup.sql.gz | psql -U postgres -d pos_db_recovery"

    log "Data restored"
}

#=============================================
# STEP 6: POINT-IN-TIME RECOVERY (if needed)
#=============================================
apply_wal_logs() {
    if [ "$RECOVERY_MODE" == "pitr" ]; then
        log "Applying WAL logs until: $TARGET_TIME"

        # Download WAL files
        aws s3 sync "${S3_BUCKET}/wal/" "${WORK_DIR}/wal/" \
            --exclude "*" \
            --include "*.gz"

        # Apply WAL files (PostgreSQL recovery mode)
        docker exec postgres-primary bash -c "
            echo \"recovery_target_time = '$TARGET_TIME'\" >> /var/lib/postgresql/data/recovery.signal
            pg_ctl restart
        "

        log "PITR completed"
    fi
}

#=============================================
# STEP 7: VERIFY RECOVERY
#=============================================
verify_recovery() {
    log "Verifying recovery..."

    # Check table counts
    TABLES=$(docker exec postgres-primary psql -U postgres -d pos_db_recovery -t -c \
        "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema');")

    log "Restored tables: $TABLES"

    # Check transaction count
    TX_COUNT=$(docker exec postgres-primary psql -U postgres -d pos_db_recovery -t -c \
        "SELECT COUNT(*) FROM transactions;")

    log "Restored transactions: $TX_COUNT"

    # Check latest transaction
    LATEST_TX=$(docker exec postgres-primary psql -U postgres -d pos_db_recovery -t -c \
        "SELECT MAX(created_at) FROM transactions;")

    log "Latest transaction: $LATEST_TX"
}

#=============================================
# STEP 8: SWAP DATABASES
#=============================================
swap_databases() {
    log "Swapping databases..."

    # Rename databases
    docker exec postgres-primary psql -U postgres -c \
        "ALTER DATABASE pos_db RENAME TO pos_db_old;"

    docker exec postgres-primary psql -U postgres -c \
        "ALTER DATABASE pos_db_recovery RENAME TO pos_db;"

    log "Databases swapped"
}

#=============================================
# STEP 9: RESTART SERVICES
#=============================================
restart_services() {
    log "Restarting services..."

    docker-compose start pos-api

    # Wait for health checks
    sleep 30

    # Verify health
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)

    if [ "$HTTP_CODE" -eq 200 ]; then
        log "Services healthy"
    else
        log "ERROR: Services not healthy after recovery"
        exit 1
    fi
}

#=============================================
# STEP 10: CLEANUP
#=============================================
cleanup() {
    log "Cleaning up..."

    rm -rf "$WORK_DIR"

    # Keep old database for 24 hours, then drop
    echo "DROP DATABASE pos_db_old;" | at now + 24 hours

    log "Cleanup scheduled"
}

#=============================================
# MAIN
#=============================================
main() {
    log "=========================================="
    log "DATABASE RECOVERY STARTED"
    log "Mode: $RECOVERY_MODE"
    [ -n "$TARGET_TIME" ] && log "Target Time: $TARGET_TIME"
    log "=========================================="

    stop_services
    download_backup
    verify_backup
    prepare_database
    restore_data
    apply_wal_logs
    verify_recovery
    swap_databases
    restart_services
    cleanup

    log "=========================================="
    log "DATABASE RECOVERY COMPLETED"
    log "=========================================="
}

main "$@"

Tenant-Specific Recovery

#!/bin/bash
# File: /pos-platform/scripts/recovery/tenant-recovery.sh
# Restore specific tenant data

TENANT_ID=$1
BACKUP_DATE=${2:-latest}

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] TENANT RECOVERY: $1"
}

#=============================================
# FIND TENANT BACKUP
#=============================================
find_backup() {
    log "Finding backup for tenant: $TENANT_ID"

    if [ "$BACKUP_DATE" == "latest" ]; then
        BACKUP_FILE=$(aws s3 ls "s3://pos-backups/tenants/${TENANT_ID}/" | \
                      sort | tail -1 | awk '{print $4}')
    else
        BACKUP_FILE="${TENANT_ID}_${BACKUP_DATE}.sql.gz"
    fi

    log "Using backup: $BACKUP_FILE"
}

#=============================================
# RESTORE TENANT SCHEMA
#=============================================
restore_tenant() {
    log "Restoring tenant schema..."

    # Download backup
    aws s3 cp "s3://pos-backups/tenants/${TENANT_ID}/${BACKUP_FILE}" /tmp/

    # Drop existing schema (with confirmation in production)
    docker exec postgres-primary psql -U postgres -d pos_db -c \
        "DROP SCHEMA IF EXISTS ${TENANT_ID} CASCADE;"

    # Restore schema
    docker exec postgres-primary bash -c \
        "gunzip -c /tmp/${BACKUP_FILE} | psql -U postgres -d pos_db"

    log "Tenant restored: $TENANT_ID"
}

#=============================================
# MAIN
#=============================================
main() {
    if [ -z "$TENANT_ID" ]; then
        echo "Usage: $0 <tenant_id> [backup_date]"
        exit 1
    fi

    find_backup
    restore_tenant

    log "Recovery completed for tenant: $TENANT_ID"
}

main "$@"

DR Testing Schedule

# Disaster Recovery Test Schedule

## Quarterly Tests

### Q1 (January)
| Test | Date | Duration | Owner |
|------|------|----------|-------|
| Full failover drill | Week 3 | 4 hours | Platform Team |
| Backup restoration test | Week 4 | 2 hours | DBA |

### Q2 (April)
| Test | Date | Duration | Owner |
|------|------|----------|-------|
| Tenant recovery test | Week 2 | 2 hours | Platform Team |
| Network failover test | Week 3 | 2 hours | Network Team |

### Q3 (July)
| Test | Date | Duration | Owner |
|------|------|----------|-------|
| Full failover drill | Week 3 | 4 hours | Platform Team |
| PITR recovery test | Week 4 | 3 hours | DBA |

### Q4 (October)
| Test | Date | Duration | Owner |
|------|------|----------|-------|
| Annual DR exercise | Week 2-3 | 8 hours | All Teams |
| Tabletop exercise | Week 4 | 2 hours | Leadership |

## Monthly Tests
- Automated backup verification
- Replica lag monitoring
- Health check validation

## Test Procedure

### Pre-Test Checklist
- [ ] Notify stakeholders
- [ ] Confirm maintenance window
- [ ] Verify backup freshness
- [ ] Prepare rollback plan
- [ ] Stage monitoring dashboards

### During Test
- [ ] Document all actions
- [ ] Record timestamps
- [ ] Note any issues
- [ ] Track RTO/RPO actual vs target

### Post-Test
- [ ] Generate test report
- [ ] Update runbooks if needed
- [ ] File improvement tickets
- [ ] Schedule follow-up for issues

Communication Templates

Outage Notification Templates

# Template: Initial Outage Notification

## Internal (Slack/Email)

Subject: [INCIDENT] POS Platform - Service Disruption

**Status**: Investigating
**Impact**: [High/Medium/Low]
**Start Time**: [YYYY-MM-DD HH:MM UTC]

**Affected Services**:
- [ ] Transaction Processing
- [ ] Inventory Management
- [ ] Order Fulfillment
- [ ] Reporting

**Current Actions**:
- Investigating root cause
- Engaged [Team Name]

**Next Update**: In 30 minutes or when status changes

---

# Template: Customer Notification

Subject: Service Status Update - POS Platform

Dear Valued Customer,

We are currently experiencing a service disruption affecting
[specific functionality]. Our team is actively working to
resolve this issue.

**What's Affected**:
[List specific features]

**What's Working**:
[List unaffected features]

**Workaround**:
[If applicable, provide workaround]

**Expected Resolution**:
We anticipate resolution within [timeframe].

We apologize for any inconvenience and will provide updates
as the situation progresses.

---

# Template: Resolution Notification

Subject: [RESOLVED] POS Platform - Service Restored

**Status**: Resolved
**Duration**: [X hours, Y minutes]
**Resolution Time**: [YYYY-MM-DD HH:MM UTC]

**Root Cause**:
[Brief description]

**Resolution**:
[What was done to fix]

**Preventive Measures**:
[What will prevent recurrence]

**Post-Incident Review**:
Scheduled for [date]

Thank you for your patience.

Summary

This chapter provides complete disaster recovery coverage:

  1. Recovery Objectives: RTO/RPO by data tier
  2. Backup Strategy: Daily dumps, WAL archiving, per-tenant backups
  3. Failover Architecture: Multi-region with automatic failover
  4. Recovery Procedures: Step-by-step scripts for full and tenant recovery
  5. DR Testing: Quarterly test schedule and procedures
  6. Communication: Templates for internal and customer notifications

Next Chapter: Chapter 33: Tenant Lifecycle


“Hope is not a strategy. Test your recovery procedures.”

Chapter 33: Tenant Lifecycle Management

Overview

This chapter defines the complete tenant lifecycle for the POS Platform, including state transitions, onboarding workflows, offboarding procedures, and billing integration.


Tenant States

State Machine Diagram

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           TENANT STATE MACHINE                                       │
└─────────────────────────────────────────────────────────────────────────────────────┘

                              ┌───────────────┐
                              │    PROSPECT   │
                              │   (Pre-sale)  │
                              └───────┬───────┘
                                      │ Sales closes deal
                                      │ Contract signed
                                      ▼
                              ┌───────────────┐
                     ┌───────►│    TRIAL      │◄──────────┐
                     │        │  (14 days)    │           │
                     │        └───────┬───────┘           │
                     │                │                   │
                     │                │ Payment received  │ Trial extended
                     │                ▼                   │ (max 30 days)
                     │        ┌───────────────┐           │
                     │        │ PROVISIONING  │───────────┘
                     │        │ (Setup phase) │
                     │        └───────┬───────┘
                     │                │
                     │                │ Setup complete
                     │                │ Go-live approved
                     │                ▼
                     │        ┌───────────────┐
   Reactivate        │        │    ACTIVE     │
   (payment          │        │ (Production)  │◄─────────────────────────┐
    received)        │        └───────┬───────┘                          │
                     │                │                                  │
                     │    ┌───────────┼───────────┐                      │
                     │    │           │           │                      │
                     │    ▼           ▼           ▼                      │
                     │  Payment    Contract    Compliance                │
                     │  Failure    Violation   Issue                     │
                     │    │           │           │                      │
                     │    └───────────┼───────────┘                      │
                     │                │                                  │
                     │                ▼                                  │
                     │        ┌───────────────┐                          │
                     └────────│   SUSPENDED   │──────────────────────────┘
                              │ (Read-only)   │     Issue resolved
                              └───────┬───────┘
                                      │
                                      │ 30 days no resolution
                                      │ OR cancellation request
                                      ▼
                              ┌───────────────┐
                              │  CANCELLED    │
                              │ (Grace period)│
                              │   (30 days)   │
                              └───────┬───────┘
                                      │
                                      │ Grace period expired
                                      ▼
                              ┌───────────────┐
                              │   ARCHIVED    │
                              │(Data retained)│
                              │  (90 days)    │
                              └───────┬───────┘
                                      │
                                      │ Retention expired
                                      │ OR GDPR deletion
                                      ▼
                              ┌───────────────┐
                              │    PURGED     │
                              │(Permanently   │
                              │  deleted)     │
                              └───────────────┘


STATE DEFINITIONS:

┌─────────────────┬──────────────────────────────────────────────────────────────────┐
│ State           │ Description                                                      │
├─────────────────┼──────────────────────────────────────────────────────────────────┤
│ PROSPECT        │ Lead in sales pipeline, no system access                        │
│ TRIAL           │ Free trial period, limited features                             │
│ PROVISIONING    │ Database/schema being set up, training in progress              │
│ ACTIVE          │ Full production access, billing active                          │
│ SUSPENDED       │ Read-only access, no transactions, billing paused               │
│ CANCELLED       │ No access, data preserved for grace period                      │
│ ARCHIVED        │ No access, data compressed and stored offline                   │
│ PURGED          │ All data permanently deleted                                    │
└─────────────────┴──────────────────────────────────────────────────────────────────┘

State Transition Rules

// File: /src/POS.Core/Tenants/TenantStateMachine.cs

public class TenantStateMachine
{
    private static readonly Dictionary<TenantState, TenantState[]> AllowedTransitions = new()
    {
        [TenantState.Prospect] = new[] { TenantState.Trial },
        [TenantState.Trial] = new[] { TenantState.Provisioning, TenantState.Cancelled },
        [TenantState.Provisioning] = new[] { TenantState.Active, TenantState.Trial },
        [TenantState.Active] = new[] { TenantState.Suspended, TenantState.Cancelled },
        [TenantState.Suspended] = new[] { TenantState.Active, TenantState.Cancelled },
        [TenantState.Cancelled] = new[] { TenantState.Archived, TenantState.Active },
        [TenantState.Archived] = new[] { TenantState.Purged },
        [TenantState.Purged] = Array.Empty<TenantState>()
    };

    public bool CanTransition(TenantState from, TenantState to)
    {
        return AllowedTransitions.TryGetValue(from, out var allowed)
            && allowed.Contains(to);
    }

    public void Transition(Tenant tenant, TenantState newState, string reason)
    {
        if (!CanTransition(tenant.State, newState))
        {
            throw new InvalidStateTransitionException(
                $"Cannot transition from {tenant.State} to {newState}");
        }

        var previousState = tenant.State;
        tenant.State = newState;
        tenant.StateChangedAt = DateTime.UtcNow;
        tenant.StateChangeReason = reason;

        // Emit domain event
        tenant.AddDomainEvent(new TenantStateChangedEvent(
            tenant.Id,
            previousState,
            newState,
            reason
        ));
    }
}

public enum TenantState
{
    Prospect,
    Trial,
    Provisioning,
    Active,
    Suspended,
    Cancelled,
    Archived,
    Purged
}

Onboarding Workflow

Complete Onboarding Process

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           TENANT ONBOARDING WORKFLOW                                 │
└─────────────────────────────────────────────────────────────────────────────────────┘

PHASE 1: SALES HANDOFF (Day 0)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Sales Team                        CRM System                   Onboarding Queue   │
│      │                                 │                              │            │
│      │ 1. Win opportunity              │                              │            │
│      ├────────────────────────────────►│                              │            │
│      │                                 │ 2. Create tenant record      │            │
│      │                                 ├─────────────────────────────►│            │
│      │ 3. Assign onboarding manager    │                              │            │
│      │◄────────────────────────────────┤                              │            │
│      │                                 │                              │            │
│  Deliverables:                                                                      │
│  □ Signed contract                                                                  │
│  □ Payment method on file                                                           │
│  □ Business requirements document                                                   │
│  □ Primary contact information                                                      │
│  □ Assigned onboarding manager                                                      │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
PHASE 2: DATABASE PROVISIONING (Day 1)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Automated System                                                                   │
│      │                                                                              │
│      │ 1. Create tenant schema                                                      │
│      │    CREATE SCHEMA tenant_xyz;                                                │
│      │                                                                              │
│      │ 2. Run migrations                                                            │
│      │    Apply all schema migrations                                              │
│      │                                                                              │
│      │ 3. Seed reference data                                                       │
│      │    - Payment methods                                                         │
│      │    - Tax categories                                                          │
│      │    - Default settings                                                        │
│      │                                                                              │
│      │ 4. Create admin user                                                         │
│      │    - Generate temporary password                                             │
│      │    - Send welcome email                                                      │
│      │                                                                              │
│  Automated Checks:                                                                  │
│  □ Schema created successfully                                                      │
│  □ All tables exist                                                                 │
│  □ Admin user can login                                                             │
│  □ API key generated                                                                │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
PHASE 3: CONFIGURATION SETUP (Days 2-3)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Onboarding Manager + Customer                                                      │
│      │                                                                              │
│      │ 1. Company profile setup                                                     │
│      │    - Business name, address, logo                                           │
│      │    - Tax settings (rates, exemptions)                                       │
│      │    - Currency and locale                                                    │
│      │                                                                              │
│      │ 2. Location configuration                                                    │
│      │    - Add store locations                                                     │
│      │    - Assign location codes                                                   │
│      │    - Set business hours                                                      │
│      │                                                                              │
│      │ 3. Payment processor setup                                                   │
│      │    - Connect Stripe account                                                  │
│      │    - Configure payment methods                                               │
│      │    - Test transactions                                                       │
│      │                                                                              │
│      │ 4. User provisioning                                                         │
│      │    - Create user accounts                                                    │
│      │    - Assign roles (Manager, Cashier, etc.)                                  │
│      │    - Configure permissions                                                   │
│      │                                                                              │
│      │ 5. Hardware setup (if applicable)                                            │
│      │    - Register POS terminals                                                  │
│      │    - Connect receipt printers                                                │
│      │    - Pair barcode scanners                                                   │
│      │                                                                              │
│  Configuration Checklist:                                                           │
│  □ Company profile complete                                                         │
│  □ At least 1 location configured                                                   │
│  □ Payment processor connected and tested                                           │
│  □ At least 1 manager user created                                                  │
│  □ Receipt template customized                                                      │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
PHASE 4: DATA MIGRATION (Days 3-7)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Migration Team + Customer                                                          │
│      │                                                                              │
│      │ 1. Data assessment                                                           │
│      │    - Review existing data sources                                            │
│      │    - Identify data quality issues                                            │
│      │    - Plan field mappings                                                     │
│      │                                                                              │
│      │ 2. Product catalog import                                                    │
│      │    - Import products from CSV/API                                            │
│      │    - Map categories                                                          │
│      │    - Validate pricing                                                        │
│      │                                                                              │
│      │ 3. Customer data import                                                      │
│      │    - Import customer records                                                 │
│      │    - Deduplicate entries                                                     │
│      │    - Validate contact info                                                   │
│      │                                                                              │
│      │ 4. Inventory import                                                          │
│      │    - Import current stock levels                                             │
│      │    - Map to locations                                                        │
│      │    - Validate quantities                                                     │
│      │                                                                              │
│      │ 5. Historical data (optional)                                                │
│      │    - Import transaction history                                              │
│      │    - Import for reporting only                                               │
│      │                                                                              │
│  Migration Validation:                                                              │
│  □ Product count matches source                                                     │
│  □ Customer count matches (after dedup)                                             │
│  □ Inventory totals reconcile                                                       │
│  □ Sample transactions verified                                                     │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
PHASE 5: TRAINING (Days 5-10)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Training Team + Customer Staff                                                     │
│      │                                                                              │
│      │ 1. Administrator training (2 hours)                                          │
│      │    - System configuration                                                    │
│      │    - User management                                                         │
│      │    - Reports and analytics                                                   │
│      │                                                                              │
│      │ 2. Manager training (2 hours)                                                │
│      │    - Day-to-day operations                                                   │
│      │    - Inventory management                                                    │
│      │    - Staff management                                                        │
│      │                                                                              │
│      │ 3. Cashier training (1 hour)                                                 │
│      │    - Transaction processing                                                  │
│      │    - Customer lookup                                                         │
│      │    - Returns and exchanges                                                   │
│      │                                                                              │
│      │ 4. Hands-on practice                                                         │
│      │    - Practice transactions                                                   │
│      │    - Test edge cases                                                         │
│      │    - Q&A session                                                             │
│      │                                                                              │
│  Training Completion:                                                               │
│  □ Admin training complete                                                          │
│  □ Manager training complete                                                        │
│  □ All cashiers trained                                                             │
│  □ Practice transactions successful                                                 │
│  □ Training materials provided                                                      │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
PHASE 6: GO-LIVE (Day 10-14)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  All Teams                                                                          │
│      │                                                                              │
│      │ 1. Pre-go-live checklist                                                     │
│      │    - All configuration verified                                              │
│      │    - Data migration validated                                                │
│      │    - Staff trained and ready                                                 │
│      │    - Backup of old system                                                    │
│      │                                                                              │
│      │ 2. Go-live execution                                                         │
│      │    - Cutover at scheduled time                                               │
│      │    - First transaction verified                                              │
│      │    - Monitor for issues                                                      │
│      │                                                                              │
│      │ 3. Hypercare period (Days 1-7)                                              │
│      │    - On-call support                                                         │
│      │    - Daily check-ins                                                         │
│      │    - Rapid issue resolution                                                  │
│      │                                                                              │
│      │ 4. Transition to BAU                                                         │
│      │    - Hand off to support team                                                │
│      │    - Schedule first review                                                   │
│      │    - Close onboarding project                                                │
│      │                                                                              │
│  Go-Live Criteria:                                                                  │
│  □ Sign-off from customer                                                           │
│  □ First successful transaction                                                     │
│  □ End-of-day close successful                                                      │
│  □ Support handoff complete                                                         │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘

Automated Provisioning Script

#!/bin/bash
# File: /pos-platform/scripts/tenants/provision-tenant.sh
# Automated tenant provisioning

set -e

TENANT_ID=$1
TENANT_NAME=$2
ADMIN_EMAIL=$3
PLAN_TYPE=${4:-standard}

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] PROVISION: $1"
}

#=============================================
# STEP 1: CREATE TENANT SCHEMA
#=============================================
create_schema() {
    log "Creating schema for tenant: $TENANT_ID"

    docker exec postgres-primary psql -U pos_admin -d pos_db << EOF
-- Create tenant schema
CREATE SCHEMA IF NOT EXISTS "tenant_${TENANT_ID}";

-- Set search path
SET search_path TO "tenant_${TENANT_ID}";

-- Run migrations (tables created here)
\i /migrations/001_create_tables.sql
\i /migrations/002_create_indexes.sql
\i /migrations/003_seed_reference_data.sql

-- Grant permissions
GRANT ALL PRIVILEGES ON SCHEMA "tenant_${TENANT_ID}" TO pos_app;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA "tenant_${TENANT_ID}" TO pos_app;
EOF

    log "Schema created"
}

#=============================================
# STEP 2: SEED TENANT DATA
#=============================================
seed_data() {
    log "Seeding tenant data..."

    docker exec postgres-primary psql -U pos_admin -d pos_db << EOF
SET search_path TO "tenant_${TENANT_ID}";

-- Insert tenant record in shared schema
INSERT INTO shared.tenants (id, name, schema_name, state, plan_type, created_at)
VALUES ('${TENANT_ID}', '${TENANT_NAME}', 'tenant_${TENANT_ID}', 'provisioning', '${PLAN_TYPE}', NOW());

-- Insert default settings
INSERT INTO settings (key, value) VALUES
    ('company_name', '${TENANT_NAME}'),
    ('timezone', 'America/New_York'),
    ('currency', 'USD'),
    ('tax_rate', '0.0825'),
    ('receipt_footer', 'Thank you for your business!');

-- Insert default payment methods
INSERT INTO payment_methods (code, name, is_active) VALUES
    ('CASH', 'Cash', true),
    ('CARD', 'Credit/Debit Card', true),
    ('GIFT', 'Gift Card', true);

-- Insert default roles
INSERT INTO roles (name, permissions) VALUES
    ('admin', '["*"]'),
    ('manager', '["transactions", "inventory", "reports", "customers"]'),
    ('cashier', '["transactions", "customers"]');
EOF

    log "Data seeded"
}

#=============================================
# STEP 3: CREATE ADMIN USER
#=============================================
create_admin() {
    log "Creating admin user..."

    # Generate temporary password
    TEMP_PASSWORD=$(openssl rand -base64 12)
    PASSWORD_HASH=$(echo -n "$TEMP_PASSWORD" | argon2 $(openssl rand -base64 16) -id -t 3 -m 16 -p 4 -l 32 -e)

    docker exec postgres-primary psql -U pos_admin -d pos_db << EOF
SET search_path TO "tenant_${TENANT_ID}";

INSERT INTO users (email, password_hash, role, must_change_password, created_at)
VALUES ('${ADMIN_EMAIL}', '${PASSWORD_HASH}', 'admin', true, NOW());
EOF

    # Send welcome email
    send_welcome_email "$ADMIN_EMAIL" "$TEMP_PASSWORD"

    log "Admin user created"
}

#=============================================
# STEP 4: GENERATE API KEY
#=============================================
generate_api_key() {
    log "Generating API key..."

    API_KEY=$(openssl rand -hex 32)
    API_KEY_HASH=$(echo -n "$API_KEY" | sha256sum | cut -d' ' -f1)

    docker exec postgres-primary psql -U pos_admin -d pos_db << EOF
INSERT INTO shared.api_keys (tenant_id, key_hash, name, created_at)
VALUES ('${TENANT_ID}', '${API_KEY_HASH}', 'Primary API Key', NOW());
EOF

    # Store API key securely (send to customer)
    echo "$API_KEY" > "/secure/keys/${TENANT_ID}.key"
    chmod 400 "/secure/keys/${TENANT_ID}.key"

    log "API key generated"
}

#=============================================
# STEP 5: UPDATE STATE
#=============================================
update_state() {
    log "Updating tenant state to 'active'..."

    docker exec postgres-primary psql -U pos_admin -d pos_db << EOF
UPDATE shared.tenants
SET state = 'active', activated_at = NOW()
WHERE id = '${TENANT_ID}';
EOF

    log "Tenant activated"
}

#=============================================
# HELPER: SEND WELCOME EMAIL
#=============================================
send_welcome_email() {
    EMAIL=$1
    PASSWORD=$2

    curl -X POST "$EMAIL_API_URL/send" \
        -H "Authorization: Bearer $EMAIL_API_KEY" \
        -H "Content-Type: application/json" \
        -d "{
            \"to\": \"$EMAIL\",
            \"template\": \"welcome\",
            \"data\": {
                \"tenant_name\": \"$TENANT_NAME\",
                \"login_url\": \"https://pos.example.com/login\",
                \"temp_password\": \"$PASSWORD\"
            }
        }"
}

#=============================================
# MAIN
#=============================================
main() {
    if [ -z "$TENANT_ID" ] || [ -z "$TENANT_NAME" ] || [ -z "$ADMIN_EMAIL" ]; then
        echo "Usage: $0 <tenant_id> <tenant_name> <admin_email> [plan_type]"
        exit 1
    fi

    log "=========================================="
    log "Provisioning tenant: $TENANT_NAME"
    log "=========================================="

    create_schema
    seed_data
    create_admin
    generate_api_key
    update_state

    log "=========================================="
    log "Provisioning complete!"
    log "=========================================="
}

main "$@"

Offboarding Workflow

Offboarding Process

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           TENANT OFFBOARDING WORKFLOW                                │
└─────────────────────────────────────────────────────────────────────────────────────┘

STEP 1: CANCELLATION REQUEST (Day 0)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  □ Cancellation request received (email/portal/phone)                               │
│  □ Reason documented                                                                │
│  □ Contract terms reviewed (notice period, penalties)                               │
│  □ Retention offer made (if applicable)                                             │
│  □ Final decision confirmed in writing                                              │
│  □ Cancellation effective date set                                                  │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 2: DATA EXPORT (Days 1-7)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  □ Customer requests data export (GDPR right to portability)                        │
│  □ Generate export package:                                                         │
│      - Transactions (CSV)                                                           │
│      - Products (CSV)                                                               │
│      - Customers (CSV)                                                              │
│      - Inventory history (CSV)                                                      │
│      - Reports (PDF)                                                                │
│  □ Export package delivered securely                                                │
│  □ Customer confirms receipt                                                        │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 3: ACCESS TERMINATION (Effective Date)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  □ Tenant state set to CANCELLED                                                    │
│  □ All user sessions terminated                                                     │
│  □ API keys revoked                                                                 │
│  □ Webhook endpoints removed                                                        │
│  □ Payment processor disconnected                                                   │
│  □ Customer notified of access termination                                          │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 4: GRACE PERIOD (30 Days)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  During this period:                                                                │
│  □ Data remains intact (no modifications)                                           │
│  □ Customer can request reactivation                                                │
│  □ Additional data exports available on request                                     │
│  □ Billing stopped                                                                  │
│                                                                                     │
│  At end of grace period:                                                            │
│  □ Final notification sent                                                          │
│  □ State changed to ARCHIVED                                                        │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 5: DATA ARCHIVAL (Day 30)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  □ Create final backup                                                              │
│  □ Encrypt backup with archival key                                                 │
│  □ Move to cold storage (Glacier)                                                   │
│  □ Drop active schema                                                               │
│  □ Release database resources                                                       │
│  □ Archive tenant record                                                            │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 6: DATA RETENTION (90 Days)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Retained data:                                                                     │
│  □ Transaction records (legal requirement)                                          │
│  □ Audit logs                                                                       │
│  □ Financial reports                                                                │
│                                                                                     │
│  Purpose:                                                                           │
│  □ Tax/audit compliance                                                             │
│  □ Legal disputes                                                                   │
│  □ Fraud investigation                                                              │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 7: GDPR DELETION (On Request or Day 120)
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  GDPR "Right to be Forgotten" Process:                                             │
│                                                                                     │
│  □ Deletion request received and verified                                           │
│  □ Legal hold check (no active litigation)                                          │
│  □ Tax record retention verified (if applicable, retain 7 years)                    │
│  □ Personal data identified:                                                        │
│      - Customer PII                                                                 │
│      - Employee data                                                                │
│      - Contact information                                                          │
│  □ Pseudonymization applied where deletion not possible                             │
│  □ Backup copies identified and purged                                              │
│  □ Deletion certificate generated                                                   │
│  □ Customer notified of completion                                                  │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘

Data Export Script

#!/bin/bash
# File: /pos-platform/scripts/tenants/export-tenant-data.sh
# Export all tenant data for offboarding

set -e

TENANT_ID=$1
OUTPUT_DIR="/exports/${TENANT_ID}"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] EXPORT: $1"
}

mkdir -p "$OUTPUT_DIR"

#=============================================
# EXPORT TRANSACTIONS
#=============================================
export_transactions() {
    log "Exporting transactions..."

    docker exec postgres-primary psql -U pos_admin -d pos_db -c "
        COPY (
            SELECT
                t.id,
                t.transaction_number,
                t.created_at,
                t.total,
                t.tax,
                t.payment_method,
                t.status,
                c.email as customer_email,
                c.name as customer_name
            FROM tenant_${TENANT_ID}.transactions t
            LEFT JOIN tenant_${TENANT_ID}.customers c ON t.customer_id = c.id
            ORDER BY t.created_at
        ) TO STDOUT WITH CSV HEADER
    " > "${OUTPUT_DIR}/transactions.csv"

    log "Exported $(wc -l < ${OUTPUT_DIR}/transactions.csv) transactions"
}

#=============================================
# EXPORT PRODUCTS
#=============================================
export_products() {
    log "Exporting products..."

    docker exec postgres-primary psql -U pos_admin -d pos_db -c "
        COPY (
            SELECT
                id,
                sku,
                name,
                description,
                price,
                cost,
                category,
                barcode,
                is_active,
                created_at
            FROM tenant_${TENANT_ID}.products
            ORDER BY name
        ) TO STDOUT WITH CSV HEADER
    " > "${OUTPUT_DIR}/products.csv"

    log "Exported $(wc -l < ${OUTPUT_DIR}/products.csv) products"
}

#=============================================
# EXPORT CUSTOMERS
#=============================================
export_customers() {
    log "Exporting customers..."

    docker exec postgres-primary psql -U pos_admin -d pos_db -c "
        COPY (
            SELECT
                id,
                email,
                name,
                phone,
                address,
                city,
                state,
                postal_code,
                total_purchases,
                last_purchase_at,
                created_at
            FROM tenant_${TENANT_ID}.customers
            ORDER BY name
        ) TO STDOUT WITH CSV HEADER
    " > "${OUTPUT_DIR}/customers.csv"

    log "Exported $(wc -l < ${OUTPUT_DIR}/customers.csv) customers"
}

#=============================================
# EXPORT INVENTORY
#=============================================
export_inventory() {
    log "Exporting inventory..."

    docker exec postgres-primary psql -U pos_admin -d pos_db -c "
        COPY (
            SELECT
                i.product_id,
                p.sku,
                p.name as product_name,
                l.name as location_name,
                i.quantity,
                i.last_updated
            FROM tenant_${TENANT_ID}.inventory i
            JOIN tenant_${TENANT_ID}.products p ON i.product_id = p.id
            JOIN tenant_${TENANT_ID}.locations l ON i.location_id = l.id
            ORDER BY p.name, l.name
        ) TO STDOUT WITH CSV HEADER
    " > "${OUTPUT_DIR}/inventory.csv"

    log "Exported inventory data"
}

#=============================================
# CREATE EXPORT PACKAGE
#=============================================
create_package() {
    log "Creating export package..."

    # Create manifest
    cat > "${OUTPUT_DIR}/manifest.json" << EOF
{
    "tenant_id": "${TENANT_ID}",
    "export_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
    "files": [
        {"name": "transactions.csv", "description": "All transactions"},
        {"name": "products.csv", "description": "Product catalog"},
        {"name": "customers.csv", "description": "Customer records"},
        {"name": "inventory.csv", "description": "Current inventory levels"}
    ]
}
EOF

    # Create encrypted zip
    zip -r -e "${OUTPUT_DIR}.zip" "$OUTPUT_DIR" -P "$EXPORT_PASSWORD"

    # Generate download link
    DOWNLOAD_URL=$(aws s3 presign "s3://pos-exports/${TENANT_ID}.zip" --expires-in 604800)

    log "Export package created"
    log "Download URL (valid 7 days): $DOWNLOAD_URL"
}

#=============================================
# MAIN
#=============================================
main() {
    if [ -z "$TENANT_ID" ]; then
        echo "Usage: $0 <tenant_id>"
        exit 1
    fi

    log "=========================================="
    log "Exporting data for tenant: $TENANT_ID"
    log "=========================================="

    export_transactions
    export_products
    export_customers
    export_inventory
    create_package

    log "=========================================="
    log "Export complete!"
    log "=========================================="
}

main "$@"

Billing Integration

Billing Events

// File: /src/POS.Core/Billing/BillingEvents.cs

public record TenantSubscriptionCreated(
    string TenantId,
    string PlanId,
    string StripeSubscriptionId,
    DateTime StartDate,
    decimal MonthlyPrice
);

public record TenantPaymentReceived(
    string TenantId,
    string StripePaymentId,
    decimal Amount,
    DateTime PaidAt
);

public record TenantPaymentFailed(
    string TenantId,
    string StripePaymentId,
    string FailureReason,
    int AttemptCount,
    DateTime NextRetryAt
);

public record TenantPlanChanged(
    string TenantId,
    string OldPlanId,
    string NewPlanId,
    DateTime EffectiveDate,
    bool IsUpgrade
);

public record TenantSubscriptionCancelled(
    string TenantId,
    string Reason,
    DateTime CancellationDate,
    DateTime EffectiveEndDate
);

Stripe Webhook Handler

// File: /src/POS.Api/Webhooks/StripeWebhookController.cs

[ApiController]
[Route("webhooks/stripe")]
public class StripeWebhookController : ControllerBase
{
    private readonly ITenantBillingService _billingService;
    private readonly ILogger<StripeWebhookController> _logger;

    [HttpPost]
    public async Task<IActionResult> HandleWebhook()
    {
        var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();

        var stripeEvent = EventUtility.ConstructEvent(
            json,
            Request.Headers["Stripe-Signature"],
            _webhookSecret
        );

        switch (stripeEvent.Type)
        {
            case Events.InvoicePaid:
                var invoice = stripeEvent.Data.Object as Invoice;
                await HandleInvoicePaid(invoice);
                break;

            case Events.InvoicePaymentFailed:
                var failedInvoice = stripeEvent.Data.Object as Invoice;
                await HandlePaymentFailed(failedInvoice);
                break;

            case Events.CustomerSubscriptionDeleted:
                var subscription = stripeEvent.Data.Object as Subscription;
                await HandleSubscriptionCancelled(subscription);
                break;
        }

        return Ok();
    }

    private async Task HandlePaymentFailed(Invoice invoice)
    {
        var tenantId = invoice.Metadata["tenant_id"];
        var attemptCount = invoice.AttemptCount;

        _logger.LogWarning(
            "Payment failed for tenant {TenantId}, attempt {Attempt}",
            tenantId, attemptCount);

        if (attemptCount >= 3)
        {
            // Suspend tenant after 3 failed attempts
            await _billingService.SuspendTenantAsync(
                tenantId,
                "Payment failed after 3 attempts"
            );
        }
    }
}

Support Tier Definitions

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           SUPPORT TIER DEFINITIONS                                   │
└─────────────────────────────────────────────────────────────────────────────────────┘

┌─────────────────┬──────────────┬──────────────┬──────────────┬──────────────────────┐
│ Feature         │ Starter      │ Professional │ Enterprise   │ Premium              │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ Monthly Price   │ $49/month    │ $149/month   │ $499/month   │ $999/month           │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ Locations       │ 1            │ 3            │ 10           │ Unlimited            │
│ Users           │ 3            │ 10           │ 50           │ Unlimited            │
│ Transactions    │ 1,000/mo     │ 10,000/mo    │ 100,000/mo   │ Unlimited            │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ Support Hours   │ Business     │ Extended     │ 24/5         │ 24/7                 │
│ Response Time   │ 24 hours     │ 8 hours      │ 4 hours      │ 1 hour               │
│ Phone Support   │ No           │ Yes          │ Yes          │ Priority Line        │
│ Dedicated CSM   │ No           │ No           │ Yes          │ Yes                  │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ Onboarding      │ Self-service │ Guided       │ White-glove  │ Custom               │
│ Training        │ Videos       │ Live session │ On-site      │ Unlimited            │
│ Data Migration  │ Self-service │ Assisted     │ Managed      │ Managed              │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ Integrations    │ Basic        │ Standard     │ All          │ All + Custom         │
│ API Access      │ Limited      │ Full         │ Full         │ Full + Priority      │
│ Custom Reports  │ No           │ 3/month      │ 10/month     │ Unlimited            │
├─────────────────┼──────────────┼──────────────┼──────────────┼──────────────────────┤
│ SLA             │ 99.5%        │ 99.9%        │ 99.95%       │ 99.99%               │
│ Backup Freq.    │ Daily        │ Daily        │ Hourly       │ Real-time            │
│ Data Retention  │ 1 year       │ 2 years      │ 5 years      │ 7 years              │
└─────────────────┴──────────────┴──────────────┴──────────────┴──────────────────────┘


---

## API Access Explained

**What is API Access?**

API Access means programmatic access to the POS Platform via REST API endpoints. Instead of using the Admin Portal or POS Client UI, developers can write code that directly interacts with the system.

### Why Tenants Want API Access

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ COMMON API ACCESS USE CASES │ └─────────────────────────────────────────────────────────────────────────────────────┘

  1. CUSTOM INTEGRATIONS ┌──────────────────────────────────────────────────────────────────────────────────┐ │ Example: Connect POS to custom ERP system │ │ │ │ ┌─────────────┐ API Call ┌─────────────┐ Update ┌──────────┐ │ │ │ Custom ERP │ ───────────────► │ POS API │ ─────────────► │ Inventory│ │ │ │ System │ POST /products │ Endpoint │ │ Database │ │ │ └─────────────┘ └─────────────┘ └──────────┘ │ │ │ │ Without API: Manual CSV export/import between systems │ │ With API: Real-time automated sync │ └──────────────────────────────────────────────────────────────────────────────────┘

  2. AUTOMATION SCRIPTS ┌──────────────────────────────────────────────────────────────────────────────────┐ │ Example: Nightly inventory reconciliation │ │ │ │ ┌─────────────┐ GET /inventory ┌─────────────┐ │ │ │ Cron Job │ ────────────────────► │ POS API │ │ │ │ (Midnight) │ │ │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Compare to │ → Generate discrepancy report → Email to manager │ │ │ physical │ │ │ │ count sheet │ │ │ └─────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────┘

  3. CUSTOM REPORTING ┌──────────────────────────────────────────────────────────────────────────────────┐ │ Example: Executive dashboard with custom KPIs │ │ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │ BI Tool │ ─ GET /sales ───► │ POS API │ │ │ │ (Tableau) │ ─ GET /inventory ─►│ │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Custom Dashboard: Profit margins, vendor performance, trends │ │ │ └─────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────────────┘

  4. MOBILE APP DEVELOPMENT ┌──────────────────────────────────────────────────────────────────────────────────┐ │ Example: Custom customer-facing app │ │ │ │ ┌─────────────┐ API Calls ┌─────────────┐ │ │ │ Custom │ ─────────────────►│ POS API │ │ │ │ Mobile App │ │ │ │ │ └─────────────┘ └─────────────┘ │ │ │ │ Features enabled: │ │ • Check loyalty points │ │ • View purchase history │ │ • Scan product for price/availability │ └──────────────────────────────────────────────────────────────────────────────────┘


### Why API Access is Tiered

API Access is restricted to higher tiers for several important reasons:

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ WHY API ACCESS IS ENTERPRISE-TIER+ │ └─────────────────────────────────────────────────────────────────────────────────────┘

  1. INFRASTRUCTURE COST ┌──────────────────────────────────────────────────────────────────────────────────┐ │ API calls consume server resources: │ │ • Each request = CPU cycles + memory + database queries │ │ • Automation scripts can make thousands of requests/hour │ │ • Rate limiting and monitoring infrastructure needed │ │ │ │ Enterprise tenants pay more → covers infrastructure cost │ └──────────────────────────────────────────────────────────────────────────────────┘

  2. SUPPORT BURDEN ┌──────────────────────────────────────────────────────────────────────────────────┐ │ API users need: │ │ • Technical documentation │ │ • Developer support (different skillset than cashier support) │ │ • Debugging assistance when integrations break │ │ • Webhook troubleshooting │ │ │ │ Enterprise tier includes dedicated CSM → can handle developer questions │ └──────────────────────────────────────────────────────────────────────────────────┘

  3. SECURITY RISK ┌──────────────────────────────────────────────────────────────────────────────────┐ │ API keys are powerful: │ │ • Can read/write ALL business data │ │ • If leaked, entire system compromised │ │ • Need audit logging, key rotation, IP whitelisting │ │ │ │ Enterprise tenants have: │ │ • More sophisticated security needs │ │ • Dedicated IT staff to manage keys │ │ • Compliance requirements (SOC 2, PCI) │ └──────────────────────────────────────────────────────────────────────────────────┘

  4. BUSINESS DIFFERENTIATION ┌──────────────────────────────────────────────────────────────────────────────────┐ │ Creates clear value ladder: │ │ │ │ Starter ($49) → UI only, simple use case │ │ Professional ($149) → Limited API, basic integrations │ │ Enterprise ($499) → Full API, custom integrations │ │ Premium ($999) → Priority API, highest rate limits │ │ │ │ Tenants who NEED API access → likely larger businesses → can afford higher tier │ └──────────────────────────────────────────────────────────────────────────────────┘


### API Access by Tier

┌─────────────────┬─────────────────────────────────────────────────────────────────────┐ │ Tier │ API Access Details │ ├─────────────────┼─────────────────────────────────────────────────────────────────────┤ │ Starter │ ❌ NO API ACCESS │ │ ($49/mo) │ • Admin Portal and POS Client only │ │ │ • Cannot generate API keys │ │ │ • No programmatic access │ ├─────────────────┼─────────────────────────────────────────────────────────────────────┤ │ Professional │ ⚠️ LIMITED API ACCESS │ │ ($149/mo) │ • Read-only endpoints: GET /products, GET /sales, GET /inventory │ │ │ • 100 requests/hour rate limit │ │ │ • 1 API key maximum │ │ │ • No webhook subscriptions │ │ │ • Basic documentation access │ ├─────────────────┼─────────────────────────────────────────────────────────────────────┤ │ Enterprise │ ✅ FULL API ACCESS │ │ ($499/mo) │ • All endpoints: GET, POST, PUT, DELETE │ │ │ • 1,000 requests/hour rate limit │ │ │ • 5 API keys with scopes │ │ │ • Webhook subscriptions (10 max) │ │ │ • Full developer documentation │ │ │ • Sandbox environment for testing │ ├─────────────────┼─────────────────────────────────────────────────────────────────────┤ │ Premium │ ✅ PRIORITY API ACCESS │ │ ($999/mo) │ • All Enterprise features PLUS: │ │ │ • 10,000 requests/hour rate limit │ │ │ • Unlimited API keys │ │ │ • Unlimited webhooks │ │ │ • Dedicated API endpoint (isolated) │ │ │ • Custom endpoint development available │ │ │ • Priority support queue for API issues │ └─────────────────┴─────────────────────────────────────────────────────────────────────┘


### API Key Management

Enterprise and Premium tenants manage API keys through Admin Portal:

ADMIN PORTAL → Settings → API Keys

┌─────────────────────────────────────────────────────────────────────────────────────┐ │ API KEYS [+ Create Key] │ ├─────────────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ │ │ Name: ERP Integration │ │ │ │ Key: sk_live_abc123…def789 [Copy] [Regenerate] │ │ │ │ Scopes: products:read, inventory:read, inventory:write │ │ │ │ Created: 2025-01-15 │ │ │ │ Last Used: 2025-01-28 14:32:05 UTC │ │ │ │ Requests Today: 847 │ │ │ │ [Revoke Key] │ │ │ └───────────────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────────────────┐ │ │ │ Name: Reporting Dashboard │ │ │ │ Key: sk_live_xyz456…uvw012 [Copy] [Regenerate] │ │ │ │ Scopes: sales:read, reports:read │ │ │ │ Created: 2025-01-20 │ │ │ │ Last Used: 2025-01-28 08:00:00 UTC │ │ │ │ Requests Today: 24 │ │ │ │ [Revoke Key] │ │ │ └───────────────────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────────────┘


---

RESPONSE TIME SLA BY SEVERITY:

┌───────────────┬─────────────┬─────────────┬─────────────┬─────────────────────────────┐
│ Severity      │ Starter     │ Professional│ Enterprise  │ Premium                     │
├───────────────┼─────────────┼─────────────┼─────────────┼─────────────────────────────┤
│ P1 (Critical) │ 8 hours     │ 4 hours     │ 1 hour      │ 15 minutes                  │
│ P2 (High)     │ 24 hours    │ 8 hours     │ 4 hours     │ 1 hour                      │
│ P3 (Medium)   │ 48 hours    │ 24 hours    │ 8 hours     │ 4 hours                     │
│ P4 (Low)      │ 5 days      │ 48 hours    │ 24 hours    │ 8 hours                     │
└───────────────┴─────────────┴─────────────┴─────────────┴─────────────────────────────┘

Summary

This chapter provides complete tenant lifecycle management:

  1. State Machine: 8 states with defined transitions
  2. Onboarding Workflow: 6-phase process with checklists
  3. Automated Provisioning: Scripts for schema creation and setup
  4. Offboarding Workflow: 7-step process including GDPR compliance
  5. Data Export: Complete export scripts for portability
  6. Billing Integration: Stripe webhook handlers
  7. Support Tiers: 4 tiers with feature comparison

Next Chapter: Chapter 34: Claude Code Command Reference


“The beginning and end of a customer relationship deserve equal attention.”

Chapter 34: Claude Code Command Reference

Complete Command Guide for POS Development

This chapter provides a comprehensive reference for all Claude Code multi-agent commands used throughout POS platform development. Use this as your quick-reference guide during implementation.


Table of Contents

  1. Quick Commands
  2. Workflow Commands
  3. Specialized Commands
  4. Command Sequences by Task
  5. Best Practices

Quick Commands

Core Development Commands

CommandAgents UsedPurpose
/o <task>Auto-selectedSmart routing - Claude figures out the best approach
/dev-teamEditor + EngineerCode implementation with automatic review
/design-teamDemo + StylistUI design with accessibility validation
/architect-reviewArchitectArchitecture validation and decisions
/engineerEngineer (read-only)Code review without modifications
/refactor-checkEngineerFind code quality issues and duplication
/researchResearcherDeep online investigation
/learnMemoryCapture discoveries for future sessions
/cleanupOrchestratorPost-task organization and documentation

When to Use Each Command

/o <task>
  Use for: General tasks where you're unsure which agent is best
  Example: /o add customer search to POS
  Result: Claude analyzes and routes to appropriate agents

/dev-team
  Use for: Any code implementation that needs review
  Example: /dev-team implement tenant middleware
  Result: Editor writes code, Engineer reviews it

/design-team
  Use for: UI/UX work with accessibility
  Example: /design-team create checkout flow mockup
  Result: Demo creates design, Stylist validates accessibility

/architect-review
  Use for: Validating major decisions
  Example: /architect-review event sourcing for inventory
  Result: Architect evaluates and documents ADR

/engineer
  Use for: Read-only code review
  Example: /engineer review PaymentService.cs
  Result: Feedback without code changes

/refactor-check
  Use for: Finding technical debt
  Example: /refactor-check src/Services/
  Result: List of duplication, violations, improvements

/research
  Use for: External research
  Example: /research PCI-DSS 4.0 changes for retail
  Result: Comprehensive research with sources

/learn
  Use for: Capturing learnings
  Example: /learn EF Core tenant isolation pattern
  Result: Saved to memory for future sessions

/cleanup
  Use for: Finishing work sessions
  Example: /cleanup after implementing inventory sync
  Result: Documentation updated, files organized

Workflow Commands

Standard Workflows

CommandStagesTotal Agents
/workflowPlan, Edit, Review3
/pos-workflowPlan, Architect, Edit, Review4
/auto-workflowDoc, Plan, Implement, Review, Doc5
/design-workflowResearch, Plan, Demo, Style, Implement, Review6

Workflow Details

/workflow - Basic Development Workflow

Stage 1: Plan
  Agent: Planner
  Output: Implementation plan with steps

Stage 2: Edit
  Agent: Editor
  Output: Code implementation

Stage 3: Review
  Agent: Engineer
  Output: Code review feedback

Use for: Standard feature implementation

Example:

/workflow add customer loyalty points calculation

/pos-workflow - Full POS Workflow

Stage 1: Plan
  Agent: Planner
  Output: Detailed implementation plan

Stage 2: Architect Review
  Agent: Architect
  Output: Architecture validation, ADR if needed

Stage 3: Edit
  Agent: Editor
  Output: Code implementation

Stage 4: Review
  Agent: Engineer
  Output: Code review with POS-specific checks

Use for: Major POS features requiring architecture validation

Example:

/pos-workflow implement offline payment queue

/auto-workflow - Fully Automated

Stage 1: Document (Pre)
  Agent: Documenter
  Output: Current state documentation

Stage 2: Plan
  Agent: Planner
  Output: Implementation strategy

Stage 3: Implement
  Agent: Editor
  Output: Code changes

Stage 4: Review
  Agent: Engineer
  Output: Quality validation

Stage 5: Document (Post)
  Agent: Documenter
  Output: Updated documentation

Use for: Complete features needing full documentation

Example:

/auto-workflow implement multi-store inventory transfer

/design-workflow - UI Development

Stage 1: Research
  Agent: Researcher
  Output: UX patterns, accessibility requirements

Stage 2: Plan
  Agent: Planner
  Output: Component structure plan

Stage 3: Demo
  Agent: Demo Creator
  Output: Visual mockups (ASCII/text)

Stage 4: Style
  Agent: Stylist
  Output: Accessibility validation, WCAG compliance

Stage 5: Implement
  Agent: Editor
  Output: Component code

Stage 6: Review
  Agent: Engineer
  Output: Code review

Use for: New UI components and screens

Example:

/design-workflow create product quick-add modal

Specialized Commands

Architecture Commands

# Create new ADR
/architect-review ADR for <decision topic>

# Validate existing architecture
/architect-review validate <component> against ADRs

# Review cross-cutting concerns
/architect-review security implications of <change>

Security Commands

# Security-focused review
/engineer security review <file or feature>

# PCI-DSS compliance check
/refactor-check PCI-DSS compliance in payment flow

# Authentication/authorization review
/architect-review auth flow for <feature>

Database Commands

# Schema review
/engineer review migration <migration-name>

# Performance analysis
/refactor-check database performance in <repository>

# Data integrity check
/architect-review data model for <entity>

Testing Commands

# Generate tests
/dev-team write tests for <feature>

# Review test coverage
/engineer review test coverage for <service>

# Integration test plan
/workflow plan integration tests for <module>

Command Sequences by Task

Task 1: Adding a New API Endpoint

Scenario: Add GET /api/v1/tenants/{tenantId}/customers/search

# Step 1: Review existing patterns
/engineer review existing customer endpoints

# Step 2: Plan and implement
/dev-team add customer search endpoint with pagination

# Step 3: Validate architecture
/architect-review customer search query patterns

# Step 4: Add tests
/dev-team write tests for customer search endpoint

# Step 5: Document
/cleanup update API documentation

Expected Files Modified:

  • Controllers/CustomersController.cs
  • Services/ICustomerService.cs
  • Services/CustomerService.cs
  • Tests/CustomerControllerTests.cs

Task 2: Creating a New Domain Entity

Scenario: Add LoyaltyProgram entity with points tracking

# Step 1: Architecture review
/architect-review domain model for loyalty program

# Step 2: Create entity and events
/dev-team create LoyaltyProgram entity with domain events

# Step 3: Add repository
/dev-team implement ILoyaltyProgramRepository

# Step 4: Create migration
/dev-team add EF Core migration for loyalty_programs

# Step 5: Review everything
/engineer review loyalty program implementation

# Step 6: Capture pattern
/learn loyalty program implementation pattern

Expected Files Created:

  • Domain/Entities/LoyaltyProgram.cs
  • Domain/Events/LoyaltyPointsEarnedEvent.cs
  • Domain/Events/LoyaltyPointsRedeemedEvent.cs
  • Infrastructure/Repositories/LoyaltyProgramRepository.cs
  • Migrations/YYYYMMDDHHMMSS_AddLoyaltyProgram.cs

Task 3: Implementing a Background Job

Scenario: Daily inventory snapshot job

# Step 1: Research patterns
/research background job patterns in ASP.NET Core

# Step 2: Architecture decision
/architect-review background job hosting strategy

# Step 3: Implement job
/dev-team implement daily inventory snapshot job

# Step 4: Add scheduling
/dev-team configure Hangfire scheduling for snapshot job

# Step 5: Add monitoring
/dev-team add job health checks and metrics

# Step 6: Test
/dev-team write integration tests for snapshot job

Expected Files Created:

  • Jobs/InventorySnapshotJob.cs
  • Jobs/IInventorySnapshotJob.cs
  • Configuration/HangfireConfig.cs
  • Tests/InventorySnapshotJobTests.cs

Task 4: Adding Tests

Scenario: Improve test coverage for PaymentService

# Step 1: Analyze current coverage
/engineer review test coverage for PaymentService

# Step 2: Identify gaps
/refactor-check find untested paths in PaymentService

# Step 3: Unit tests
/dev-team write unit tests for PaymentService edge cases

# Step 4: Integration tests
/dev-team write integration tests for payment flow

# Step 5: Validate
/engineer review new payment tests

Test Categories to Cover:

  • Unit tests for business logic
  • Integration tests for database operations
  • Mock tests for external payment gateway
  • Edge case tests (failures, timeouts, partial payments)

Task 5: Security Review

Scenario: Pre-deployment security audit

# Step 1: Authentication review
/engineer security review authentication flow

# Step 2: Authorization review
/architect-review RBAC implementation

# Step 3: Data protection
/refactor-check sensitive data handling

# Step 4: Input validation
/engineer review input validation in controllers

# Step 5: Dependency audit
/research security vulnerabilities in dependencies

# Step 6: Document findings
/cleanup create security review report

Security Checklist Integration: See Chapter 36: Checklists for complete security review checklist.


Task 6: UI Mockup Creation

Scenario: Design new receipt customization screen

# Step 1: Research
/research receipt customization UX patterns

# Step 2: Create mockup
/design-team create receipt customization screen mockup

# Step 3: Accessibility review
/design-team validate accessibility for receipt editor

# Step 4: Get architecture input
/architect-review receipt template storage approach

# Step 5: Implement
/dev-team implement receipt customization component

# Step 6: Review
/engineer review receipt customization implementation

Design Artifacts:

  • ASCII mockup in markdown
  • Component hierarchy diagram
  • Accessibility checklist (WCAG 2.1 AA)
  • State management plan

Best Practices

Command Selection Guidelines

Feature Size        | Recommended Command
--------------------|--------------------
Quick fix           | /dev-team
Small feature       | /workflow
Major feature       | /pos-workflow
New UI screen       | /design-workflow
Architecture change | /architect-review first
Bug investigation   | /engineer then /dev-team
Research needed     | /research then /workflow

Chaining Commands Effectively

Good Pattern: Research then implement

/research multi-tenant caching strategies
# Read output, understand options
/architect-review caching strategy for tenant data
# Get ADR created
/dev-team implement tenant cache with Redis

Good Pattern: Review then fix

/engineer review InventoryService
# Get feedback list
/dev-team fix InventoryService issues
# Address each point

Bad Pattern: Skipping review

/dev-team implement critical payment feature
# Missing: /architect-review and /engineer review

Memory and Learning

# After solving a tricky problem
/learn how we handled concurrent inventory updates

# After making an architecture decision
/learn tenant isolation middleware pattern

# After debugging a complex issue
/learn debugging tips for offline sync conflicts

Session Management

Start of Session:

# Check what's pending
/o what's the status of POS implementation?

# Review recent changes
/engineer review changes since last session

End of Session:

# Clean up
/cleanup

# Document progress
/learn progress on <feature> implementation

Command Reference Card

Print this section for quick reference:

+------------------------------------------------------------------+
|                    CLAUDE CODE QUICK REFERENCE                    |
+------------------------------------------------------------------+
| QUICK COMMANDS                                                    |
| /o <task>         - Smart routing (figures out best approach)    |
| /dev-team         - Code with review (Editor + Engineer)          |
| /design-team      - UI with accessibility (Demo + Stylist)        |
| /architect-review - Architecture validation                       |
| /engineer         - Code review only (read-only)                  |
| /refactor-check   - Find code quality issues                      |
| /research         - Deep investigation                            |
| /learn            - Capture discoveries                           |
| /cleanup          - Post-task organization                        |
+------------------------------------------------------------------+
| WORKFLOWS                                                         |
| /workflow         - Plan -> Edit -> Review                        |
| /pos-workflow     - Plan -> Architect -> Edit -> Review           |
| /auto-workflow    - Doc -> Plan -> Implement -> Review -> Doc     |
| /design-workflow  - Research -> Plan -> Demo -> Style -> Impl     |
+------------------------------------------------------------------+
| COMMON SEQUENCES                                                  |
| New Endpoint:   /engineer review -> /dev-team -> /architect-review|
| New Entity:     /architect-review -> /dev-team -> /engineer       |
| Security Audit: /engineer security -> /refactor-check -> /cleanup |
| UI Component:   /design-workflow (all-in-one)                     |
+------------------------------------------------------------------+

Troubleshooting Commands

When Things Go Wrong

# Agent seems confused about context
/o reset context and continue with <task>

# Need to undo changes
/engineer review what changed
# Then git reset or manual fix

# Command not producing expected results
/o explain what /dev-team does for <task>
# Clarify expectations

# Need more detail from agent
/o expand on <specific aspect>

Getting Unstuck

# Stuck on implementation approach
/research alternatives for <problem>
/architect-review compare approaches

# Stuck on debugging
/engineer analyze error in <file>
/research common causes of <error>

# Stuck on design
/design-team brainstorm approaches for <UI problem>

Summary

NeedCommand
Write code with review/dev-team
Just review code/engineer
Design UI/design-team
Major architecture decision/architect-review
Research something/research
Full feature with docs/auto-workflow
Remember something/learn
Finish session/cleanup
Not sure what to use/o <task>

This reference is designed to be printed and kept nearby during development sessions.

Chapter 35: Glossary

Complete A-Z Reference for POS Platform Terminology

This glossary provides definitions for all technical terms, acronyms, and domain concepts used throughout this Blueprint Book.


A

ADR (Architecture Decision Record)

A document that captures an important architectural decision along with its context and consequences. ADRs provide a historical record of why decisions were made.

Example: “ADR-001: Use schema-per-tenant for data isolation”

Aggregate

In Domain-Driven Design, a cluster of domain objects that can be treated as a single unit. An aggregate has a root entity and enforces consistency boundaries.

Example: Order is an aggregate root containing OrderLines, Payments, and Discounts.

API Gateway

A server that acts as the single entry point for all client requests. It handles request routing, composition, and protocol translation.

Audit Log

An append-only record of all significant events in the system. Used for compliance, debugging, and analytics.

POS Context: Every inventory change, transaction, and user action is logged.


B

Background Job

A task that runs asynchronously outside the main request/response cycle. Used for scheduled tasks, long-running operations, and deferred processing.

POS Context: Daily inventory snapshots, report generation, sync operations.

Barcode

A machine-readable representation of data, typically printed on product labels. Common formats include UPC-A, EAN-13, Code 128, and QR codes.

Blazor

A .NET web framework for building interactive web UIs using C# instead of JavaScript. Stanly uses Blazor Server for its admin interface.

Bounded Context

In DDD, a logical boundary within which a particular domain model is defined and applicable. Different bounded contexts may have different models for the same real-world concept.

POS Context: “Inventory” context vs “Sales” context may model products differently.

BRIN Index (Block Range Index)

A PostgreSQL index type optimized for large tables with naturally ordered data (like timestamps). More compact than B-tree for sequential data.

Usage: CREATE INDEX idx_events_created ON events USING BRIN (created_at);

Bridge

A software component that connects two different systems. In Stanly, the Bridge connects store computers running QuickBooks POS to the central Stanly server.


C

Cash Drawer

The physical drawer containing cash, typically connected to a POS terminal. Opened programmatically when cash transactions occur.

Checkout

The process of completing a sale, including scanning items, applying discounts, collecting payment, and generating a receipt.

Circuit Breaker

A design pattern that prevents cascading failures by detecting failures and stopping attempts to invoke a failing service.

Example: If payment gateway fails 5 times, stop trying for 30 seconds.

Command (CQRS)

An operation that modifies state. Commands are imperative (“CreateOrder”, “ApplyDiscount”) and may be rejected if invalid.

Connection String

A string containing information needed to connect to a database, including server, port, database name, and credentials.

Example: Host=postgres16;Port=5432;Database=pos_db;Username=pos_user;Password=xxx

CORS (Cross-Origin Resource Sharing)

A security mechanism that allows or restricts web pages from making requests to a different domain than the one serving the page.

CQRS (Command Query Responsibility Segregation)

An architectural pattern that separates read operations (queries) from write operations (commands). Allows optimizing each path independently.

Customer Display

A secondary screen facing the customer showing items being scanned, prices, and transaction totals.


D

Dead Letter Queue

A queue where messages that cannot be processed are sent for later analysis. Prevents message loss and enables debugging.

Dependency Injection (DI)

A technique where objects receive their dependencies from external sources rather than creating them internally. Promotes loose coupling and testability.

Discrepancy

A difference between expected and actual values. In inventory, the difference between system quantity and physical count.

Docker

A platform for developing, shipping, and running applications in containers. Provides consistent environments across development and production.

Docker Compose

A tool for defining and running multi-container Docker applications using YAML configuration files.

Domain Event

A record that something significant happened in the domain. Events are named in past tense (“OrderCreated”, “PaymentReceived”).

Domain-Driven Design (DDD)

An approach to software development that focuses on modeling the business domain and using a ubiquitous language shared by developers and domain experts.

DTO (Data Transfer Object)

An object that carries data between processes. DTOs are simple containers with no business logic.


E

EF Core (Entity Framework Core)

Microsoft’s object-relational mapper (ORM) for .NET. Maps database tables to C# classes and handles CRUD operations.

EMV

A global standard for chip-based credit and debit card transactions. Named after Europay, Mastercard, and Visa.

Entity

In DDD, an object defined by its identity rather than its attributes. Entities have a unique identifier that persists over time.

Example: A Customer is an entity - even if their name changes, they’re still the same customer.

Event Sourcing

A pattern where state is stored as a sequence of events rather than current values. The current state is derived by replaying all events.

Benefit: Complete audit trail, ability to reconstruct any historical state.

Event Store

A database optimized for storing and retrieving events. Provides append-only storage and efficient event streaming.


F

Failover

The automatic switching to a backup system when the primary system fails.

Fiscal Printer

A specialized printer that generates legally-compliant receipts with tax calculations. Required in some jurisdictions.

Fitness Function

An automated test that verifies architectural characteristics (performance, security, scalability) are maintained as the system evolves.

Example: “API response time must be < 200ms for 95th percentile”

Flyway

A database migration tool that manages schema versioning and applies migrations in order.

Fulfillment

The process of preparing and shipping an order to the customer.


G

GDPR (General Data Protection Regulation)

EU regulation on data protection and privacy. Requires consent for data collection, right to deletion, and data portability.

Gift Card

A prepaid stored-value card issued by a retailer. Can be physical or digital.

gRPC

A high-performance RPC framework using Protocol Buffers. Alternative to REST for service-to-service communication.


H

Hangfire

A .NET library for running background jobs. Provides scheduling, retry logic, and a dashboard.

Hardware Security Module (HSM)

A physical device that safeguards cryptographic keys. Used for PCI-DSS compliance.

Heartbeat

A periodic signal sent to indicate a system is alive and functioning. In Stanly, bridges send heartbeats every 60 seconds.

Horizontal Scaling

Adding more machines to handle increased load. Contrast with vertical scaling (adding resources to existing machines).

Hot Path

The code path executed for the most common operations. Must be optimized for performance.


I

Idempotency

The property where an operation produces the same result regardless of how many times it’s executed. Critical for retry logic.

Example: Creating an order with idempotency key ensures duplicates aren’t created on retry.

Idempotency Key

A unique identifier included with requests to enable idempotent operations.

Index (Database)

A data structure that improves query performance by providing quick lookup paths. Types include B-tree, BRIN, GIN, and GiST.

Integration Test

A test that verifies multiple components work together correctly. Tests real database, real services.

Inventory

The quantity and value of goods available for sale. Tracked by SKU and location.


J

Job

See Background Job.

JSON Web Token

See JWT.

JWT (JSON Web Token)

A compact, URL-safe means of representing claims between parties. Used for authentication in APIs.

Structure: Header.Payload.Signature (base64 encoded)


K

Kiosk Mode

A locked-down interface mode where users can only access specific application features. Prevents tampering with settings.


L

Layaway

A payment plan where items are reserved and paid for over time before being picked up.

Load Balancer

A device or software that distributes network traffic across multiple servers.

Location

A physical place where inventory is stored and/or sold. Each location has separate inventory counts.

Stanly Locations: HQ, GM, HM, LM, NM

Logging

Recording application events for debugging, monitoring, and audit purposes.


M

Materialized View

A database view that stores query results physically. Faster to query but must be refreshed when source data changes.

Microservice

An architectural style where applications are composed of small, independent services that communicate over a network.

Middleware

Software that sits between the application and the network/OS, handling cross-cutting concerns like authentication, logging, and error handling.

Migration (Database)

A version-controlled change to database schema. Applied in order to evolve the database structure.

Multi-Tenancy

An architecture where a single instance of software serves multiple customers (tenants), with data isolation between them.

Strategies: Shared database, schema-per-tenant, database-per-tenant.


N

N+1 Query Problem

A performance anti-pattern where code executes N additional queries to fetch related data for N items. Solved with eager loading or batch queries.


O

OAuth 2.0

An authorization framework that enables third-party applications to obtain limited access to user accounts.

Offline-First

A design approach where applications work without network connectivity and sync when connection is available.

ORM (Object-Relational Mapping)

A technique for converting data between incompatible type systems in object-oriented programming languages and relational databases.

Outbox Pattern

A pattern for reliable message publishing where messages are saved to a database table (outbox) before being published to a message broker.


P

Pagination

Dividing large result sets into smaller pages for display and transmission.

Partitioning

Dividing a database table into smaller, more manageable pieces while maintaining a single logical table.

Types: Range partitioning (by date), list partitioning (by tenant).

Payment Gateway

A service that authorizes credit card payments and transfers funds.

PCI-DSS (Payment Card Industry Data Security Standard)

A set of security standards for organizations that handle credit card data.

Key Requirements: Network security, cardholder data protection, vulnerability management, access control, monitoring, security policy.

PLU (Price Look-Up)

A 4 or 5 digit number assigned to produce items for checkout identification.

POS (Point of Sale)

The place and system where a retail transaction is completed. Includes hardware (terminal, scanner, printer) and software.

PostgreSQL

An open-source relational database known for robustness, extensibility, and standards compliance.

Projection

In event sourcing, a read model built by processing events. Optimized for specific query patterns.


Q

QBXML

QuickBooks’ XML-based API format for communicating with QuickBooks Point of Sale.

Query (CQRS)

An operation that returns data without modifying state. Queries can be optimized independently from commands.

Queue

A data structure that holds items in order. Used for background jobs, message passing, and load leveling.


R

RBAC (Role-Based Access Control)

An access control method where permissions are assigned to roles, and roles are assigned to users.

POS Roles: SuperAdmin, TenantAdmin, Manager, Cashier, Auditor.

Read Replica

A database copy that handles read queries, reducing load on the primary database.

Receipt

A document acknowledging a transaction. Can be printed, emailed, or displayed digitally.

Reconciliation

The process of comparing two sets of records to ensure they match. Used for inventory and financial data.

Refund

A return of payment to a customer, typically for returned merchandise.

Repository Pattern

A design pattern that provides an abstraction layer between the domain and data mapping layers.

REST (Representational State Transfer)

An architectural style for web services using HTTP methods (GET, POST, PUT, DELETE) to operate on resources.

Retry Policy

A strategy for automatically retrying failed operations with configurable delays and limits.

RFID (Radio-Frequency Identification)

Technology using electromagnetic fields to automatically identify and track tags attached to objects. Raptag uses RFID for inventory scanning.

Row-Level Security (RLS)

A PostgreSQL feature that restricts which rows a user can access based on policies.


S

SaaS (Software as a Service)

A software distribution model where applications are hosted centrally and accessed via the internet.

Saga

A pattern for managing distributed transactions by defining a sequence of local transactions with compensating actions for rollback.

Schema

The structure of a database including tables, columns, types, and relationships.

Schema-Per-Tenant

A multi-tenancy strategy where each tenant has their own database schema within a shared database.

SDK (Software Development Kit)

A collection of tools and libraries for building applications for a specific platform.

Seeding

Populating a database with initial data required for the application to function.

Serilog

A .NET logging library with structured logging capabilities.

Service Bus

A messaging infrastructure that enables asynchronous communication between services.

Session

A server-side storage mechanism that maintains state across multiple requests from the same client.

Sharding

Distributing data across multiple databases based on a shard key (like tenant ID).

SignalR

A .NET library for adding real-time web functionality using WebSockets.

SKU (Stock Keeping Unit)

A unique identifier for a distinct product. Used for inventory tracking and sales analysis.

Example: “NXP0323” identifies a specific product variant.

Snapshot

A point-in-time copy of data. Used in event sourcing to avoid replaying all events.

Soft Delete

Marking records as deleted without physically removing them. Enables recovery and audit.

Implementation: is_deleted boolean column, excluded from normal queries.

Split Payment

A transaction where payment is made using multiple payment methods (e.g., $50 cash + $30 credit card).

Swagger/OpenAPI

A specification for describing REST APIs. Enables automatic documentation and client generation.


T

Tailscale

A VPN service using WireGuard that creates secure mesh networks. Used for connecting store bridges to central Stanly.

Tenant

A customer organization in a multi-tenant system. Each tenant’s data is isolated from others.

Tenant ID

A unique identifier for a tenant, typically a UUID. Used to scope all data and operations.

Token

A piece of data representing identity or authorization. See JWT.

Transaction

In databases, a unit of work that is atomic (all or nothing). In retail, a sale or return event.


U

Ubiquitous Language

In DDD, a common language shared by developers and domain experts, used in code and conversations.

Unit of Work

A pattern that maintains a list of objects affected by a business transaction and coordinates writing out changes.

Unit Test

A test that verifies a single unit of code (function, method) in isolation.

UPC (Universal Product Code)

A barcode symbology used for tracking items in stores. 12-digit format in North America.

UUID (Universally Unique Identifier)

A 128-bit identifier that is unique across space and time. Format: 550e8400-e29b-41d4-a716-446655440000


V

Value Object

In DDD, an object defined by its attributes rather than identity. Two value objects with the same attributes are equal.

Example: Money(100, "USD") is a value object.

Vault

A system for managing secrets (passwords, API keys, certificates). Examples: HashiCorp Vault, Azure Key Vault.

Vertical Scaling

Adding resources (CPU, RAM) to existing machines. Contrast with Horizontal Scaling.

View (Database)

A virtual table based on a SQL query. Can simplify complex queries and provide security.

Void

Canceling a transaction before it’s completed or settled.

VPN (Virtual Private Network)

A secure connection between networks over the internet. See Tailscale.


W

WebSocket

A protocol providing full-duplex communication over a single TCP connection. Used for real-time features.

Webhook

An HTTP callback that occurs when something happens. A way for apps to receive real-time notifications.


X

XSS (Cross-Site Scripting)

A security vulnerability where attackers inject malicious scripts into web pages viewed by other users.

XUNIT

A .NET unit testing framework.


Y

YAML (YAML Ain’t Markup Language)

A human-readable data serialization format used for configuration files (docker-compose.yml, etc.).


Z

Zero Downtime Deployment

Deploying new versions without any interruption to users. Achieved through rolling updates, blue-green deployments, or canary releases.


Domain-Specific Terms (Retail/POS)

TermDefinition
BasketCollection of items a customer intends to purchase
Cash FloatStarting cash amount in the register at beginning of shift
Cash UpEnd-of-day process of counting cash and reconciling with sales
ClerkEmployee operating the POS terminal
CompComplimentary item given free to customer
EoDEnd of Day - daily closing procedures
House AccountCredit account for regular customers
LayawayPayment plan where items are reserved until fully paid
MarkdownPrice reduction on items
No SaleOpening cash drawer without a transaction
On HandCurrent inventory quantity
Open TicketTransaction started but not completed
Over/ShortDifference between expected and actual cash
PLUPrice Look-Up code for produce
Rain CheckPromise to sell at sale price when item is restocked
ShrinkageInventory loss due to theft, damage, or errors
SKUStock Keeping Unit - unique product identifier
TenderPayment method (cash, credit, etc.)
TillCash drawer
VoidCancel a line item or entire transaction
X-ReadMid-day sales report without resetting totals
Z-ReadEnd-of-day report that resets totals

Use Ctrl+F (or Cmd+F on Mac) to quickly find terms in this glossary.

Chapter 36: Checklists

Ready-to-Use Checklists for POS Development and Operations

This chapter provides comprehensive checklists for common development, deployment, and operational tasks. Print these and use them to ensure nothing is missed.


Table of Contents

  1. New Feature Checklist
  2. Code Review Checklist
  3. Security Review Checklist
  4. API Endpoint Checklist
  5. Database Migration Checklist
  6. Deployment Checklist
  7. Go-Live Checklist
  8. Tenant Onboarding Checklist
  9. End-of-Day Checklist
  10. PCI-DSS Audit Checklist

1. New Feature Checklist

Use this checklist when implementing any new feature in the POS platform.

Planning Phase

  • Requirements documented - Clear acceptance criteria defined
  • Architecture review - /architect-review completed for major features
  • ADR created - If architectural decision was made
  • Database schema designed - Entity models and relationships defined
  • API contracts defined - Endpoints, request/response formats documented
  • UI mockups approved - For features with user interface changes
  • Tenant impact assessed - How does this affect multi-tenancy?

Implementation Phase

  • Domain entities created - Following DDD patterns
  • Domain events defined - Named in past tense, all relevant events
  • Repository interfaces - Abstraction layer defined
  • Repository implementations - EF Core implementations
  • Service interfaces - Business logic abstraction
  • Service implementations - Business logic with proper logging
  • API controllers - RESTful endpoints with proper authorization
  • DTOs created - Request/response models separate from domain
  • Validation added - FluentValidation or DataAnnotations
  • Error handling - Proper exception handling and responses

Testing Phase

  • Unit tests written - Cover all business logic branches
  • Integration tests written - Test with real database
  • API tests written - Verify endpoint contracts
  • Tenant isolation tested - Verify data doesn’t leak
  • Edge cases covered - Null values, empty lists, max values
  • Error scenarios tested - Verify proper error responses

Documentation Phase

  • API documentation updated - Swagger annotations complete
  • README updated - If setup/configuration changed
  • CHANGELOG updated - Feature listed with version
  • User documentation - If user-facing feature

Final Review

  • Code review completed - /engineer review passed
  • Security review - No vulnerabilities introduced
  • Performance verified - No N+1 queries, proper indexing
  • Migrations tested - Applied and rolled back successfully

2. Code Review Checklist

Use this checklist when reviewing code (or when preparing code for review).

General Quality

  • Code compiles - No build errors or warnings
  • Tests pass - All existing and new tests green
  • No dead code - Unused variables, methods removed
  • No commented-out code - Remove or document why
  • Meaningful names - Variables, methods, classes have clear names
  • Small methods - Functions do one thing well
  • Proper indentation - Consistent formatting

Architecture & Design

  • Single Responsibility - Each class has one reason to change
  • Dependency Injection - No new for services, use DI
  • Interface segregation - No fat interfaces
  • Proper layering - Controllers don’t contain business logic
  • Repository pattern - Data access abstracted
  • No circular dependencies - Clean dependency graph

Error Handling

  • Exceptions caught appropriately - Not swallowing exceptions
  • Meaningful error messages - Users and developers can understand
  • Logging on errors - Stack traces logged for debugging
  • Graceful degradation - Feature fails safely

Security

  • Authorization checked - Proper [Authorize] attributes
  • Input validated - All user input sanitized
  • No SQL injection - Parameterized queries only
  • No XSS vulnerabilities - Output encoded
  • Secrets not hardcoded - Use configuration/vault
  • Tenant isolation - Data scoped to tenant

Performance

  • No N+1 queries - Use Include/eager loading
  • Proper async/await - No blocking calls
  • Appropriate caching - Frequently accessed data cached
  • Database indexes - Queries use indexes
  • No memory leaks - Disposable objects disposed

Documentation

  • XML comments on public APIs - Summary, params, returns
  • Complex logic documented - Why, not just what
  • TODO items tracked - Linked to issues if deferred

3. Security Review Checklist

Use this checklist before deploying features that handle sensitive data.

Authentication

  • Strong password policy - Minimum length, complexity
  • Password hashing - bcrypt/Argon2, not MD5/SHA1
  • Account lockout - After failed attempts
  • Session management - Proper timeout, secure cookies
  • Multi-factor authentication - For admin accounts
  • JWT properly validated - Signature, expiration, issuer

Authorization

  • Role-based access - RBAC properly implemented
  • Least privilege - Users have minimum necessary permissions
  • Authorization on all endpoints - No unprotected APIs
  • Tenant isolation enforced - Users can’t access other tenants
  • Resource ownership verified - Users can only modify own resources

Data Protection

  • Sensitive data encrypted - At rest and in transit
  • TLS/HTTPS enforced - No plaintext transmission
  • PII minimized - Only collect what’s necessary
  • Data retention policy - Old data purged
  • Backup encryption - Backups are encrypted

Input Validation

  • All input validated - Type, length, format
  • Whitelist validation - Accept known good
  • SQL injection prevented - Parameterized queries
  • XSS prevented - Output encoding
  • CSRF protection - Anti-forgery tokens
  • File upload restrictions - Type, size limits

Logging & Monitoring

  • Security events logged - Login, logout, failures
  • PII not logged - Passwords, card numbers excluded
  • Log integrity - Logs protected from tampering
  • Alerting configured - Suspicious activity triggers alerts
  • Audit trail - Who did what, when

Infrastructure

  • Firewall configured - Only necessary ports open
  • Dependencies updated - No known vulnerabilities
  • Secrets in vault - Not in code or config files
  • Container hardened - Non-root user, minimal image
  • Network segmentation - Database not publicly accessible

4. API Endpoint Checklist

Use this checklist when adding or modifying API endpoints.

Design

  • RESTful naming - Resource-based URLs
  • Proper HTTP methods - GET, POST, PUT, DELETE used correctly
  • Versioning - /api/v1/ prefix
  • Consistent naming - camelCase, plural resources
  • Pagination - Large collections paginated
  • Filtering/sorting - Query parameters for flexibility

Implementation

  • Controller attribute - [ApiController] applied
  • Route attribute - Explicit routes defined
  • Authorization - [Authorize] with roles/policies
  • Model binding - [FromBody], [FromQuery] specified
  • Validation - ModelState checked or auto-validation
  • Response types - [ProducesResponseType] specified
  • Cancellation token - Async methods accept token

Request Handling

  • Input validation - All inputs validated
  • Idempotency - POST/PUT are idempotent where needed
  • Rate limiting - Appropriate limits configured
  • Request logging - Requests logged (excluding sensitive data)
  • Content negotiation - Accept header respected

Response Format

  • Consistent structure - Standard envelope if used
  • Proper status codes - 200, 201, 400, 401, 403, 404, 500
  • Error format - Standard error response structure
  • No sensitive data - Passwords, tokens not in responses
  • Proper content type - application/json

Documentation

  • Swagger annotations - Summary, description, examples
  • Request examples - Sample payloads documented
  • Response examples - Success and error responses
  • Authentication documented - How to authenticate

5. Database Migration Checklist

Use this checklist when creating and applying database migrations.

Before Creating Migration

  • Schema reviewed - Changes discussed with team
  • Backwards compatible - Can roll back if needed
  • Data preservation - Existing data won’t be lost
  • Performance impact - Large table changes planned
  • Index strategy - New indexes identified

Creating Migration

  • Meaningful name - Descriptive migration name
  • Single responsibility - One logical change per migration
  • Up and Down - Both directions implemented
  • Idempotent - Can run multiple times safely
  • Data migration - If data transformation needed

Testing Migration

  • Local test - Applied to local database
  • Rollback tested - Down migration works
  • Data verified - Existing data intact
  • Performance tested - Large tables migrate acceptably
  • All tenants tested - Works for all tenant schemas

Deploying Migration

  • Backup taken - Database backed up before migration
  • Maintenance window - Users notified if downtime
  • Migration logged - Record of when applied
  • Verification query - Confirm migration successful
  • Rollback plan - Know how to undo if problems

After Migration

  • Application tested - Features work with new schema
  • Performance checked - No query regressions
  • Monitoring reviewed - No errors in logs
  • Documentation updated - Schema docs reflect changes

6. Deployment Checklist

Use this checklist for every deployment to staging or production.

Pre-Deployment

  • All tests passing - CI pipeline green
  • Code reviewed - All changes approved
  • Security scan - No new vulnerabilities
  • Dependencies updated - If applicable
  • CHANGELOG updated - Version and changes documented
  • Rollback plan - Know how to revert if issues

Environment Preparation

  • Configuration updated - Environment variables set
  • Secrets rotated - If scheduled rotation
  • Database migrations - Applied before deployment
  • Feature flags - New features disabled initially
  • Monitoring ready - Dashboards and alerts configured

Deployment Steps

  • Notify stakeholders - Team aware of deployment
  • Health check ready - Endpoint to verify deployment
  • Deploy to staging first - Verify in staging environment
  • Smoke tests passed - Critical paths work
  • Deploy to production - Rolling update or blue-green
  • Health check verified - All instances healthy

Post-Deployment

  • Smoke tests in production - Critical paths verified
  • Monitoring checked - No errors, performance normal
  • User validation - Key users confirm functionality
  • Deployment logged - Record version, time, deployer
  • Documentation updated - If operational changes

If Problems Occur

  • Assess impact - How many users affected?
  • Decide rollback - Roll back or fix forward?
  • Execute rollback - If decided, roll back quickly
  • Notify stakeholders - Communicate status
  • Root cause analysis - Document what went wrong

7. Go-Live Checklist

Use this comprehensive checklist before launching the POS system for a new tenant.

Infrastructure

  • Production environment ready - All containers running
  • Database provisioned - Tenant schema created
  • SSL certificates - Valid and not expiring soon
  • DNS configured - Custom domain if applicable
  • Load balancer - Configured and tested
  • Backup system - Automated backups running
  • Disaster recovery - Tested and documented

Security

  • Security audit complete - No critical findings
  • PCI-DSS compliance - If handling cards
  • Penetration testing - Completed without issues
  • Access controls - Proper roles configured
  • Secrets secured - In vault, not in code

Data

  • Data migrated - From legacy system if applicable
  • Data validated - Migrated data is correct
  • Seed data - Default settings configured
  • Test data removed - No test records in production

Integration

  • Payment gateway - Connected and tested
  • Shopify integration - If applicable, syncing
  • QuickBooks integration - If applicable, bridges connected
  • Email service - Transactional emails working
  • SMS service - If applicable, verified

Training

  • Admin training - Tenant admins trained
  • Staff training - Cashiers trained
  • Documentation - User guides available
  • Support process - Help desk configured

Operational

  • Monitoring active - All dashboards live
  • Alerting configured - On-call schedule set
  • Support team ready - Staff available for issues
  • Escalation path - Know who to call for critical issues

Final Verification

  • End-to-end test - Complete transaction flow
  • Offline mode tested - Works without internet
  • Receipt printing - Printers configured
  • Cash drawer - Opens correctly
  • Reports - Generate correctly
  • Stakeholder sign-off - Approval to go live

8. Tenant Onboarding Checklist

Use this checklist when setting up a new tenant in the POS platform.

Account Setup

  • Tenant record created - In system database
  • Tenant ID generated - UUID assigned
  • Tenant schema created - Database schema provisioned
  • Admin user created - Initial admin account
  • Password sent securely - Not in plain email

Configuration

  • Business information - Name, address, tax ID
  • Timezone configured - Correct timezone set
  • Currency configured - Default currency set
  • Tax rates - Local tax rates configured
  • Receipt template - Customized with logo
  • Email templates - Customized branding

Locations

  • Locations created - All store locations added
  • Location settings - Hours, addresses configured
  • Inventory locations - Mapped to physical areas
  • Fulfillment settings - Shipping from locations

Users

  • User accounts created - All staff accounts
  • Roles assigned - Proper permissions
  • PIN codes set - For quick clock-in
  • Training scheduled - Users know how to use system

Hardware

  • POS terminals - Configured and tested
  • Receipt printers - Installed and tested
  • Barcode scanners - Connected and working
  • Cash drawers - Opening on command
  • Customer displays - If applicable, configured

Inventory

  • Categories created - Product categories set up
  • Products imported - From spreadsheet or legacy system
  • Barcodes mapped - SKUs linked to barcodes
  • Initial counts - Starting inventory recorded
  • Pricing verified - All prices correct

Payments

  • Payment methods - Cash, card, etc. enabled
  • Payment gateway - Connected to tenant’s account
  • Refund policy - Configured in system
  • Gift cards - If applicable, enabled

Testing

  • Test transaction - Complete sale end-to-end
  • Test refund - Return processed correctly
  • Test receipt - Prints correctly
  • Test reports - Generate correctly
  • Test sync - Data syncs to cloud

Final Steps

  • Go-live date set - Scheduled with tenant
  • Support contact - Tenant knows how to get help
  • Documentation shared - User guides provided
  • Billing configured - Subscription set up

9. End-of-Day Checklist

Use this checklist for daily store closing procedures.

Register Closure

  • No open tickets - All pending transactions completed
  • Z-report generated - End of day report printed
  • Cash counted - Physical cash counted
  • Over/short recorded - Discrepancy documented
  • Cash deposited - Taken to safe or bank

Reconciliation

  • Credit card batch - Batch closed and settled
  • Gift card balance - Reconciled with system
  • Returns verified - All returns have receipts
  • Voids reviewed - Manager approval on voids
  • Discounts reviewed - All discounts authorized

Inventory

  • Received inventory - All receipts processed
  • Transfers complete - Inter-store transfers logged
  • Damaged items - Recorded in system
  • Low stock noted - Reorder list generated

Equipment

  • Registers logged out - All users signed out
  • Printers - Paper refilled if needed
  • Scanners - Charging if wireless
  • Terminals - Shut down or locked

Security

  • Safe locked - All valuables secured
  • Doors locked - All entrances secured
  • Alarm set - Security system armed
  • Lights - Appropriate lights on/off

Data Backup

  • Sync completed - All data uploaded to cloud
  • Local backup - If offline backup required
  • Verify sync - Confirm data in cloud dashboard

Manager Sign-Off

  • Reports reviewed - Day’s performance checked
  • Issues logged - Any problems documented
  • Next day prep - Opening tasks noted
  • Shift closed - System day closed

10. PCI-DSS Audit Checklist

Use this checklist to verify PCI-DSS compliance requirements.

Requirement 1: Network Security

  • Firewall installed - Protecting cardholder data
  • Default passwords changed - No vendor defaults
  • Network segmentation - CDE isolated from other networks
  • Firewall rules documented - All rules justified
  • Inbound/outbound restricted - Minimal access

Requirement 2: Secure Configuration

  • Hardening standards - Systems hardened
  • Unnecessary services disabled - Minimal attack surface
  • Security parameters - Properly configured
  • One function per server - Where possible
  • Non-console admin encrypted - SSH, TLS for admin

Requirement 3: Protect Stored Data

  • Cardholder data minimized - Only store what’s needed
  • PAN masked - Display only last 4 digits
  • PAN encrypted - If stored (avoid if possible)
  • Encryption keys managed - Secure key management
  • Sensitive auth data - Not stored after authorization

Requirement 4: Encrypt Transmission

  • TLS 1.2+ - For all cardholder data transmission
  • Certificates valid - Not expired, trusted CA
  • No fallback - Insecure protocols disabled
  • Wireless encryption - WPA2/WPA3 for WiFi

Requirement 5: Anti-Malware

  • Antivirus deployed - On all systems
  • Signatures updated - Automatic updates
  • Scans scheduled - Regular scans running
  • Logs reviewed - Alerts investigated

Requirement 6: Secure Development

  • Secure SDLC - Security in development lifecycle
  • Code review - All changes reviewed
  • Vulnerability testing - Regular security testing
  • Patches applied - Critical patches within 30 days
  • Change management - Formal change process

Requirement 7: Access Control

  • Need to know - Access based on job function
  • Access approval - Documented authorization
  • Default deny - Unless explicitly allowed
  • Privileged access limited - Minimal admin accounts

Requirement 8: User Identification

  • Unique IDs - Each user has unique account
  • Strong passwords - Complexity requirements
  • MFA for remote - Two-factor for remote access
  • Account lockout - After failed attempts
  • Session timeout - Idle sessions terminated

Requirement 9: Physical Security

  • Physical access controlled - To systems with card data
  • Visitor procedures - Logged, escorted
  • Media handling - Secure storage and destruction
  • POS terminal security - Protected from tampering

Requirement 10: Logging & Monitoring

  • Audit logs enabled - All access to card data
  • Log integrity - Protected from modification
  • Time synchronization - All systems synced
  • Log review - Daily review process
  • Log retention - At least 1 year, 3 months online

Requirement 11: Security Testing

  • Vulnerability scans - Quarterly external scans
  • Internal scans - Quarterly internal scans
  • Penetration testing - Annual pen test
  • IDS/IPS - Intrusion detection in place
  • Change detection - File integrity monitoring

Requirement 12: Security Policies

  • Security policy - Documented and published
  • Risk assessment - Annual risk assessment
  • User awareness - Security training program
  • Incident response - Plan documented and tested
  • Service providers - Compliant or managed

Using These Checklists

Digital Tracking

Create issues or tasks for each checklist item in your project management tool:

# Example: Create GitHub issues from checklist
/dev-team create issues from deployment checklist

Print physical copies for:

  • End-of-Day Checklist (daily use)
  • Tenant Onboarding (per new customer)
  • Go-Live Checklist (major deployments)

Team Responsibility

Assign checklist sections to team members:

ChecklistOwner
Code ReviewDeveloper
Security ReviewSecurity Lead
DeploymentDevOps
Go-LiveProject Manager
End-of-DayStore Manager

Checklists ensure consistency. Use them every time, not just when you remember.

Chapter 37: Troubleshooting

Common Issues and Solutions for POS Platform

This chapter provides solutions for common problems encountered during development, deployment, and operation of the POS platform.


Table of Contents

  1. Database Connection Issues
  2. Tenant Isolation Failures
  3. Sync Conflicts
  4. Payment Processing Errors
  5. Offline Mode Problems
  6. Performance Issues
  7. Authentication Failures
  8. Integration Errors
  9. Build and Deployment Failures

1. Database Connection Issues

Issue: Container Cannot Connect to PostgreSQL

Symptoms:

  • Application fails to start
  • Error: “Connection refused” or “Host not found”
  • EF Core throws NpgsqlException

Possible Causes:

  1. PostgreSQL container not running
  2. Container not on correct Docker network
  3. Wrong connection string
  4. Firewall blocking port

Diagnostic Steps:

# Check if postgres16 is running
docker ps | grep postgres16

# Check network connectivity from app container
docker exec <app-container> ping postgres16

# Test port accessibility
docker exec <app-container> nc -zv postgres16 5432

# View PostgreSQL logs
docker logs postgres16 --tail 100

Resolution:

  1. Container not running:

    cd /volume1/docker/postgres
    docker-compose up -d
    
  2. Network misconfiguration:

    # Verify network exists
    docker network ls | grep postgres_default
    
    # Create if missing
    docker network create postgres_default
    
    # Connect container to network
    docker network connect postgres_default <app-container>
    
  3. Wrong connection string:

    # Correct format from container:
    Host=postgres16;Port=5432;Database=pos_db;Username=pos_user;Password=xxx
    
    # Correct format from host:
    Host=localhost;Port=5433;Database=pos_db;Username=pos_user;Password=xxx
    

Prevention:

  • Always specify postgres_default as external network in docker-compose
  • Use environment variables for connection strings
  • Implement connection retry logic with exponential backoff

Issue: “Role does not exist” Error

Symptoms:

  • Error: FATAL: role "pos_user" does not exist

Possible Causes:

  • Database user not created
  • Wrong username in connection string

Resolution:

# Create the user
docker exec -it postgres16 psql -U postgres << EOF
CREATE USER pos_user WITH PASSWORD 'secure_password';
CREATE DATABASE pos_db OWNER pos_user;
GRANT ALL PRIVILEGES ON DATABASE pos_db TO pos_user;
EOF

2. Tenant Isolation Failures

Issue: Data Leaking Between Tenants

Symptoms:

  • User sees data from another tenant
  • Queries return unexpected results
  • Security audit fails

Possible Causes:

  1. Missing TenantId filter in query
  2. Middleware not setting tenant context
  3. Background job not setting tenant
  4. DbContext not configured for tenant

Diagnostic Steps:

-- Check for records missing tenant_id
SELECT table_name
FROM information_schema.columns
WHERE column_name = 'tenant_id'
  AND table_schema = 'public';

-- Find orphaned records
SELECT COUNT(*) FROM orders WHERE tenant_id IS NULL;

Resolution:

  1. Missing filter - Add global query filter:

    // In DbContext.OnModelCreating
    modelBuilder.Entity<Order>()
        .HasQueryFilter(o => o.TenantId == _tenantProvider.TenantId);
    
  2. Middleware issue:

    // Verify middleware order in Program.cs
    app.UseAuthentication();
    app.UseTenantMiddleware();  // Must be after auth
    app.UseAuthorization();
    
  3. Background job:

    // Always set tenant in background jobs
    using (var scope = _scopeFactory.CreateScope())
    {
        var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
        tenantProvider.SetTenant(tenantId);
        // ... do work
    }
    

Prevention:

  • Enable Row-Level Security in PostgreSQL
  • Add integration tests that verify isolation
  • Review all queries for tenant filtering
  • Use tenant-scoped DbContext factory

Issue: “Invalid TenantId” on Valid Request

Symptoms:

  • 400 Bad Request with tenant errors
  • User cannot access their own data

Possible Causes:

  • Tenant ID not in JWT claims
  • Tenant lookup failing
  • Caching stale tenant data

Resolution:

// Debug: Log tenant resolution
_logger.LogDebug("Resolving tenant from claim: {TenantClaim}",
    context.User.FindFirst("tenant_id")?.Value);

// Clear tenant cache
_cache.Remove($"tenant:{tenantId}");

3. Sync Conflicts

Issue: Offline Changes Overwritten

Symptoms:

  • User makes offline edits, they disappear after sync
  • Error: “Conflict detected”
  • Data reverts to old state

Possible Causes:

  1. Last-write-wins without conflict detection
  2. Version mismatch
  3. Sync order incorrect

Diagnostic Steps:

-- Check version history
SELECT id, version, modified_at
FROM inventory_items
WHERE sku = 'ABC123'
ORDER BY version DESC;

-- Check event log
SELECT * FROM inventory_events
WHERE sku = 'ABC123'
ORDER BY created_at DESC LIMIT 10;

Resolution:

  1. Implement optimistic concurrency:

    public async Task<bool> UpdateAsync(Item item, int expectedVersion)
    {
        var affected = await _db.Items
            .Where(i => i.Id == item.Id && i.Version == expectedVersion)
            .ExecuteUpdateAsync(s => s
                .SetProperty(i => i.Name, item.Name)
                .SetProperty(i => i.Version, expectedVersion + 1));
    
        return affected > 0;  // False if version mismatch
    }
    
  2. Queue offline changes with timestamps:

    // Store in local queue with client timestamp
    _localQueue.Enqueue(new SyncItem
    {
        Operation = "Update",
        ClientTimestamp = DateTimeOffset.UtcNow,
        Data = item
    });
    

Prevention:

  • Use vector clocks or version vectors
  • Implement merge strategies for specific entity types
  • Show user when conflicts occur and let them choose

Issue: Sync Never Completes

Symptoms:

  • “Syncing…” message never goes away
  • Partial data sync
  • Timeout errors

Possible Causes:

  • Network interruption during sync
  • Large payload timeout
  • Server error during sync

Resolution:

// Implement chunked sync
public async Task SyncAsync()
{
    var chunks = _localQueue.Chunk(100);
    foreach (var chunk in chunks)
    {
        try
        {
            await _api.SyncBatchAsync(chunk);
            _localQueue.MarkSynced(chunk);
        }
        catch (TimeoutException)
        {
            // Will retry next sync
            break;
        }
    }
}

4. Payment Processing Errors

Issue: Payment Gateway Timeout

Symptoms:

  • Payment hangs for 30+ seconds
  • Error: “Request timeout”
  • Uncertain if payment processed

Possible Causes:

  1. Network latency
  2. Gateway overloaded
  3. Invalid timeout configuration

Diagnostic Steps:

# Test gateway connectivity
curl -X GET https://api.paymentgateway.com/health -w "\nTime: %{time_total}s\n"

# Check recent payment attempts in logs
grep "payment" /var/log/pos/*.log | tail -50

Resolution:

  1. Implement idempotency:

    public async Task<PaymentResult> ProcessPaymentAsync(
        PaymentRequest request,
        string idempotencyKey)
    {
        // Check if already processed
        var existing = await _db.Payments
            .FirstOrDefaultAsync(p => p.IdempotencyKey == idempotencyKey);
        if (existing != null)
            return existing.ToResult();
    
        // Process with gateway
        var result = await _gateway.ChargeAsync(request);
    
        // Save with idempotency key
        await _db.Payments.AddAsync(new Payment
        {
            IdempotencyKey = idempotencyKey,
            Status = result.Status
        });
    
        return result;
    }
    
  2. Add timeout with retry:

    var policy = Policy
        .Handle<TimeoutException>()
        .RetryAsync(3, onRetry: (ex, count) =>
        {
            _logger.LogWarning("Payment retry {Count}: {Message}", count, ex.Message);
        });
    
    await policy.ExecuteAsync(() => _gateway.ChargeAsync(request));
    

Prevention:

  • Always use idempotency keys
  • Set reasonable timeouts (15-30 seconds)
  • Implement circuit breaker for gateway calls
  • Queue payments if offline

Issue: Card Declined

Symptoms:

  • Payment rejected
  • Error code from gateway

Common Decline Codes:

CodeMeaningAction
insufficient_fundsNot enough balanceTry different card
card_declinedGeneric declineContact card issuer
expired_cardCard expiredUse different card
incorrect_cvcWrong CVVRe-enter
processing_errorGateway issueRetry

Resolution:

public string GetUserFriendlyMessage(string errorCode)
{
    return errorCode switch
    {
        "insufficient_funds" => "Card declined. Please try a different payment method.",
        "expired_card" => "This card has expired. Please use a different card.",
        "incorrect_cvc" => "The security code is incorrect. Please verify and try again.",
        _ => "Payment could not be processed. Please try again or use a different card."
    };
}

5. Offline Mode Problems

Issue: Application Won’t Start Offline

Symptoms:

  • App requires internet to launch
  • Loading screen indefinitely
  • Error: “Network request failed”

Possible Causes:

  1. Missing service worker
  2. No cached data
  3. API call in startup

Diagnostic Steps:

  • Check browser DevTools > Application > Service Workers
  • Check IndexedDB for cached data
  • Monitor Network tab for failed requests

Resolution:

  1. Ensure service worker registered:

    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('SW registered'))
        .catch(err => console.error('SW failed', err));
    }
    
  2. Add offline fallback in startup:

    public async Task InitializeAsync()
    {
        try
        {
            await _api.FetchInitialData();
        }
        catch (HttpRequestException)
        {
            _logger.LogWarning("Offline - using cached data");
            await LoadFromCache();
        }
    }
    

Prevention:

  • Cache essential data proactively
  • Implement offline-first architecture
  • Test app startup with network disabled

Issue: Offline Queue Growing Too Large

Symptoms:

  • Local storage filling up
  • App slowing down
  • “Storage quota exceeded”

Possible Causes:

  • Extended offline period
  • Sync failing silently
  • No queue size limit

Resolution:

// Implement queue management
public async Task AddToQueue(SyncItem item)
{
    var queueSize = await _localDb.SyncQueue.CountAsync();

    if (queueSize >= MAX_QUEUE_SIZE)
    {
        // Warn user
        await _notifications.ShowAsync(
            "Sync queue is full. Please connect to internet.");

        // Optional: Remove oldest low-priority items
        await _localDb.SyncQueue
            .Where(q => q.Priority == Priority.Low)
            .OrderBy(q => q.CreatedAt)
            .Take(100)
            .ExecuteDeleteAsync();
    }

    await _localDb.SyncQueue.AddAsync(item);
}

6. Performance Issues

Issue: Slow API Responses

Symptoms:

  • API calls taking > 1 second
  • Users complaining of lag
  • Timeouts occurring

Possible Causes:

  1. N+1 query problem
  2. Missing database indexes
  3. Large payloads
  4. No caching

Diagnostic Steps:

-- Find slow queries
SELECT query, calls, mean_time, total_time
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;

-- Check missing indexes
SELECT relname, seq_scan, idx_scan
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY seq_scan DESC;

Resolution:

  1. Fix N+1 queries:

    // Bad
    var orders = await _db.Orders.ToListAsync();
    foreach (var order in orders)
        order.Items = await _db.OrderItems.Where(...).ToListAsync();
    
    // Good
    var orders = await _db.Orders
        .Include(o => o.Items)
        .ToListAsync();
    
  2. Add missing indexes:

    CREATE INDEX idx_orders_tenant_date
    ON orders (tenant_id, created_at DESC);
    
    CREATE INDEX idx_inventory_sku
    ON inventory_items (sku);
    
  3. Implement caching:

    public async Task<Product> GetProductAsync(string sku)
    {
        return await _cache.GetOrCreateAsync($"product:{sku}", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
            return await _db.Products.FindAsync(sku);
        });
    }
    

Prevention:

  • Enable query logging in development
  • Set up performance monitoring
  • Establish response time budgets

Issue: Memory Usage Growing

Symptoms:

  • Container memory increasing over time
  • Out of memory errors
  • Slow garbage collection

Possible Causes:

  • Memory leak in code
  • Unbounded caches
  • Event handler accumulation
  • Large objects in memory

Diagnostic Steps:

# Monitor container memory
docker stats <container-name>

# Get memory dump (if dotnet-dump installed)
dotnet-dump collect -p <process-id>

Resolution:

  1. Dispose resources properly:

    // Use 'using' for disposables
    await using var connection = new NpgsqlConnection(connectionString);
    await connection.OpenAsync();
    
  2. Limit cache size:

    services.AddMemoryCache(options =>
    {
        options.SizeLimit = 1000;  // Max entries
    });
    
    _cache.Set(key, value, new MemoryCacheEntryOptions
    {
        Size = 1,
        SlidingExpiration = TimeSpan.FromMinutes(10)
    });
    
  3. Unsubscribe from events:

    public class MyComponent : IDisposable
    {
        public MyComponent(IEventBus bus)
        {
            _subscription = bus.Subscribe<OrderCreated>(HandleOrder);
        }
    
        public void Dispose()
        {
            _subscription?.Dispose();
        }
    }
    

7. Authentication Failures

Issue: JWT Token Rejected

Symptoms:

  • 401 Unauthorized responses
  • “Invalid token” errors
  • User suddenly logged out

Possible Causes:

  1. Token expired
  2. Wrong signing key
  3. Clock skew between servers
  4. Token issued for different audience

Diagnostic Steps:

# Decode JWT (don't do this with sensitive tokens in production)
echo "<token>" | cut -d. -f2 | base64 -d | jq

# Check claims
# Look for: exp, iss, aud

Resolution:

  1. Token expired - Implement refresh flow:

    if (response.StatusCode == HttpStatusCode.Unauthorized)
    {
        var newToken = await RefreshTokenAsync();
        // Retry with new token
    }
    
  2. Clock skew - Add tolerance:

    services.AddAuthentication().AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ClockSkew = TimeSpan.FromMinutes(5)
        };
    });
    
  3. Wrong key - Verify signing key matches:

    # Both services must use same key
    echo $JWT_SIGNING_KEY | base64
    

Issue: User Cannot Log In

Symptoms:

  • Login fails with valid credentials
  • “Invalid username or password”
  • Account not locked

Possible Causes:

  1. Password hashing mismatch
  2. User account disabled
  3. Tenant not active
  4. Case sensitivity issues

Resolution:

public async Task<LoginResult> LoginAsync(string email, string password)
{
    // Case-insensitive email lookup
    var user = await _db.Users
        .FirstOrDefaultAsync(u => u.Email.ToLower() == email.ToLower());

    if (user == null)
    {
        _logger.LogWarning("Login failed: user not found for {Email}", email);
        return LoginResult.Failed("Invalid credentials");
    }

    if (!user.IsActive)
    {
        _logger.LogWarning("Login failed: user {Email} is inactive", email);
        return LoginResult.Failed("Account is disabled");
    }

    if (!_hasher.Verify(password, user.PasswordHash))
    {
        _logger.LogWarning("Login failed: wrong password for {Email}", email);
        return LoginResult.Failed("Invalid credentials");
    }

    return LoginResult.Success(GenerateToken(user));
}

8. Integration Errors

Issue: Shopify Webhook Not Received

Symptoms:

  • Orders not appearing in POS
  • Inventory not syncing
  • Webhook endpoint returning errors

Possible Causes:

  1. Webhook not registered
  2. HMAC verification failing
  3. Endpoint not accessible
  4. SSL certificate issues

Diagnostic Steps:

# Check webhook registration
curl -X GET "https://{store}.myshopify.com/admin/api/2024-01/webhooks.json" \
  -H "X-Shopify-Access-Token: {token}"

# Test endpoint accessibility
curl -X POST https://your-domain.com/webhooks/shopify \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

Resolution:

  1. Register webhook:

    curl -X POST "https://{store}.myshopify.com/admin/api/2024-01/webhooks.json" \
      -H "X-Shopify-Access-Token: {token}" \
      -H "Content-Type: application/json" \
      -d '{
        "webhook": {
          "topic": "orders/create",
          "address": "https://your-domain.com/webhooks/shopify",
          "format": "json"
        }
      }'
    
  2. Fix HMAC verification:

    public bool VerifyWebhook(HttpRequest request)
    {
        var hmacHeader = request.Headers["X-Shopify-Hmac-SHA256"];
        using var reader = new StreamReader(request.Body);
        var body = await reader.ReadToEndAsync();
    
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
        var hash = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
    
        return hash == hmacHeader;
    }
    

Issue: Bridge Not Connecting

Symptoms:

  • Bridge status shows “Offline”
  • Commands stuck in pending
  • Heartbeats not received

Possible Causes:

  1. Tailscale VPN not connected
  2. Wrong server URL in bridge config
  3. Firewall blocking
  4. Bridge service not running

Diagnostic Steps:

# Check Tailscale status
tailscale status

# Test connectivity from bridge machine
curl http://100.124.10.65:2500/health

# Check bridge logs
Get-Content C:\ProgramData\StanlyBridge\logs\*.log -Tail 50

Resolution:

  1. Reconnect Tailscale:

    tailscale up
    
  2. Verify bridge configuration:

    // appsettings.json
    {
      "ServerUrl": "http://100.124.10.65:2500",
      "StoreCode": "GM"
    }
    
  3. Restart bridge service:

    Restart-Service StanlyBridge
    

9. Build and Deployment Failures

Issue: Docker Build Fails

Symptoms:

  • docker-compose up --build errors
  • Missing dependencies
  • “No such file or directory”

Possible Causes:

  1. Dockerfile syntax error
  2. Missing files in context
  3. Network issues downloading packages
  4. Incompatible base image

Resolution:

  1. Check .dockerignore:

    # Make sure necessary files aren't ignored
    # Bad:
    *.json
    
    # Good:
    *.log
    node_modules
    
  2. Multi-stage build issues:

    # Ensure COPY --from references correct stage
    FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
    WORKDIR /src
    COPY ["src/App/App.csproj", "src/App/"]
    RUN dotnet restore "src/App/App.csproj"
    
    FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
    COPY --from=build /app/publish .  # 'build' must match stage name
    
  3. Clear Docker cache:

    docker builder prune
    docker-compose build --no-cache
    

Issue: Migration Fails on Deployment

Symptoms:

  • Container starts but crashes
  • “Database migration failed”
  • Schema out of sync

Possible Causes:

  1. Migration order issue
  2. Conflicting migrations
  3. Database connection during migration

Resolution:

  1. Run migrations separately:

    # Don't auto-migrate on startup
    # Instead, run migrations explicitly
    docker exec <container> dotnet ef database update
    
  2. Check migration history:

    SELECT * FROM "__EFMigrationsHistory" ORDER BY "MigrationId";
    
  3. Reset if needed (dev only!):

    # Remove all migrations and recreate
    dotnet ef database drop
    dotnet ef database update
    

Prevention:

  • Test migrations on copy of production data
  • Never modify published migrations
  • Keep migrations small and focused

Quick Reference: Error Codes

Error CodeMeaningFirst Step
400Bad RequestCheck request body/params
401UnauthorizedCheck token validity
403ForbiddenCheck user permissions
404Not FoundCheck ID/resource exists
409ConflictCheck version/concurrency
422Validation ErrorCheck input constraints
500Server ErrorCheck application logs
502Bad GatewayCheck upstream services
503Service UnavailableCheck service health
504Gateway TimeoutCheck network/timeouts

When All Else Fails

  1. Check the logs: docker logs <container> --tail 500
  2. Check the database: Direct query to verify data
  3. Check the network: docker network inspect
  4. Restart the container: Sometimes it just works
  5. Ask for help: Post in team chat with:
    • Exact error message
    • Steps to reproduce
    • What you’ve already tried
    • Relevant log snippets

The best debugging tool is a good night’s sleep. But if you need to fix it now, use these guides.

Appendix A: Complete API Reference

Version: 1.0.0 Last Updated: December 29, 2025 Base URL: https://api.pos-platform.com/api/v1


Overview

This appendix contains the complete API reference for the POS Platform, organized by domain. All endpoints require authentication unless marked as public.

Authentication

All authenticated requests must include a Bearer token:

Authorization: Bearer <jwt_token>

Role Hierarchy

RoleLevelCapabilities
SuperAdmin5Full system access
Admin4Tenant-wide administration
Manager3Location management, overrides
Cashier2POS operations
Viewer1Read-only access

Common Response Codes

CodeMeaning
200Success
201Created
204No Content
400Bad Request
401Unauthorized
403Forbidden
404Not Found
409Conflict
422Validation Error
429Rate Limited
500Server Error

Domain 1: Authentication

POST /auth/login

Description: Authenticate user and receive JWT token

Authentication: None (public)

Request Body:

{
  "email": "user@example.com",
  "password": "securePassword123",
  "tenantId": "tenant_nexus"
}

Response: 200 OK

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "dGhpcyBpcyBhIHJlZnJl...",
  "expiresAt": "2025-12-29T16:00:00Z",
  "user": {
    "id": "usr_abc123",
    "email": "user@example.com",
    "firstName": "John",
    "lastName": "Doe",
    "role": "cashier",
    "locationId": "loc_gm",
    "permissions": ["sales.create", "sales.void", "inventory.view"]
  }
}

Errors: 401 Invalid credentials, 423 Account locked


POST /auth/refresh

Description: Refresh an expired access token

Authentication: None (requires valid refresh token)

Request Body:

{
  "refreshToken": "dGhpcyBpcyBhIHJlZnJl..."
}

Response: 200 OK

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "bmV3IHJlZnJlc2ggdG9r...",
  "expiresAt": "2025-12-29T18:00:00Z"
}

Errors: 401 Invalid or expired refresh token


POST /auth/logout

Description: Invalidate current session

Authentication: Bearer token (Any role)

Request Body: None

Response: 204 No Content


POST /auth/password/change

Description: Change current user’s password

Authentication: Bearer token (Any role)

Request Body:

{
  "currentPassword": "oldPassword123",
  "newPassword": "newSecurePassword456"
}

Response: 204 No Content

Errors: 400 Password requirements not met, 401 Current password incorrect


POST /auth/password/reset

Description: Request password reset email

Authentication: None (public)

Request Body:

{
  "email": "user@example.com",
  "tenantId": "tenant_nexus"
}

Response: 202 Accepted

{
  "message": "If the email exists, a reset link has been sent"
}

Domain 2: Tenants

GET /tenants

Description: List all tenants (SuperAdmin only)

Authentication: Bearer token (SuperAdmin)

Query Parameters:

ParameterTypeDescription
statusstringFilter by status (active, suspended, trial)
pageintPage number (default: 1)
limitintItems per page (default: 20, max: 100)

Response: 200 OK

{
  "data": [
    {
      "id": "tenant_nexus",
      "name": "Nexus Clothing",
      "subdomain": "nexus",
      "status": "active",
      "plan": "enterprise",
      "createdAt": "2025-01-01T00:00:00Z",
      "locationCount": 5,
      "userCount": 25
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 45,
    "pages": 3
  }
}

POST /tenants

Description: Create a new tenant

Authentication: Bearer token (SuperAdmin)

Request Body:

{
  "name": "New Retail Store",
  "subdomain": "newretail",
  "plan": "professional",
  "adminUser": {
    "email": "admin@newretail.com",
    "firstName": "Jane",
    "lastName": "Smith",
    "password": "initialPassword123"
  },
  "settings": {
    "timezone": "America/New_York",
    "currency": "USD",
    "taxRate": 6.0
  }
}

Response: 201 Created

{
  "id": "tenant_newretail",
  "name": "New Retail Store",
  "subdomain": "newretail",
  "status": "trial",
  "trialEndsAt": "2025-01-28T00:00:00Z",
  "adminUserId": "usr_admin123"
}

Errors: 409 Subdomain already exists, 422 Validation error


GET /tenants/

Description: Get tenant details

Authentication: Bearer token (SuperAdmin or tenant Admin)

Response: 200 OK

{
  "id": "tenant_nexus",
  "name": "Nexus Clothing",
  "subdomain": "nexus",
  "status": "active",
  "plan": "enterprise",
  "settings": {
    "timezone": "America/New_York",
    "currency": "USD",
    "taxRate": 6.0,
    "loyaltyEnabled": true,
    "rfidEnabled": true
  },
  "usage": {
    "locations": 5,
    "users": 25,
    "monthlyTransactions": 12500,
    "storageUsedMB": 2048
  },
  "createdAt": "2025-01-01T00:00:00Z",
  "updatedAt": "2025-12-29T10:00:00Z"
}

PATCH /tenants/

Description: Update tenant settings

Authentication: Bearer token (SuperAdmin or tenant Admin)

Request Body:

{
  "name": "Nexus Clothing Inc.",
  "settings": {
    "taxRate": 6.5
  }
}

Response: 200 OK (returns updated tenant)


POST /tenants/{tenantId}/suspend

Description: Suspend a tenant account

Authentication: Bearer token (SuperAdmin)

Request Body:

{
  "reason": "Payment overdue",
  "suspendAt": "2025-12-30T00:00:00Z"
}

Response: 200 OK


POST /tenants/{tenantId}/activate

Description: Reactivate a suspended tenant

Authentication: Bearer token (SuperAdmin)

Response: 200 OK


Domain 3: Locations

GET /locations

Description: List all locations for current tenant

Authentication: Bearer token (Viewer+)

Query Parameters:

ParameterTypeDescription
statusstringFilter by status (active, inactive)
typestringFilter by type (store, warehouse, popup)

Response: 200 OK

{
  "data": [
    {
      "id": "loc_gm",
      "code": "GM",
      "name": "Greenbrier Mall",
      "type": "store",
      "status": "active",
      "address": {
        "street": "1401 Greenbrier Pkwy",
        "city": "Chesapeake",
        "state": "VA",
        "zip": "23320"
      },
      "phone": "757-555-0100",
      "timezone": "America/New_York",
      "shopifyLocationId": "19718045760"
    }
  ]
}

POST /locations

Description: Create a new location

Authentication: Bearer token (Admin)

Request Body:

{
  "code": "NL",
  "name": "New Location",
  "type": "store",
  "address": {
    "street": "123 Main St",
    "city": "Norfolk",
    "state": "VA",
    "zip": "23510"
  },
  "phone": "757-555-0200",
  "timezone": "America/New_York",
  "settings": {
    "fulfillmentPriority": 5,
    "canShipOnline": true
  }
}

Response: 201 Created


GET /locations/

Description: Get location details

Authentication: Bearer token (Viewer+)

Response: 200 OK

{
  "id": "loc_gm",
  "code": "GM",
  "name": "Greenbrier Mall",
  "type": "store",
  "status": "active",
  "address": {
    "street": "1401 Greenbrier Pkwy",
    "city": "Chesapeake",
    "state": "VA",
    "zip": "23320"
  },
  "phone": "757-555-0100",
  "timezone": "America/New_York",
  "settings": {
    "fulfillmentPriority": 1,
    "canShipOnline": true,
    "showInventoryOnWeb": true
  },
  "registers": [
    {
      "id": "reg_01",
      "name": "Register 1",
      "status": "active"
    }
  ],
  "operatingHours": {
    "monday": { "open": "10:00", "close": "21:00" },
    "tuesday": { "open": "10:00", "close": "21:00" },
    "wednesday": { "open": "10:00", "close": "21:00" },
    "thursday": { "open": "10:00", "close": "21:00" },
    "friday": { "open": "10:00", "close": "21:00" },
    "saturday": { "open": "10:00", "close": "21:00" },
    "sunday": { "open": "12:00", "close": "18:00" }
  }
}

PATCH /locations/

Description: Update location details

Authentication: Bearer token (Admin)

Request Body:

{
  "name": "Greenbrier Mall Store",
  "settings": {
    "fulfillmentPriority": 2
  }
}

Response: 200 OK


Domain 4: Users & Employees

GET /users

Description: List all users for current tenant

Authentication: Bearer token (Admin)

Query Parameters:

ParameterTypeDescription
rolestringFilter by role
locationIdstringFilter by location
statusstringactive, inactive, locked

Response: 200 OK

{
  "data": [
    {
      "id": "usr_abc123",
      "email": "john.doe@example.com",
      "firstName": "John",
      "lastName": "Doe",
      "role": "cashier",
      "locationId": "loc_gm",
      "status": "active",
      "lastLoginAt": "2025-12-29T08:00:00Z"
    }
  ]
}

POST /users

Description: Create a new user

Authentication: Bearer token (Admin)

Request Body:

{
  "email": "newuser@example.com",
  "firstName": "Jane",
  "lastName": "Smith",
  "role": "cashier",
  "locationId": "loc_gm",
  "pin": "1234",
  "permissions": ["sales.create", "sales.void"]
}

Response: 201 Created


GET /users/

Description: Get user details

Authentication: Bearer token (Admin or self)

Response: 200 OK


PATCH /users/

Description: Update user details

Authentication: Bearer token (Admin)

Request Body:

{
  "role": "manager",
  "permissions": ["sales.create", "sales.void", "inventory.adjust"]
}

Response: 200 OK


DELETE /users/

Description: Deactivate user (soft delete)

Authentication: Bearer token (Admin)

Response: 204 No Content


POST /users/{userId}/reset-pin

Description: Reset user’s POS PIN

Authentication: Bearer token (Admin)

Request Body:

{
  "newPin": "5678"
}

Response: 204 No Content


GET /employees/{employeeId}/timeclock

Description: Get employee time clock entries

Authentication: Bearer token (Manager+)

Query Parameters:

ParameterTypeDescription
startDatedateStart of date range
endDatedateEnd of date range

Response: 200 OK

{
  "data": [
    {
      "id": "tc_001",
      "employeeId": "usr_abc123",
      "clockIn": "2025-12-29T08:00:00Z",
      "clockOut": "2025-12-29T17:00:00Z",
      "hoursWorked": 9.0,
      "breaks": [
        {
          "start": "2025-12-29T12:00:00Z",
          "end": "2025-12-29T12:30:00Z",
          "type": "lunch"
        }
      ]
    }
  ]
}

POST /employees/{employeeId}/clock-in

Description: Clock in employee

Authentication: Bearer token (Cashier+ or self)

Request Body:

{
  "locationId": "loc_gm",
  "registerId": "reg_01"
}

Response: 201 Created

{
  "id": "tc_002",
  "employeeId": "usr_abc123",
  "clockIn": "2025-12-29T08:00:00Z",
  "locationId": "loc_gm"
}

POST /employees/{employeeId}/clock-out

Description: Clock out employee

Authentication: Bearer token (Cashier+ or self)

Response: 200 OK

{
  "id": "tc_002",
  "clockOut": "2025-12-29T17:00:00Z",
  "hoursWorked": 9.0
}

Domain 5: Products & Catalog

GET /products

Description: List products in catalog

Authentication: Bearer token (Viewer+)

Query Parameters:

ParameterTypeDescription
searchstringSearch by name, SKU, barcode
categoryIdstringFilter by category
vendorIdstringFilter by vendor
statusstringactive, discontinued, draft
pageintPage number
limitintItems per page

Response: 200 OK

{
  "data": [
    {
      "id": "prod_abc123",
      "name": "Classic V-Neck Tee",
      "sku": "NXP0323",
      "barcode": "657381512532",
      "categoryId": "cat_shirts",
      "vendorId": "vendor_abc",
      "status": "active",
      "basePrice": 29.99,
      "cost": 12.50,
      "variants": [
        {
          "id": "var_001",
          "sku": "NXP0323-S-BLK",
          "options": { "size": "S", "color": "Black" },
          "price": 29.99,
          "barcode": "657381512533"
        }
      ],
      "images": [
        {
          "url": "https://cdn.example.com/images/nxp0323.jpg",
          "alt": "Classic V-Neck Tee",
          "position": 1
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 5000
  }
}

POST /products

Description: Create a new product

Authentication: Bearer token (Admin)

Request Body:

{
  "name": "New Product",
  "sku": "NXP9999",
  "categoryId": "cat_shirts",
  "vendorId": "vendor_abc",
  "basePrice": 39.99,
  "cost": 15.00,
  "description": "Product description here",
  "variants": [
    {
      "sku": "NXP9999-S-BLK",
      "options": { "size": "S", "color": "Black" },
      "price": 39.99,
      "barcode": "657381599999"
    }
  ]
}

Response: 201 Created


GET /products/

Description: Get product details

Authentication: Bearer token (Viewer+)

Response: 200 OK


PATCH /products/

Description: Update product

Authentication: Bearer token (Admin)

Request Body:

{
  "basePrice": 34.99,
  "status": "active"
}

Response: 200 OK


DELETE /products/

Description: Discontinue product (soft delete)

Authentication: Bearer token (Admin)

Response: 204 No Content


GET /products/{productId}/variants

Description: List all variants for a product

Authentication: Bearer token (Viewer+)

Response: 200 OK


POST /products/{productId}/variants

Description: Add variant to product

Authentication: Bearer token (Admin)

Request Body:

{
  "sku": "NXP0323-XL-BLK",
  "options": { "size": "XL", "color": "Black" },
  "price": 29.99,
  "barcode": "657381512599"
}

Response: 201 Created


GET /categories

Description: List product categories

Authentication: Bearer token (Viewer+)

Response: 200 OK

{
  "data": [
    {
      "id": "cat_shirts",
      "name": "Shirts",
      "parentId": null,
      "children": [
        {
          "id": "cat_tees",
          "name": "T-Shirts",
          "parentId": "cat_shirts"
        },
        {
          "id": "cat_polos",
          "name": "Polos",
          "parentId": "cat_shirts"
        }
      ]
    }
  ]
}

GET /vendors

Description: List vendors

Authentication: Bearer token (Viewer+)

Response: 200 OK


Domain 6: Inventory

GET /inventory

Description: Get inventory levels across locations

Authentication: Bearer token (Viewer+)

Query Parameters:

ParameterTypeDescription
locationIdstringFilter by location
variantIdstringFilter by variant
skustringFilter by SKU
belowReorderbooleanShow only items below reorder point

Response: 200 OK

{
  "data": [
    {
      "variantId": "var_001",
      "sku": "NXP0323-S-BLK",
      "productName": "Classic V-Neck Tee - S Black",
      "levels": [
        {
          "locationId": "loc_gm",
          "locationName": "Greenbrier Mall",
          "onHand": 15,
          "available": 13,
          "reserved": 2,
          "reorderPoint": 5,
          "reorderQty": 20
        },
        {
          "locationId": "loc_hm",
          "locationName": "Peninsula Town Center",
          "onHand": 8,
          "available": 8,
          "reserved": 0,
          "reorderPoint": 5,
          "reorderQty": 20
        }
      ],
      "totalOnHand": 23,
      "totalAvailable": 21
    }
  ]
}

GET /inventory/locations/

Description: Get inventory for specific location

Authentication: Bearer token (Viewer+)

Response: 200 OK


POST /inventory/adjustments

Description: Create inventory adjustment

Authentication: Bearer token (Manager+)

Request Body:

{
  "locationId": "loc_gm",
  "adjustmentType": "cycle_count",
  "items": [
    {
      "variantId": "var_001",
      "systemQty": 15,
      "countedQty": 13,
      "reason": "shrinkage"
    }
  ],
  "notes": "Quarterly cycle count - Section A"
}

Response: 201 Created

{
  "id": "adj_001",
  "status": "completed",
  "items": [
    {
      "variantId": "var_001",
      "variance": -2,
      "previousOnHand": 15,
      "newOnHand": 13,
      "costImpact": -25.00
    }
  ],
  "totalVariance": -2,
  "totalCostImpact": -25.00
}

GET /inventory/adjustments

Description: List inventory adjustments

Authentication: Bearer token (Manager+)

Query Parameters:

ParameterTypeDescription
locationIdstringFilter by location
typestringcycle_count, shrinkage, damage, correction
startDatedateStart date
endDatedateEnd date

Response: 200 OK


POST /inventory/transfers

Description: Create inventory transfer request

Authentication: Bearer token (Manager+)

Request Body:

{
  "fromLocationId": "loc_hq",
  "toLocationId": "loc_gm",
  "priority": "normal",
  "reason": "low_stock",
  "items": [
    {
      "variantId": "var_001",
      "quantity": 10
    }
  ],
  "notes": "Restocking for weekend sale"
}

Response: 201 Created

{
  "id": "xfer_001",
  "status": "pending",
  "fromLocationId": "loc_hq",
  "toLocationId": "loc_gm",
  "items": [
    {
      "variantId": "var_001",
      "quantityRequested": 10
    }
  ],
  "expectedShipDate": "2025-12-30",
  "expectedArrivalDate": "2025-12-31"
}

GET /inventory/transfers/

Description: Get transfer details

Authentication: Bearer token (Viewer+)

Response: 200 OK


POST /inventory/transfers/{transferId}/ship

Description: Mark transfer as shipped

Authentication: Bearer token (Manager+)

Request Body:

{
  "items": [
    {
      "variantId": "var_001",
      "quantityShipped": 10
    }
  ],
  "trackingNumber": "1Z999AA10123456784",
  "carrier": "UPS"
}

Response: 200 OK


POST /inventory/transfers/{transferId}/receive

Description: Receive transfer at destination

Authentication: Bearer token (Manager+)

Request Body:

{
  "items": [
    {
      "variantId": "var_001",
      "quantityReceived": 10,
      "quantityDamaged": 0
    }
  ],
  "notes": null
}

Response: 200 OK


Domain 7: Sales & Orders

POST /sales

Description: Create a new sale transaction

Authentication: Bearer token (Cashier+)

Request Body:

{
  "locationId": "loc_gm",
  "registerId": "reg_01",
  "customerId": "cust_john_doe",
  "lineItems": [
    {
      "variantId": "var_001",
      "quantity": 2,
      "unitPrice": 29.99,
      "discountAmount": 0,
      "discountReason": null
    }
  ],
  "discounts": [
    {
      "type": "percentage",
      "value": 10,
      "code": "SAVE10",
      "appliesTo": "order"
    }
  ],
  "payments": [
    {
      "method": "card",
      "amount": 53.98,
      "reference": "tok_visa_4242"
    }
  ]
}

Response: 201 Created

{
  "id": "ord_xyz789",
  "orderNumber": "ORD-2025-00001",
  "receiptNumber": "GM-2025-001234",
  "status": "completed",
  "lineItems": [
    {
      "id": "li_001",
      "variantId": "var_001",
      "sku": "NXP0323-S-BLK",
      "name": "Classic V-Neck Tee - S Black",
      "quantity": 2,
      "unitPrice": 29.99,
      "lineTotal": 59.98
    }
  ],
  "subtotal": 59.98,
  "discountTotal": 6.00,
  "taxAmount": 3.24,
  "total": 57.22,
  "payments": [
    {
      "id": "pay_001",
      "method": "card",
      "amount": 57.22,
      "status": "completed",
      "authCode": "AUTH123456",
      "lastFour": "4242"
    }
  ],
  "customerId": "cust_john_doe",
  "loyaltyPointsEarned": 57,
  "createdAt": "2025-12-29T14:30:00Z",
  "createdBy": "usr_cashier1"
}

Errors: 400 Bad Request, 422 Validation Error, 402 Payment Failed


GET /sales/

Description: Get sale details

Authentication: Bearer token (Cashier+)

Response: 200 OK


GET /sales

Description: List sales with filters

Authentication: Bearer token (Cashier+)

Query Parameters:

ParameterTypeDescription
locationIdstringFilter by location
registerIdstringFilter by register
startDatedatetimeStart of date range
endDatedatetimeEnd of date range
customerIdstringFilter by customer
statusstringcompleted, voided, refunded
minAmountdecimalMinimum total
maxAmountdecimalMaximum total

Response: 200 OK


POST /sales/{saleId}/void

Description: Void a sale (requires manager)

Authentication: Bearer token (Manager+)

Request Body:

{
  "reason": "customer_changed_mind",
  "managerPin": "1234"
}

Response: 200 OK

{
  "id": "ord_xyz789",
  "status": "voided",
  "voidedAt": "2025-12-29T14:35:00Z",
  "voidedBy": "usr_manager1",
  "voidReason": "customer_changed_mind",
  "refundAmount": 57.22
}

POST /returns

Description: Process a return

Authentication: Bearer token (Cashier+)

Request Body:

{
  "originalOrderId": "ord_xyz789",
  "originalReceiptNumber": "GM-2025-001234",
  "locationId": "loc_gm",
  "items": [
    {
      "originalLineItemId": "li_001",
      "variantId": "var_001",
      "quantityReturned": 1,
      "reason": "wrong_size",
      "condition": "resaleable"
    }
  ],
  "refundMethod": "original_payment"
}

Response: 201 Created

{
  "id": "ret_abc123",
  "returnReceiptNumber": "RET-GM-2025-0001",
  "originalOrderId": "ord_xyz789",
  "items": [
    {
      "variantId": "var_001",
      "quantityReturned": 1,
      "refundAmount": 28.61,
      "inventoryRestocked": true
    }
  ],
  "totalRefund": 28.61,
  "refundTransactionId": "refund_001",
  "loyaltyPointsDeducted": 29,
  "createdAt": "2025-12-29T15:00:00Z"
}

GET /returns/

Description: Get return details

Authentication: Bearer token (Cashier+)

Response: 200 OK


Domain 8: Customers & Loyalty

GET /customers

Description: List customers

Authentication: Bearer token (Cashier+)

Query Parameters:

ParameterTypeDescription
searchstringSearch by name, email, phone
tierstringFilter by loyalty tier
tagstringFilter by tag
hasEmailbooleanHas email address
pageintPage number
limitintItems per page

Response: 200 OK

{
  "data": [
    {
      "id": "cust_john_doe",
      "customerNumber": "CUST-2025-00001",
      "firstName": "John",
      "lastName": "Doe",
      "email": "john.doe@example.com",
      "phone": "555-0100",
      "loyalty": {
        "tier": "gold",
        "pointsBalance": 1250,
        "lifetimeSpend": 2500.00
      },
      "tags": ["vip", "birthday_month"],
      "createdAt": "2025-01-15T00:00:00Z"
    }
  ]
}

POST /customers

Description: Create a new customer

Authentication: Bearer token (Cashier+)

Request Body:

{
  "firstName": "Jane",
  "lastName": "Smith",
  "email": "jane.smith@example.com",
  "phone": "555-0200",
  "address": {
    "street": "123 Main St",
    "city": "Chesapeake",
    "state": "VA",
    "zip": "23320"
  },
  "marketingOptIn": true,
  "smsOptIn": false,
  "enrollInLoyalty": true
}

Response: 201 Created


GET /customers/

Description: Get customer details

Authentication: Bearer token (Cashier+)

Response: 200 OK

{
  "id": "cust_john_doe",
  "customerNumber": "CUST-2025-00001",
  "firstName": "John",
  "lastName": "Doe",
  "email": "john.doe@example.com",
  "phone": "555-0100",
  "address": {
    "street": "456 Oak Ave",
    "city": "Virginia Beach",
    "state": "VA",
    "zip": "23451"
  },
  "loyalty": {
    "programId": "loyalty_standard",
    "tier": "gold",
    "pointsBalance": 1250,
    "pointsToNextTier": 750,
    "lifetimeSpend": 2500.00,
    "lifetimePoints": 3000
  },
  "preferences": {
    "marketingOptIn": true,
    "smsOptIn": true,
    "preferredContactMethod": "email"
  },
  "tags": ["vip", "birthday_month"],
  "purchaseHistory": {
    "totalOrders": 25,
    "totalSpend": 2500.00,
    "averageOrderValue": 100.00,
    "lastPurchase": "2025-12-28T14:00:00Z"
  },
  "createdAt": "2025-01-15T00:00:00Z",
  "updatedAt": "2025-12-28T14:00:00Z"
}

PATCH /customers/

Description: Update customer details

Authentication: Bearer token (Cashier+)

Request Body:

{
  "phone": "555-0300",
  "preferences": {
    "smsOptIn": true
  }
}

Response: 200 OK


GET /customers/{customerId}/orders

Description: Get customer’s order history

Authentication: Bearer token (Cashier+)

Response: 200 OK


POST /customers/{customerId}/loyalty/redeem

Description: Redeem loyalty points

Authentication: Bearer token (Cashier+)

Request Body:

{
  "points": 500,
  "orderId": "ord_xyz790"
}

Response: 200 OK

{
  "pointsRedeemed": 500,
  "discountAmount": 5.00,
  "previousBalance": 1250,
  "newBalance": 750
}

POST /customers/merge

Description: Merge duplicate customer records

Authentication: Bearer token (Admin)

Request Body:

{
  "survivingCustomerId": "cust_john_doe",
  "mergeCustomerIds": ["cust_john_d", "cust_jdoe"],
  "conflictResolutions": {
    "email": "cust_john_doe"
  }
}

Response: 200 OK


Domain 9: Payments

POST /payments/process

Description: Process a payment

Authentication: Bearer token (Cashier+)

Request Body:

{
  "orderId": "ord_xyz789",
  "method": "card",
  "amount": 57.22,
  "token": "tok_visa_4242",
  "terminalId": "term_verifone_01"
}

Response: 200 OK

{
  "id": "pay_001",
  "status": "approved",
  "amount": 57.22,
  "authorizationCode": "AUTH123456",
  "transactionId": "txn_gateway_abc",
  "cardBrand": "visa",
  "lastFour": "4242",
  "entryMethod": "chip",
  "batchId": "batch_2025-12-29"
}

Errors: 402 Payment Declined


POST /payments/refund

Description: Process a refund

Authentication: Bearer token (Manager+)

Request Body:

{
  "originalPaymentId": "pay_001",
  "amount": 28.61,
  "reason": "return"
}

Response: 200 OK


GET /payments/batch/

Description: Get payment batch details

Authentication: Bearer token (Manager+)

Response: 200 OK


POST /payments/batch/{batchId}/settle

Description: Settle payment batch

Authentication: Bearer token (Manager+)

Response: 200 OK


Domain 10: Gift Cards

POST /giftcards

Description: Create/sell a gift card

Authentication: Bearer token (Cashier+)

Request Body:

{
  "amount": 50.00,
  "purchasedBy": "cust_john_doe",
  "recipientEmail": "jane@example.com",
  "recipientName": "Jane",
  "message": "Happy Birthday!",
  "type": "digital"
}

Response: 201 Created

{
  "id": "gc_001",
  "cardNumber": "6012XXXXXXXXXXXX1234",
  "balance": 50.00,
  "status": "active",
  "expiresAt": null
}

GET /giftcards/{cardNumber}/balance

Description: Check gift card balance

Authentication: Bearer token (Cashier+)

Response: 200 OK

{
  "cardNumber": "6012XXXXXXXXXXXX1234",
  "balance": 50.00,
  "status": "active",
  "expiresAt": null
}

POST /giftcards/{cardNumber}/redeem

Description: Redeem gift card for payment

Authentication: Bearer token (Cashier+)

Request Body:

{
  "orderId": "ord_xyz790",
  "amount": 35.00
}

Response: 200 OK


Domain 11: Cash Management

POST /shifts/open

Description: Open a new shift

Authentication: Bearer token (Manager+)

Request Body:

{
  "registerId": "reg_01",
  "openingFloat": 267.50,
  "floatBreakdown": {
    "bills_20": 5,
    "bills_10": 5,
    "bills_5": 10,
    "bills_1": 50,
    "quarters": 40,
    "dimes": 50,
    "nickels": 40,
    "pennies": 50
  }
}

Response: 201 Created

{
  "id": "shift_001",
  "registerId": "reg_01",
  "openedAt": "2025-12-29T08:00:00Z",
  "openedBy": "usr_manager1",
  "openingFloat": 267.50,
  "status": "active"
}

POST /shifts/{shiftId}/close

Description: Close shift and reconcile

Authentication: Bearer token (Manager+)

Request Body:

{
  "closingCount": {
    "bills_100": 2,
    "bills_50": 3,
    "bills_20": 15,
    "bills_10": 10,
    "bills_5": 20,
    "bills_1": 75,
    "quarters": 80,
    "dimes": 100,
    "nickels": 80,
    "pennies": 100
  }
}

Response: 200 OK

{
  "id": "shift_001",
  "closedAt": "2025-12-29T17:00:00Z",
  "expectedCash": 725.50,
  "actualCash": 723.00,
  "variance": -2.50,
  "varianceSeverity": "notable",
  "summary": {
    "cashSales": 458.00,
    "cardSales": 1250.00,
    "returns": 45.00,
    "paidOuts": 25.00,
    "tillDrops": 200.00
  }
}

POST /shifts/{shiftId}/till-drop

Description: Record till drop to safe

Authentication: Bearer token (Cashier+)

Request Body:

{
  "amount": 200.00,
  "breakdown": {
    "bills_100": 2
  }
}

Response: 201 Created


POST /shifts/{shiftId}/paid-out

Description: Record paid out (petty cash)

Authentication: Bearer token (Manager+)

Request Body:

{
  "amount": 25.00,
  "category": "office_supplies",
  "description": "Printer paper",
  "receiptAttached": true
}

Response: 201 Created


GET /shifts/

Description: Get shift details

Authentication: Bearer token (Manager+)

Response: 200 OK


Domain 12: RFID (Optional Module)

POST /rfid/tags/print

Description: Queue RFID tags for printing

Authentication: Bearer token (Manager+)

Request Body:

{
  "printerId": "printer_zebra_01",
  "items": [
    {
      "variantId": "var_001",
      "quantity": 50
    }
  ],
  "templateId": "tmpl_standard"
}

Response: 202 Accepted

{
  "jobId": "print_job_001",
  "status": "queued",
  "totalTags": 50
}

GET /rfid/tags/print/

Description: Get print job status

Authentication: Bearer token (Manager+)

Response: 200 OK


POST /rfid/scans/sessions

Description: Start RFID scan session

Authentication: Bearer token (Cashier+)

Request Body:

{
  "locationId": "loc_gm",
  "zoneId": "zone_sales_floor",
  "sessionType": "cycle_count"
}

Response: 201 Created

{
  "sessionId": "scan_001",
  "status": "active",
  "startedAt": "2025-12-29T10:00:00Z"
}

POST /rfid/scans/sessions/{sessionId}/tags

Description: Submit scanned tags (batch)

Authentication: Bearer token (Cashier+)

Request Body:

{
  "tags": [
    {
      "epc": "30340123456789012345678901",
      "rssi": -45,
      "timestamp": "2025-12-29T10:05:00Z"
    }
  ]
}

Response: 200 OK


POST /rfid/scans/sessions/{sessionId}/complete

Description: Complete scan session

Authentication: Bearer token (Cashier+)

Response: 200 OK

{
  "sessionId": "scan_001",
  "summary": {
    "totalTagsScanned": 145,
    "uniqueSkus": 142,
    "expected": 150,
    "variance": 8,
    "variancePercentage": 5.33
  },
  "completedAt": "2025-12-29T10:30:00Z"
}

Domain 13: Sync & Offline

POST /sync/push

Description: Push offline changes to server

Authentication: Bearer token (Cashier+)

Request Body:

{
  "deviceId": "dev_pos_01",
  "lastSyncTimestamp": "2025-12-29T10:00:00Z",
  "events": [
    {
      "localSequence": 1,
      "eventType": "OrderCompleted",
      "timestamp": "2025-12-29T10:30:00Z",
      "payload": { }
    }
  ],
  "inventoryDeltas": [
    {
      "variantId": "var_001",
      "locationId": "loc_gm",
      "lastSyncQty": 15,
      "delta": -2
    }
  ]
}

Response: 200 OK

{
  "success": true,
  "syncedEvents": 5,
  "conflicts": [
    {
      "type": "inventory",
      "variantId": "var_001",
      "resolution": "delta_merged",
      "serverValue": 12,
      "localDelta": -2,
      "resolvedValue": 10
    }
  ],
  "serverTimestamp": "2025-12-29T12:00:00Z"
}

GET /sync/pull

Description: Pull updates from server

Authentication: Bearer token (Cashier+)

Query Parameters:

ParameterTypeDescription
sincedatetimeLast sync timestamp
typesstring[]Event types to pull

Response: 200 OK


GET /sync/status

Description: Get sync status for device

Authentication: Bearer token (Cashier+)

Response: 200 OK

{
  "deviceId": "dev_pos_01",
  "lastSync": "2025-12-29T12:00:00Z",
  "pendingPush": 0,
  "pendingPull": 15,
  "status": "synced"
}

Domain 14: Reports

GET /reports/sales/daily

Description: Daily sales summary

Authentication: Bearer token (Manager+)

Query Parameters:

ParameterTypeDescription
datedateReport date
locationIdstringFilter by location

Response: 200 OK

{
  "date": "2025-12-29",
  "summary": {
    "grossSales": 5250.00,
    "discounts": 250.00,
    "returns": 150.00,
    "netSales": 4850.00,
    "tax": 291.00,
    "transactionCount": 85,
    "averageTicket": 57.06,
    "unitsPerTransaction": 2.3
  },
  "byPaymentMethod": {
    "cash": 1250.00,
    "card": 3500.00,
    "giftCard": 100.00
  },
  "byCategory": [
    { "category": "Shirts", "sales": 2500.00, "units": 75 },
    { "category": "Pants", "sales": 1500.00, "units": 30 }
  ],
  "topItems": [
    { "sku": "NXP0323", "name": "Classic V-Neck", "units": 25, "sales": 749.75 }
  ]
}

GET /reports/inventory/valuation

Description: Inventory valuation report

Authentication: Bearer token (Manager+)

Query Parameters:

ParameterTypeDescription
locationIdstringFilter by location
asOfDatedateValuation date

Response: 200 OK


GET /reports/employees/timeclock

Description: Employee time clock report

Authentication: Bearer token (Manager+)

Response: 200 OK


GET /reports/customers/loyalty

Description: Loyalty program report

Authentication: Bearer token (Manager+)

Response: 200 OK


Webhooks

Configuring Webhooks

Description: Register webhook endpoints

Authentication: Bearer token (Admin)

Request Body:

{
  "url": "https://your-server.com/webhooks",
  "events": [
    "order.completed",
    "order.refunded",
    "inventory.low_stock",
    "customer.created"
  ],
  "secret": "whsec_your_secret_key"
}

Webhook Events

EventDescription
order.completedSale completed
order.voidedSale voided
order.refundedReturn processed
inventory.low_stockBelow reorder point
inventory.adjustedManual adjustment
customer.createdNew customer
customer.updatedCustomer modified
sync.conflictOffline conflict detected

Webhook Payload Format

{
  "id": "evt_webhook_001",
  "type": "order.completed",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "data": {
    "orderId": "ord_xyz789",
    "orderNumber": "ORD-2025-00001",
    "total": 57.22
  }
}

Rate Limits

Endpoint TypeRate Limit
Authentication10 requests/minute
Read operations1000 requests/minute
Write operations100 requests/minute
Bulk operations10 requests/minute
Webhooks1000 events/minute

API Versioning

The API uses URL versioning:

  • Current version: v1
  • URL format: /api/v1/{resource}
  • Deprecated versions are supported for 12 months
  • Version header: X-API-Version: 2025-12-29

This API reference covers 75+ endpoints across 14 domains. For additional details, see the OpenAPI specification at /api/v1/docs.

Appendix B: Database Entity Relationship Diagram

Version: 1.0.0 Last Updated: December 29, 2025 Database: PostgreSQL 16 Total Tables: 51


Overview

This appendix contains the complete Entity Relationship Diagram (ERD) for the POS Platform database. The schema is organized by domain with a schema-per-tenant multi-tenancy model.


Schema Organization

pos_platform (database)
    |
    +-- shared (schema)
    |       Contains: tenants, modules, system settings
    |
    +-- tenant_nexus (schema per tenant)
    |       Contains: All tenant-specific tables
    |
    +-- tenant_retailco (schema per tenant)
            Contains: All tenant-specific tables

Complete Entity Relationship Diagram

╔═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗
║                                    POS PLATFORM - COMPLETE ENTITY RELATIONSHIP DIAGRAM                             ║
║                                                  51 Tables | 14 Domains                                            ║
╠═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╣
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                         DOMAIN 1: MULTI-TENANCY (shared schema)                             ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐                                       ║  ║
║  ║    │        tenants           │           │    tenant_modules        │                                       ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤                                       ║  ║
║  ║    │ PK id UUID               │───────────│ PK id UUID               │                                       ║  ║
║  ║    │    name VARCHAR(100)     │     1:N   │ FK tenant_id UUID        │──┐                                    ║  ║
║  ║    │    subdomain VARCHAR(50) │           │    module_code VARCHAR   │  │                                    ║  ║
║  ║    │    status ENUM           │           │    enabled BOOLEAN       │  │     ┌──────────────────────────┐  ║  ║
║  ║    │    plan ENUM             │           │    config JSONB          │  │     │    system_settings       │  ║  ║
║  ║    │    schema_name VARCHAR   │           │    activated_at TIMESTP  │  │     ├──────────────────────────┤  ║  ║
║  ║    │    settings JSONB        │           └──────────────────────────┘  ├────►│ PK id UUID               │  ║  ║
║  ║    │    created_at TIMESTAMP  │                                         │     │ FK tenant_id UUID        │  ║  ║
║  ║    │    updated_at TIMESTAMP  │                                         │     │    key VARCHAR(100)      │  ║  ║
║  ║    └──────────────────────────┘                                         │     │    value JSONB           │  ║  ║
║  ║                                                                          │     │    updated_at TIMESTAMP  │  ║  ║
║  ║                                                                          │     └──────────────────────────┘  ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                           │                                                        ║
║                                                           │ tenant_id (implicit via schema)                        ║
║                                                           ▼                                                        ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                     DOMAIN 2: LOCATIONS & REGISTERS                                          ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │       locations          │           │       registers          │       │    operating_hours       │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │    code VARCHAR(10)      │    1:N    │ FK location_id UUID      │       │ FK location_id UUID      │◄──┤   ║  ║
║  ║    │    name VARCHAR(100)     │           │    name VARCHAR(50)      │       │    day_of_week INT       │   ║  ║
║  ║    │    type ENUM             │           │    status ENUM           │       │    open_time TIME        │   ║  ║
║  ║    │    status ENUM           │           │    terminal_id VARCHAR   │       │    close_time TIME       │   ║  ║
║  ║    │    address_line1 VARCHAR │           │    last_active TIMESTAMP │       │    is_closed BOOLEAN     │   ║  ║
║  ║    │    address_line2 VARCHAR │           │    config JSONB          │       └──────────────────────────┘   ║  ║
║  ║    │    city VARCHAR(100)     │           └──────────────────────────┘                                       ║  ║
║  ║    │    state VARCHAR(50)     │                                                                              ║  ║
║  ║    │    zip VARCHAR(20)       │                                                                              ║  ║
║  ║    │    country VARCHAR(2)    │                                                                              ║  ║
║  ║    │    phone VARCHAR(20)     │                                                                              ║  ║
║  ║    │    timezone VARCHAR(50)  │                                                                              ║  ║
║  ║    │    shopify_location_id   │                                                                              ║  ║
║  ║    │    settings JSONB        │                                                                              ║  ║
║  ║    │    created_at TIMESTAMP  │                                                                              ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ║              │                                                                                                ║  ║
║  ╚══════════════╪═══════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                 │                                                                                                   ║
║                 │ location_id                                                                                       ║
║                 ▼                                                                                                   ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                        DOMAIN 3: USERS & EMPLOYEES                                           ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │         users            │           │    user_permissions      │       │      user_sessions       │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │    email VARCHAR(255)    │    1:N    │ FK user_id UUID          │       │ FK user_id UUID          │◄──┤   ║  ║
║  ║    │    password_hash VARCHAR │           │    permission VARCHAR    │       │    token_hash VARCHAR    │   ║  ║
║  ║    │    first_name VARCHAR    │           │    granted_by UUID       │       │    device_info JSONB     │   ║  ║
║  ║    │    last_name VARCHAR     │           │    granted_at TIMESTAMP  │       │    ip_address INET       │   ║  ║
║  ║    │    role ENUM             │           └──────────────────────────┘       │    expires_at TIMESTAMP  │   ║  ║
║  ║    │    pin_hash VARCHAR      │                                              │    created_at TIMESTAMP  │   ║  ║
║  ║    │ FK home_location_id UUID │◄─────────────────────────────────────────────└──────────────────────────┘   ║  ║
║  ║    │    status ENUM           │                                                                              ║  ║
║  ║    │    last_login TIMESTAMP  │           ┌──────────────────────────┐                                       ║  ║
║  ║    │    created_at TIMESTAMP  │           │    time_clock_entries    │                                       ║  ║
║  ║    └──────────────────────────┘           ├──────────────────────────┤                                       ║  ║
║  ║              │                            │ PK id UUID               │                                       ║  ║
║  ║              │                            │ FK user_id UUID          │◄──────────────────────────────────────┤   ║  ║
║  ║              │                            │ FK location_id UUID      │                                       ║  ║
║  ║              │                            │    clock_in TIMESTAMP    │                                       ║  ║
║  ║              │                            │    clock_out TIMESTAMP   │                                       ║  ║
║  ║              │                            │    break_minutes INT     │                                       ║  ║
║  ║              │                            │    status ENUM           │                                       ║  ║
║  ║              │                            │    notes TEXT            │                                       ║  ║
║  ║              │                            └──────────────────────────┘                                       ║  ║
║  ╚══════════════╪═══════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                 │                                                                                                   ║
║                 │ user_id                                                                                           ║
║                 ▼                                                                                                   ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                        DOMAIN 4: PRODUCTS & CATALOG                                          ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │      categories          │           │       products           │       │    product_variants      │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │◄──────────│ PK id UUID               │──────►│ PK id UUID               │   ║  ║
║  ║    │ FK parent_id UUID (self) │     N:1   │    sku VARCHAR(50)       │  1:N  │ FK product_id UUID       │   ║  ║
║  ║    │    name VARCHAR(100)     │           │    name VARCHAR(255)     │       │    sku VARCHAR(50)       │   ║  ║
║  ║    │    slug VARCHAR(100)     │           │    description TEXT      │       │    barcode VARCHAR(50)   │   ║  ║
║  ║    │    sort_order INT        │           │ FK category_id UUID      │       │    options JSONB         │   ║  ║
║  ║    │    is_active BOOLEAN     │           │ FK vendor_id UUID        │       │    price DECIMAL(10,2)   │   ║  ║
║  ║    └──────────────────────────┘           │    base_price DECIMAL    │       │    compare_price DECIMAL │   ║  ║
║  ║                                           │    cost DECIMAL(10,2)    │       │    cost DECIMAL(10,2)    │   ║  ║
║  ║    ┌──────────────────────────┐           │    tax_class VARCHAR     │       │    weight DECIMAL        │   ║  ║
║  ║    │        vendors           │           │    status ENUM           │       │    is_active BOOLEAN     │   ║  ║
║  ║    ├──────────────────────────┤           │    shopify_product_id    │       │    shopify_variant_id    │   ║  ║
║  ║    │ PK id UUID               │◄──────────│    created_at TIMESTAMP  │       │    created_at TIMESTAMP  │   ║  ║
║  ║    │    name VARCHAR(100)     │     N:1   └──────────────────────────┘       └──────────────────────────┘   ║  ║
║  ║    │    code VARCHAR(20)      │                       │                                  │                   ║  ║
║  ║    │    contact_name VARCHAR  │                       │                                  │                   ║  ║
║  ║    │    email VARCHAR(255)    │                       │                                  │                   ║  ║
║  ║    │    phone VARCHAR(20)     │                       ▼                                  ▼                   ║  ║
║  ║    │    address JSONB         │           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │    payment_terms VARCHAR │           │    product_images        │       │    variant_prices        │   ║  ║
║  ║    │    is_active BOOLEAN     │           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    └──────────────────────────┘           │ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║                                           │ FK product_id UUID       │       │ FK variant_id UUID       │   ║  ║
║  ║                                           │    url VARCHAR(500)      │       │ FK price_list_id UUID    │   ║  ║
║  ║                                           │    alt_text VARCHAR      │       │    price DECIMAL(10,2)   │   ║  ║
║  ║                                           │    position INT          │       │    effective_from DATE   │   ║  ║
║  ║                                           └──────────────────────────┘       │    effective_to DATE     │   ║  ║
║  ║                                                                              └──────────────────────────┘   ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                           │                                                        ║
║                                                           │ variant_id                                             ║
║                                                           ▼                                                        ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                            DOMAIN 5: INVENTORY                                                ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │    inventory_levels      │           │  inventory_transactions  │       │  inventory_reservations  │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │           │ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │ FK variant_id UUID       │◄──────────│ FK variant_id UUID       │       │ FK variant_id UUID       │◄──┤   ║  ║
║  ║    │ FK location_id UUID      │     1:N   │ FK location_id UUID      │       │ FK location_id UUID      │   ║  ║
║  ║    │    on_hand INT           │           │    transaction_type ENUM │       │ FK order_id UUID         │   ║  ║
║  ║    │    available INT         │           │    quantity INT          │       │    quantity INT          │   ║  ║
║  ║    │    reserved INT          │           │    previous_qty INT      │       │    expires_at TIMESTAMP  │   ║  ║
║  ║    │    reorder_point INT     │           │    new_qty INT           │       │    status ENUM           │   ║  ║
║  ║    │    reorder_qty INT       │           │    reference_type VARCHAR│       │    created_at TIMESTAMP  │   ║  ║
║  ║    │    bin_location VARCHAR  │           │    reference_id UUID     │       └──────────────────────────┘   ║  ║
║  ║    │    updated_at TIMESTAMP  │           │    cost DECIMAL(10,2)    │                                       ║  ║
║  ║    │ UK (variant_id, loc_id)  │           │    notes TEXT            │                                       ║  ║
║  ║    └──────────────────────────┘           │ FK created_by UUID       │                                       ║  ║
║  ║              │                            │    created_at TIMESTAMP  │                                       ║  ║
║  ║              │                            └──────────────────────────┘                                       ║  ║
║  ║              │                                                                                                ║  ║
║  ║              │           ┌──────────────────────────┐       ┌──────────────────────────┐                     ║  ║
║  ║              │           │   inventory_transfers    │       │   transfer_line_items    │                     ║  ║
║  ║              │           ├──────────────────────────┤       ├──────────────────────────┤                     ║  ║
║  ║              │           │ PK id UUID               │──────►│ PK id UUID               │                     ║  ║
║  ║              │           │ FK from_location_id UUID │  1:N  │ FK transfer_id UUID      │                     ║  ║
║  ║              │           │ FK to_location_id UUID   │       │ FK variant_id UUID       │                     ║  ║
║  ║              └──────────►│    status ENUM           │       │    qty_requested INT     │                     ║  ║
║  ║                          │    priority ENUM         │       │    qty_shipped INT       │                     ║  ║
║  ║                          │    tracking_number VARCH │       │    qty_received INT      │                     ║  ║
║  ║                          │    carrier VARCHAR       │       │    qty_damaged INT       │                     ║  ║
║  ║                          │ FK requested_by UUID     │       └──────────────────────────┘                     ║  ║
║  ║                          │ FK shipped_by UUID       │                                                        ║  ║
║  ║                          │ FK received_by UUID      │                                                        ║  ║
║  ║                          │    shipped_at TIMESTAMP  │                                                        ║  ║
║  ║                          │    received_at TIMESTAMP │                                                        ║  ║
║  ║                          │    created_at TIMESTAMP  │                                                        ║  ║
║  ║                          └──────────────────────────┘                                                        ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                           │                                                        ║
║                                                           │ variant_id, location_id                                ║
║                                                           ▼                                                        ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                         DOMAIN 6: ORDERS & SALES                                             ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │         orders           │           │      order_line_items    │       │     order_discounts      │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │    order_number VARCHAR  │     1:N   │ FK order_id UUID         │       │ FK order_id UUID         │◄──┤   ║  ║
║  ║    │    receipt_number VARCHAR│           │ FK variant_id UUID       │       │ FK line_item_id UUID     │   ║  ║
║  ║    │ FK location_id UUID      │           │    sku VARCHAR           │       │    discount_type ENUM    │   ║  ║
║  ║    │ FK register_id UUID      │           │    name VARCHAR          │       │    discount_value DECIMAL│   ║  ║
║  ║    │ FK customer_id UUID      │           │    quantity INT          │       │    discount_amount DECIM │   ║  ║
║  ║    │ FK created_by UUID       │           │    unit_price DECIMAL    │       │    code VARCHAR          │   ║  ║
║  ║    │    status ENUM           │           │    discount_amount DECIM │       │    reason VARCHAR        │   ║  ║
║  ║    │    subtotal DECIMAL      │           │    tax_amount DECIMAL    │       └──────────────────────────┘   ║  ║
║  ║    │    discount_total DECIM  │           │    line_total DECIMAL    │                                       ║  ║
║  ║    │    tax_total DECIMAL     │           │    cost DECIMAL          │                                       ║  ║
║  ║    │    total DECIMAL(10,2)   │           │    fulfillment_status EN │                                       ║  ║
║  ║    │    channel ENUM          │           └──────────────────────────┘                                       ║  ║
║  ║    │    source VARCHAR        │                       │                                                      ║  ║
║  ║    │    notes TEXT            │                       │                                                      ║  ║
║  ║    │    metadata JSONB        │                       │                                                      ║  ║
║  ║    │    voided_at TIMESTAMP   │                       │                                                      ║  ║
║  ║    │ FK voided_by UUID        │                       ▼                                                      ║  ║
║  ║    │    void_reason VARCHAR   │           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │    created_at TIMESTAMP  │           │        returns           │       │    return_line_items     │   ║  ║
║  ║    │    completed_at TIMESTP  │           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    └──────────────────────────┘           │ PK id UUID               │──────►│ PK id UUID               │   ║  ║
║  ║              │                            │    return_number VARCHAR │  1:N  │ FK return_id UUID        │   ║  ║
║  ║              │                            │ FK original_order_id UUID│       │ FK original_line_id UUID │   ║  ║
║  ║              │                            │ FK location_id UUID      │       │ FK variant_id UUID       │   ║  ║
║  ║              │                            │ FK customer_id UUID      │       │    quantity INT          │   ║  ║
║  ║              │                            │ FK processed_by UUID     │       │    refund_amount DECIMAL │   ║  ║
║  ║              │                            │    status ENUM           │       │    reason ENUM           │   ║  ║
║  ║              │                            │    refund_total DECIMAL  │       │    condition ENUM        │   ║  ║
║  ║              │                            │    refund_method ENUM    │       │    restocked BOOLEAN     │   ║  ║
║  ║              │                            │    created_at TIMESTAMP  │       └──────────────────────────┘   ║  ║
║  ║              │                            └──────────────────────────┘                                       ║  ║
║  ╚══════════════╪═══════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                 │                                                                                                   ║
║                 │ order_id                                                                                          ║
║                 ▼                                                                                                   ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                            DOMAIN 7: PAYMENTS                                                 ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │        payments          │           │   payment_refunds        │       │    payment_batches       │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │ FK order_id UUID         │     1:N   │ FK payment_id UUID       │◄──────│ FK location_id UUID      │   ║  ║
║  ║    │    payment_method ENUM   │           │ FK return_id UUID        │  N:1  │    batch_date DATE       │   ║  ║
║  ║    │    amount DECIMAL(10,2)  │           │    amount DECIMAL        │       │    status ENUM           │   ║  ║
║  ║    │    status ENUM           │           │    status ENUM           │       │    total_amount DECIMAL  │   ║  ║
║  ║    │    authorization_code    │           │    gateway_refund_id     │       │    transaction_count INT │   ║  ║
║  ║    │    gateway_transaction_id│           │    created_at TIMESTAMP  │       │    settled_at TIMESTAMP  │   ║  ║
║  ║    │    card_brand VARCHAR    │           └──────────────────────────┘       │    created_at TIMESTAMP  │   ║  ║
║  ║    │    card_last_four VARCHAR│                                              └──────────────────────────┘   ║  ║
║  ║    │    entry_method ENUM     │                                                          │                   ║  ║
║  ║    │    terminal_id VARCHAR   │                                                          │                   ║  ║
║  ║    │ FK batch_id UUID         │◄─────────────────────────────────────────────────────────┘                   ║  ║
║  ║    │    tip_amount DECIMAL    │                                                                              ║  ║
║  ║    │    metadata JSONB        │                                                                              ║  ║
║  ║    │    created_at TIMESTAMP  │                                                                              ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                       DOMAIN 8: CUSTOMERS & LOYALTY                                          ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │       customers          │           │   loyalty_transactions   │       │    customer_tags         │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │    customer_number VARCH │     1:N   │ FK customer_id UUID      │       │ FK customer_id UUID      │◄──┤   ║  ║
║  ║    │    first_name VARCHAR    │           │ FK order_id UUID         │       │ FK tag_id UUID           │   ║  ║
║  ║    │    last_name VARCHAR     │           │    transaction_type ENUM │       │    applied_at TIMESTAMP  │   ║  ║
║  ║    │    email VARCHAR(255)    │           │    points INT            │       │    expires_at TIMESTAMP  │   ║  ║
║  ║    │    phone VARCHAR(20)     │           │    balance_after INT     │       │    applied_by UUID       │   ║  ║
║  ║    │    address JSONB         │           │    description VARCHAR   │       └──────────────────────────┘   ║  ║
║  ║    │    loyalty_tier ENUM     │           │    created_at TIMESTAMP  │                                       ║  ║
║  ║    │    loyalty_points INT    │           └──────────────────────────┘       ┌──────────────────────────┐   ║  ║
║  ║    │    lifetime_spend DECIM  │                                              │          tags            │   ║  ║
║  ║    │    total_orders INT      │                                              ├──────────────────────────┤   ║  ║
║  ║    │    marketing_opt_in BOOL │                                              │ PK id UUID               │   ║  ║
║  ║    │    sms_opt_in BOOLEAN    │           ┌──────────────────────────┐       │    name VARCHAR(50)      │   ║  ║
║  ║    │    tax_exempt BOOLEAN    │           │    customer_notes        │       │    category VARCHAR      │   ║  ║
║  ║    │    notes TEXT            │           ├──────────────────────────┤       │    color VARCHAR(7)      │   ║  ║
║  ║    │    metadata JSONB        │           │ PK id UUID               │       │    is_auto BOOLEAN       │   ║  ║
║  ║    │    created_at TIMESTAMP  │──────────►│ FK customer_id UUID      │       └──────────────────────────┘   ║  ║
║  ║    │    updated_at TIMESTAMP  │     1:N   │ FK created_by UUID       │                                       ║  ║
║  ║    └──────────────────────────┘           │    note TEXT             │                                       ║  ║
║  ║                                           │    created_at TIMESTAMP  │                                       ║  ║
║  ║                                           └──────────────────────────┘                                       ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                           DOMAIN 9: GIFT CARDS                                                ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐                                       ║  ║
║  ║    │       gift_cards         │           │  gift_card_transactions  │                                       ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤                                       ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │                                       ║  ║
║  ║    │    card_number VARCHAR   │     1:N   │ FK gift_card_id UUID     │                                       ║  ║
║  ║    │    card_number_hash VARCH│           │ FK order_id UUID         │                                       ║  ║
║  ║    │    initial_balance DECIM │           │    transaction_type ENUM │                                       ║  ║
║  ║    │    current_balance DECIM │           │    amount DECIMAL        │                                       ║  ║
║  ║    │    status ENUM           │           │    balance_after DECIMAL │                                       ║  ║
║  ║    │    type ENUM             │           │    reference VARCHAR     │                                       ║  ║
║  ║    │    purchased_at TIMESTAMP│           │    created_at TIMESTAMP  │                                       ║  ║
║  ║    │ FK purchased_by UUID     │           └──────────────────────────┘                                       ║  ║
║  ║    │ FK purchase_order_id UUID│                                                                              ║  ║
║  ║    │    recipient_email VARCH │                                                                              ║  ║
║  ║    │    recipient_name VARCHAR│                                                                              ║  ║
║  ║    │    message TEXT          │                                                                              ║  ║
║  ║    │    expires_at TIMESTAMP  │                                                                              ║  ║
║  ║    │    created_at TIMESTAMP  │                                                                              ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                       DOMAIN 10: CASH MANAGEMENT                                              ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │         shifts           │           │     cash_movements       │       │     cash_counts          │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │──────────►│ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │ FK register_id UUID      │     1:N   │ FK shift_id UUID         │       │ FK shift_id UUID         │◄──┤   ║  ║
║  ║    │ FK opened_by UUID        │           │    movement_type ENUM    │       │    count_type ENUM       │   ║  ║
║  ║    │ FK closed_by UUID        │           │    amount DECIMAL        │       │    expected DECIMAL      │   ║  ║
║  ║    │    status ENUM           │           │ FK performed_by UUID     │       │    actual DECIMAL        │   ║  ║
║  ║    │    opening_float DECIMAL │           │ FK witnessed_by UUID     │       │    variance DECIMAL      │   ║  ║
║  ║    │    expected_cash DECIMAL │           │    reason VARCHAR        │       │    breakdown JSONB       │   ║  ║
║  ║    │    actual_cash DECIMAL   │           │    reference_number VARC │       │ FK counted_by UUID       │   ║  ║
║  ║    │    variance DECIMAL      │           │    notes TEXT            │       │    counted_at TIMESTAMP  │   ║  ║
║  ║    │    opened_at TIMESTAMP   │           │    created_at TIMESTAMP  │       │    notes TEXT            │   ║  ║
║  ║    │    closed_at TIMESTAMP   │           └──────────────────────────┘       └──────────────────────────┘   ║  ║
║  ║    │    notes TEXT            │                                                                              ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                           DOMAIN 11: RFID                                                     ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │       rfid_tags          │           │   rfid_scan_sessions     │       │      rfid_scans          │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │           │ PK id UUID               │──────►│ PK id UUID               │   ║  ║
║  ║    │    epc VARCHAR(64)       │           │ FK location_id UUID      │  1:N  │ FK session_id UUID       │   ║  ║
║  ║    │ FK variant_id UUID       │           │    zone_id VARCHAR       │       │ FK tag_id UUID           │   ║  ║
║  ║    │    serial_number BIGINT  │           │    session_type ENUM     │       │    epc VARCHAR(64)       │   ║  ║
║  ║    │    status ENUM           │           │ FK started_by UUID       │       │    rssi INT              │   ║  ║
║  ║    │ FK current_location UUID │           │ FK completed_by UUID     │       │    antenna_id INT        │   ║  ║
║  ║    │ FK printed_at_location   │           │    status ENUM           │       │    read_count INT        │   ║  ║
║  ║    │    printed_at TIMESTAMP  │           │    started_at TIMESTAMP  │       │    first_seen TIMESTAMP  │   ║  ║
║  ║    │ FK printed_by UUID       │           │    completed_at TIMESTAMP│       │    last_seen TIMESTAMP   │   ║  ║
║  ║    │    last_seen_at TIMESTP  │           │    summary JSONB         │       └──────────────────────────┘   ║  ║
║  ║    │    created_at TIMESTAMP  │           └──────────────────────────┘                                       ║  ║
║  ║    │ UK epc                   │                                                                              ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                         DOMAIN 12: EVENTS & SYNC                                              ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐       ┌──────────────────────────┐   ║  ║
║  ║    │      domain_events       │           │      sync_queue          │       │    conflict_resolutions  │   ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤       ├──────────────────────────┤   ║  ║
║  ║    │ PK id UUID               │           │ PK id UUID               │       │ PK id UUID               │   ║  ║
║  ║    │    event_type VARCHAR    │           │    device_id VARCHAR     │       │    conflict_type VARCHAR │   ║  ║
║  ║    │    aggregate_type VARCHAR│           │    direction ENUM        │       │    entity_type VARCHAR   │   ║  ║
║  ║    │    aggregate_id UUID     │           │    event_type VARCHAR    │       │    entity_id UUID        │   ║  ║
║  ║    │    payload JSONB         │           │    payload JSONB         │       │    server_value JSONB    │   ║  ║
║  ║    │    correlation_id UUID   │           │    local_sequence INT    │       │    local_value JSONB     │   ║  ║
║  ║    │    causation_id UUID     │           │    status ENUM           │       │    resolved_value JSONB  │   ║  ║
║  ║    │    version INT           │           │    attempts INT          │       │    resolution_method EN  │   ║  ║
║  ║    │    created_at TIMESTAMP  │           │    last_attempt TIMESTP  │       │ FK resolved_by UUID      │   ║  ║
║  ║    │ IX (aggregate_type, id)  │           │    error_message TEXT    │       │    resolved_at TIMESTAMP │   ║  ║
║  ║    │ IX (created_at)          │           │    created_at TIMESTAMP  │       │    notes TEXT            │   ║  ║
║  ║    └──────────────────────────┘           └──────────────────────────┘       └──────────────────────────┘   ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
║  ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════╗  ║
║  ║                                         DOMAIN 13: AUDIT & LOGS                                               ║  ║
║  ╠══════════════════════════════════════════════════════════════════════════════════════════════════════════════╣  ║
║  ║                                                                                                              ║  ║
║  ║    ┌──────────────────────────┐           ┌──────────────────────────┐                                       ║  ║
║  ║    │       audit_logs         │           │     api_request_logs     │                                       ║  ║
║  ║    ├──────────────────────────┤           ├──────────────────────────┤                                       ║  ║
║  ║    │ PK id UUID               │           │ PK id UUID               │                                       ║  ║
║  ║    │    action VARCHAR(50)    │           │    method VARCHAR(10)    │                                       ║  ║
║  ║    │    entity_type VARCHAR   │           │    path VARCHAR(500)     │                                       ║  ║
║  ║    │    entity_id UUID        │           │    status_code INT       │                                       ║  ║
║  ║    │    old_values JSONB      │           │    duration_ms INT       │                                       ║  ║
║  ║    │    new_values JSONB      │           │ FK user_id UUID          │                                       ║  ║
║  ║    │ FK performed_by UUID     │           │    ip_address INET       │                                       ║  ║
║  ║    │    ip_address INET       │           │    user_agent VARCHAR    │                                       ║  ║
║  ║    │    user_agent VARCHAR    │           │    request_body JSONB    │                                       ║  ║
║  ║    │    created_at TIMESTAMP  │           │    created_at TIMESTAMP  │                                       ║  ║
║  ║    │ IX (entity_type, id)     │           │ IX (created_at)          │                                       ║  ║
║  ║    │ IX (performed_by)        │           │ IX (user_id)             │                                       ║  ║
║  ║    │ IX (created_at)          │           └──────────────────────────┘                                       ║  ║
║  ║    └──────────────────────────┘                                                                              ║  ║
║  ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════╝  ║
║                                                                                                                     ║
╚═══════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝

Table Summary by Domain

DomainTablesPrimary Tables
1. Multi-Tenancy3tenants, tenant_modules, system_settings
2. Locations3locations, registers, operating_hours
3. Users4users, user_permissions, user_sessions, time_clock_entries
4. Products5categories, vendors, products, product_variants, product_images, variant_prices
5. Inventory5inventory_levels, inventory_transactions, inventory_reservations, inventory_transfers, transfer_line_items
6. Orders6orders, order_line_items, order_discounts, returns, return_line_items
7. Payments3payments, payment_refunds, payment_batches
8. Customers5customers, loyalty_transactions, customer_tags, tags, customer_notes
9. Gift Cards2gift_cards, gift_card_transactions
10. Cash3shifts, cash_movements, cash_counts
11. RFID3rfid_tags, rfid_scan_sessions, rfid_scans
12. Events3domain_events, sync_queue, conflict_resolutions
13. Audit2audit_logs, api_request_logs
TOTAL51

Key Relationships

One-to-Many (1:N)

ParentChildForeign Key
tenantstenant_modulestenant_id
locationsregisterslocation_id
locationsoperating_hourslocation_id
usersuser_permissionsuser_id
usersuser_sessionsuser_id
userstime_clock_entriesuser_id
categoriescategories (self)parent_id
categoriesproductscategory_id
vendorsproductsvendor_id
productsproduct_variantsproduct_id
productsproduct_imagesproduct_id
product_variantsinventory_levelsvariant_id
product_variantsinventory_transactionsvariant_id
product_variantsorder_line_itemsvariant_id
ordersorder_line_itemsorder_id
ordersorder_discountsorder_id
orderspaymentsorder_id
ordersreturnsoriginal_order_id
returnsreturn_line_itemsreturn_id
paymentspayment_refundspayment_id
payment_batchespaymentsbatch_id
customersorderscustomer_id
customersloyalty_transactionscustomer_id
customerscustomer_tagscustomer_id
customerscustomer_notescustomer_id
gift_cardsgift_card_transactionsgift_card_id
shiftscash_movementsshift_id
shiftscash_countsshift_id
inventory_transferstransfer_line_itemstransfer_id
rfid_scan_sessionsrfid_scanssession_id

Many-to-Many (M:N)

Table AJunctionTable B
customerscustomer_tagstags
product_variantsvariant_pricesprice_lists

Indexes

Critical Performance Indexes

-- Orders lookup
CREATE INDEX idx_orders_location_date ON orders(location_id, created_at DESC);
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_receipt ON orders(receipt_number);

-- Inventory queries
CREATE INDEX idx_inventory_levels_variant_location
    ON inventory_levels(variant_id, location_id);
CREATE INDEX idx_inventory_levels_location_reorder
    ON inventory_levels(location_id) WHERE on_hand <= reorder_point;

-- Product search
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_product_variants_barcode ON product_variants(barcode);
CREATE INDEX idx_products_search ON products USING gin(to_tsvector('english', name));

-- Customer lookup
CREATE INDEX idx_customers_email ON customers(lower(email));
CREATE INDEX idx_customers_phone ON customers(phone);
CREATE INDEX idx_customers_search ON customers
    USING gin(to_tsvector('english', first_name || ' ' || last_name));

-- Event sourcing
CREATE INDEX idx_domain_events_aggregate ON domain_events(aggregate_type, aggregate_id);
CREATE INDEX idx_domain_events_created ON domain_events(created_at);

-- Audit trail
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_audit_logs_user ON audit_logs(performed_by);
CREATE INDEX idx_audit_logs_time ON audit_logs(created_at DESC);

-- RFID
CREATE UNIQUE INDEX idx_rfid_tags_epc ON rfid_tags(epc);
CREATE INDEX idx_rfid_tags_variant ON rfid_tags(variant_id);

Partitioning Strategy

Time-Based Partitioning

-- Orders partitioned by month
CREATE TABLE orders (
    id UUID,
    created_at TIMESTAMP,
    -- other columns
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2025_01 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE orders_2025_02 PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- etc.

-- Domain events partitioned by month
CREATE TABLE domain_events (
    id UUID,
    created_at TIMESTAMP,
    -- other columns
) PARTITION BY RANGE (created_at);

-- Audit logs partitioned by month
CREATE TABLE audit_logs (
    id UUID,
    created_at TIMESTAMP,
    -- other columns
) PARTITION BY RANGE (created_at);

Constraints Summary

Unique Constraints

TableColumnsPurpose
tenantssubdomainUnique tenant subdomain
locationscodeUnique location code per tenant
usersemailUnique user email per tenant
productsskuUnique SKU per tenant
product_variantsskuUnique variant SKU per tenant
product_variantsbarcodeUnique barcode per tenant
ordersorder_numberUnique order number per tenant
ordersreceipt_numberUnique receipt per tenant
customerscustomer_numberUnique customer ID per tenant
gift_cardscard_numberUnique card number per tenant
rfid_tagsepcGlobally unique EPC
inventory_levelsvariant_id, location_idOne record per variant-location

Check Constraints

-- Positive quantities
ALTER TABLE inventory_levels ADD CONSTRAINT chk_on_hand_positive
    CHECK (on_hand >= 0);
ALTER TABLE order_line_items ADD CONSTRAINT chk_quantity_positive
    CHECK (quantity > 0);

-- Valid percentages
ALTER TABLE order_discounts ADD CONSTRAINT chk_discount_valid
    CHECK (discount_value >= 0 AND discount_value <= 100);

-- Valid statuses
ALTER TABLE orders ADD CONSTRAINT chk_order_status
    CHECK (status IN ('pending', 'completed', 'voided', 'refunded'));

-- Balance constraints
ALTER TABLE gift_cards ADD CONSTRAINT chk_balance_not_negative
    CHECK (current_balance >= 0);

Data Types Reference

Custom ENUM Types

-- Tenant status
CREATE TYPE tenant_status AS ENUM ('active', 'suspended', 'trial', 'cancelled');

-- Location type
CREATE TYPE location_type AS ENUM ('store', 'warehouse', 'popup', 'mobile');

-- User role
CREATE TYPE user_role AS ENUM ('super_admin', 'admin', 'manager', 'cashier', 'viewer');

-- Order status
CREATE TYPE order_status AS ENUM ('pending', 'completed', 'voided', 'refunded');

-- Payment method
CREATE TYPE payment_method AS ENUM ('cash', 'card', 'gift_card', 'loyalty', 'other');

-- Payment status
CREATE TYPE payment_status AS ENUM ('pending', 'approved', 'declined', 'refunded');

-- Inventory transaction type
CREATE TYPE inv_transaction_type AS ENUM (
    'sale', 'return', 'adjustment', 'transfer_out', 'transfer_in', 'receipt', 'shrinkage'
);

-- Cash movement type
CREATE TYPE cash_movement_type AS ENUM (
    'till_drop', 'pickup', 'paid_in', 'paid_out', 'float_adjust'
);

-- RFID tag status
CREATE TYPE rfid_status AS ENUM ('active', 'sold', 'returned', 'void', 'lost');

-- Sync direction
CREATE TYPE sync_direction AS ENUM ('push', 'pull');

This ERD represents the complete database schema for the POS Platform with 51 tables across 13 domains.

Appendix C: Domain Events Catalog

Version: 1.0.0 Last Updated: December 29, 2025 Total Events: 45+


Overview

This appendix contains the complete catalog of domain events for the POS Platform. These events form the foundation of the event-driven architecture, enabling real-time updates, audit trails, and offline synchronization.


Event Structure

All events follow this standard envelope:

{
  "eventId": "evt_uuid",
  "eventType": "EventName",
  "timestamp": "2025-12-29T14:30:00.000Z",
  "tenantId": "tenant_nexus",
  "correlationId": "uuid",
  "causationId": "uuid",
  "version": 1,
  "payload": { }
}
FieldTypeDescription
eventIdUUIDUnique event identifier
eventTypestringEvent type name
timestampISO 8601When event occurred
tenantIdstringTenant identifier
correlationIdUUIDLinks related events
causationIdUUIDEvent that caused this event
versionintSchema version
payloadobjectEvent-specific data

Sales Events

1. OrderCreated

Trigger: Customer begins checkout Producer: POS Terminal, Web Store Consumers: Analytics, Inventory Reservation

{
  "eventType": "OrderCreated",
  "eventId": "evt_ord_001",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "orderNumber": "ORD-2025-00001",
    "locationId": "loc_gm",
    "registerId": "reg_01",
    "createdBy": "usr_cashier1",
    "customerId": "cust_john_doe",
    "lineItems": [
      {
        "lineItemId": "li_001",
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "name": "Classic V-Neck Tee - M Black",
        "quantity": 2,
        "unitPrice": 29.99,
        "discountAmount": 0,
        "taxAmount": 4.80,
        "lineTotal": 64.78
      }
    ],
    "subtotal": 59.98,
    "discountTotal": 0,
    "taxTotal": 4.80,
    "total": 64.78,
    "status": "pending",
    "channel": "pos"
  }
}

2. PaymentAttempted

Trigger: Customer initiates payment Producer: Payment Terminal Consumers: Payment Gateway, Fraud Detection

{
  "eventType": "PaymentAttempted",
  "eventId": "evt_pay_001",
  "timestamp": "2025-12-29T14:31:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "paymentAttemptId": "pa_001",
    "paymentMethod": "card",
    "terminalId": "term_verifone_01",
    "amount": 64.78,
    "currency": "USD",
    "cardPresent": true,
    "entryMethod": "chip",
    "cardBrand": "visa",
    "lastFour": "4242"
  }
}

3. PaymentCompleted

Trigger: Payment gateway confirms success Producer: Payment Gateway Adapter Consumers: Order Service, Receipt Service, Inventory

{
  "eventType": "PaymentCompleted",
  "eventId": "evt_pay_002",
  "timestamp": "2025-12-29T14:31:15Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "paymentId": "pay_001",
    "paymentAttemptId": "pa_001",
    "amount": 64.78,
    "authorizationCode": "AUTH123456",
    "transactionId": "txn_gateway_abc",
    "batchId": "batch_2025-12-29",
    "cardBrand": "visa",
    "lastFour": "4242",
    "entryMethod": "chip",
    "receiptData": {
      "merchantName": "Nexus Clothing - Greenbrier",
      "merchantId": "MID123456",
      "approvalCode": "123456"
    }
  }
}

4. PaymentFailed

Trigger: Payment gateway declines Producer: Payment Gateway Adapter Consumers: Order Service, POS UI, Analytics

{
  "eventType": "PaymentFailed",
  "eventId": "evt_pay_003",
  "timestamp": "2025-12-29T14:31:20Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "paymentAttemptId": "pa_001",
    "failureReason": "insufficient_funds",
    "failureCode": "DECLINED_05",
    "retriable": true,
    "suggestedAction": "Try different payment method",
    "gatewayResponse": {
      "code": "51",
      "message": "Insufficient funds"
    }
  }
}

5. OrderCompleted

Trigger: All payments successful, order finalized Producer: Order Service Consumers: Inventory, Analytics, Loyalty, Receipt

{
  "eventType": "OrderCompleted",
  "eventId": "evt_ord_002",
  "timestamp": "2025-12-29T14:31:30Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "orderNumber": "ORD-2025-00001",
    "receiptNumber": "GM-2025-001234",
    "locationId": "loc_gm",
    "registerId": "reg_01",
    "customerId": "cust_john_doe",
    "lineItems": [
      {
        "lineItemId": "li_001",
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantity": 2,
        "unitPrice": 29.99,
        "lineTotal": 59.98
      }
    ],
    "payments": [
      {
        "paymentId": "pay_001",
        "method": "card",
        "amount": 64.78
      }
    ],
    "subtotal": 59.98,
    "discountTotal": 0,
    "taxTotal": 4.80,
    "total": 64.78,
    "loyaltyPointsEarned": 65,
    "completedAt": "2025-12-29T14:31:30Z",
    "completedBy": "usr_cashier1",
    "shiftId": "shift_2025-12-29_am"
  }
}

6. OrderVoided

Trigger: Manager voids order Producer: POS Application Consumers: Inventory, Analytics, Audit

{
  "eventType": "OrderVoided",
  "eventId": "evt_ord_003",
  "timestamp": "2025-12-29T14:35:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "orderNumber": "ORD-2025-00001",
    "voidReason": "customer_changed_mind",
    "voidedBy": "usr_manager1",
    "voidedAt": "2025-12-29T14:35:00Z",
    "authorizationCode": "MGR-VOID-001",
    "originalTotal": 64.78,
    "refundRequired": false,
    "inventoryReleased": true,
    "lineItems": [
      {
        "variantId": "var_nxp0323_m_blk",
        "quantity": 2
      }
    ]
  }
}

7. ReturnInitiated

Trigger: Customer requests return Producer: POS Application Consumers: Return Service, Inventory, Fraud

{
  "eventType": "ReturnInitiated",
  "eventId": "evt_ret_001",
  "timestamp": "2025-12-29T15:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ret_abc123",
  "payload": {
    "returnId": "ret_abc123",
    "originalOrderId": "ord_xyz789",
    "originalReceiptNumber": "GM-2025-001234",
    "locationId": "loc_hm",
    "customerId": "cust_john_doe",
    "returnItems": [
      {
        "originalLineItemId": "li_001",
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityReturned": 1,
        "returnReason": "wrong_size",
        "condition": "resaleable",
        "refundAmount": 32.39
      }
    ],
    "totalRefund": 32.39,
    "refundMethod": "original_payment",
    "initiatedBy": "usr_cashier2"
  }
}

8. ReturnCompleted

Trigger: Refund processed Producer: Return Service Consumers: Inventory, Payment, Loyalty, Analytics

{
  "eventType": "ReturnCompleted",
  "eventId": "evt_ret_002",
  "timestamp": "2025-12-29T15:05:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ret_abc123",
  "payload": {
    "returnId": "ret_abc123",
    "returnReceiptNumber": "RET-HM-2025-0001",
    "originalOrderId": "ord_xyz789",
    "refundTransactionId": "refund_txn_001",
    "refundAmount": 32.39,
    "refundMethod": "card",
    "loyaltyPointsDeducted": 32,
    "inventoryRestocked": [
      {
        "variantId": "var_nxp0323_m_blk",
        "locationId": "loc_hm",
        "quantityAdded": 1,
        "condition": "resaleable"
      }
    ],
    "processedBy": "usr_cashier2",
    "completedAt": "2025-12-29T15:05:00Z",
    "shiftId": "shift_2025-12-29_pm"
  }
}

9. ReceiptRequested

Trigger: Customer requests receipt Producer: POS Application Consumers: Receipt Service, Communication

{
  "eventType": "ReceiptRequested",
  "eventId": "evt_rcpt_001",
  "timestamp": "2025-12-29T14:32:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "orderId": "ord_xyz789",
    "receiptNumber": "GM-2025-001234",
    "deliveryMethod": "email",
    "destination": "john@example.com",
    "includePromotions": true,
    "loyaltyBalance": 1250,
    "requestedBy": "usr_cashier1"
  }
}

Inventory Events

10. StockReserved

Trigger: Order created, items reserved Producer: Inventory Service Consumers: Order Service, Stock Visibility

{
  "eventType": "StockReserved",
  "eventId": "evt_inv_001",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "reservationId": "res_001",
    "orderId": "ord_xyz789",
    "locationId": "loc_gm",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityReserved": 2,
        "previousOnHand": 15,
        "previousAvailable": 15,
        "newAvailable": 13
      }
    ],
    "expiresAt": "2025-12-29T15:00:00Z"
  }
}

11. StockCommitted

Trigger: Payment completed Producer: Inventory Service Consumers: Reporting, Reorder, Sync

{
  "eventType": "StockCommitted",
  "eventId": "evt_inv_002",
  "timestamp": "2025-12-29T14:31:30Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "commitId": "commit_001",
    "reservationId": "res_001",
    "orderId": "ord_xyz789",
    "receiptNumber": "GM-2025-001234",
    "locationId": "loc_gm",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantitySold": 2,
        "previousOnHand": 15,
        "newOnHand": 13,
        "unitCost": 12.50,
        "totalCostOfGoodsSold": 25.00
      }
    ],
    "transactionType": "sale"
  }
}

12. StockReleased

Trigger: Order voided/abandoned Producer: Inventory Service Consumers: Order Service, Stock Visibility

{
  "eventType": "StockReleased",
  "eventId": "evt_inv_003",
  "timestamp": "2025-12-29T14:40:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "releaseId": "rel_001",
    "reservationId": "res_001",
    "orderId": "ord_xyz789",
    "locationId": "loc_gm",
    "releaseReason": "order_voided",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityReleased": 2,
        "previousAvailable": 13,
        "newAvailable": 15
      }
    ]
  }
}

13. StockReceived

Trigger: Vendor shipment received Producer: Receiving Service Consumers: Inventory, AP, Reporting

{
  "eventType": "StockReceived",
  "eventId": "evt_inv_004",
  "timestamp": "2025-12-29T09:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "po_12345",
  "payload": {
    "receiptId": "rcpt_001",
    "purchaseOrderId": "po_12345",
    "vendorId": "vendor_nike",
    "locationId": "loc_hq",
    "receivedBy": "usr_warehouse1",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityOrdered": 50,
        "quantityReceived": 48,
        "quantityDamaged": 2,
        "previousOnHand": 100,
        "newOnHand": 148,
        "unitCost": 12.50,
        "totalCost": 600.00
      }
    ],
    "totalItemsReceived": 48,
    "totalCost": 600.00,
    "discrepancyNotes": "2 units damaged in shipping"
  }
}

14. StockAdjusted

Trigger: Manual adjustment (count, shrinkage) Producer: Inventory Management Consumers: Inventory, Reporting, Audit

{
  "eventType": "StockAdjusted",
  "eventId": "evt_inv_005",
  "timestamp": "2025-12-29T11:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "adj_001",
  "payload": {
    "adjustmentId": "adj_001",
    "locationId": "loc_gm",
    "adjustedBy": "usr_manager1",
    "adjustmentType": "cycle_count",
    "authorizationCode": "MGR-ADJ-001",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "systemQuantity": 15,
        "countedQuantity": 13,
        "variance": -2,
        "varianceReason": "shrinkage",
        "previousOnHand": 15,
        "newOnHand": 13,
        "costImpact": -25.00
      }
    ],
    "totalVariance": -2,
    "totalCostImpact": -25.00,
    "notes": "Quarterly cycle count - Section A"
  }
}

15. TransferRequested

Trigger: Store requests stock Producer: Inventory Management Consumers: Transfer Service, Notifications

{
  "eventType": "TransferRequested",
  "eventId": "evt_inv_006",
  "timestamp": "2025-12-29T10:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "xfer_001",
  "payload": {
    "transferId": "xfer_001",
    "fromLocationId": "loc_hq",
    "toLocationId": "loc_gm",
    "requestedBy": "usr_gm_manager",
    "priority": "normal",
    "requestReason": "low_stock",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityRequested": 10,
        "sourceOnHand": 100,
        "destinationOnHand": 3
      }
    ],
    "expectedShipDate": "2025-12-30",
    "expectedArrivalDate": "2025-12-31"
  }
}

16. TransferShipped

Trigger: Source location ships Producer: Transfer Service Consumers: Inventory, Tracking

{
  "eventType": "TransferShipped",
  "eventId": "evt_inv_007",
  "timestamp": "2025-12-29T14:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "xfer_001",
  "payload": {
    "transferId": "xfer_001",
    "fromLocationId": "loc_hq",
    "toLocationId": "loc_gm",
    "shippedBy": "usr_warehouse1",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityShipped": 10,
        "previousFromOnHand": 100,
        "newFromOnHand": 90
      }
    ],
    "trackingNumber": "1Z999AA10123456784",
    "carrier": "UPS",
    "shippedAt": "2025-12-29T14:00:00Z"
  }
}

17. TransferReceived

Trigger: Destination receives Producer: Transfer Service Consumers: Inventory, Notifications

{
  "eventType": "TransferReceived",
  "eventId": "evt_inv_008",
  "timestamp": "2025-12-30T09:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "xfer_001",
  "payload": {
    "transferId": "xfer_001",
    "fromLocationId": "loc_hq",
    "toLocationId": "loc_gm",
    "receivedBy": "usr_gm_associate1",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityExpected": 10,
        "quantityReceived": 10,
        "quantityDamaged": 0,
        "previousToOnHand": 3,
        "newToOnHand": 13
      }
    ],
    "receivedAt": "2025-12-30T09:00:00Z",
    "discrepancyNotes": null
  }
}

18. StockRestocked

Trigger: Return item restocked Producer: Return Service Consumers: Inventory, Reporting

{
  "eventType": "StockRestocked",
  "eventId": "evt_inv_009",
  "timestamp": "2025-12-29T15:05:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ret_abc123",
  "payload": {
    "restockId": "restock_001",
    "returnId": "ret_abc123",
    "originalOrderId": "ord_xyz789",
    "locationId": "loc_hm",
    "items": [
      {
        "variantId": "var_nxp0323_m_blk",
        "sku": "NXP0323-M-BLK",
        "quantityRestocked": 1,
        "condition": "resaleable",
        "restockLocation": "sales_floor",
        "previousOnHand": 20,
        "newOnHand": 21
      }
    ],
    "restockedBy": "usr_cashier2"
  }
}

Customer Events

19. CustomerCreated

Trigger: New customer registered Producer: Customer Service Consumers: Loyalty, Marketing, Analytics

{
  "eventType": "CustomerCreated",
  "eventId": "evt_cust_001",
  "timestamp": "2025-12-29T14:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "cust_john_doe",
  "payload": {
    "customerId": "cust_john_doe",
    "customerNumber": "CUST-2025-00001",
    "createdAt": "2025-12-29T14:00:00Z",
    "createdBy": "usr_cashier1",
    "creationSource": "pos",
    "locationId": "loc_gm",
    "profile": {
      "firstName": "John",
      "lastName": "Doe",
      "email": "john.doe@example.com",
      "phone": "555-0100",
      "marketingOptIn": true,
      "smsOptIn": false
    },
    "loyalty": {
      "enrolled": true,
      "programId": "loyalty_standard",
      "tierLevel": "bronze",
      "pointsBalance": 0
    }
  }
}

20. CustomerUpdated

Trigger: Profile modified Producer: Customer Service Consumers: Sync, Marketing, Analytics

{
  "eventType": "CustomerUpdated",
  "eventId": "evt_cust_002",
  "timestamp": "2025-12-29T15:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "cust_john_doe",
  "payload": {
    "customerId": "cust_john_doe",
    "updatedBy": "usr_cashier2",
    "updateSource": "pos",
    "locationId": "loc_hm",
    "changes": [
      {
        "field": "phone",
        "previousValue": "555-0100",
        "newValue": "555-0200",
        "changedAt": "2025-12-29T15:30:00Z"
      },
      {
        "field": "address.city",
        "previousValue": null,
        "newValue": "Chesapeake",
        "changedAt": "2025-12-29T15:30:00Z"
      }
    ]
  }
}

21. CustomerMerged

Trigger: Duplicates consolidated Producer: Customer Service Consumers: Order, Loyalty, Analytics

{
  "eventType": "CustomerMerged",
  "eventId": "evt_cust_003",
  "timestamp": "2025-12-29T16:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "merge_001",
  "payload": {
    "mergeId": "merge_001",
    "survivingCustomerId": "cust_john_doe",
    "mergedCustomerIds": ["cust_john_d", "cust_jdoe"],
    "mergedBy": "usr_admin1",
    "mergeReason": "duplicate_registration",
    "dataConsolidation": {
      "ordersTransferred": 5,
      "loyaltyPointsCombined": 1500,
      "previousTierLevels": ["bronze", "silver"],
      "newTierLevel": "silver"
    },
    "conflictResolutions": [
      {
        "field": "email",
        "values": ["john.doe@example.com", "jdoe@work.com"],
        "resolution": "kept_primary",
        "selectedValue": "john.doe@example.com"
      }
    ]
  }
}

22. LoyaltyPointsEarned

Trigger: Purchase completed Producer: Loyalty Service Consumers: Customer, Notifications, Analytics

{
  "eventType": "LoyaltyPointsEarned",
  "eventId": "evt_cust_004",
  "timestamp": "2025-12-29T14:31:30Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "customerId": "cust_john_doe",
    "orderId": "ord_xyz789",
    "receiptNumber": "GM-2025-001234",
    "locationId": "loc_gm",
    "pointsEarned": 65,
    "earnRate": 1.0,
    "bonusMultiplier": 1.0,
    "qualifyingAmount": 64.78,
    "excludedAmount": 0,
    "previousBalance": 1250,
    "newBalance": 1315,
    "tierLevel": "silver",
    "pointsToNextTier": 685
  }
}

23. LoyaltyPointsRedeemed

Trigger: Points used for discount Producer: Loyalty Service Consumers: Order, Analytics

{
  "eventType": "LoyaltyPointsRedeemed",
  "eventId": "evt_cust_005",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "customerId": "cust_john_doe",
    "orderId": "ord_xyz789",
    "locationId": "loc_gm",
    "pointsRedeemed": 500,
    "redemptionType": "discount",
    "discountAmount": 5.00,
    "redemptionRate": 100,
    "previousBalance": 1750,
    "newBalance": 1250,
    "minimumBalanceRequired": 100
  }
}

24. LoyaltyPointsDeducted

Trigger: Return processed Producer: Loyalty Service Consumers: Customer, Notifications

{
  "eventType": "LoyaltyPointsDeducted",
  "eventId": "evt_cust_006",
  "timestamp": "2025-12-29T15:05:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ret_abc123",
  "payload": {
    "customerId": "cust_john_doe",
    "returnId": "ret_abc123",
    "originalOrderId": "ord_xyz789",
    "pointsDeducted": 32,
    "deductionReason": "return",
    "refundAmount": 32.39,
    "previousBalance": 1315,
    "newBalance": 1283,
    "tierImpact": "none"
  }
}

25. LoyaltyTierChanged

Trigger: Threshold reached Producer: Loyalty Service Consumers: Customer, Marketing, Notifications

{
  "eventType": "LoyaltyTierChanged",
  "eventId": "evt_cust_007",
  "timestamp": "2025-12-29T14:31:30Z",
  "tenantId": "tenant_nexus",
  "correlationId": "cust_john_doe",
  "payload": {
    "customerId": "cust_john_doe",
    "previousTier": "silver",
    "newTier": "gold",
    "changeType": "upgrade",
    "changeReason": "spending_threshold",
    "qualifyingSpend": 2000.00,
    "tierThreshold": 2000.00,
    "effectiveDate": "2025-12-29",
    "expirationDate": "2026-12-29",
    "newBenefits": [
      "1.5x points on all purchases",
      "Free shipping on orders $50+",
      "Early access to sales",
      "Birthday triple points"
    ]
  }
}

26. CustomerTagged

Trigger: Tag applied Producer: Marketing Service Consumers: Marketing Automation, Analytics

{
  "eventType": "CustomerTagged",
  "eventId": "evt_cust_008",
  "timestamp": "2025-12-29T14:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "cust_john_doe",
  "payload": {
    "customerId": "cust_john_doe",
    "tagId": "tag_vip_2025",
    "tagName": "VIP 2025",
    "tagCategory": "loyalty",
    "taggedBy": "system",
    "tagSource": "auto_rule",
    "ruleId": "rule_vip_qualification",
    "expiresAt": "2025-12-31T23:59:59Z",
    "metadata": {
      "qualificationReason": "annual_spend_over_5000"
    }
  }
}

27. CustomerOptInChanged

Trigger: Preference changed Producer: Customer Service Consumers: Marketing, Compliance

{
  "eventType": "CustomerOptInChanged",
  "eventId": "evt_cust_009",
  "timestamp": "2025-12-29T14:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "cust_john_doe",
  "payload": {
    "customerId": "cust_john_doe",
    "optInType": "sms_marketing",
    "previousValue": false,
    "newValue": true,
    "changedBy": "cust_john_doe",
    "changeSource": "self_service",
    "ipAddress": "192.168.1.100",
    "consentTimestamp": "2025-12-29T14:00:00Z",
    "consentMethod": "checkbox",
    "consentText": "I agree to receive promotional SMS messages"
  }
}

Gift Card Events

28. GiftCardPurchased

Trigger: Gift card sold Producer: Gift Card Service Consumers: Customer, Financial, Analytics

{
  "eventType": "GiftCardPurchased",
  "eventId": "evt_gc_001",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "gc_001",
  "payload": {
    "giftCardId": "gc_001",
    "cardNumber": "6012XXXXXXXXXXXX1234",
    "purchasedBy": "cust_john_doe",
    "recipientEmail": "jane@example.com",
    "recipientName": "Jane Doe",
    "orderId": "ord_gc001",
    "locationId": "loc_gm",
    "initialBalance": 50.00,
    "purchaseAmount": 50.00,
    "cardType": "digital",
    "deliveryMethod": "email",
    "activationDate": "2025-12-29",
    "expirationDate": null,
    "personalMessage": "Happy Birthday!"
  }
}

29. GiftCardRedeemed

Trigger: Card used as payment Producer: Gift Card Service Consumers: Order, Financial

{
  "eventType": "GiftCardRedeemed",
  "eventId": "evt_gc_002",
  "timestamp": "2025-12-29T15:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz790",
  "payload": {
    "giftCardId": "gc_001",
    "cardNumber": "6012XXXXXXXXXXXX1234",
    "redeemedBy": "cust_jane_doe",
    "orderId": "ord_xyz790",
    "locationId": "loc_hm",
    "amountRedeemed": 35.00,
    "previousBalance": 50.00,
    "newBalance": 15.00,
    "transactionType": "purchase"
  }
}

30. GiftCardBalanceChecked

Trigger: Balance inquiry Producer: Gift Card Service Consumers: Analytics

{
  "eventType": "GiftCardBalanceChecked",
  "eventId": "evt_gc_003",
  "timestamp": "2025-12-29T14:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "gc_001",
  "payload": {
    "giftCardId": "gc_001",
    "cardNumber": "6012XXXXXXXXXXXX1234",
    "currentBalance": 15.00,
    "checkedBy": null,
    "checkSource": "web",
    "locationId": null
  }
}

Employee Events

31. EmployeeClockedIn

Trigger: Employee starts shift Producer: Time Clock Service Consumers: Payroll, Reporting

{
  "eventType": "EmployeeClockedIn",
  "eventId": "evt_emp_001",
  "timestamp": "2025-12-29T08:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "tc_001",
  "payload": {
    "timeClockEntryId": "tc_001",
    "employeeId": "usr_cashier1",
    "locationId": "loc_gm",
    "registerId": "reg_01",
    "clockInTime": "2025-12-29T08:00:00Z",
    "clockInMethod": "pin",
    "scheduledStart": "2025-12-29T08:00:00Z",
    "minutesEarly": 0,
    "minutesLate": 0
  }
}

32. EmployeeClockedOut

Trigger: Employee ends shift Producer: Time Clock Service Consumers: Payroll, Reporting

{
  "eventType": "EmployeeClockedOut",
  "eventId": "evt_emp_002",
  "timestamp": "2025-12-29T17:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "tc_001",
  "payload": {
    "timeClockEntryId": "tc_001",
    "employeeId": "usr_cashier1",
    "locationId": "loc_gm",
    "clockOutTime": "2025-12-29T17:00:00Z",
    "clockOutMethod": "pin",
    "totalHoursWorked": 9.0,
    "breakMinutes": 30,
    "overtimeHours": 1.0,
    "scheduledEnd": "2025-12-29T16:00:00Z"
  }
}

33. BreakStarted

Trigger: Employee starts break Producer: Time Clock Service Consumers: Floor Coverage

{
  "eventType": "BreakStarted",
  "eventId": "evt_emp_003",
  "timestamp": "2025-12-29T12:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "tc_001",
  "payload": {
    "timeClockEntryId": "tc_001",
    "employeeId": "usr_cashier1",
    "locationId": "loc_gm",
    "breakType": "lunch",
    "breakStartTime": "2025-12-29T12:00:00Z",
    "expectedDuration": 30
  }
}

34. BreakEnded

Trigger: Employee returns from break Producer: Time Clock Service Consumers: Floor Coverage

{
  "eventType": "BreakEnded",
  "eventId": "evt_emp_004",
  "timestamp": "2025-12-29T12:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "tc_001",
  "payload": {
    "timeClockEntryId": "tc_001",
    "employeeId": "usr_cashier1",
    "locationId": "loc_gm",
    "breakType": "lunch",
    "breakEndTime": "2025-12-29T12:30:00Z",
    "actualDuration": 30,
    "overBreak": false
  }
}

Cash Management Events

35. ShiftOpened

Trigger: Manager opens cash drawer Producer: Cash Management Service Consumers: Reporting, Audit

{
  "eventType": "ShiftOpened",
  "eventId": "evt_cash_001",
  "timestamp": "2025-12-29T08:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "shift_001",
  "payload": {
    "shiftId": "shift_001",
    "registerId": "reg_01",
    "locationId": "loc_gm",
    "openedBy": "usr_manager1",
    "openedAt": "2025-12-29T08:00:00Z",
    "openingFloat": 267.50,
    "floatBreakdown": {
      "bills_20": 5,
      "bills_10": 5,
      "bills_5": 10,
      "bills_1": 50,
      "quarters": 40,
      "dimes": 50,
      "nickels": 40,
      "pennies": 50
    },
    "countVariance": 0
  }
}

36. TillDropped

Trigger: Cash removed to safe Producer: Cash Management Service Consumers: Reporting, Audit

{
  "eventType": "TillDropped",
  "eventId": "evt_cash_002",
  "timestamp": "2025-12-29T14:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "shift_001",
  "payload": {
    "shiftId": "shift_001",
    "dropId": "drop_001",
    "registerId": "reg_01",
    "locationId": "loc_gm",
    "droppedBy": "usr_cashier1",
    "dropAmount": 200.00,
    "breakdown": {
      "bills_100": 2
    },
    "drawerBalanceBefore": 467.50,
    "drawerBalanceAfter": 267.50,
    "dropReason": "excess_cash",
    "dropSlipNumber": "DROP-2025-12-29-001"
  }
}

37. CashPickedUp

Trigger: Manager removes cash Producer: Cash Management Service Consumers: Reporting, Audit

{
  "eventType": "CashPickedUp",
  "eventId": "evt_cash_003",
  "timestamp": "2025-12-29T15:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "shift_001",
  "payload": {
    "shiftId": "shift_001",
    "pickupId": "pickup_001",
    "registerId": "reg_01",
    "locationId": "loc_gm",
    "performedBy": "usr_manager1",
    "witnessedBy": "usr_cashier1",
    "pickupAmount": 300.00,
    "pickupReason": "bank_deposit",
    "drawerBalanceBefore": 567.50,
    "drawerBalanceAfter": 267.50
  }
}

38. PaidOut

Trigger: Petty cash expense Producer: Cash Management Service Consumers: Reporting, AP, Audit

{
  "eventType": "PaidOut",
  "eventId": "evt_cash_004",
  "timestamp": "2025-12-29T11:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "shift_001",
  "payload": {
    "shiftId": "shift_001",
    "paidOutId": "paidout_001",
    "registerId": "reg_01",
    "locationId": "loc_gm",
    "performedBy": "usr_manager1",
    "amount": 25.00,
    "category": "office_supplies",
    "description": "Printer paper",
    "vendorName": "Office Depot",
    "receiptAttached": true,
    "drawerBalanceBefore": 292.50,
    "drawerBalanceAfter": 267.50
  }
}

39. ShiftClosed

Trigger: End of day close Producer: Cash Management Service Consumers: Reporting, Audit

{
  "eventType": "ShiftClosed",
  "eventId": "evt_cash_005",
  "timestamp": "2025-12-29T21:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "shift_001",
  "payload": {
    "shiftId": "shift_001",
    "registerId": "reg_01",
    "locationId": "loc_gm",
    "closedBy": "usr_manager1",
    "closedAt": "2025-12-29T21:00:00Z",
    "expectedCash": 725.50,
    "actualCash": 723.00,
    "variance": -2.50,
    "varianceSeverity": "notable",
    "closingBreakdown": {
      "bills_100": 2,
      "bills_50": 3,
      "bills_20": 15,
      "bills_10": 10,
      "bills_5": 20,
      "bills_1": 75,
      "quarters": 80,
      "dimes": 100
    },
    "summary": {
      "openingFloat": 267.50,
      "cashSales": 458.00,
      "cashReturns": -45.00,
      "paidOuts": -25.00,
      "paidIns": 0,
      "tillDrops": -200.00,
      "expectedClosing": 455.50
    }
  }
}

RFID Events

40. RfidTagPrinted

Trigger: Tag printed and encoded Producer: RFID Print Service Consumers: Tag Registry, Inventory

{
  "eventType": "RfidTagPrinted",
  "eventId": "evt_rfid_001",
  "timestamp": "2025-12-29T08:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "print_job_001",
  "payload": {
    "tagId": "tag_001",
    "epc": "30340123456789012345678901",
    "variantId": "var_nxp0323_m_blk",
    "sku": "NXP0323-M-BLK",
    "serialNumber": 1234567,
    "printJobId": "print_job_001",
    "printerId": "printer_zebra_01",
    "locationId": "loc_hq",
    "printedBy": "usr_warehouse1",
    "templateId": "tmpl_standard"
  }
}

41. RfidScanSessionStarted

Trigger: Inventory scan begins Producer: RFID Mobile App Consumers: Scan Session Service

{
  "eventType": "RfidScanSessionStarted",
  "eventId": "evt_rfid_002",
  "timestamp": "2025-12-29T10:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "scan_session_001",
  "payload": {
    "sessionId": "scan_session_001",
    "locationId": "loc_gm",
    "zoneId": "zone_sales_floor",
    "startedBy": "usr_associate1",
    "deviceId": "rfid_handheld_01",
    "sessionType": "cycle_count",
    "expectedSkuCount": 150
  }
}

42. RfidTagScanned

Trigger: Tag read during scan Producer: RFID Mobile App Consumers: Real-time Dashboard

{
  "eventType": "RfidTagScanned",
  "eventId": "evt_rfid_003",
  "timestamp": "2025-12-29T10:05:23Z",
  "tenantId": "tenant_nexus",
  "correlationId": "scan_session_001",
  "payload": {
    "scanEventId": "scan_evt_001",
    "sessionId": "scan_session_001",
    "tagId": "tag_001",
    "epc": "30340123456789012345678901",
    "rssi": -45,
    "antennaId": 1,
    "readCount": 3,
    "firstSeenAt": "2025-12-29T10:05:23Z",
    "lastSeenAt": "2025-12-29T10:05:25Z"
  }
}

43. RfidScanSessionCompleted

Trigger: Scan session ends Producer: RFID Mobile App Consumers: Inventory, Variance Report

{
  "eventType": "RfidScanSessionCompleted",
  "eventId": "evt_rfid_004",
  "timestamp": "2025-12-29T10:30:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "scan_session_001",
  "payload": {
    "sessionId": "scan_session_001",
    "locationId": "loc_gm",
    "completedBy": "usr_associate1",
    "duration": 1800,
    "summary": {
      "totalTagsScanned": 145,
      "uniqueSkusFound": 142,
      "expectedSkus": 150,
      "varianceCount": 8,
      "missingSkus": ["NXP0323-M-BLK", "NXP0324-L-WHT"],
      "extraSkus": []
    },
    "variancePercentage": 5.33,
    "requiresRecount": false,
    "autoAdjust": false
  }
}

44. RfidTagStatusChanged

Trigger: Tag lifecycle change Producer: Various Services Consumers: Tag Registry, Analytics

{
  "eventType": "RfidTagStatusChanged",
  "eventId": "evt_rfid_005",
  "timestamp": "2025-12-29T14:31:30Z",
  "tenantId": "tenant_nexus",
  "correlationId": "ord_xyz789",
  "payload": {
    "tagId": "tag_001",
    "epc": "30340123456789012345678901",
    "previousStatus": "active",
    "newStatus": "sold",
    "triggeredBy": "sale",
    "referenceId": "ord_xyz789",
    "referenceType": "order",
    "locationId": "loc_gm",
    "changedAt": "2025-12-29T14:31:30Z"
  }
}

Sync Events

45. SyncConflictDetected

Trigger: Offline sync conflict Producer: Sync Service Consumers: Conflict Resolution, Admin Dashboard

{
  "eventType": "SyncConflictDetected",
  "eventId": "evt_sync_001",
  "timestamp": "2025-12-29T12:00:00Z",
  "tenantId": "tenant_nexus",
  "correlationId": "sync_batch_001",
  "payload": {
    "conflictId": "conflict_001",
    "deviceId": "dev_pos_01",
    "conflictType": "inventory_quantity",
    "entityType": "inventory_level",
    "entityId": "invlvl_001",
    "variantId": "var_nxp0323_m_blk",
    "locationId": "loc_gm",
    "serverValue": {
      "quantity": 12,
      "lastUpdated": "2025-12-29T11:45:00Z"
    },
    "localValue": {
      "quantity": 15,
      "lastUpdated": "2025-12-29T10:30:00Z",
      "delta": -2
    },
    "resolution": {
      "method": "delta_merge",
      "resolvedValue": 10,
      "automated": true
    },
    "syncTimestamp": "2025-12-29T12:00:00Z"
  }
}

Event Summary by Domain

DomainEvent CountEvents
Sales9OrderCreated, PaymentAttempted, PaymentCompleted, PaymentFailed, OrderCompleted, OrderVoided, ReturnInitiated, ReturnCompleted, ReceiptRequested
Inventory9StockReserved, StockCommitted, StockReleased, StockReceived, StockAdjusted, TransferRequested, TransferShipped, TransferReceived, StockRestocked
Customer9CustomerCreated, CustomerUpdated, CustomerMerged, LoyaltyPointsEarned, LoyaltyPointsRedeemed, LoyaltyPointsDeducted, LoyaltyTierChanged, CustomerTagged, CustomerOptInChanged
Gift Card3GiftCardPurchased, GiftCardRedeemed, GiftCardBalanceChecked
Employee4EmployeeClockedIn, EmployeeClockedOut, BreakStarted, BreakEnded
Cash5ShiftOpened, TillDropped, CashPickedUp, PaidOut, ShiftClosed
RFID5RfidTagPrinted, RfidScanSessionStarted, RfidTagScanned, RfidScanSessionCompleted, RfidTagStatusChanged
Sync1SyncConflictDetected
TOTAL45

This catalog documents all 45+ domain events that power the POS Platform’s event-driven architecture.

Appendix D: UI Mockups

Version: 1.0.0 Last Updated: December 29, 2025


Overview

This appendix contains complete ASCII wireframe mockups for all screens in the POS Platform. These serve as the definitive visual reference for implementation.


POS Client Application

1. Login Screen

+==============================================================================+
|                                                                              |
|                                                                              |
|                        ╔══════════════════════════════╗                      |
|                        ║                              ║                      |
|                        ║       NEXUS CLOTHING         ║                      |
|                        ║         POINT OF SALE        ║                      |
|                        ║                              ║                      |
|                        ╚══════════════════════════════╝                      |
|                                                                              |
|                                                                              |
|                        +------------------------------+                      |
|                        |  Employee ID or Email        |                      |
|                        |  [________________________]  |                      |
|                        +------------------------------+                      |
|                                                                              |
|                        +------------------------------+                      |
|                        |  PIN                         |                      |
|                        |  [****                    ]  |                      |
|                        +------------------------------+                      |
|                                                                              |
|                                                                              |
|                        +------------------------------+                      |
|                        |                              |                      |
|                        |        [  SIGN IN  ]         |                      |
|                        |                              |                      |
|                        +------------------------------+                      |
|                                                                              |
|                                                                              |
|                           Forgot PIN? Contact Manager                        |
|                                                                              |
|                                                                              |
|  +-----------+                                           +-----------+       |
|  | OFFLINE   |                                           | v2.1.0    |       |
|  +-----------+                                           +-----------+       |
|                                                                              |
+==============================================================================+
                        Store: Greenbrier Mall (GM)

2. Main Sale Screen

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 2:30 PM  |
+==============================================================================+
|                                                                              |
|  +----------------------------------+  +----------------------------------+  |
|  |  SEARCH / SCAN                   |  |  CURRENT SALE          #0001234 |  |
|  |  [SKU, Barcode, or Product... ] |  |                                  |  |
|  +----------------------------------+  |  +------------------------------+|  |
|                                        |  | Item              Qty   Price ||  |
|  +----------------------------------+  |  +------------------------------+|  |
|  |  QUICK CATEGORIES                |  |  | Classic V-Neck Tee    2 $59.98||  |
|  |                                  |  |  |   NXP0323-M-BLK              ||  |
|  |  +--------+ +--------+ +--------+|  |  |   [-] [2] [+]    [X Remove]  ||  |
|  |  | SHIRTS | | PANTS  | | ACCESS ||  |  +------------------------------+|  |
|  |  +--------+ +--------+ +--------+|  |  | Slim Fit Chinos       1 $79.99||  |
|  |                                  |  |  |   NXP0456-32-KHK             ||  |
|  |  +--------+ +--------+ +--------+|  |  |   [-] [1] [+]    [X Remove]  ||  |
|  |  | SHOES  | |  SALE  | |  NEW   ||  |  +------------------------------+|  |
|  |  +--------+ +--------+ +--------+|  |  |                              ||  |
|  |                                  |  |  |                              ||  |
|  +----------------------------------+  |  |                              ||  |
|                                        |  |                              ||  |
|  +----------------------------------+  |  +------------------------------+|  |
|  |  RECENT ITEMS                    |  |                                  |  |
|  |                                  |  |  +------------------------------+|  |
|  |  +------+ +------+ +------+      |  |  | Subtotal:            $139.97 ||  |
|  |  |      | |      | |      |      |  |  | Discount (10%):       -$14.00||  |
|  |  | Tee  | | Polo | |Chinos|      |  |  | Tax (6%):              $7.56 ||  |
|  |  |$29.99| |$44.99| |$79.99|      |  |  +------------------------------+|  |
|  |  +------+ +------+ +------+      |  |  |                              ||  |
|  |                                  |  |  | TOTAL:               $133.53 ||  |
|  |  +------+ +------+ +------+      |  |  |                              ||  |
|  |  |      | |      | |      |      |  |  +------------------------------+|  |
|  |  | Belt | | Socks| | Hat  |      |  |                                  |  |
|  |  |$34.99| |$12.99| |$24.99|      |  +----------------------------------+  |
|  |  +------+ +------+ +------+      |                                        |
|  |                                  |  +----------------------------------+  |
|  +----------------------------------+  |                                  |  |
|                                        |  [ CUSTOMER ]  [ DISCOUNT ]      |  |
|  +----------------------------------+  |                                  |  |
|  |  FUNCTIONS                       |  |  [       PAY $133.53       ]    |  |
|  |                                  |  |                                  |  |
|  |  [Returns] [Hold] [Gift Card]    |  |  [ VOID SALE ]   [ HOLD SALE ]  |  |
|  |  [No Sale] [Time] [Manager]      |  |                                  |  |
|  +----------------------------------+  +----------------------------------+  |
|                                                                              |
+==============================================================================+
|  Status: ONLINE    |    Shift: AM    |    Drawer: Open    |    Sales: 23   |
+==============================================================================+

3. Payment Screen

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 2:31 PM  |
+==============================================================================+
|                                                                              |
|  +----------------------------------+  +----------------------------------+  |
|  |  PAYMENT                         |  |  ORDER SUMMARY        #0001234  |  |
|  |                                  |  |                                  |  |
|  |  Amount Due:         $133.53     |  |  Items: 3                        |  |
|  |                                  |  |  Subtotal: $139.97               |  |
|  |  +------------------------------+|  |  Discount: -$14.00               |  |
|  |  |                              ||  |  Tax: $7.56                      |  |
|  |  |   PAYMENT METHOD             ||  |                                  |  |
|  |  |                              ||  |  -------------------------------- |  |
|  |  |  +--------+  +--------+      ||  |  TOTAL: $133.53                  |  |
|  |  |  |        |  |        |      ||  |                                  |  |
|  |  |  |  CARD  |  |  CASH  |      ||  |  -------------------------------- |  |
|  |  |  |        |  |        |      ||  |                                  |  |
|  |  |  +--------+  +--------+      ||  |  Customer: John Doe              |  |
|  |  |                              ||  |  Loyalty: Gold (1,250 pts)       |  |
|  |  |  +--------+  +--------+      ||  |                                  |  |
|  |  |  |        |  |        |      ||  |  Points to earn: 134             |  |
|  |  |  |  GIFT  |  | SPLIT  |      ||  |                                  |  |
|  |  |  |  CARD  |  |        |      ||  +----------------------------------+  |
|  |  |  +--------+  +--------+      ||                                        |
|  |  |                              ||  +----------------------------------+  |
|  |  +------------------------------+|  |  PAYMENTS APPLIED                |  |
|  |                                  |  |                                  |  |
|  |  +------------------------------+|  |  +----------------------------+  |  |
|  |  |  CARD SELECTED               ||  |  | Visa ****4242      $133.53 |  |  |
|  |  |                              ||  |  +----------------------------+  |  |
|  |  |  Present, insert, or tap     ||  |                                  |  |
|  |  |  card on terminal            ||  |  Balance Due:           $0.00   |  |
|  |  |                              ||  |                                  |  |
|  |  |    +------------------+      ||  +----------------------------------+  |
|  |  |    |                  |      ||                                        |
|  |  |    |   [PROCESSING]   |      ||                                        |
|  |  |    |                  |      ||                                        |
|  |  |    +------------------+      ||                                        |
|  |  |                              ||                                        |
|  |  +------------------------------+|                                        |
|  |                                  |                                        |
|  |  [  CANCEL  ]                    |                                        |
|  +----------------------------------+                                        |
|                                                                              |
+==============================================================================+

4. Receipt Screen

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 2:32 PM  |
+==============================================================================+
|                                                                              |
|                        +----------------------------------+                  |
|                        |                                  |                  |
|                        |       TRANSACTION COMPLETE       |                  |
|                        |                                  |                  |
|                        |    +-----------------------+     |                  |
|                        |    |                       |     |                  |
|                        |    |   NEXUS CLOTHING      |     |                  |
|                        |    |   Greenbrier Mall     |     |                  |
|                        |    |   1401 Greenbrier Pkwy|     |                  |
|                        |    |   Chesapeake, VA 23320|     |                  |
|                        |    |   (757) 555-0100      |     |                  |
|                        |    |                       |     |                  |
|                        |    |   12/29/25  2:32 PM   |     |                  |
|                        |    |   Receipt: GM-001234  |     |                  |
|                        |    |   Cashier: John D.    |     |                  |
|                        |    |                       |     |                  |
|                        |    |   Classic V-Neck  x2  |     |                  |
|                        |    |              $59.98   |     |                  |
|                        |    |   Slim Fit Chinos x1  |     |                  |
|                        |    |              $79.99   |     |                  |
|                        |    |                       |     |                  |
|                        |    |   Subtotal:  $139.97  |     |                  |
|                        |    |   Discount:  -$14.00  |     |                  |
|                        |    |   Tax:         $7.56  |     |                  |
|                        |    |   -----------------   |     |                  |
|                        |    |   TOTAL:     $133.53  |     |                  |
|                        |    |                       |     |                  |
|                        |    |   Visa ****4242       |     |                  |
|                        |    |   Auth: 123456        |     |                  |
|                        |    |                       |     |                  |
|                        |    |   Loyalty: +134 pts   |     |                  |
|                        |    |   Balance: 1,384 pts  |     |                  |
|                        |    |                       |     |                  |
|                        |    +-----------------------+     |                  |
|                        |                                  |                  |
|                        |   RECEIPT OPTIONS                |                  |
|                        |                                  |                  |
|                        |   [ PRINT ]  [ EMAIL ]  [ SMS ]  |                  |
|                        |                                  |                  |
|                        |   [ NO RECEIPT - NEW SALE ]      |                  |
|                        |                                  |                  |
|                        +----------------------------------+                  |
|                                                                              |
+==============================================================================+

5. Customer Lookup

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 2:25 PM  |
+==============================================================================+
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  CUSTOMER LOOKUP                                              [ X ]   |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  Search: [ john doe                                              ] [GO]|  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  SEARCH RESULTS (3 found)                                              |  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [*] John Doe                                                       ||  |
|  |  |     john.doe@example.com | (555) 555-0100                          ||  |
|  |  |     Gold Member | 1,250 points | Last visit: 12/15/25              ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [ ] John Doe Jr                                                    ||  |
|  |  |     johnjr@example.com | (555) 555-0101                            ||  |
|  |  |     Bronze Member | 250 points | Last visit: 11/20/25              ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [ ] Johnny Doeson                                                  ||  |
|  |  |     johnny.d@example.com | (555) 555-0102                          ||  |
|  |  |     Silver Member | 850 points | Last visit: 12/01/25              ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  SELECTED CUSTOMER DETAILS                                             |  |
|  |                                                                        |  |
|  |  Name: John Doe                     Tier: Gold                         |  |
|  |  Email: john.doe@example.com        Points: 1,250                      |  |
|  |  Phone: (555) 555-0100              Lifetime Spend: $2,450.00          |  |
|  |                                                                        |  |
|  |  Recent Purchases:                                                     |  |
|  |  - 12/15/25: $89.99 (Jacket)                                          |  |
|  |  - 12/01/25: $45.00 (Shirts x2)                                       |  |
|  |  - 11/20/25: $120.00 (Pants, Belt)                                    |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  [ CREATE NEW ]              [ SELECT CUSTOMER ]           [ CANCEL ]       |
|                                                                              |
+==============================================================================+

6. Returns Screen

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 3:00 PM  |
+==============================================================================+
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  PROCESS RETURN                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +----------------------------------+  +----------------------------------+  |
|  |  FIND ORIGINAL RECEIPT           |  |  RECEIPT DETAILS                 |  |
|  |                                  |  |                                  |  |
|  |  Receipt #: [GM-001200      ]    |  |  Receipt: GM-001200              |  |
|  |     - or -                       |  |  Date: 12/20/2025                |  |
|  |  Scan item barcode               |  |  Location: Greenbrier Mall       |  |
|  |     - or -                       |  |  Cashier: Jane S.                |  |
|  |  Customer lookup                 |  |                                  |  |
|  |                                  |  |  Customer: John Doe              |  |
|  |  [  SEARCH  ]                    |  |  Payment: Visa ****4242          |  |
|  |                                  |  |                                  |  |
|  +----------------------------------+  +----------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  ITEMS FROM RECEIPT                                      Select items  |  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [X] Classic V-Neck Tee - M Black         Qty: 1/2      $29.99      ||  |
|  |  |     NXP0323-M-BLK                                                  ||  |
|  |  |     Reason: [ Wrong Size        v ]  Condition: [ Resaleable   v ] ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [ ] Classic V-Neck Tee - M Black         Qty: 0/1      $29.99      ||  |
|  |  |     NXP0323-M-BLK                                                  ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  |  +--------------------------------------------------------------------+|  |
|  |  | [ ] Slim Fit Chinos - 32 Khaki           Qty: 0/1      $79.99      ||  |
|  |  |     NXP0456-32-KHK                                                 ||  |
|  |  +--------------------------------------------------------------------+|  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  RETURN SUMMARY                                                        |  |
|  |                                                                        |  |
|  |  Items to Return: 1                                                    |  |
|  |  Refund Amount: $29.99 + $1.80 tax = $31.79                           |  |
|  |  Refund Method: Original Payment (Visa ****4242)                       |  |
|  |  Loyalty Points to Deduct: 30                                          |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  [ CANCEL ]                      [ PROCESS RETURN $31.79 ]                  |
|                                                                              |
+==============================================================================+

7. Inventory Lookup

+==============================================================================+
|  NEXUS POS    Greenbrier Mall    Register 1    John D.    12/29/25 2:45 PM  |
+==============================================================================+
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  INVENTORY LOOKUP                                              [ X ]   |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  Search: [ NXP0323                                              ] [GO] |  |
|  |                                                                        |  |
|  |  Filters: [All Categories v] [All Sizes v] [All Colors v] [In Stock v]|  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  PRODUCT: Classic V-Neck Tee                              SKU: NXP0323 |  |
|  |                                                                        |  |
|  |  Price: $29.99                    Category: Shirts > T-Shirts          |  |
|  |  Vendor: ABC Apparel              Last Received: 12/15/2025            |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  INVENTORY BY LOCATION                                                 |  |
|  |                                                                        |  |
|  |  +---------+---------+---------+---------+---------+---------+         |  |
|  |  |  Size   |   HQ    |   GM    |   HM    |   LM    |   NM    |  TOTAL  |  |
|  |  +---------+---------+---------+---------+---------+---------+         |  |
|  |  | S-BLK   |   25    |   [8]   |    5    |    3    |    7    |    48   |  |
|  |  | M-BLK   |   30    |  [12]   |    8    |    6    |    4    |    60   |  |
|  |  | L-BLK   |   20    |   [5]   |    7    |    4    |    9    |    45   |  |
|  |  | XL-BLK  |   15    |   [3]   |    2    |    1    |    3    |    24   |  |
|  |  +---------+---------+---------+---------+---------+---------+         |  |
|  |  | S-WHT   |   20    |   [6]   |    4    |    5    |    5    |    40   |  |
|  |  | M-WHT   |   25    |  [10]   |    6    |    4    |    5    |    50   |  |
|  |  | L-WHT   |   18    |   [4]   |    5    |    3    |    6    |    36   |  |
|  |  | XL-WHT  |   12    |   [2]   |    1    |    2    |    2    |    19   |  |
|  |  +---------+---------+---------+---------+---------+---------+         |  |
|  |                                                                        |  |
|  |  [Your Store] highlighted                                              |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  ACTIONS                                                               |  |
|  |                                                                        |  |
|  |  [ REQUEST TRANSFER ]     [ ADD TO SALE ]     [ VIEW HISTORY ]         |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
+==============================================================================+

Admin Portal

8. Admin Dashboard

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  +------+                                                                    |
|  | HOME |  Dashboard  |  Inventory  |  Products  |  Employees  |  Reports  |
+==============================================================================+
|                                                                              |
|  TODAY'S PERFORMANCE                                       December 29, 2025 |
|                                                                              |
|  +-------------------+  +-------------------+  +-------------------+         |
|  |   TOTAL SALES     |  |   TRANSACTIONS    |  |   AVG TICKET      |         |
|  |                   |  |                   |  |                   |         |
|  |     $12,450       |  |       185         |  |     $67.30        |         |
|  |                   |  |                   |  |                   |         |
|  |   +15% vs LY      |  |   +8% vs LY       |  |   +6% vs LY       |         |
|  +-------------------+  +-------------------+  +-------------------+         |
|                                                                              |
|  +-------------------+  +-------------------+  +-------------------+         |
|  |   RETURNS         |  |   ITEMS SOLD      |  |   CUSTOMERS       |         |
|  |                   |  |                   |  |                   |         |
|  |     $450          |  |       425         |  |       142         |         |
|  |                   |  |                   |  |                   |         |
|  |   3.6% of sales   |  |   2.3 per txn     |  |   28 new today    |         |
|  +-------------------+  +-------------------+  +-------------------+         |
|                                                                              |
|  +------------------------------------+  +-------------------------------+   |
|  |  SALES BY LOCATION                 |  |  HOURLY SALES TODAY           |   |
|  |                                    |  |                               |   |
|  |  GM  $$$$$$$$$$$$$  $4,250 (34%)   |  |  $1.5k +                      |   |
|  |  HM  $$$$$$$$       $3,100 (25%)   |  |       |   ___                 |   |
|  |  LM  $$$$$$$        $2,800 (22%)   |  |  $1k  +  /   \___             |   |
|  |  NM  $$$$$          $2,300 (19%)   |  |       | /        \___         |   |
|  |                                    |  |  $500 +/             \        |   |
|  |  Total: $12,450                    |  |       +--+--+--+--+--+--+--+  |   |
|  |                                    |  |       9  10 11 12 1  2  3    |   |
|  +------------------------------------+  +-------------------------------+   |
|                                                                              |
|  +------------------------------------+  +-------------------------------+   |
|  |  TOP SELLING ITEMS                 |  |  ALERTS & NOTIFICATIONS       |   |
|  |                                    |  |                               |   |
|  |  1. Classic V-Neck Tee      45 qty |  |  [!] Low stock: NXP0789 @ LM  |   |
|  |  2. Slim Fit Chinos         32 qty |  |  [!] Low stock: NXP0456 @ NM  |   |
|  |  3. Leather Belt            28 qty |  |  [i] Transfer received at GM  |   |
|  |  4. Cotton Polo             25 qty |  |  [i] New customer signup: 28  |   |
|  |  5. Casual Sneakers         22 qty |  |  [$] Variance alert: GM -$5   |   |
|  |                                    |  |                               |   |
|  +------------------------------------+  +-------------------------------+   |
|                                                                              |
+==============================================================================+

9. Inventory Management

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  Dashboard  | +----------+ |  Products  |  Employees  |  Reports  | Settings |
|             | |INVENTORY | |                                                 |
+==============================================================================+
|                                                                              |
|  INVENTORY MANAGEMENT                                                        |
|                                                                              |
|  +-----+----------+------------+-------------+----------+--------+           |
|  |Levels|Transfers| Adjustments|  Receiving  |  Counts  |  Alerts|           |
|  +-----+----------+------------+-------------+----------+--------+           |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  FILTER: Location [All Locations v]  Category [All     v]  Status [All v] |  |
|  |  Search: [                                                        ] [GO] |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  | [ ] |  SKU       |  Product Name           | HQ | GM | HM | LM | NM |TOT |  |
|  +------------------------------------------------------------------------+  |
|  | [ ] | NXP0323-S  | Classic V-Neck - S BLK  | 25 |  8 |  5 |  3 |  7 | 48 |  |
|  | [ ] | NXP0323-M  | Classic V-Neck - M BLK  | 30 | 12 |  8 |  6 |  4 | 60 |  |
|  | [X] | NXP0323-L  | Classic V-Neck - L BLK  | 20 |  5 |  7 |  4 |  9 | 45 |  |
|  | [ ] | NXP0323-XL | Classic V-Neck - XL BLK | 15 |  3 |  2 |  1 |  3 | 24 |  |
|  | [ ] | NXP0456-30 | Slim Fit Chinos - 30    | 18 |  6 |  4 |  5 |  3 | 36 |  |
|  | [ ] | NXP0456-32 | Slim Fit Chinos - 32    | 22 |  8 |  6 |  4 |  5 | 45 |  |
|  | [!] | NXP0789-M  | Cotton Polo - M         |  5 |  2 |  1 |  0 |  1 |  9 |  |
|  | [!] | NXP0789-L  | Cotton Polo - L         |  8 |  1 |  2 |  1 |  0 | 12 |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  Page 1 of 250                           [< Prev]  [1] [2] [3] ... [Next >]  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  BULK ACTIONS                        SELECTED: 1 item(s)               |  |
|  |                                                                        |  |
|  |  [ Create Transfer ]  [ Adjust Qty ]  [ Export CSV ]  [ Print Labels ] |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  Legend: [!] = Below reorder point                                           |
|                                                                              |
+==============================================================================+

10. Product Catalog

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  Dashboard  |  Inventory  | +--------+ |  Employees  |  Reports  | Settings |
|                           | |PRODUCTS| |                                     |
+==============================================================================+
|                                                                              |
|  PRODUCT CATALOG                                        [ + ADD PRODUCT ]   |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  Search: [                        ]  Category: [All Categories      v] |  |
|  |  Vendor: [All Vendors     v]         Status: [Active    v]             |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |        |                          |           |        |       |        |  |
|  | IMAGE  |  PRODUCT                 |   SKU     | PRICE  | STOCK | STATUS |  |
|  |        |                          |           |        |       |        |  |
|  +------------------------------------------------------------------------+  |
|  | +----+ |  Classic V-Neck Tee      |           |        |       |        |  |
|  | |    | |  8 variants              | NXP0323   | $29.99 |  322  | Active |  |
|  | +----+ |  Category: Shirts        |           |        |       | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  | +----+ |  Slim Fit Chinos         |           |        |       |        |  |
|  | |    | |  6 variants              | NXP0456   | $79.99 |  245  | Active |  |
|  | +----+ |  Category: Pants         |           |        |       | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  | +----+ |  Cotton Polo             |           |        |       |        |  |
|  | |    | |  4 variants              | NXP0789   | $44.99 |   42  | Active |  |
|  | +----+ |  Category: Shirts        |           |        |       | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  | +----+ |  Leather Belt            |           |        |       |        |  |
|  | |    | |  3 variants              | NXP0234   | $34.99 |  128  | Active |  |
|  | +----+ |  Category: Accessories   |           |        |       | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  | +----+ |  Winter Jacket           |           |        |       |        |  |
|  | |    | |  4 variants              | NXP0567   | $149.99|   18  | Draft  |  |
|  | +----+ |  Category: Outerwear     |           |        |       | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  Showing 1-5 of 1,250 products           [< Prev]  [1] [2] [3] ... [Next >] |
|                                                                              |
+==============================================================================+

11. Employee Management

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  Dashboard  |  Inventory  |  Products  | +---------+ |  Reports  | Settings |
|                                         | |EMPLOYEES| |                      |
+==============================================================================+
|                                                                              |
|  EMPLOYEE MANAGEMENT                                    [ + ADD EMPLOYEE ]  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  Search: [                        ]  Location: [All Locations      v]  |  |
|  |  Role: [All Roles   v]               Status: [Active    v]             |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  NAME           |  EMAIL                | ROLE      | LOCATION | STATUS |  |
|  +------------------------------------------------------------------------+  |
|  |  John Doe       |  john.d@nexus.com     | Admin     | All      | Active |  |
|  |                 |  Last login: Today 2:30 PM                   | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  |  Jane Smith     |  jane.s@nexus.com     | Manager   | GM       | Active |  |
|  |                 |  Last login: Today 8:15 AM                   | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  |  Mike Johnson   |  mike.j@nexus.com     | Cashier   | GM       | Active |  |
|  |                 |  Last login: Today 8:00 AM                   | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  |  Sarah Williams |  sarah.w@nexus.com    | Cashier   | HM       | Active |  |
|  |                 |  Last login: Yesterday 5:00 PM               | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|  |  Tom Brown      |  tom.b@nexus.com      | Cashier   | LM       |Inactive|  |
|  |                 |  Last login: 12/15/2025                      | [Edit] |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  CURRENTLY CLOCKED IN                                                  |  |
|  |                                                                        |  |
|  |  +----------+  +----------+  +----------+  +----------+               |  |
|  |  | GM: 3    |  | HM: 2    |  | LM: 2    |  | NM: 2    |               |  |
|  |  +----------+  +----------+  +----------+  +----------+               |  |
|  |                                                                        |  |
|  |  Jane S. (GM) - Since 8:15 AM    Sarah W. (HM) - Since 9:00 AM        |  |
|  |  Mike J. (GM) - Since 8:00 AM    Chris D. (HM) - Since 9:30 AM        |  |
|  |  Lisa M. (GM) - Since 10:00 AM                                         |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
+==============================================================================+

12. Reports Dashboard

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  Dashboard  |  Inventory  |  Products  |  Employees  | +-------+ | Settings |
|                                                       | |REPORTS| |          |
+==============================================================================+
|                                                                              |
|  REPORTS                                                                     |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  Date Range: [12/01/2025] to [12/29/2025]   Location: [All      v]    |  |
|  |                                              Compare: [Last Year v]    |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
|  +----------------------------+  +----------------------------+             |
|  |  SALES REPORTS             |  |  INVENTORY REPORTS         |             |
|  |                            |  |                            |             |
|  |  > Daily Sales Summary     |  |  > Current Stock Levels    |             |
|  |  > Sales by Category       |  |  > Stock Valuation         |             |
|  |  > Sales by Employee       |  |  > Inventory Movement      |             |
|  |  > Sales by Hour           |  |  > Reorder Report          |             |
|  |  > Sales by Payment Type   |  |  > Shrinkage Analysis      |             |
|  |  > Discount Analysis       |  |  > Dead Stock Report       |             |
|  |  > Refund Report           |  |  > Transfer History        |             |
|  |                            |  |                            |             |
|  +----------------------------+  +----------------------------+             |
|                                                                              |
|  +----------------------------+  +----------------------------+             |
|  |  CUSTOMER REPORTS          |  |  EMPLOYEE REPORTS          |             |
|  |                            |  |                            |             |
|  |  > Customer List           |  |  > Time Clock Report       |             |
|  |  > New Customers           |  |  > Sales by Employee       |             |
|  |  > Top Customers           |  |  > Commission Report       |             |
|  |  > Loyalty Points Summary  |  |  > Void/Return by Employee |             |
|  |  > Customer Retention      |  |  > Productivity Analysis   |             |
|  |  > Marketing Campaign      |  |                            |             |
|  |                            |  |                            |             |
|  +----------------------------+  +----------------------------+             |
|                                                                              |
|  +------------------------------------------------------------------------+  |
|  |  QUICK REPORT: Daily Sales Summary                   [ Generate ]      |  |
|  +------------------------------------------------------------------------+  |
|  |                                                                        |  |
|  |  +------------------------------------------------------------------+  |  |
|  |  |  DATE       |   TRANS  |   GROSS   |  DISCOUNT |   NET     | vs LY|  |  |
|  |  +------------------------------------------------------------------+  |  |
|  |  |  12/29/25   |    185   | $13,200   |   -$750   | $12,450   | +15% |  |  |
|  |  |  12/28/25   |    172   | $11,800   |   -$650   | $11,150   | +12% |  |  |
|  |  |  12/27/25   |    198   | $14,500   |   -$900   | $13,600   | +18% |  |  |
|  |  +------------------------------------------------------------------+  |  |
|  |                                                                        |  |
|  |  [ Export PDF ]    [ Export Excel ]    [ Email Report ]                |  |
|  |                                                                        |  |
|  +------------------------------------------------------------------------+  |
|                                                                              |
+==============================================================================+

13. Settings Page

+==============================================================================+
|  NEXUS ADMIN                                          John Doe | [Logout]   |
+------------------------------------------------------------------------------+
|  Dashboard  |  Inventory  |  Products  |  Employees  |  Reports  | +------+ |
|                                                                   ||SETTINGS||
+==============================================================================+
|                                                                              |
|  SETTINGS                                                                    |
|                                                                              |
|  +----------------+  +----------------------------------------------------+  |
|  |                |  |                                                    |  |
|  |  > General     |  |  GENERAL SETTINGS                                  |  |
|  |    Locations   |  |                                                    |  |
|  |    Registers   |  |  +------------------------------------------------+|  |
|  |    Tax         |  |  |  COMPANY INFORMATION                           ||  |
|  |                |  |  |                                                ||  |
|  |  > Sales       |  |  |  Company Name:  [ Nexus Clothing            ]  ||  |
|  |    Receipts    |  |  |  Address:       [ 1401 Greenbrier Pkwy      ]  ||  |
|  |    Discounts   |  |  |  City/State:    [ Chesapeake    ] [ VA   v ]  ||  |
|  |    Returns     |  |  |  ZIP:           [ 23320                     ]  ||  |
|  |                |  |  |  Phone:         [ (757) 555-0100            ]  ||  |
|  |  > Inventory   |  |  |  Email:         [ info@nexusclothing.com    ]  ||  |
|  |    Reorder     |  |  |                                                ||  |
|  |    Transfers   |  |  +------------------------------------------------+|  |
|  |    Counting    |  |                                                    |  |
|  |                |  |  +------------------------------------------------+|  |
|  |  > Customers   |  |  |  REGIONAL SETTINGS                             ||  |
|  |    Loyalty     |  |  |                                                ||  |
|  |    Marketing   |  |  |  Timezone:      [ America/New_York       v ]  ||  |
|  |                |  |  |  Currency:      [ USD - US Dollar        v ]  ||  |
|  |  > Payments    |  |  |  Date Format:   [ MM/DD/YYYY             v ]  ||  |
|  |    Terminals   |  |  |  Start of Week: [ Sunday                 v ]  ||  |
|  |    Gift Cards  |  |  |                                                ||  |
|  |                |  |  +------------------------------------------------+|  |
|  |  > Users       |  |                                                    |  |
|  |    Roles       |  |  +------------------------------------------------+|  |
|  |    Permissions |  |  |  TAX SETTINGS                                  ||  |
|  |                |  |  |                                                ||  |
|  |  > Integration |  |  |  Default Tax Rate:  [ 6.0         ] %          ||  |
|  |    Shopify     |  |  |  Tax Included in Price: [ ] Yes  [X] No        ||  |
|  |    QuickBooks  |  |  |                                                ||  |
|  |    API Keys    |  |  +------------------------------------------------+|  |
|  |                |  |                                                    |  |
|  +----------------+  |  [ SAVE CHANGES ]                    [ CANCEL ]    |  |
|                      |                                                    |  |
|                      +----------------------------------------------------+  |
|                                                                              |
+==============================================================================+

Mobile RFID App (Raptag)

14. Raptag Main Menu

+---------------------------+
|  [=]   RAPTAG   [?] [!]  |
+---------------------------+
|                           |
|  Store: Greenbrier Mall   |
|  User: John Doe           |
|                           |
+---------------------------+
|                           |
|  +---------------------+  |
|  |                     |  |
|  |    SCAN INVENTORY   |  |
|  |                     |  |
|  +---------------------+  |
|                           |
|  +---------------------+  |
|  |                     |  |
|  |   RECEIVE SHIPMENT  |  |
|  |                     |  |
|  +---------------------+  |
|                           |
|  +---------------------+  |
|  |                     |  |
|  |    FIND ITEM        |  |
|  |                     |  |
|  +---------------------+  |
|                           |
|  +---------------------+  |
|  |                     |  |
|  |    VIEW HISTORY     |  |
|  |                     |  |
|  +---------------------+  |
|                           |
+---------------------------+
|  [ONLINE]    v2.1.0      |
+---------------------------+

15. Raptag Scan Session

+---------------------------+
|  [<]  SCAN SESSION   [X] |
+---------------------------+
|                           |
|  Zone: Sales Floor        |
|  Started: 10:00 AM        |
|  Duration: 00:25:42       |
|                           |
+---------------------------+
|                           |
|  +---------------------+  |
|  |                     |  |
|  |   SCANNING...       |  |
|  |                     |  |
|  |   |||||||||||||||   |  |
|  |                     |  |
|  +---------------------+  |
|                           |
|  Tags Found: 145          |
|  Unique SKUs: 142         |
|  Expected: 150            |
|                           |
+---------------------------+
|                           |
|  LAST SCANNED:            |
|                           |
|  NXP0323-M-BLK            |
|  Classic V-Neck Tee       |
|  RSSI: -42 dB             |
|                           |
+---------------------------+
|                           |
|  [ PAUSE ]  [ COMPLETE ]  |
|                           |
+---------------------------+

16. Raptag Scan Results

+---------------------------+
|  [<]  SCAN RESULTS       |
+---------------------------+
|                           |
|  Session Complete         |
|  Duration: 00:32:15       |
|                           |
+---------------------------+
|                           |
|  +--------+  +--------+   |
|  | FOUND  |  |MISSING |   |
|  |  142   |  |   8    |   |
|  +--------+  +--------+   |
|                           |
|  Variance: 5.3%           |
|                           |
+---------------------------+
|                           |
|  MISSING ITEMS:           |
|                           |
|  [!] NXP0323-M-BLK (2)    |
|  [!] NXP0324-L-WHT (1)    |
|  [!] NXP0456-32-KHK (2)   |
|  [!] NXP0789-S-NAV (3)    |
|                           |
+---------------------------+
|                           |
|  [EXPORT]  [ADJUST INV]   |
|                           |
|  [ COMPLETE & SYNC ]      |
|                           |
+---------------------------+

Component Library Reference

Buttons

+-----------------------------------------------------------------------------+
|  BUTTON STYLES                                                              |
+-----------------------------------------------------------------------------+

  PRIMARY:       [  Button Text  ]     <- Blue background, white text
                 +----------------+

  SECONDARY:     [  Button Text  ]     <- Gray background, dark text
                 +----------------+

  DANGER:        [  Button Text  ]     <- Red background, white text
                 +----------------+

  SUCCESS:       [  Button Text  ]     <- Green background, white text
                 +----------------+

  OUTLINE:       [  Button Text  ]     <- Border only, no fill
                 +----------------+

  DISABLED:      [  Button Text  ]     <- Grayed out, no interaction
                 +----------------+

  SIZES:

  SMALL:    [ Sm ]

  MEDIUM:   [  Medium  ]

  LARGE:    [     Large     ]

Form Elements

+-----------------------------------------------------------------------------+
|  FORM ELEMENTS                                                              |
+-----------------------------------------------------------------------------+

  TEXT INPUT:
  +----------------------------+
  |  Label                     |
  |  [________________________]|
  |  Helper text goes here    |
  +----------------------------+

  SELECT:
  +----------------------------+
  |  Label                     |
  |  [ Selected Option     v ] |
  +----------------------------+

  CHECKBOX:
  [X] Checked option
  [ ] Unchecked option

  RADIO:
  (*) Selected option
  ( ) Unselected option

  TOGGLE:
  [ OFF |====] or [====| ON ]

  SEARCH:
  +-----------------------------------+
  |  [Q] Search...             [GO]  |
  +-----------------------------------+

  DATE PICKER:
  +----------------------------+
  |  [12/29/2025         [C]] |
  +----------------------------+

Status Indicators

+-----------------------------------------------------------------------------+
|  STATUS INDICATORS                                                          |
+-----------------------------------------------------------------------------+

  BADGES:

  [Active]     <- Green
  [Pending]    <- Yellow
  [Inactive]   <- Gray
  [Error]      <- Red
  [New]        <- Blue

  ALERTS:

  +-------------------------------------------+
  |  [i] Info: This is an informational alert |
  +-------------------------------------------+

  +-------------------------------------------+
  |  [!] Warning: This requires attention     |
  +-------------------------------------------+

  +-------------------------------------------+
  |  [X] Error: Something went wrong          |
  +-------------------------------------------+

  +-------------------------------------------+
  |  [*] Success: Operation completed         |
  +-------------------------------------------+

  PROGRESS:

  [====================          ] 65%

  Loading...  [====    ]

Data Display

+-----------------------------------------------------------------------------+
|  DATA DISPLAY                                                               |
+-----------------------------------------------------------------------------+

  TABLE:
  +--------+----------------+--------+--------+
  | Header | Header         | Header | Header |
  +--------+----------------+--------+--------+
  | Data   | Data           | Data   | Action |
  | Data   | Data           | Data   | Action |
  | Data   | Data           | Data   | Action |
  +--------+----------------+--------+--------+

  CARD:
  +----------------------------+
  |  CARD TITLE                |
  |                            |
  |  Card content goes here    |
  |  with supporting text.     |
  |                            |
  |  [ Action ]                |
  +----------------------------+

  STAT BOX:
  +-------------------+
  |   LABEL           |
  |   $12,450         |
  |   +15% vs LY      |
  +-------------------+

  LIST:
  +----------------------------+
  | > Item 1                   |
  | > Item 2                   |
  | > Item 3                   |
  +----------------------------+

These mockups provide the definitive visual reference for implementing the POS Platform user interface.

Appendix E: Code Templates

Version: 1.0.0 Last Updated: December 29, 2025 Language: C# (.NET 8.0)


Overview

This appendix contains copy-paste code templates for common patterns in the POS Platform. All templates follow the established architecture and coding standards.


Table of Contents

  1. Entity Template
  2. Repository Interface Template
  3. Repository Implementation Template
  4. Service Interface Template
  5. Service Implementation Template
  6. Controller Template
  7. DTO Templates
  8. Validator Template
  9. Event Handler Template
  10. Integration Test Template
  11. Unit Test Template
  12. Domain Event Template

1. Entity Template

// File: src/POS.Core/Entities/Product.cs

using System;
using System.Collections.Generic;

namespace POS.Core.Entities;

/// <summary>
/// Represents a product in the catalog.
/// </summary>
public class Product : BaseEntity, IAuditableEntity, ITenantEntity
{
    /// <summary>
    /// Gets or sets the tenant identifier.
    /// </summary>
    public Guid TenantId { get; set; }

    /// <summary>
    /// Gets or sets the SKU (Stock Keeping Unit).
    /// </summary>
    public required string Sku { get; set; }

    /// <summary>
    /// Gets or sets the product name.
    /// </summary>
    public required string Name { get; set; }

    /// <summary>
    /// Gets or sets the product description.
    /// </summary>
    public string? Description { get; set; }

    /// <summary>
    /// Gets or sets the category identifier.
    /// </summary>
    public Guid? CategoryId { get; set; }

    /// <summary>
    /// Gets or sets the vendor identifier.
    /// </summary>
    public Guid? VendorId { get; set; }

    /// <summary>
    /// Gets or sets the base price.
    /// </summary>
    public decimal BasePrice { get; set; }

    /// <summary>
    /// Gets or sets the cost price.
    /// </summary>
    public decimal Cost { get; set; }

    /// <summary>
    /// Gets or sets the product status.
    /// </summary>
    public ProductStatus Status { get; set; } = ProductStatus.Active;

    /// <summary>
    /// Gets or sets the Shopify product ID for integration.
    /// </summary>
    public string? ShopifyProductId { get; set; }

    // Navigation properties
    public virtual Category? Category { get; set; }
    public virtual Vendor? Vendor { get; set; }
    public virtual ICollection<ProductVariant> Variants { get; set; } = new List<ProductVariant>();
    public virtual ICollection<ProductImage> Images { get; set; } = new List<ProductImage>();

    // Audit properties
    public DateTime CreatedAt { get; set; }
    public Guid? CreatedBy { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public Guid? UpdatedBy { get; set; }
}

/// <summary>
/// Product status enumeration.
/// </summary>
public enum ProductStatus
{
    Draft,
    Active,
    Discontinued,
    Archived
}

2. Repository Interface Template

// File: src/POS.Core/Interfaces/Repositories/IProductRepository.cs

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using POS.Core.Entities;

namespace POS.Core.Interfaces.Repositories;

/// <summary>
/// Repository interface for Product entity operations.
/// </summary>
public interface IProductRepository : IRepository<Product>
{
    /// <summary>
    /// Gets a product by SKU.
    /// </summary>
    /// <param name="sku">The SKU to search for.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The product if found, null otherwise.</returns>
    Task<Product?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default);

    /// <summary>
    /// Gets products by category.
    /// </summary>
    /// <param name="categoryId">The category identifier.</param>
    /// <param name="includeVariants">Whether to include variants.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of products in the category.</returns>
    Task<IReadOnlyList<Product>> GetByCategoryAsync(
        Guid categoryId,
        bool includeVariants = false,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Gets products by vendor.
    /// </summary>
    /// <param name="vendorId">The vendor identifier.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of products from the vendor.</returns>
    Task<IReadOnlyList<Product>> GetByVendorAsync(
        Guid vendorId,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Searches products by name or SKU.
    /// </summary>
    /// <param name="searchTerm">The search term.</param>
    /// <param name="page">Page number (1-based).</param>
    /// <param name="pageSize">Items per page.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Paginated list of matching products.</returns>
    Task<PagedResult<Product>> SearchAsync(
        string searchTerm,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Checks if a SKU exists.
    /// </summary>
    /// <param name="sku">The SKU to check.</param>
    /// <param name="excludeProductId">Product ID to exclude from check.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>True if SKU exists, false otherwise.</returns>
    Task<bool> SkuExistsAsync(
        string sku,
        Guid? excludeProductId = null,
        CancellationToken cancellationToken = default);
}

3. Repository Implementation Template

// File: src/POS.Infrastructure/Repositories/ProductRepository.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using POS.Core.Entities;
using POS.Core.Interfaces.Repositories;
using POS.Infrastructure.Data;

namespace POS.Infrastructure.Repositories;

/// <summary>
/// Repository implementation for Product entity.
/// </summary>
public class ProductRepository : Repository<Product>, IProductRepository
{
    public ProductRepository(ApplicationDbContext context) : base(context)
    {
    }

    /// <inheritdoc />
    public async Task<Product?> GetBySkuAsync(
        string sku,
        CancellationToken cancellationToken = default)
    {
        return await _dbSet
            .Include(p => p.Variants)
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Sku == sku, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<Product>> GetByCategoryAsync(
        Guid categoryId,
        bool includeVariants = false,
        CancellationToken cancellationToken = default)
    {
        var query = _dbSet
            .Where(p => p.CategoryId == categoryId)
            .Where(p => p.Status == ProductStatus.Active);

        if (includeVariants)
        {
            query = query.Include(p => p.Variants);
        }

        return await query
            .OrderBy(p => p.Name)
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<Product>> GetByVendorAsync(
        Guid vendorId,
        CancellationToken cancellationToken = default)
    {
        return await _dbSet
            .Where(p => p.VendorId == vendorId)
            .Include(p => p.Variants)
            .OrderBy(p => p.Name)
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<PagedResult<Product>> SearchAsync(
        string searchTerm,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        var query = _dbSet
            .Where(p => p.Status == ProductStatus.Active)
            .Where(p => EF.Functions.ILike(p.Name, $"%{searchTerm}%") ||
                       EF.Functions.ILike(p.Sku, $"%{searchTerm}%"));

        var totalCount = await query.CountAsync(cancellationToken);

        var items = await query
            .Include(p => p.Variants)
            .OrderBy(p => p.Name)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return new PagedResult<Product>(items, totalCount, page, pageSize);
    }

    /// <inheritdoc />
    public async Task<bool> SkuExistsAsync(
        string sku,
        Guid? excludeProductId = null,
        CancellationToken cancellationToken = default)
    {
        var query = _dbSet.Where(p => p.Sku == sku);

        if (excludeProductId.HasValue)
        {
            query = query.Where(p => p.Id != excludeProductId.Value);
        }

        return await query.AnyAsync(cancellationToken);
    }
}

4. Service Interface Template

// File: src/POS.Core/Interfaces/Services/IProductService.cs

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using POS.Core.DTOs;

namespace POS.Core.Interfaces.Services;

/// <summary>
/// Service interface for product operations.
/// </summary>
public interface IProductService
{
    /// <summary>
    /// Gets a product by ID.
    /// </summary>
    Task<ProductDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);

    /// <summary>
    /// Gets a product by SKU.
    /// </summary>
    Task<ProductDto?> GetBySkuAsync(string sku, CancellationToken cancellationToken = default);

    /// <summary>
    /// Gets all products with optional filtering.
    /// </summary>
    Task<PagedResult<ProductDto>> GetAllAsync(
        ProductFilterDto filter,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Creates a new product.
    /// </summary>
    Task<ProductDto> CreateAsync(
        CreateProductDto dto,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates an existing product.
    /// </summary>
    Task<ProductDto> UpdateAsync(
        Guid id,
        UpdateProductDto dto,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Deletes a product (soft delete).
    /// </summary>
    Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);

    /// <summary>
    /// Adds a variant to a product.
    /// </summary>
    Task<ProductVariantDto> AddVariantAsync(
        Guid productId,
        CreateVariantDto dto,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Updates a product variant.
    /// </summary>
    Task<ProductVariantDto> UpdateVariantAsync(
        Guid variantId,
        UpdateVariantDto dto,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Searches products.
    /// </summary>
    Task<PagedResult<ProductDto>> SearchAsync(
        string searchTerm,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default);
}

5. Service Implementation Template

// File: src/POS.Application/Services/ProductService.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using FluentValidation;
using Microsoft.Extensions.Logging;
using POS.Core.DTOs;
using POS.Core.Entities;
using POS.Core.Exceptions;
using POS.Core.Interfaces.Repositories;
using POS.Core.Interfaces.Services;

namespace POS.Application.Services;

/// <summary>
/// Service implementation for product operations.
/// </summary>
public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IMapper _mapper;
    private readonly IValidator<CreateProductDto> _createValidator;
    private readonly IValidator<UpdateProductDto> _updateValidator;
    private readonly ILogger<ProductService> _logger;
    private readonly IDomainEventDispatcher _eventDispatcher;

    public ProductService(
        IProductRepository productRepository,
        IUnitOfWork unitOfWork,
        IMapper mapper,
        IValidator<CreateProductDto> createValidator,
        IValidator<UpdateProductDto> updateValidator,
        ILogger<ProductService> logger,
        IDomainEventDispatcher eventDispatcher)
    {
        _productRepository = productRepository;
        _unitOfWork = unitOfWork;
        _mapper = mapper;
        _createValidator = createValidator;
        _updateValidator = updateValidator;
        _logger = logger;
        _eventDispatcher = eventDispatcher;
    }

    /// <inheritdoc />
    public async Task<ProductDto?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        var product = await _productRepository.GetByIdAsync(id, cancellationToken);
        return product is null ? null : _mapper.Map<ProductDto>(product);
    }

    /// <inheritdoc />
    public async Task<ProductDto?> GetBySkuAsync(
        string sku,
        CancellationToken cancellationToken = default)
    {
        var product = await _productRepository.GetBySkuAsync(sku, cancellationToken);
        return product is null ? null : _mapper.Map<ProductDto>(product);
    }

    /// <inheritdoc />
    public async Task<PagedResult<ProductDto>> GetAllAsync(
        ProductFilterDto filter,
        CancellationToken cancellationToken = default)
    {
        var result = await _productRepository.SearchAsync(
            filter.SearchTerm ?? "",
            filter.Page,
            filter.PageSize,
            cancellationToken);

        return new PagedResult<ProductDto>(
            _mapper.Map<List<ProductDto>>(result.Items),
            result.TotalCount,
            result.Page,
            result.PageSize);
    }

    /// <inheritdoc />
    public async Task<ProductDto> CreateAsync(
        CreateProductDto dto,
        CancellationToken cancellationToken = default)
    {
        // Validate
        var validationResult = await _createValidator.ValidateAsync(dto, cancellationToken);
        if (!validationResult.IsValid)
        {
            throw new ValidationException(validationResult.Errors);
        }

        // Check SKU uniqueness
        if (await _productRepository.SkuExistsAsync(dto.Sku, null, cancellationToken))
        {
            throw new BusinessException($"SKU '{dto.Sku}' already exists.");
        }

        // Create entity
        var product = _mapper.Map<Product>(dto);
        product.Status = ProductStatus.Active;

        await _productRepository.AddAsync(product, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Product created: {ProductId} - {Sku}", product.Id, product.Sku);

        // Dispatch domain event
        await _eventDispatcher.DispatchAsync(new ProductCreatedEvent(product.Id, product.Sku));

        return _mapper.Map<ProductDto>(product);
    }

    /// <inheritdoc />
    public async Task<ProductDto> UpdateAsync(
        Guid id,
        UpdateProductDto dto,
        CancellationToken cancellationToken = default)
    {
        // Validate
        var validationResult = await _updateValidator.ValidateAsync(dto, cancellationToken);
        if (!validationResult.IsValid)
        {
            throw new ValidationException(validationResult.Errors);
        }

        // Get existing product
        var product = await _productRepository.GetByIdAsync(id, cancellationToken);
        if (product is null)
        {
            throw new NotFoundException($"Product with ID {id} not found.");
        }

        // Check SKU uniqueness if changed
        if (dto.Sku != product.Sku &&
            await _productRepository.SkuExistsAsync(dto.Sku, id, cancellationToken))
        {
            throw new BusinessException($"SKU '{dto.Sku}' already exists.");
        }

        // Update entity
        _mapper.Map(dto, product);

        _productRepository.Update(product);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Product updated: {ProductId} - {Sku}", product.Id, product.Sku);

        return _mapper.Map<ProductDto>(product);
    }

    /// <inheritdoc />
    public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
    {
        var product = await _productRepository.GetByIdAsync(id, cancellationToken);
        if (product is null)
        {
            throw new NotFoundException($"Product with ID {id} not found.");
        }

        // Soft delete - change status
        product.Status = ProductStatus.Archived;

        _productRepository.Update(product);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Product archived: {ProductId} - {Sku}", product.Id, product.Sku);
    }

    /// <inheritdoc />
    public async Task<ProductVariantDto> AddVariantAsync(
        Guid productId,
        CreateVariantDto dto,
        CancellationToken cancellationToken = default)
    {
        var product = await _productRepository.GetByIdAsync(productId, cancellationToken);
        if (product is null)
        {
            throw new NotFoundException($"Product with ID {productId} not found.");
        }

        var variant = _mapper.Map<ProductVariant>(dto);
        variant.ProductId = productId;

        product.Variants.Add(variant);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        _logger.LogInformation("Variant added: {VariantId} to Product {ProductId}",
            variant.Id, productId);

        return _mapper.Map<ProductVariantDto>(variant);
    }

    /// <inheritdoc />
    public async Task<ProductVariantDto> UpdateVariantAsync(
        Guid variantId,
        UpdateVariantDto dto,
        CancellationToken cancellationToken = default)
    {
        // Implementation similar to UpdateAsync
        throw new NotImplementedException();
    }

    /// <inheritdoc />
    public async Task<PagedResult<ProductDto>> SearchAsync(
        string searchTerm,
        int page = 1,
        int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        var result = await _productRepository.SearchAsync(
            searchTerm, page, pageSize, cancellationToken);

        return new PagedResult<ProductDto>(
            _mapper.Map<List<ProductDto>>(result.Items),
            result.TotalCount,
            result.Page,
            result.PageSize);
    }
}

6. Controller Template

// File: src/POS.API/Controllers/ProductsController.cs

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using POS.Core.DTOs;
using POS.Core.Interfaces.Services;

namespace POS.API.Controllers;

/// <summary>
/// API controller for product operations.
/// </summary>
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
[Produces("application/json")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(
        IProductService productService,
        ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    /// <summary>
    /// Gets all products with optional filtering.
    /// </summary>
    /// <param name="filter">Filter parameters.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Paginated list of products.</returns>
    [HttpGet]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<PagedResult<ProductDto>>> GetAll(
        [FromQuery] ProductFilterDto filter,
        CancellationToken cancellationToken)
    {
        var result = await _productService.GetAllAsync(filter, cancellationToken);
        return Ok(result);
    }

    /// <summary>
    /// Gets a product by ID.
    /// </summary>
    /// <param name="id">The product ID.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The product if found.</returns>
    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        var product = await _productService.GetByIdAsync(id, cancellationToken);

        if (product is null)
        {
            return NotFound();
        }

        return Ok(product);
    }

    /// <summary>
    /// Gets a product by SKU.
    /// </summary>
    /// <param name="sku">The product SKU.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The product if found.</returns>
    [HttpGet("sku/{sku}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> GetBySku(
        string sku,
        CancellationToken cancellationToken)
    {
        var product = await _productService.GetBySkuAsync(sku, cancellationToken);

        if (product is null)
        {
            return NotFound();
        }

        return Ok(product);
    }

    /// <summary>
    /// Creates a new product.
    /// </summary>
    /// <param name="dto">The product data.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The created product.</returns>
    [HttpPost]
    [Authorize(Policy = "CanManageProducts")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
    public async Task<ActionResult<ProductDto>> Create(
        [FromBody] CreateProductDto dto,
        CancellationToken cancellationToken)
    {
        var product = await _productService.CreateAsync(dto, cancellationToken);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }

    /// <summary>
    /// Updates an existing product.
    /// </summary>
    /// <param name="id">The product ID.</param>
    /// <param name="dto">The updated product data.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The updated product.</returns>
    [HttpPut("{id:guid}")]
    [Authorize(Policy = "CanManageProducts")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductDto>> Update(
        Guid id,
        [FromBody] UpdateProductDto dto,
        CancellationToken cancellationToken)
    {
        var product = await _productService.UpdateAsync(id, dto, cancellationToken);
        return Ok(product);
    }

    /// <summary>
    /// Deletes a product (soft delete).
    /// </summary>
    /// <param name="id">The product ID.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>No content on success.</returns>
    [HttpDelete("{id:guid}")]
    [Authorize(Policy = "CanManageProducts")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Delete(
        Guid id,
        CancellationToken cancellationToken)
    {
        await _productService.DeleteAsync(id, cancellationToken);
        return NoContent();
    }

    /// <summary>
    /// Adds a variant to a product.
    /// </summary>
    /// <param name="id">The product ID.</param>
    /// <param name="dto">The variant data.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The created variant.</returns>
    [HttpPost("{id:guid}/variants")]
    [Authorize(Policy = "CanManageProducts")]
    [ProducesResponseType(typeof(ProductVariantDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult<ProductVariantDto>> AddVariant(
        Guid id,
        [FromBody] CreateVariantDto dto,
        CancellationToken cancellationToken)
    {
        var variant = await _productService.AddVariantAsync(id, dto, cancellationToken);
        return CreatedAtAction(nameof(GetById), new { id }, variant);
    }

    /// <summary>
    /// Searches products by name or SKU.
    /// </summary>
    /// <param name="q">Search query.</param>
    /// <param name="page">Page number.</param>
    /// <param name="pageSize">Page size.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Matching products.</returns>
    [HttpGet("search")]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
    public async Task<ActionResult<PagedResult<ProductDto>>> Search(
        [FromQuery] string q,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 20,
        CancellationToken cancellationToken = default)
    {
        var result = await _productService.SearchAsync(q, page, pageSize, cancellationToken);
        return Ok(result);
    }
}

7. DTO Templates

// File: src/POS.Core/DTOs/ProductDtos.cs

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace POS.Core.DTOs;

/// <summary>
/// Product data transfer object.
/// </summary>
public record ProductDto
{
    public Guid Id { get; init; }
    public required string Sku { get; init; }
    public required string Name { get; init; }
    public string? Description { get; init; }
    public Guid? CategoryId { get; init; }
    public string? CategoryName { get; init; }
    public Guid? VendorId { get; init; }
    public string? VendorName { get; init; }
    public decimal BasePrice { get; init; }
    public decimal Cost { get; init; }
    public string Status { get; init; } = "Active";
    public List<ProductVariantDto> Variants { get; init; } = new();
    public List<ProductImageDto> Images { get; init; } = new();
    public DateTime CreatedAt { get; init; }
    public DateTime? UpdatedAt { get; init; }
}

/// <summary>
/// Product variant data transfer object.
/// </summary>
public record ProductVariantDto
{
    public Guid Id { get; init; }
    public required string Sku { get; init; }
    public string? Barcode { get; init; }
    public Dictionary<string, string> Options { get; init; } = new();
    public decimal Price { get; init; }
    public decimal? CompareAtPrice { get; init; }
    public decimal Cost { get; init; }
    public bool IsActive { get; init; }
}

/// <summary>
/// Product image data transfer object.
/// </summary>
public record ProductImageDto
{
    public Guid Id { get; init; }
    public required string Url { get; init; }
    public string? AltText { get; init; }
    public int Position { get; init; }
}

/// <summary>
/// DTO for creating a new product.
/// </summary>
public record CreateProductDto
{
    [Required]
    [StringLength(50)]
    public required string Sku { get; init; }

    [Required]
    [StringLength(255)]
    public required string Name { get; init; }

    [StringLength(2000)]
    public string? Description { get; init; }

    public Guid? CategoryId { get; init; }

    public Guid? VendorId { get; init; }

    [Range(0, 999999.99)]
    public decimal BasePrice { get; init; }

    [Range(0, 999999.99)]
    public decimal Cost { get; init; }

    public List<CreateVariantDto>? Variants { get; init; }
}

/// <summary>
/// DTO for updating an existing product.
/// </summary>
public record UpdateProductDto
{
    [Required]
    [StringLength(50)]
    public required string Sku { get; init; }

    [Required]
    [StringLength(255)]
    public required string Name { get; init; }

    [StringLength(2000)]
    public string? Description { get; init; }

    public Guid? CategoryId { get; init; }

    public Guid? VendorId { get; init; }

    [Range(0, 999999.99)]
    public decimal BasePrice { get; init; }

    [Range(0, 999999.99)]
    public decimal Cost { get; init; }

    public string? Status { get; init; }
}

/// <summary>
/// DTO for creating a product variant.
/// </summary>
public record CreateVariantDto
{
    [Required]
    [StringLength(50)]
    public required string Sku { get; init; }

    [StringLength(50)]
    public string? Barcode { get; init; }

    public Dictionary<string, string> Options { get; init; } = new();

    [Range(0, 999999.99)]
    public decimal Price { get; init; }

    [Range(0, 999999.99)]
    public decimal? CompareAtPrice { get; init; }

    [Range(0, 999999.99)]
    public decimal Cost { get; init; }
}

/// <summary>
/// DTO for updating a product variant.
/// </summary>
public record UpdateVariantDto
{
    [Required]
    [StringLength(50)]
    public required string Sku { get; init; }

    [StringLength(50)]
    public string? Barcode { get; init; }

    public Dictionary<string, string>? Options { get; init; }

    [Range(0, 999999.99)]
    public decimal? Price { get; init; }

    [Range(0, 999999.99)]
    public decimal? CompareAtPrice { get; init; }

    [Range(0, 999999.99)]
    public decimal? Cost { get; init; }

    public bool? IsActive { get; init; }
}

/// <summary>
/// Product filter DTO.
/// </summary>
public record ProductFilterDto
{
    public string? SearchTerm { get; init; }
    public Guid? CategoryId { get; init; }
    public Guid? VendorId { get; init; }
    public string? Status { get; init; }

    [Range(1, int.MaxValue)]
    public int Page { get; init; } = 1;

    [Range(1, 100)]
    public int PageSize { get; init; } = 20;
}

/// <summary>
/// Paginated result wrapper.
/// </summary>
public record PagedResult<T>
{
    public IReadOnlyList<T> Items { get; init; }
    public int TotalCount { get; init; }
    public int Page { get; init; }
    public int PageSize { get; init; }
    public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
    public bool HasNextPage => Page < TotalPages;
    public bool HasPreviousPage => Page > 1;

    public PagedResult(IReadOnlyList<T> items, int totalCount, int page, int pageSize)
    {
        Items = items;
        TotalCount = totalCount;
        Page = page;
        PageSize = pageSize;
    }
}

8. Validator Template

// File: src/POS.Application/Validators/CreateProductValidator.cs

using FluentValidation;
using POS.Core.DTOs;
using POS.Core.Interfaces.Repositories;

namespace POS.Application.Validators;

/// <summary>
/// Validator for CreateProductDto.
/// </summary>
public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
    private readonly IProductRepository _productRepository;
    private readonly ICategoryRepository _categoryRepository;

    public CreateProductValidator(
        IProductRepository productRepository,
        ICategoryRepository categoryRepository)
    {
        _productRepository = productRepository;
        _categoryRepository = categoryRepository;

        RuleFor(x => x.Sku)
            .NotEmpty()
                .WithMessage("SKU is required.")
            .MaximumLength(50)
                .WithMessage("SKU cannot exceed 50 characters.")
            .Matches(@"^[A-Z0-9\-]+$")
                .WithMessage("SKU must contain only uppercase letters, numbers, and hyphens.")
            .MustAsync(BeUniqueSku)
                .WithMessage("SKU already exists.");

        RuleFor(x => x.Name)
            .NotEmpty()
                .WithMessage("Product name is required.")
            .MaximumLength(255)
                .WithMessage("Product name cannot exceed 255 characters.");

        RuleFor(x => x.Description)
            .MaximumLength(2000)
                .WithMessage("Description cannot exceed 2000 characters.");

        RuleFor(x => x.BasePrice)
            .GreaterThanOrEqualTo(0)
                .WithMessage("Base price must be zero or greater.");

        RuleFor(x => x.Cost)
            .GreaterThanOrEqualTo(0)
                .WithMessage("Cost must be zero or greater.")
            .LessThanOrEqualTo(x => x.BasePrice)
                .When(x => x.BasePrice > 0)
                .WithMessage("Cost should not exceed the base price.");

        RuleFor(x => x.CategoryId)
            .MustAsync(CategoryExists)
                .When(x => x.CategoryId.HasValue)
                .WithMessage("Category does not exist.");

        RuleForEach(x => x.Variants)
            .SetValidator(new CreateVariantValidator());
    }

    private async Task<bool> BeUniqueSku(string sku, CancellationToken cancellationToken)
    {
        return !await _productRepository.SkuExistsAsync(sku, null, cancellationToken);
    }

    private async Task<bool> CategoryExists(Guid? categoryId, CancellationToken cancellationToken)
    {
        if (!categoryId.HasValue) return true;
        return await _categoryRepository.ExistsAsync(categoryId.Value, cancellationToken);
    }
}

/// <summary>
/// Validator for CreateVariantDto.
/// </summary>
public class CreateVariantValidator : AbstractValidator<CreateVariantDto>
{
    public CreateVariantValidator()
    {
        RuleFor(x => x.Sku)
            .NotEmpty()
                .WithMessage("Variant SKU is required.")
            .MaximumLength(50)
                .WithMessage("Variant SKU cannot exceed 50 characters.");

        RuleFor(x => x.Barcode)
            .MaximumLength(50)
                .WithMessage("Barcode cannot exceed 50 characters.")
            .Matches(@"^[0-9]*$")
                .When(x => !string.IsNullOrEmpty(x.Barcode))
                .WithMessage("Barcode must contain only numbers.");

        RuleFor(x => x.Price)
            .GreaterThanOrEqualTo(0)
                .WithMessage("Price must be zero or greater.");

        RuleFor(x => x.CompareAtPrice)
            .GreaterThan(x => x.Price)
                .When(x => x.CompareAtPrice.HasValue)
                .WithMessage("Compare at price must be greater than regular price.");

        RuleFor(x => x.Cost)
            .GreaterThanOrEqualTo(0)
                .WithMessage("Cost must be zero or greater.");
    }
}

9. Event Handler Template

// File: src/POS.Application/EventHandlers/OrderCompletedEventHandler.cs

using System.Threading;
using System.Threading.Tasks;
using MediatR;
using Microsoft.Extensions.Logging;
using POS.Core.Events;
using POS.Core.Interfaces.Services;

namespace POS.Application.EventHandlers;

/// <summary>
/// Handles the OrderCompleted domain event.
/// </summary>
public class OrderCompletedEventHandler : INotificationHandler<OrderCompletedEvent>
{
    private readonly IInventoryService _inventoryService;
    private readonly ILoyaltyService _loyaltyService;
    private readonly IAnalyticsService _analyticsService;
    private readonly INotificationService _notificationService;
    private readonly ILogger<OrderCompletedEventHandler> _logger;

    public OrderCompletedEventHandler(
        IInventoryService inventoryService,
        ILoyaltyService loyaltyService,
        IAnalyticsService analyticsService,
        INotificationService notificationService,
        ILogger<OrderCompletedEventHandler> logger)
    {
        _inventoryService = inventoryService;
        _loyaltyService = loyaltyService;
        _analyticsService = analyticsService;
        _notificationService = notificationService;
        _logger = logger;
    }

    /// <summary>
    /// Handles the OrderCompleted event.
    /// </summary>
    public async Task Handle(
        OrderCompletedEvent notification,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation(
            "Processing OrderCompleted event for Order {OrderId}",
            notification.OrderId);

        try
        {
            // Commit inventory reservations
            await _inventoryService.CommitReservationsAsync(
                notification.OrderId,
                notification.LineItems,
                cancellationToken);

            // Award loyalty points if customer attached
            if (notification.CustomerId.HasValue)
            {
                await _loyaltyService.AwardPointsAsync(
                    notification.CustomerId.Value,
                    notification.OrderId,
                    notification.Total,
                    cancellationToken);
            }

            // Record analytics
            await _analyticsService.RecordSaleAsync(
                notification.OrderId,
                notification.LocationId,
                notification.Total,
                notification.LineItems.Count,
                cancellationToken);

            // Send receipt notification if requested
            if (notification.SendReceipt)
            {
                await _notificationService.SendReceiptAsync(
                    notification.OrderId,
                    notification.CustomerEmail,
                    notification.ReceiptMethod,
                    cancellationToken);
            }

            _logger.LogInformation(
                "Successfully processed OrderCompleted event for Order {OrderId}",
                notification.OrderId);
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex,
                "Error processing OrderCompleted event for Order {OrderId}",
                notification.OrderId);

            // Re-throw to trigger retry logic
            throw;
        }
    }
}

10. Integration Test Template

// File: tests/POS.IntegrationTests/Controllers/ProductsControllerTests.cs

using System;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using POS.API;
using POS.Core.DTOs;
using POS.IntegrationTests.Fixtures;
using Xunit;

namespace POS.IntegrationTests.Controllers;

/// <summary>
/// Integration tests for ProductsController.
/// </summary>
[Collection("Database")]
public class ProductsControllerTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;
    private readonly DatabaseFixture _dbFixture;

    public ProductsControllerTests(
        WebApplicationFactory<Program> factory,
        DatabaseFixture dbFixture)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Configure test database
                dbFixture.ConfigureServices(services);
            });
        });

        _client = _factory.CreateClient();
        _dbFixture = dbFixture;
    }

    public async Task InitializeAsync()
    {
        await _dbFixture.ResetDatabaseAsync();
        await AuthenticateAsync();
    }

    public Task DisposeAsync() => Task.CompletedTask;

    private async Task AuthenticateAsync()
    {
        var loginDto = new { Email = "test@example.com", Password = "Test123!" };
        var response = await _client.PostAsJsonAsync("/api/v1/auth/login", loginDto);
        var result = await response.Content.ReadFromJsonAsync<LoginResult>();
        _client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", result?.Token);
    }

    [Fact]
    public async Task GetAll_ReturnsProducts()
    {
        // Arrange
        await SeedProductsAsync();

        // Act
        var response = await _client.GetAsync("/api/v1/products");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var result = await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>();
        result.Should().NotBeNull();
        result!.Items.Should().NotBeEmpty();
        result.TotalCount.Should().BeGreaterThan(0);
    }

    [Fact]
    public async Task GetById_ExistingProduct_ReturnsProduct()
    {
        // Arrange
        var productId = await CreateTestProductAsync();

        // Act
        var response = await _client.GetAsync($"/api/v1/products/{productId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        product.Should().NotBeNull();
        product!.Id.Should().Be(productId);
    }

    [Fact]
    public async Task GetById_NonExistingProduct_ReturnsNotFound()
    {
        // Arrange
        var nonExistingId = Guid.NewGuid();

        // Act
        var response = await _client.GetAsync($"/api/v1/products/{nonExistingId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NotFound);
    }

    [Fact]
    public async Task Create_ValidProduct_ReturnsCreated()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "TEST-001",
            Name = "Test Product",
            Description = "A test product",
            BasePrice = 29.99m,
            Cost = 12.50m
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/v1/products", createDto);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        product.Should().NotBeNull();
        product!.Sku.Should().Be("TEST-001");
        product.Name.Should().Be("Test Product");

        // Verify location header
        response.Headers.Location.Should().NotBeNull();
    }

    [Fact]
    public async Task Create_DuplicateSku_ReturnsConflict()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "DUPLICATE-SKU",
            Name = "First Product",
            BasePrice = 29.99m,
            Cost = 12.50m
        };

        await _client.PostAsJsonAsync("/api/v1/products", createDto);

        var duplicateDto = new CreateProductDto
        {
            Sku = "DUPLICATE-SKU",
            Name = "Second Product",
            BasePrice = 39.99m,
            Cost = 15.00m
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/v1/products", duplicateDto);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Conflict);
    }

    [Fact]
    public async Task Create_InvalidData_ReturnsBadRequest()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "", // Invalid - empty
            Name = "", // Invalid - empty
            BasePrice = -10m, // Invalid - negative
            Cost = 12.50m
        };

        // Act
        var response = await _client.PostAsJsonAsync("/api/v1/products", createDto);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task Update_ValidData_ReturnsUpdatedProduct()
    {
        // Arrange
        var productId = await CreateTestProductAsync();

        var updateDto = new UpdateProductDto
        {
            Sku = "UPDATED-SKU",
            Name = "Updated Product Name",
            BasePrice = 39.99m,
            Cost = 15.00m
        };

        // Act
        var response = await _client.PutAsJsonAsync(
            $"/api/v1/products/{productId}",
            updateDto);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        product!.Name.Should().Be("Updated Product Name");
        product.BasePrice.Should().Be(39.99m);
    }

    [Fact]
    public async Task Delete_ExistingProduct_ReturnsNoContent()
    {
        // Arrange
        var productId = await CreateTestProductAsync();

        // Act
        var response = await _client.DeleteAsync($"/api/v1/products/{productId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NoContent);

        // Verify product is soft-deleted
        var getResponse = await _client.GetAsync($"/api/v1/products/{productId}");
        var product = await getResponse.Content.ReadFromJsonAsync<ProductDto>();
        product!.Status.Should().Be("Archived");
    }

    private async Task SeedProductsAsync()
    {
        for (int i = 1; i <= 5; i++)
        {
            var dto = new CreateProductDto
            {
                Sku = $"SEED-{i:D3}",
                Name = $"Seeded Product {i}",
                BasePrice = 29.99m,
                Cost = 12.50m
            };
            await _client.PostAsJsonAsync("/api/v1/products", dto);
        }
    }

    private async Task<Guid> CreateTestProductAsync()
    {
        var dto = new CreateProductDto
        {
            Sku = $"TEST-{Guid.NewGuid():N}".Substring(0, 20),
            Name = "Test Product",
            BasePrice = 29.99m,
            Cost = 12.50m
        };

        var response = await _client.PostAsJsonAsync("/api/v1/products", dto);
        var product = await response.Content.ReadFromJsonAsync<ProductDto>();
        return product!.Id;
    }
}

record LoginResult(string Token);

11. Unit Test Template

// File: tests/POS.UnitTests/Services/ProductServiceTests.cs

using System;
using System.Threading;
using System.Threading.Tasks;
using AutoMapper;
using FluentAssertions;
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Logging;
using Moq;
using POS.Application.Services;
using POS.Core.DTOs;
using POS.Core.Entities;
using POS.Core.Exceptions;
using POS.Core.Interfaces.Repositories;
using POS.Core.Interfaces.Services;
using Xunit;

namespace POS.UnitTests.Services;

/// <summary>
/// Unit tests for ProductService.
/// </summary>
public class ProductServiceTests
{
    private readonly Mock<IProductRepository> _productRepositoryMock;
    private readonly Mock<IUnitOfWork> _unitOfWorkMock;
    private readonly Mock<IMapper> _mapperMock;
    private readonly Mock<IValidator<CreateProductDto>> _createValidatorMock;
    private readonly Mock<IValidator<UpdateProductDto>> _updateValidatorMock;
    private readonly Mock<ILogger<ProductService>> _loggerMock;
    private readonly Mock<IDomainEventDispatcher> _eventDispatcherMock;
    private readonly ProductService _sut;

    public ProductServiceTests()
    {
        _productRepositoryMock = new Mock<IProductRepository>();
        _unitOfWorkMock = new Mock<IUnitOfWork>();
        _mapperMock = new Mock<IMapper>();
        _createValidatorMock = new Mock<IValidator<CreateProductDto>>();
        _updateValidatorMock = new Mock<IValidator<UpdateProductDto>>();
        _loggerMock = new Mock<ILogger<ProductService>>();
        _eventDispatcherMock = new Mock<IDomainEventDispatcher>();

        _sut = new ProductService(
            _productRepositoryMock.Object,
            _unitOfWorkMock.Object,
            _mapperMock.Object,
            _createValidatorMock.Object,
            _updateValidatorMock.Object,
            _loggerMock.Object,
            _eventDispatcherMock.Object);
    }

    [Fact]
    public async Task GetByIdAsync_ExistingProduct_ReturnsProductDto()
    {
        // Arrange
        var productId = Guid.NewGuid();
        var product = new Product
        {
            Id = productId,
            Sku = "TEST-001",
            Name = "Test Product"
        };
        var productDto = new ProductDto
        {
            Id = productId,
            Sku = "TEST-001",
            Name = "Test Product"
        };

        _productRepositoryMock
            .Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        _mapperMock
            .Setup(x => x.Map<ProductDto>(product))
            .Returns(productDto);

        // Act
        var result = await _sut.GetByIdAsync(productId);

        // Assert
        result.Should().NotBeNull();
        result!.Id.Should().Be(productId);
        result.Sku.Should().Be("TEST-001");
    }

    [Fact]
    public async Task GetByIdAsync_NonExistingProduct_ReturnsNull()
    {
        // Arrange
        var productId = Guid.NewGuid();

        _productRepositoryMock
            .Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        // Act
        var result = await _sut.GetByIdAsync(productId);

        // Assert
        result.Should().BeNull();
    }

    [Fact]
    public async Task CreateAsync_ValidDto_CreatesAndReturnsProduct()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "NEW-001",
            Name = "New Product",
            BasePrice = 29.99m,
            Cost = 12.50m
        };

        var product = new Product
        {
            Id = Guid.NewGuid(),
            Sku = "NEW-001",
            Name = "New Product"
        };

        var productDto = new ProductDto
        {
            Id = product.Id,
            Sku = "NEW-001",
            Name = "New Product"
        };

        _createValidatorMock
            .Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());

        _productRepositoryMock
            .Setup(x => x.SkuExistsAsync("NEW-001", null, It.IsAny<CancellationToken>()))
            .ReturnsAsync(false);

        _mapperMock
            .Setup(x => x.Map<Product>(createDto))
            .Returns(product);

        _mapperMock
            .Setup(x => x.Map<ProductDto>(product))
            .Returns(productDto);

        // Act
        var result = await _sut.CreateAsync(createDto);

        // Assert
        result.Should().NotBeNull();
        result.Sku.Should().Be("NEW-001");

        _productRepositoryMock.Verify(
            x => x.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()),
            Times.Once);

        _unitOfWorkMock.Verify(
            x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
            Times.Once);

        _eventDispatcherMock.Verify(
            x => x.DispatchAsync(It.IsAny<ProductCreatedEvent>()),
            Times.Once);
    }

    [Fact]
    public async Task CreateAsync_DuplicateSku_ThrowsBusinessException()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "EXISTING-SKU",
            Name = "New Product",
            BasePrice = 29.99m,
            Cost = 12.50m
        };

        _createValidatorMock
            .Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());

        _productRepositoryMock
            .Setup(x => x.SkuExistsAsync("EXISTING-SKU", null, It.IsAny<CancellationToken>()))
            .ReturnsAsync(true);

        // Act
        var act = () => _sut.CreateAsync(createDto);

        // Assert
        await act.Should().ThrowAsync<BusinessException>()
            .WithMessage("*EXISTING-SKU*already exists*");
    }

    [Fact]
    public async Task CreateAsync_InvalidDto_ThrowsValidationException()
    {
        // Arrange
        var createDto = new CreateProductDto
        {
            Sku = "",
            Name = "",
            BasePrice = -10m,
            Cost = 12.50m
        };

        var validationResult = new ValidationResult(new[]
        {
            new ValidationFailure("Sku", "SKU is required."),
            new ValidationFailure("Name", "Name is required.")
        });

        _createValidatorMock
            .Setup(x => x.ValidateAsync(createDto, It.IsAny<CancellationToken>()))
            .ReturnsAsync(validationResult);

        // Act
        var act = () => _sut.CreateAsync(createDto);

        // Assert
        await act.Should().ThrowAsync<ValidationException>();
    }

    [Fact]
    public async Task UpdateAsync_ExistingProduct_UpdatesAndReturnsProduct()
    {
        // Arrange
        var productId = Guid.NewGuid();
        var updateDto = new UpdateProductDto
        {
            Sku = "UPDATED-SKU",
            Name = "Updated Name",
            BasePrice = 39.99m,
            Cost = 15.00m
        };

        var existingProduct = new Product
        {
            Id = productId,
            Sku = "OLD-SKU",
            Name = "Old Name"
        };

        var updatedProductDto = new ProductDto
        {
            Id = productId,
            Sku = "UPDATED-SKU",
            Name = "Updated Name"
        };

        _updateValidatorMock
            .Setup(x => x.ValidateAsync(updateDto, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());

        _productRepositoryMock
            .Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync(existingProduct);

        _productRepositoryMock
            .Setup(x => x.SkuExistsAsync("UPDATED-SKU", productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync(false);

        _mapperMock
            .Setup(x => x.Map<ProductDto>(existingProduct))
            .Returns(updatedProductDto);

        // Act
        var result = await _sut.UpdateAsync(productId, updateDto);

        // Assert
        result.Should().NotBeNull();
        result.Sku.Should().Be("UPDATED-SKU");

        _productRepositoryMock.Verify(
            x => x.Update(It.IsAny<Product>()),
            Times.Once);

        _unitOfWorkMock.Verify(
            x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
            Times.Once);
    }

    [Fact]
    public async Task UpdateAsync_NonExistingProduct_ThrowsNotFoundException()
    {
        // Arrange
        var productId = Guid.NewGuid();
        var updateDto = new UpdateProductDto
        {
            Sku = "UPDATED-SKU",
            Name = "Updated Name",
            BasePrice = 39.99m,
            Cost = 15.00m
        };

        _updateValidatorMock
            .Setup(x => x.ValidateAsync(updateDto, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());

        _productRepositoryMock
            .Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync((Product?)null);

        // Act
        var act = () => _sut.UpdateAsync(productId, updateDto);

        // Assert
        await act.Should().ThrowAsync<NotFoundException>()
            .WithMessage($"*{productId}*not found*");
    }

    [Fact]
    public async Task DeleteAsync_ExistingProduct_SoftDeletesProduct()
    {
        // Arrange
        var productId = Guid.NewGuid();
        var product = new Product
        {
            Id = productId,
            Sku = "TO-DELETE",
            Name = "Product to Delete",
            Status = ProductStatus.Active
        };

        _productRepositoryMock
            .Setup(x => x.GetByIdAsync(productId, It.IsAny<CancellationToken>()))
            .ReturnsAsync(product);

        // Act
        await _sut.DeleteAsync(productId);

        // Assert
        product.Status.Should().Be(ProductStatus.Archived);

        _productRepositoryMock.Verify(
            x => x.Update(product),
            Times.Once);

        _unitOfWorkMock.Verify(
            x => x.SaveChangesAsync(It.IsAny<CancellationToken>()),
            Times.Once);
    }
}

12. Domain Event Template

// File: src/POS.Core/Events/OrderCompletedEvent.cs

using System;
using System.Collections.Generic;
using MediatR;

namespace POS.Core.Events;

/// <summary>
/// Domain event raised when an order is completed.
/// </summary>
public record OrderCompletedEvent : INotification
{
    /// <summary>
    /// Gets the event ID.
    /// </summary>
    public Guid EventId { get; init; } = Guid.NewGuid();

    /// <summary>
    /// Gets the timestamp when the event occurred.
    /// </summary>
    public DateTime Timestamp { get; init; } = DateTime.UtcNow;

    /// <summary>
    /// Gets the order ID.
    /// </summary>
    public required Guid OrderId { get; init; }

    /// <summary>
    /// Gets the order number.
    /// </summary>
    public required string OrderNumber { get; init; }

    /// <summary>
    /// Gets the receipt number.
    /// </summary>
    public required string ReceiptNumber { get; init; }

    /// <summary>
    /// Gets the location ID.
    /// </summary>
    public required Guid LocationId { get; init; }

    /// <summary>
    /// Gets the register ID.
    /// </summary>
    public Guid? RegisterId { get; init; }

    /// <summary>
    /// Gets the customer ID.
    /// </summary>
    public Guid? CustomerId { get; init; }

    /// <summary>
    /// Gets the customer email.
    /// </summary>
    public string? CustomerEmail { get; init; }

    /// <summary>
    /// Gets the line items.
    /// </summary>
    public required IReadOnlyList<OrderLineItemEvent> LineItems { get; init; }

    /// <summary>
    /// Gets the payment details.
    /// </summary>
    public required IReadOnlyList<PaymentEvent> Payments { get; init; }

    /// <summary>
    /// Gets the subtotal.
    /// </summary>
    public decimal Subtotal { get; init; }

    /// <summary>
    /// Gets the discount total.
    /// </summary>
    public decimal DiscountTotal { get; init; }

    /// <summary>
    /// Gets the tax total.
    /// </summary>
    public decimal TaxTotal { get; init; }

    /// <summary>
    /// Gets the order total.
    /// </summary>
    public required decimal Total { get; init; }

    /// <summary>
    /// Gets the loyalty points earned.
    /// </summary>
    public int LoyaltyPointsEarned { get; init; }

    /// <summary>
    /// Gets whether to send receipt.
    /// </summary>
    public bool SendReceipt { get; init; }

    /// <summary>
    /// Gets the receipt delivery method.
    /// </summary>
    public string? ReceiptMethod { get; init; }

    /// <summary>
    /// Gets the user who completed the order.
    /// </summary>
    public required Guid CompletedBy { get; init; }

    /// <summary>
    /// Gets the shift ID.
    /// </summary>
    public Guid? ShiftId { get; init; }
}

/// <summary>
/// Order line item event data.
/// </summary>
public record OrderLineItemEvent
{
    public required Guid LineItemId { get; init; }
    public required Guid VariantId { get; init; }
    public required string Sku { get; init; }
    public required string Name { get; init; }
    public required int Quantity { get; init; }
    public required decimal UnitPrice { get; init; }
    public decimal DiscountAmount { get; init; }
    public decimal TaxAmount { get; init; }
    public required decimal LineTotal { get; init; }
    public decimal Cost { get; init; }
}

/// <summary>
/// Payment event data.
/// </summary>
public record PaymentEvent
{
    public required Guid PaymentId { get; init; }
    public required string Method { get; init; }
    public required decimal Amount { get; init; }
    public string? AuthorizationCode { get; init; }
    public string? LastFour { get; init; }
}

Usage Notes

  1. Entity Template: Inherit from BaseEntity and implement tenant/audit interfaces as needed.

  2. Repository Interface: Define only operations specific to the entity; generic CRUD is in IRepository<T>.

  3. Repository Implementation: Use Entity Framework Core’s DbSet and LINQ for queries.

  4. Service Interface: Keep it focused on business operations, not CRUD.

  5. Service Implementation: Handle validation, business rules, and coordinate between repositories.

  6. Controller Template: Use [FromBody] for complex objects, [FromQuery] for filters.

  7. DTOs: Use records for immutability; separate Create/Update/Response DTOs.

  8. Validators: Use FluentValidation with async rules for database checks.

  9. Event Handlers: Handle one event type per handler; keep handlers focused.

  10. Integration Tests: Use WebApplicationFactory and test against real database.

  11. Unit Tests: Use Moq for dependencies; test business logic in isolation.

  12. Domain Events: Use MediatR INotification; include all relevant data in the event.


These templates provide the foundation for consistent, maintainable code across the POS Platform.

Appendix F: Promotion Rules Reference

Competitive Intelligence Applied

This appendix documents the complete promotions system, inspired by Retail Pro’s sophisticated promotions engine - their most powerful feature after 35 years of retail software development.


Promotion Types

1. Percentage Discount

Reduces price by a percentage.

{
  "type": "percentage",
  "value": 25,
  "appliesTo": "category",
  "targetIds": ["cat_apparel"]
}

Examples:

  • 25% off all apparel
  • 10% off entire purchase
  • 15% off clearance items

2. Fixed Amount Discount

Reduces price by a fixed dollar amount.

{
  "type": "fixed_amount",
  "value": 10.00,
  "appliesTo": "item",
  "targetIds": ["item_shirt_001"]
}

Examples:

  • $10 off any shirt
  • $5 off purchase over $50
  • $25 off seasonal items

3. Buy X Get Y (BXGY)

Buy a quantity, get additional items free or discounted.

{
  "type": "buy_x_get_y",
  "buyQuantity": 2,
  "getQuantity": 1,
  "getDiscountPercent": 100,
  "appliesTo": "category",
  "targetIds": ["cat_socks"]
}

Examples:

  • Buy 2 get 1 free
  • Buy 3 get 1 50% off
  • Buy 1 get 2nd 25% off

4. Bundle Pricing

Fixed price for a combination of items.

{
  "type": "bundle",
  "bundlePrice": 99.00,
  "requiredItems": [
    { "categoryId": "cat_shirts", "quantity": 3 }
  ]
}

Examples:

  • 3 shirts for $99
  • Outfit bundle: shirt + pants + belt for $149
  • Mix & match: any 5 items for $75

5. Threshold Discount

Spend X amount, save Y amount.

{
  "type": "threshold",
  "thresholds": [
    { "spend": 50, "save": 10 },
    { "spend": 100, "save": 25 },
    { "spend": 200, "save": 60 }
  ]
}

Examples:

  • Spend $50 save $10
  • Spend $100 save $25
  • Tiered: Spend more, save more

6. BOGO (Buy One Get One)

Special case of BXGY, commonly used.

{
  "type": "bogo",
  "getDiscountPercent": 50,
  "appliesTo": "all"
}

Examples:

  • BOGO 50% off
  • BOGO Free (getDiscountPercent: 100)
  • BOGO $10 off

Condition Rules

Minimum Purchase Amount

{
  "conditions": {
    "minPurchaseAmount": 75.00
  }
}

Only applies if cart subtotal >= $75.00


Minimum Quantity

{
  "conditions": {
    "minQuantity": 3
  }
}

Only applies if qualifying items quantity >= 3


Customer Type Requirement

{
  "conditions": {
    "customerTypes": ["gold", "platinum", "employee"]
  }
}

Only applies to customers in specified loyalty tiers.

Customer Types:

TypeDescription
guestNo customer attached
basicStandard customer
silverSilver loyalty tier
goldGold loyalty tier
platinumPlatinum loyalty tier
employeeStaff discount
vipVIP customers

Excluded Items

{
  "conditions": {
    "excludedItems": ["item_giftcard", "item_clearance_001"],
    "excludedCategories": ["cat_gift_cards", "cat_already_on_sale"]
  }
}

Items in these lists are never eligible for this promotion.


First Purchase Only

{
  "conditions": {
    "firstPurchaseOnly": true
  }
}

Only applies to customers with zero purchase history.


Scheduling

Date Range

{
  "schedule": {
    "startAt": "2025-12-20T00:00:00Z",
    "endAt": "2025-12-26T23:59:59Z"
  }
}

Promotion only active during this window.


Active Days

{
  "schedule": {
    "activeDays": ["friday", "saturday", "sunday"]
  }
}

Only active on specified days of week.


Active Hours (Retail Pro Feature)

{
  "schedule": {
    "activeHours": {
      "start": "10:00",
      "end": "14:00"
    }
  }
}

Only active during specified hours (lunch special, happy hour, etc.)


Recurring Schedule

{
  "schedule": {
    "recurring": true,
    "recurrence": {
      "type": "weekly",
      "days": ["tuesday"],
      "startTime": "17:00",
      "endTime": "20:00"
    }
  }
}

Repeats every Tuesday 5-8 PM (e.g., “Taco Tuesday” equivalent).


Usage Limits

Total Uses

{
  "limits": {
    "maxUsesTotal": 1000
  }
}

Promotion ends after 1000 total uses across all customers.


Per Customer

{
  "limits": {
    "maxUsesPerCustomer": 3
  }
}

Each customer can use this promotion max 3 times.


Per Day

{
  "limits": {
    "maxUsesPerDay": 100
  }
}

Max 100 uses per day (flash sale protection).


Per Transaction

{
  "limits": {
    "maxUsesPerTransaction": 1
  }
}

Can only be applied once per sale (e.g., one coupon per purchase).


Stacking Rules

Non-Stackable (Exclusive)

{
  "stacking": {
    "stackable": false,
    "priority": 10
  }
}

Cannot combine with other promotions. Higher priority wins.


Stackable with Restrictions

{
  "stacking": {
    "stackable": true,
    "stackableWith": ["promo_loyalty", "promo_birthday"],
    "excludeWithCodes": ["CLEARANCE", "EMPLOYEE"]
  }
}

Can stack with specific promotions, excludes others.


Priority System

When multiple exclusive promotions apply:

PriorityWins
1Lowest - applies last if stackable
10Higher - wins over lower
100Highest - always wins
{
  "stacking": {
    "priority": 50
  }
}

Location Restrictions

Specific Locations

{
  "locationIds": ["loc_gm", "loc_hm"]
}

Only valid at Greenbrier Mall and Hampton locations.


Exclude Locations

{
  "excludeLocationIds": ["loc_outlet"]
}

Valid everywhere except outlet store.


All Locations

{
  "locationIds": null
}

null means valid at all locations.


Promotion Evaluation Algorithm

┌─────────────────────────────────────────────────────────────────────────┐
│                      PROMOTION EVALUATION FLOW                           │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  1. GATHER CONTEXT                                                       │
│     ├── Cart items (itemId, categoryId, quantity, price)                 │
│     ├── Customer info (customerId, loyaltyTier, purchaseHistory)         │
│     ├── Location (locationId)                                            │
│     └── Current time (for schedule checks)                               │
│                                                                          │
│  2. FILTER ACTIVE PROMOTIONS                                             │
│     ├── Is within startAt - endAt range?                                 │
│     ├── Is active today (activeDays)?                                    │
│     ├── Is active now (activeHours)?                                     │
│     ├── Is location valid (locationIds)?                                 │
│     └── Has uses remaining (limits)?                                     │
│                                                                          │
│  3. CHECK ELIGIBILITY PER PROMOTION                                      │
│     ├── Does cart meet minPurchaseAmount?                                │
│     ├── Does cart meet minQuantity?                                      │
│     ├── Is customer type eligible?                                       │
│     ├── Are there non-excluded items that qualify?                       │
│     └── Is customer eligible (maxUsesPerCustomer)?                       │
│                                                                          │
│  4. CALCULATE DISCOUNTS                                                  │
│     ├── For each eligible promotion:                                     │
│     │   └── Calculate discount amount                                    │
│     │                                                                    │
│  5. RESOLVE STACKING                                                     │
│     ├── Group by stackable vs non-stackable                              │
│     ├── For non-stackable: keep highest priority                         │
│     ├── For stackable: apply all that stackableWith allows               │
│     └── Exclude any excludeWithCodes conflicts                           │
│                                                                          │
│  6. RETURN RESULT                                                        │
│     ├── applicablePromotions[] (will be applied)                         │
│     ├── autoAppliedPromotions[] (loyalty, etc.)                          │
│     ├── ineligiblePromotions[] (and reasons)                             │
│     └── totalDiscount, newSubtotal                                       │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Database Schema

-- Promotions table
CREATE TABLE promotions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES shared.tenants(id),
    name            VARCHAR(255) NOT NULL,
    code            VARCHAR(50),                    -- Promo code (optional)
    description     TEXT,
    type            VARCHAR(50) NOT NULL,           -- percentage, fixed_amount, etc.
    value           DECIMAL(10,2),                  -- Discount value
    applies_to      VARCHAR(50) NOT NULL,           -- all, category, item, vendor
    target_ids      TEXT[],                         -- Array of category/item IDs
    conditions      JSONB DEFAULT '{}',             -- Complex conditions
    schedule        JSONB DEFAULT '{}',             -- Scheduling rules
    limits          JSONB DEFAULT '{}',             -- Usage limits
    stacking        JSONB DEFAULT '{}',             -- Stacking rules
    location_ids    UUID[],                         -- NULL = all locations
    status          VARCHAR(20) DEFAULT 'active',   -- active, paused, expired
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    created_by      UUID REFERENCES employees(id)
);

-- Promotion usage tracking
CREATE TABLE promotion_usage (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    promotion_id    UUID NOT NULL REFERENCES promotions(id),
    sale_id         UUID NOT NULL REFERENCES sales(id),
    customer_id     UUID REFERENCES customers(id),
    location_id     UUID NOT NULL,
    discount_amount DECIMAL(10,2) NOT NULL,
    used_at         TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes for performance
CREATE INDEX idx_promotions_tenant_status ON promotions(tenant_id, status);
CREATE INDEX idx_promotions_code ON promotions(code) WHERE code IS NOT NULL;
CREATE INDEX idx_promotion_usage_promo ON promotion_usage(promotion_id);
CREATE INDEX idx_promotion_usage_customer ON promotion_usage(customer_id);
CREATE INDEX idx_promotion_usage_date ON promotion_usage(used_at);

Example Promotions

1. Holiday Weekend Sale

{
  "name": "Holiday Weekend Sale",
  "code": "HOLIDAY25",
  "type": "percentage",
  "value": 25,
  "appliesTo": "category",
  "targetIds": ["cat_apparel", "cat_accessories"],
  "conditions": {
    "minPurchaseAmount": 50.00,
    "excludedCategories": ["cat_gift_cards", "cat_clearance"]
  },
  "schedule": {
    "startAt": "2025-12-20T00:00:00Z",
    "endAt": "2025-12-26T23:59:59Z"
  },
  "limits": {
    "maxUsesPerCustomer": 2
  },
  "stacking": {
    "stackable": false,
    "priority": 50
  }
}

2. Gold Member Auto-Discount

{
  "name": "Gold Member Discount",
  "code": null,
  "type": "percentage",
  "value": 10,
  "appliesTo": "all",
  "conditions": {
    "customerTypes": ["gold", "platinum"]
  },
  "schedule": {},
  "limits": {},
  "stacking": {
    "stackable": true,
    "priority": 5
  }
}

Automatically applied, no code needed, stacks with other promos.


3. Buy 2 Get 1 Free Socks

{
  "name": "Sock Sale",
  "type": "buy_x_get_y",
  "buyQuantity": 2,
  "getQuantity": 1,
  "getDiscountPercent": 100,
  "appliesTo": "category",
  "targetIds": ["cat_socks"],
  "schedule": {
    "activeDays": ["saturday", "sunday"]
  }
}

Weekend only, auto-applies when 3+ socks in cart.


4. Employee Discount

{
  "name": "Employee Discount",
  "code": "STAFF",
  "type": "percentage",
  "value": 40,
  "appliesTo": "all",
  "conditions": {
    "customerTypes": ["employee"],
    "excludedCategories": ["cat_gift_cards"]
  },
  "stacking": {
    "stackable": false,
    "priority": 100
  }
}

40% off for employees, highest priority, never stacks.


5. Flash Sale (Limited Quantity)

{
  "name": "Flash Sale - First 50",
  "type": "percentage",
  "value": 50,
  "appliesTo": "item",
  "targetIds": ["item_bomber_jacket"],
  "schedule": {
    "startAt": "2025-01-15T12:00:00Z",
    "endAt": "2025-01-15T14:00:00Z"
  },
  "limits": {
    "maxUsesTotal": 50,
    "maxUsesPerCustomer": 1
  }
}

2-hour flash sale, first 50 customers only.


API Usage Examples

Create a Promotion

POST /api/v1/promotions
Authorization: Bearer {token}

{
  "name": "Summer Sale",
  "code": "SUMMER30",
  "type": "percentage",
  "value": 30,
  "appliesTo": "category",
  "targetIds": ["cat_swimwear", "cat_sandals"],
  "schedule": {
    "startAt": "2025-06-01T00:00:00Z",
    "endAt": "2025-08-31T23:59:59Z"
  }
}

Evaluate Cart for Promotions

POST /api/v1/promotions/evaluate
Authorization: Bearer {token}

{
  "locationId": "loc_gm",
  "customerId": "cust_jane",
  "lineItems": [
    { "itemId": "item_001", "categoryId": "cat_swimwear", "quantity": 2, "unitPrice": 45.00 }
  ],
  "subtotal": 90.00,
  "appliedCodes": ["SUMMER30"]
}

Check Promotion Usage

GET /api/v1/promotions/{id}/usage?from=2025-06-01&to=2025-06-30
Authorization: Bearer {token}

Response:

{
  "promotionId": "promo_summer30",
  "period": { "from": "2025-06-01", "to": "2025-06-30" },
  "totalUses": 342,
  "totalDiscountGiven": 4567.89,
  "uniqueCustomers": 287,
  "byLocation": [
    { "locationId": "loc_gm", "uses": 145, "discount": 1890.50 },
    { "locationId": "loc_hm", "uses": 112, "discount": 1456.78 }
  ],
  "byDay": [
    { "date": "2025-06-01", "uses": 23 },
    { "date": "2025-06-02", "uses": 31 }
  ]
}

Implementation Checklist

  • Create promotions table with JSONB columns
  • Create promotion_usage tracking table
  • Implement PromotionService with evaluation logic
  • Add /api/v1/promotions endpoints
  • Build promotion editor UI in Admin Portal
  • Add promotion display to POS cart
  • Implement auto-apply for loyalty promotions
  • Add promotion analytics to Reports
  • Test stacking logic thoroughly
  • Load test evaluation endpoint

Competitive Advantage

FeatureOur SystemRetail ProLightspeed
Minute-level scheduling
Customer type targeting⚠️ Limited
Complex stacking rules
Per-location restrictions
Usage limits (total/customer/day)⚠️ Limited
Auto-apply (no code needed)⚠️ Limited
Real-time evaluation API

We match Retail Pro’s 35-year sophistication in a modern architecture.


Appendix F - Version 1.0 Based on competitive analysis of Retail Pro Prism promotions engine

Appendix G: QuickBooks POS Migration Guide

For Retailers Displaced by QB POS V19 Discontinuation

You’re Not Alone: On October 3, 2023, Intuit discontinued QuickBooks Point of Sale V19 after 20+ years, leaving approximately 1,438 businesses scrambling for alternatives. This guide is for you.


What Happened to QuickBooks POS?

Timeline of Events

DateEvent
1999QuickBooks POS launched
2019V19 released (final version)
February 2023Intuit announces discontinuation
February 2023Sales to new customers cease
October 3, 2023Official end-of-life - services terminated
TodaySoftware still runs but with no support, updates, or payment processing

What Stopped Working

  • QuickBooks Point of Sale Payments (merchant processing)
  • Gift Card Service (Givex integration)
  • Security updates and patches
  • Technical support
  • Future compatibility guarantees

What Still Works (For Now)

  • Local sales transactions (cash only or external card processing)
  • Inventory management (locally)
  • Reports (historical data access)
  • QuickBooks Desktop sync (until QB Desktop is also discontinued)

Why Consider Our POS Platform?

We Built What You Loved About QB POS

What You Loved in QB POSOur Platform
Works offline✅ Full offline-first architecture
Multi-store inventory✅ Real-time sync (better than file-based)
QuickBooks integration✅ QuickBooks Online API (modern)
One-time pricing option✅ Self-hosted = no recurring fees
Layaway management✅ Built-in
Employee commissions✅ Built-in
Standard hardware✅ Same printers, scanners, drawers

We Fixed What Frustrated You

QB POS Pain PointOur Solution
“Store Exchange” file sync delaysReal-time PostgreSQL database sync
Proprietary database (no direct access)Standard PostgreSQL (query directly)
V19 daily crashesModern architecture, tested thoroughly
Desktop-only, Windows-onlyWeb-based + Desktop + Mobile
No Shopify/e-commerceNative Shopify integration
7-month discontinuation noticeOpen source = you own the code forever
Expensive support ($79/month)Self-service docs + community

Feature Comparison: QB POS V19 vs Our Platform

Core POS Operations

FeatureQB POS V19Our PlatformNotes
Ring salesSame workflow
Returns/exchangesReceipt lookup included
Split paymentsAny combination
Hold/suspend saleResume anytime
LayawayFull lifecycle
Gift cards✅ (Givex - $15/mo)✅ (Built-in - free)No third-party dependency
DiscountsLine item and transaction
Price override✅ (Manager)✅ (Manager PIN)Same security model

Inventory Management

FeatureQB POS V19Our PlatformNotes
Track quantitiesPer-location
Low stock alertsConfigurable thresholds
Reorder pointsAuto-generate POs
Multi-store visibilityReal-time (not delayed)
Inter-store transfersWith verification
Physical countsCycle and full counts
Barcode printingStandard label formats
RFID scanningNative Raptag integration

Customer Management

FeatureQB POS V19Our PlatformNotes
Customer profilesFull history
Purchase historySearchable
Loyalty programsPoints-based
Store creditTrack and redeem
Customer types✅ (VIP, Employee)Fully customizable
Email marketingBuilt-in

Employee Management

FeatureQB POS V19Our PlatformNotes
User accountsRole-based
PIN loginFast entry
Time clockClock in/out
CommissionsPer-sale tracking
TipsTip pooling options
Performance reportsBy employee
Manager overridesPIN-protected

Multi-Store

FeatureQB POS V19Our PlatformNotes
Multiple locations✅ (Up to 20)✅ (Unlimited)No artificial limits
Centralized purchasingHQ model
Store transfersReal-time
Company-wide reportsAll stores
Sync methodFile-based (“Store Exchange”)Real-time databaseMajor upgrade
Sync frequencyManual/scheduledInstantNo more waiting

Integration

FeatureQB POS V19Our PlatformNotes
QuickBooks Desktop✅ (Native)⚠️ Via bridgeTransitional support
QuickBooks Online✅ (API)Modern integration
Shopify✅ (Native)Bidirectional sync
E-commerceOrders flow to POS
Payment processorsIntuit Payments onlyMultiple optionsStripe, Square, etc.

Technical

FeatureQB POS V19Our PlatformNotes
Operating systemWindows onlyAny (Web-based)Mac, Linux, tablets
DatabaseProprietaryPostgreSQLStandard, queryable
Offline mode✅ Full✅ FullSQLite local cache
Cloud backupAutomatic
Mobile appRaptag for RFID
API accessQBXML/COMREST/JSONModern, documented
Multi-tenantSaaS-ready

Migration Path

Phase 1: Data Export from QB POS (Do This Now)

While your QB POS is still working, export everything:

Export Inventory

QB POS Menu: File > Utilities > Export > Items

Export Format: CSV or Excel
Fields to include:
- Item Number (SKU)
- Item Name
- Description
- Department/Category
- Size, Color, Style (attributes)
- Cost
- Regular Price
- Vendor
- Reorder Point
- Quantity on Hand (per location)
- UPC/Barcode

Save as: qbpos_inventory_export_YYYYMMDD.csv

Export Customers

QB POS Menu: File > Utilities > Export > Customers

Export Format: CSV or Excel
Fields to include:
- Customer Name
- First Name, Last Name
- Company
- Address, City, State, ZIP
- Phone, Email
- Customer Type
- Account Balance
- Store Credit Balance
- Loyalty Points (if applicable)

Save as: qbpos_customers_export_YYYYMMDD.csv

QB POS Menu: Reports > Sales > Sales History

Date Range: All time (or last 2-3 years)
Export to Excel

Save as: qbpos_sales_history_YYYYMMDD.xlsx

Export Vendors

QB POS Menu: File > Utilities > Export > Vendors

Export Format: CSV

Save as: qbpos_vendors_export_YYYYMMDD.csv

Phase 2: Hardware Assessment

Compatible Hardware (Keep Using)

Hardware TypeCompatible ModelsNotes
Receipt PrintersEpson TM-T88 seriesIndustry standard
Star TSP100/TSP600Works via ESC/POS
Star mPOPCombined printer/drawer
Barcode ScannersHoneywell Hyperion 1300gUSB HID
Honeywell Voyager 1202gWireless
Any USB HID scannerKeyboard wedge mode
Cash DrawersStar 13x13, 16x16Printer-kick or USB
APG VasarioStandard interface
Label PrintersZebra GC420ZPL compatible
DYMO LabelWriterStandard labels

Hardware That Won’t Work

HardwareReasonReplacement
Intuit Card ReaderTied to discontinued Intuit PaymentsAny modern card terminal
Proprietary PIN padsIntuit-specificStandard payment terminal

Phase 3: Data Import to Our Platform

Import Inventory

# API endpoint for bulk import
POST /api/v1/items/bulk-import
Content-Type: application/json

{
  "source": "quickbooks_pos",
  "mappings": {
    "Item Number": "sku",
    "Item Name": "name",
    "Description": "description",
    "Department": "categoryName",
    "Cost": "cost",
    "Regular Price": "price",
    "Vendor": "vendorName",
    "Reorder Point": "reorderPoint",
    "UPC": "barcode"
  },
  "file": "base64_encoded_csv_content"
}

Import Customers

POST /api/v1/customers/bulk-import
Content-Type: application/json

{
  "source": "quickbooks_pos",
  "mappings": {
    "First Name": "firstName",
    "Last Name": "lastName",
    "Email": "email",
    "Phone": "phone",
    "Customer Type": "customerType",
    "Store Credit Balance": "storeCredit"
  },
  "file": "base64_encoded_csv_content"
}

Phase 4: Configuration

Set Up Locations

For each of your QB POS stores:
1. Create Location in our system
2. Map to Shopify location (if applicable)
3. Configure registers
4. Set up receipt templates

Configure Tax Rates

QB POS: Company > Preferences > Sales Tax
Export your tax rates and configure in:
Settings > Tax Configuration

Set Up Payment Methods

Configure your new payment processor:
- Stripe (recommended)
- Square
- Other integrated processor

Phase 5: Parallel Testing

Run both systems for 1-2 weeks:

  1. Process sales in BOTH systems
  2. Compare end-of-day reports
  3. Verify inventory accuracy
  4. Test all workflows (returns, layaways, etc.)
  5. Train staff on new system

Phase 6: Go Live

  1. Final inventory sync
  2. Cut over payment processing
  3. Retire QB POS
  4. Keep QB POS data archived (read-only access)

Common Migration Questions

“What about my transaction history?”

Option A: Export reports to Excel for reference (recommended) Option B: Keep QB POS installed in read-only mode for historical lookups Option C: Professional data migration service can import historical transactions

“What about outstanding layaways?”

  1. Export layaway report from QB POS
  2. Create matching layaways in new system
  3. Ensure deposit amounts match
  4. Complete layaways in new system as customers return

“What about gift card balances?”

  1. Export gift card report from QB POS
  2. Import balances to new gift card system
  3. Physical cards continue to work (barcode lookup)
  4. Consider offering bonus for balance verification

“What about my employees’ time records?”

  1. Export time entries from QB POS
  2. Final payroll should be run from QB POS data
  3. New time tracking starts fresh in new system
  4. Keep QB POS records for tax/audit purposes

“Will my barcode scanners still work?”

Yes! USB barcode scanners work as keyboard input devices. They’ll work with any system.

“What about my receipt printer?”

Yes! Epson and Star printers use standard ESC/POS commands. We support them natively.

“Is there training available?”

  • Video tutorials for each workflow
  • In-app help (F1 key)
  • Documentation at [your-docs-url]
  • Migration support available

Cost Comparison

QuickBooks POS V19 (What You Were Paying)

ItemOne-TimeMonthly
Software (Pro)$1,360-
Multi-Store upgrade+$160-
Support (optional)-$79
Gift Cards (Givex)-$14.95
Payment Processing-2.4% + $0.25/trans
Total Year 1$1,520~$94/mo + processing

Our Platform (Self-Hosted)

ItemOne-TimeMonthly
Software License$0$0
NAS/Server Hardware~$500-
Electricity/Internet-~$20
Payment Processing-Varies by processor
Total Year 1~$500~$20/mo + processing

5-Year Total Cost of Ownership

Solution5-Year Cost
QB POS V19 (was)~$7,000+
Shopify POS (Intuit recommended)~$6,000+
Our Platform (self-hosted)~$1,700

What You’ll Gain (Beyond QB POS)

Real-Time Multi-Store Sync

QB POS "Store Exchange":
- Manual file export/import
- Hours of delay between syncs
- Conflict resolution nightmares

Our Platform:
- Instant database replication
- Changes visible immediately
- Automatic conflict resolution

Shopify Integration

QB POS:
- No e-commerce connection
- Manual order entry
- Inventory discrepancies

Our Platform:
- Shopify orders flow to POS
- Inventory syncs bidirectionally
- Unified customer view

RFID Inventory (Raptag)

QB POS:
- Manual barcode scanning
- Physical counts take days
- Easy to miss items

Our Platform + Raptag:
- Scan entire store in minutes
- Wave scanner over racks
- 99.9% accuracy

Modern Access

QB POS:
- Windows desktop only
- Must be at store
- No remote visibility

Our Platform:
- Any browser, any device
- Check sales from anywhere
- Real-time dashboards

Migration Checklist

Before Migration

  • Export all inventory to CSV
  • Export all customers to CSV
  • Export sales history reports
  • Export vendor list
  • Document current tax rates
  • List all active layaways
  • List all gift card balances
  • Inventory all hardware
  • Back up QB POS data files
  • Cancel Intuit Payments (if still active)
  • Cancel Givex gift card service

During Migration

  • Install our platform
  • Import inventory data
  • Import customer data
  • Configure locations
  • Set up registers
  • Configure receipt templates
  • Set up payment processing
  • Configure tax rates
  • Create employee accounts
  • Test all hardware
  • Run parallel testing (1-2 weeks)

After Migration

  • Final inventory verification
  • Migrate outstanding layaways
  • Import gift card balances
  • Train all staff
  • Go live on new system
  • Archive QB POS data (keep accessible)
  • Decommission QB POS (don’t uninstall yet)
  • 30-day review and optimization

Getting Help

Self-Service Resources

  • Documentation: Blueprint Book
  • Video Tutorials: Coming soon
  • FAQ: This appendix + online knowledge base

Migration Support

For complex migrations (multiple stores, large inventories):

  • Professional services available
  • Custom data mapping
  • On-site training

Community

  • GitHub Issues for bug reports
  • Community forum for questions
  • Feature requests welcome

Final Thoughts

We understand your frustration. You chose QuickBooks POS, used it for years (maybe decades), invested in hardware and training, and then Intuit pulled the rug out with 7 months notice.

We’re building something different:

  • Open architecture you can trust
  • Modern technology that will last
  • The features you loved, without the limitations
  • Your data, your control

Welcome to your new POS home.


Appendix G - Version 1.0 For QuickBooks POS V19 users seeking a migration path Last updated: December 2025

Appendix H: Product & Variant Model

Understanding the Parent-Child Relationship

This appendix documents how products and variants work in the POS Platform, with specific attention to Shopify compatibility and the Option1/Option2/Option3 system.


Core Concept: Products vs Variants

The Hierarchy

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                         PRODUCT-VARIANT HIERARCHY                                    │
└─────────────────────────────────────────────────────────────────────────────────────┘

                              PRODUCT (Parent)
                        ┌──────────────────────────┐
                        │  id: 12345               │
                        │  name: "Galaxy V-Neck"   │
                        │  description: "Soft..."  │
                        │  vendor: "Acme Apparel"  │
                        │  category: "Men's Tops"  │
                        │  tags: ["summer", "new"] │
                        │  status: "active"        │
                        │                          │
                        │  ┌──────────────────────┐│
                        │  │ OPTIONS              ││
                        │  │ 1. Color (Red, Blue) ││
                        │  │ 2. Size (S, M, L, XL)││
                        │  │ 3. (unused)          ││
                        │  └──────────────────────┘│
                        └─────────────┬────────────┘
                                      │
           ┌──────────────────────────┼──────────────────────────┐
           │              │              │              │        │
           ▼              ▼              ▼              ▼        ▼
    ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
    │ VARIANT 1  │ │ VARIANT 2  │ │ VARIANT 3  │ │ VARIANT 8  │ ...
    │            │ │            │ │            │ │            │
    │ Red / S    │ │ Red / M    │ │ Red / L    │ │ Blue / XL  │
    │            │ │            │ │            │ │            │
    │ SKU:       │ │ SKU:       │ │ SKU:       │ │ SKU:       │
    │ NXJ1078-   │ │ NXJ1078-   │ │ NXJ1078-   │ │ NXJ1078-   │
    │ RED-S      │ │ RED-M      │ │ RED-L      │ │ BLU-XL     │
    │            │ │            │ │            │ │            │
    │ Barcode:   │ │ Barcode:   │ │ Barcode:   │ │ Barcode:   │
    │ 0657381... │ │ 0657381... │ │ 0657381... │ │ 0657381... │
    │            │ │            │ │            │ │            │
    │ Price:     │ │ Price:     │ │ Price:     │ │ Price:     │
    │ $29.00     │ │ $29.00     │ │ $29.00     │ │ $32.00     │
    │            │ │            │ │            │ │            │
    │ Inventory: │ │ Inventory: │ │ Inventory: │ │ Inventory: │
    │ GM: 5      │ │ GM: 8      │ │ GM: 3      │ │ GM: 2      │
    │ HM: 3      │ │ HM: 6      │ │ HM: 4      │ │ HM: 0      │
    │ LM: 7      │ │ LM: 4      │ │ LM: 5      │ │ LM: 3      │
    └────────────┘ └────────────┘ └────────────┘ └────────────┘

    Color × Size = 2 × 4 = 8 VARIANTS (Cartesian product)

What Lives Where

DataParent ProductVariant
Name/Title✅ “Galaxy V-Neck Tee”Derived: “Galaxy V-Neck - Red / S”
Description✅ Full HTML description❌ (inherits from parent)
Category✅ “Men’s Tops”❌ (inherits from parent)
Vendor✅ “Acme Apparel”❌ (inherits from parent)
Tags✅ [“summer”, “new”, “sale”]❌ (inherits from parent)
SEO/URL✅ /products/galaxy-vneck❌ (one URL per product)
Options✅ Color, Size definitionsValues: Red, S
SKU✅ Unique per variant
Barcode✅ Unique UPC per variant
PriceBase price (optional)✅ Actual selling price
Cost✅ Per-variant cost
Inventory✅ Per-variant, per-location
Images✅ Multiple product imagesSingle variant-specific image

The Option System: Option1, Option2, Option3

Shopify Compatibility

Shopify supports exactly 3 options per product. This is a hard limit that cannot be extended via configuration or apps.

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                           OPTION SYSTEM                                              │
└─────────────────────────────────────────────────────────────────────────────────────┘

PRODUCT LEVEL:
┌─────────────────────────────────────────────────────────────────────────────────────┐
│  options: [                                                                          │
│    { name: "Color",    position: 1, values: ["Red", "Blue", "Green", "Black"] },    │
│    { name: "Size",     position: 2, values: ["S", "M", "L", "XL", "XXL"] },         │
│    { name: "Material", position: 3, values: ["Cotton", "Polyester"] }               │
│  ]                                                                                   │
└─────────────────────────────────────────────────────────────────────────────────────┘

VARIANT LEVEL:
┌─────────────────────────────────────────────────────────────────────────────────────┐
│  {                                                                                   │
│    "sku": "SHIRT-RED-M-COT",                                                        │
│    "option1": "Red",         ← Maps to options[0] (Color)                           │
│    "option2": "M",           ← Maps to options[1] (Size)                            │
│    "option3": "Cotton",      ← Maps to options[2] (Material)                        │
│    "price": 29.99,                                                                   │
│    "barcode": "0657381512532"                                                       │
│  }                                                                                   │
└─────────────────────────────────────────────────────────────────────────────────────┘

Option Naming Conventions

IndustryOption1 (Primary)Option2 (Secondary)Option3 (Tertiary)
ApparelColorSizeMaterial / Fit
FootwearSizeWidthColor
ElectronicsCapacity (32GB)ColorConnectivity
FurnitureColorSizeMaterial
FoodFlavorSizePack Quantity

Variant Count Calculation

VARIANT FORMULA: Option1.values × Option2.values × Option3.values

Example 1: Simple T-Shirt
┌─────────────────────────────────────────────────┐
│  Color: Red, Blue, Black (3 values)             │
│  Size: S, M, L, XL (4 values)                   │
│  Material: (not used)                           │
│                                                 │
│  Total Variants: 3 × 4 = 12                     │
└─────────────────────────────────────────────────┘

Example 2: Premium Dress Shirt
┌─────────────────────────────────────────────────┐
│  Color: White, Blue, Pink, Lavender (4 values)  │
│  Size: 14.5-32, 15-32, 15-34, ... (10 sizes)   │
│  Fit: Slim, Regular (2 values)                  │
│                                                 │
│  Total Variants: 4 × 10 × 2 = 80                │
└─────────────────────────────────────────────────┘

Example 3: Maximum Complexity (WARNING)
┌─────────────────────────────────────────────────┐
│  Color: 10 colors                               │
│  Size: 10 sizes                                 │
│  Material: 3 materials                          │
│                                                 │
│  Total Variants: 10 × 10 × 3 = 300              │
│                                                 │
│  ⚠️  EXCEEDS SHOPIFY LIMIT OF 100!              │
│  (2,048 limit available with new API)           │
└─────────────────────────────────────────────────┘

Product Creation Flow

Step-by-Step Workflow

┌─────────────────────────────────────────────────────────────────────────────────────┐
│                        PRODUCT CREATION WORKFLOW                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘

STEP 1: CREATE PARENT PRODUCT
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  Admin Portal → Products → [+ New Product]                                          │
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  BASIC INFORMATION                                                           │   │
│  │                                                                               │   │
│  │  Product Name *         [Galaxy V-Neck Tee                               ]   │   │
│  │                                                                               │   │
│  │  Description            [────────────────────────────────────────────────]   │   │
│  │                         [Premium cotton v-neck with a modern fit.        ]   │   │
│  │                         [Perfect for everyday wear.                      ]   │   │
│  │                         [────────────────────────────────────────────────]   │   │
│  │                                                                               │   │
│  │  Vendor/Brand *         [Nexus Premier                              ▼ ]      │   │
│  │  Category *             [Men's Tops > T-Shirts                      ▼ ]      │   │
│  │  Product Type           [Apparel                                    ▼ ]      │   │
│  │                                                                               │   │
│  │  Tags                   [summer] [new arrival] [basics] [+ Add Tag]          │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│                                                           [Save & Continue →]       │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 2: DEFINE OPTIONS
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  PRODUCT OPTIONS                                                             │   │
│  │                                                                               │   │
│  │  Does this product have variants (size, color, etc.)?                        │   │
│  │  ● Yes, this product has multiple options                                    │   │
│  │  ○ No, this is a simple product (single variant)                             │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  OPTION 1 *                                                                   │   │
│  │  Name:   [Color                                      ▼ ]                      │   │
│  │  Values: [Red    ] [Blue   ] [Navy   ] [Black  ] [+ Add]                     │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  OPTION 2                                                        [+ Add Option] │
│  │  Name:   [Size                                       ▼ ]                      │   │
│  │  Values: [S      ] [M      ] [L      ] [XL     ] [+ Add]                     │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  OPTION 3 (Optional)                                             [+ Add Option] │
│  │  Name:   [                                           ▼ ]                      │   │
│  │  Values:                                                                      │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  VARIANT PREVIEW                                                              │   │
│  │  Based on your options, 16 variants will be created:                         │   │
│  │                                                                               │   │
│  │  Red/S, Red/M, Red/L, Red/XL,                                                │   │
│  │  Blue/S, Blue/M, Blue/L, Blue/XL,                                            │   │
│  │  Navy/S, Navy/M, Navy/L, Navy/XL,                                            │   │
│  │  Black/S, Black/M, Black/L, Black/XL                                         │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│                                                           [Save & Continue →]       │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 3: CONFIGURE VARIANTS
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  VARIANT DETAILS                                                 [Bulk Edit] │   │
│  │                                                                               │   │
│  │  ┌───────────────────────────────────────────────────────────────────────┐   │   │
│  │  │ Default Price: [$29.00    ]    Default Cost: [$12.00    ]             │   │   │
│  │  │ [✓] Apply to all variants    [✓] Apply to all variants               │   │   │
│  │  └───────────────────────────────────────────────────────────────────────┘   │   │
│  │                                                                               │   │
│  │  ┌─────────────────────────────────────────────────────────────────────────┐ │   │
│  │  │ Variant      │ SKU              │ Barcode       │ Price  │ Cost   │ ✓  │ │   │
│  │  ├──────────────┼──────────────────┼───────────────┼────────┼────────┼────┤ │   │
│  │  │ Red / S      │ NXJ1078-RED-S    │ 0657381512501 │ $29.00 │ $12.00 │ ✓  │ │   │
│  │  │ Red / M      │ NXJ1078-RED-M    │ 0657381512502 │ $29.00 │ $12.00 │ ✓  │ │   │
│  │  │ Red / L      │ NXJ1078-RED-L    │ 0657381512503 │ $29.00 │ $12.00 │ ✓  │ │   │
│  │  │ Red / XL     │ NXJ1078-RED-XL   │ 0657381512504 │ $32.00 │ $13.00 │ ✓  │ │   │
│  │  │ Blue / S     │ NXJ1078-BLU-S    │ 0657381512505 │ $29.00 │ $12.00 │ ✓  │ │   │
│  │  │ Blue / M     │ NXJ1078-BLU-M    │ 0657381512506 │ $29.00 │ $12.00 │ ✓  │ │   │
│  │  │ ...          │ ...              │ ...           │ ...    │ ...    │    │ │   │
│  │  └─────────────────────────────────────────────────────────────────────────┘ │   │
│  │                                                                               │   │
│  │  SKU PATTERN: [NXJ1078-{color:3}-{size}]              [Auto-Generate SKUs]   │   │
│  │                                                                               │   │
│  │  ⚠️  2 variants have higher prices (XL sizes)                                │   │
│  │  ⚠️  0 variants are missing barcodes                                         │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│                                                           [Save & Continue →]       │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 4: SET INVENTORY
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  INVENTORY BY LOCATION                                                       │   │
│  │                                                                               │   │
│  │  Track inventory: [✓] Track quantity   [ ] Don't track (infinite stock)     │   │
│  │                                                                               │   │
│  │  ┌─────────────────────────────────────────────────────────────────────────┐ │   │
│  │  │ Variant      │ HQ (Warehouse) │ GM       │ HM       │ LM       │ NM     │ │   │
│  │  ├──────────────┼────────────────┼──────────┼──────────┼──────────┼────────┤ │   │
│  │  │ Red / S      │ [100         ] │ [5     ] │ [3     ] │ [4     ] │ [2   ] │ │   │
│  │  │ Red / M      │ [150         ] │ [8     ] │ [6     ] │ [7     ] │ [4   ] │ │   │
│  │  │ Red / L      │ [120         ] │ [6     ] │ [5     ] │ [5     ] │ [3   ] │ │   │
│  │  │ Red / XL     │ [80          ] │ [3     ] │ [2     ] │ [3     ] │ [2   ] │ │   │
│  │  │ Blue / S     │ [90          ] │ [4     ] │ [3     ] │ [3     ] │ [2   ] │ │   │
│  │  │ ...          │ ...            │ ...      │ ...      │ ...      │ ...    │ │   │
│  │  └─────────────────────────────────────────────────────────────────────────┘ │   │
│  │                                                                               │   │
│  │  TOTALS:                                                                      │   │
│  │  ┌─────────────────────────────────────────────────────────────────────────┐ │   │
│  │  │  HQ: 1,520 units  │  GM: 92  │  HM: 68  │  LM: 75  │  NM: 45           │ │   │
│  │  │                                                                         │ │   │
│  │  │  NETWORK TOTAL: 1,800 units                                             │ │   │
│  │  └─────────────────────────────────────────────────────────────────────────┘ │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│                                                           [Save & Continue →]       │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 5: ADD IMAGES
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  PRODUCT IMAGES                                                              │   │
│  │                                                                               │   │
│  │  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐            │   │
│  │  │             │ │             │ │             │ │             │            │   │
│  │  │   [MAIN]    │ │   [BACK]    │ │   [RED]     │ │   [BLUE]    │            │   │
│  │  │    📷      │ │    📷      │ │    📷      │ │    📷      │            │   │
│  │  │             │ │             │ │             │ │             │            │   │
│  │  └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘            │   │
│  │   Primary        Position 2       Color swatch    Color swatch              │   │
│  │                                   → Red variants  → Blue variants            │   │
│  │                                                                               │   │
│  │  [+ Upload Images]  [Link from URL]                                          │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  VARIANT IMAGE MAPPING (Optional)                                            │   │
│  │                                                                               │   │
│  │  ┌─────────────────────────────────────────────────────────────────────────┐ │   │
│  │  │  Color   │ Image                                                        │ │   │
│  │  ├──────────┼──────────────────────────────────────────────────────────────┤ │   │
│  │  │  Red     │ [red-vneck.jpg                                          ▼ ] │ │   │
│  │  │  Blue    │ [blue-vneck.jpg                                         ▼ ] │ │   │
│  │  │  Navy    │ [navy-vneck.jpg                                         ▼ ] │ │   │
│  │  │  Black   │ [black-vneck.jpg                                        ▼ ] │ │   │
│  │  └─────────────────────────────────────────────────────────────────────────┘ │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│                                                           [Save & Continue →]       │
└─────────────────────────────────────────────────────────────────────────────────────┘
                                         │
                                         ▼
STEP 6: REVIEW & PUBLISH
┌─────────────────────────────────────────────────────────────────────────────────────┐
│                                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────────────┐   │
│  │  REVIEW PRODUCT                                                              │   │
│  │                                                                               │   │
│  │  ✓ Basic Information complete                                                │   │
│  │  ✓ 2 options defined (Color, Size)                                           │   │
│  │  ✓ 16 variants configured                                                    │   │
│  │  ✓ All variants have SKUs                                                    │   │
│  │  ⚠️  4 variants missing barcodes                                             │   │
│  │  ✓ Inventory set for all locations                                          │   │
│  │  ✓ 4 images uploaded                                                         │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  PUBLISH STATUS                                                               │   │
│  │                                                                               │   │
│  │  ○ Draft (not visible to customers)                                          │   │
│  │  ● Active (visible on storefront)                                            │   │
│  │  ○ Archived (hidden, preserved for records)                                  │   │
│  │                                                                               │   │
│  │  ──────────────────────────────────────────────────────────────────────────  │   │
│  │                                                                               │   │
│  │  SHOPIFY SYNC                                                                │   │
│  │                                                                               │   │
│  │  [✓] Sync to Shopify                                                         │   │
│  │      Product will be created/updated in Shopify store                       │   │
│  │                                                                               │   │
│  └─────────────────────────────────────────────────────────────────────────────┘   │
│                                                                                     │
│  [← Back to Edit]                                      [Save as Draft] [Publish]   │
│                                                                                     │
└─────────────────────────────────────────────────────────────────────────────────────┘

Database Schema

Products Table (Parent)

CREATE TABLE products (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES shared.tenants(id),

    -- Basic Information
    name            VARCHAR(255) NOT NULL,
    description     TEXT,
    handle          VARCHAR(255) NOT NULL,           -- URL slug
    vendor_id       UUID REFERENCES vendors(id),
    category_id     UUID REFERENCES categories(id),
    product_type    VARCHAR(100),

    -- Options Definition (up to 3)
    option1_name    VARCHAR(50),                     -- e.g., "Color"
    option1_values  TEXT[],                          -- e.g., {"Red", "Blue", "Black"}
    option2_name    VARCHAR(50),                     -- e.g., "Size"
    option2_values  TEXT[],                          -- e.g., {"S", "M", "L", "XL"}
    option3_name    VARCHAR(50),                     -- e.g., "Material"
    option3_values  TEXT[],                          -- e.g., {"Cotton", "Polyester"}

    -- Status
    status          VARCHAR(20) DEFAULT 'draft',     -- draft, active, archived

    -- Shopify Sync
    shopify_product_id  BIGINT,
    shopify_synced_at   TIMESTAMPTZ,

    -- Metadata
    tags            TEXT[],
    metadata        JSONB DEFAULT '{}',

    -- Audit
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),
    created_by      UUID,
    deleted_at      TIMESTAMPTZ,                     -- Soft delete

    -- Constraints
    UNIQUE (tenant_id, handle),
    CHECK (status IN ('draft', 'active', 'archived'))
);

-- Indexes
CREATE INDEX idx_products_tenant_status ON products(tenant_id, status);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_vendor ON products(vendor_id);
CREATE INDEX idx_products_shopify ON products(shopify_product_id);
CREATE INDEX idx_products_tags ON products USING GIN(tags);

Product Variants Table (Children)

CREATE TABLE product_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,

    -- Identifiers (CRITICAL - must be unique)
    sku             VARCHAR(100) NOT NULL,
    barcode         VARCHAR(50),                     -- UPC/EAN

    -- Option Values (maps to parent's option definitions)
    option1_value   VARCHAR(100),                    -- e.g., "Red"
    option2_value   VARCHAR(100),                    -- e.g., "M"
    option3_value   VARCHAR(100),                    -- e.g., "Cotton"

    -- Derived title (for display)
    title           VARCHAR(255) GENERATED ALWAYS AS (
                      COALESCE(option1_value, '') ||
                      CASE WHEN option2_value IS NOT NULL THEN ' / ' || option2_value ELSE '' END ||
                      CASE WHEN option3_value IS NOT NULL THEN ' / ' || option3_value ELSE '' END
                    ) STORED,

    -- Pricing
    price           DECIMAL(10,2) NOT NULL,
    compare_at_price DECIMAL(10,2),                  -- "Was" price for sales
    cost            DECIMAL(10,2),                   -- Cost of goods

    -- Physical
    weight          DECIMAL(8,2),
    weight_unit     VARCHAR(10) DEFAULT 'lb',
    requires_shipping BOOLEAN DEFAULT true,

    -- Tax
    taxable         BOOLEAN DEFAULT true,
    tax_code        VARCHAR(50),

    -- Status
    position        INTEGER DEFAULT 1,               -- Display order
    is_active       BOOLEAN DEFAULT true,

    -- Shopify Sync
    shopify_variant_id      BIGINT,
    shopify_inventory_item_id BIGINT,
    shopify_synced_at       TIMESTAMPTZ,

    -- Audit
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW(),

    -- Constraints
    UNIQUE (tenant_id, sku),
    UNIQUE (tenant_id, barcode) WHERE barcode IS NOT NULL
);

-- Indexes
CREATE INDEX idx_variants_product ON product_variants(product_id);
CREATE INDEX idx_variants_sku ON product_variants(sku);
CREATE INDEX idx_variants_barcode ON product_variants(barcode);
CREATE INDEX idx_variants_shopify ON product_variants(shopify_variant_id);

Inventory Levels Table (Per-Variant, Per-Location)

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 product_variants(id) ON DELETE CASCADE,
    location_id     UUID NOT NULL REFERENCES locations(id),

    -- Quantities
    quantity_on_hand    INTEGER NOT NULL DEFAULT 0,
    quantity_committed  INTEGER NOT NULL DEFAULT 0,  -- Reserved for orders
    quantity_available  INTEGER GENERATED ALWAYS AS (quantity_on_hand - quantity_committed) STORED,

    -- Reorder
    reorder_point       INTEGER,
    reorder_quantity    INTEGER,

    -- Shopify Sync
    shopify_inventory_level_id  BIGINT,

    -- Audit
    updated_at      TIMESTAMPTZ DEFAULT NOW(),

    -- Constraints
    UNIQUE (variant_id, location_id),
    CHECK (quantity_on_hand >= 0),
    CHECK (quantity_committed >= 0)
);

-- Indexes
CREATE INDEX idx_inventory_variant ON inventory_levels(variant_id);
CREATE INDEX idx_inventory_location ON inventory_levels(location_id);
CREATE INDEX idx_inventory_low_stock ON inventory_levels(quantity_available)
    WHERE quantity_available <= reorder_point;

API Endpoints

Create Product with Variants

POST /api/v1/products
Content-Type: application/json

{
  "name": "Galaxy V-Neck Tee",
  "description": "Premium cotton v-neck with a modern fit.",
  "vendorId": "vendor_abc123",
  "categoryId": "cat_mens_tops",
  "productType": "Apparel",
  "tags": ["summer", "new arrival", "basics"],

  "options": [
    { "name": "Color", "values": ["Red", "Blue", "Navy", "Black"] },
    { "name": "Size", "values": ["S", "M", "L", "XL"] }
  ],

  "variants": [
    {
      "sku": "NXJ1078-RED-S",
      "barcode": "0657381512501",
      "option1Value": "Red",
      "option2Value": "S",
      "price": 29.00,
      "cost": 12.00,
      "inventory": [
        { "locationCode": "HQ", "quantity": 100 },
        { "locationCode": "GM", "quantity": 5 },
        { "locationCode": "HM", "quantity": 3 }
      ]
    },
    {
      "sku": "NXJ1078-RED-M",
      "barcode": "0657381512502",
      "option1Value": "Red",
      "option2Value": "M",
      "price": 29.00,
      "cost": 12.00,
      "inventory": [
        { "locationCode": "HQ", "quantity": 150 },
        { "locationCode": "GM", "quantity": 8 }
      ]
    }
    // ... more variants
  ],

  "status": "active",
  "syncToShopify": true
}

Response

{
  "id": "prod_abc123",
  "name": "Galaxy V-Neck Tee",
  "handle": "galaxy-v-neck-tee",
  "status": "active",
  "shopifyProductId": 7891234567890,

  "options": [
    { "name": "Color", "position": 1, "values": ["Red", "Blue", "Navy", "Black"] },
    { "name": "Size", "position": 2, "values": ["S", "M", "L", "XL"] }
  ],

  "variants": [
    {
      "id": "var_001",
      "sku": "NXJ1078-RED-S",
      "title": "Red / S",
      "price": 29.00,
      "shopifyVariantId": 39332384801985,
      "totalInventory": 108
    },
    {
      "id": "var_002",
      "sku": "NXJ1078-RED-M",
      "title": "Red / M",
      "price": 29.00,
      "shopifyVariantId": 39332384802001,
      "totalInventory": 158
    }
  ],

  "totalVariants": 16,
  "totalInventory": 1800,

  "createdAt": "2025-01-15T10:30:00Z",
  "updatedAt": "2025-01-15T10:30:00Z"
}

SKU Generation Patterns

{BRAND}{STYLE}-{OPTION1}-{OPTION2}[-{OPTION3}]

Examples:
NXJ1078-RED-S         ← Nexus Clothing, Style 1078, Red, Small
NXP0892-KHK-32        ← Nexus Clothing, Pants 0892, Khaki, Size 32
BRT-BLU-155           ← Burton, Blue, 155cm

Auto-Generation Logic

function generateSku(product, variant) {
  const brand = product.vendorCode || 'GEN';  // 3-char brand code
  const style = product.styleNumber;           // 4-digit style

  let sku = `${brand}${style}`;

  if (variant.option1Value) {
    sku += `-${abbreviate(variant.option1Value, 3)}`;  // RED, BLU, BLK
  }
  if (variant.option2Value) {
    sku += `-${variant.option2Value}`;  // S, M, L, 32, 34
  }
  if (variant.option3Value) {
    sku += `-${abbreviate(variant.option3Value, 3)}`;  // COT, PLY
  }

  return sku.toUpperCase();
}

function abbreviate(value, length) {
  // Standard abbreviations
  const map = {
    'Red': 'RED', 'Blue': 'BLU', 'Black': 'BLK', 'White': 'WHT',
    'Navy': 'NAV', 'Green': 'GRN', 'Grey': 'GRY', 'Gray': 'GRY',
    'Cotton': 'COT', 'Polyester': 'PLY', 'Wool': 'WOL',
    'Small': 'S', 'Medium': 'M', 'Large': 'L', 'Extra Large': 'XL'
  };
  return map[value] || value.substring(0, length).toUpperCase();
}

Shopify Sync Mapping

Product Fields

POS FieldShopify FieldNotes
nametitleDirect mapping
descriptionbody_htmlMay include HTML
handlehandleURL slug, auto-generated
vendor.namevendorString, not ID
category.nameproduct_typeString, not ID
tags[]tagsComma-separated string
statusstatusactive/draft/archived
option1_nameoptions[0].namee.g., “Color”
option1_values[]options[0].values[]e.g., [“Red”, “Blue”]

Variant Fields

POS FieldShopify FieldNotes
skuskuPrimary sync key
barcodebarcodeUPC/EAN
option1_valueoption1e.g., “Red”
option2_valueoption2e.g., “M”
option3_valueoption3e.g., “Cotton”
pricepriceDecimal as string
compare_at_pricecompare_at_priceSale pricing
costN/AShopify uses inventory_item.cost
weightweightWith weight_unit
taxabletaxableBoolean
requires_shippingrequires_shippingBoolean

Inventory Sync

POS inventory_levels      →     Shopify InventoryLevel
─────────────────────────────────────────────────────────
variant.shopify_inventory_item_id  →  inventory_item_id
location.shopify_location_id       →  location_id
quantity_available                 →  available

POST /admin/api/2025-01/inventory_levels/set.json
{
  "location_id": 655441491,
  "inventory_item_id": 808950810,
  "available": 42
}

Variant Limits & Workarounds

Shopify Limits

LimitValueWorkaround
Max Options3Split into separate products
Max Variants100 (legacy) / 2,048 (new)Split into separate products
Max Images250 per productUse CDN for extras
SKU Length255 charsKeep under 100 recommended

When to Split Products

Split a product into multiple Shopify products when:

  1. More than 3 options needed (e.g., Size, Color, Material, Inseam)
  2. Variant count exceeds 100 (or 2,048 with new API)
  3. Variants need unique descriptions (Shopify shares description)
  4. SEO requires separate pages (different keywords per variant group)
SPLIT EXAMPLE:
─────────────────────────────────────────────────────
Original: "Dress Shirt" with Color × Size × Fit × Collar = 500+ variants

Split into:
├── "Dress Shirt - Slim Fit"     (Color × Size = 100 variants)
├── "Dress Shirt - Regular Fit"  (Color × Size = 100 variants)
├── "Dress Shirt - Classic Fit"  (Color × Size = 100 variants)
└── ... (grouped by Fit, which becomes implicit)

Link them with:
- Same "product_group" tag
- Cross-reference in descriptions
- Collection grouping

Implementation Checklist

  • Create products table with option columns
  • Create product_variants table with option values
  • Create inventory_levels table with per-location tracking
  • Implement SKU auto-generation
  • Build product creation wizard UI (6 steps)
  • Implement Shopify sync service for products
  • Implement Shopify sync service for variants
  • Implement Shopify sync service for inventory levels
  • Add bulk import from CSV
  • Add variant matrix editor for quick updates
  • Handle Shopify >100 variant split logic

Appendix H - Version 1.0 Product and Variant Model Documentation Last updated: December 2025