Chapter 23: Phase 4 - POS Client Implementation

23.1 Overview

Phase 4 delivers the POS Client Terminal - the revenue-generating touchpoint where customer transactions occur. This 6-week dedicated phase (Weeks 14-19) builds the native, offline-capable application that cashiers and floor staff use daily.

Key Insight: The Web Admin Portal is the main portal for platform operations. The POS Client is the main portal for client operations. Both deserve equal architectural attention.

Why POS Client Needs Its Own Phase

AspectWeb Admin PortalPOS Client Terminal
Primary UsersStore managers, adminsCashiers, floor staff
TechnologyBlazor Server (web).NET MAUI Blazor Hybrid (native)
ConnectivityAlways onlineOffline-first required
HardwareNonePrinters, scanners, cash drawers, payment terminals
UI ComplexityStandard CRUD formsTouch-optimized, configurable layouts
SessionsLong (hours)Short (per transaction)
Critical PathBusiness managementRevenue generation

23.2 Technology Decision: .NET MAUI Blazor Hybrid

Why Not PWA?

FactorPWANative/HybridWinner
Offline reliabilityiOS evicts data after 7 days unusedTrue SQLite, persistentNative
Receipt printersRequires bridge appDirect P/InvokeNative
Cash drawersNo Web API existsNative accessNative
Barcode scanningSafari doesn’t supportZXing.Net.MauiNative
Update deploymentInstant (web push)Portal downloadPWA
Development costLowerHigherPWA

Verdict: PWA is insufficient for mission-critical POS. Native/Hybrid required.

Why .NET MAUI Blazor Hybrid?

RationaleBenefit
Aligns with stackBackend uses ASP.NET Core + Blazor
Matches ADR-002Offline-first SQLite architecture maps directly
Single codebaseAndroid tablets, Windows back-office, macOS
Hardware accessNative APIs for printers, scanners
Skill reuseSame Blazor components for web admin portal

23.3 Phase 4 Scope

POS CLIENT SCOPE
================

1. UI/UX DESIGN
   ├── Touch-optimized sale screen
   ├── Product grid with categories
   ├── Cart management
   ├── Customer lookup
   ├── Retail Pro-style drag-and-drop layout configuration
   └── Cashier vs Manager mode switching

2. CORE FEATURES
   ├── Sale processing (full workflow)
   ├── Payment handling (cash, card, split)
   ├── Receipt printing
   ├── Returns/exchanges
   ├── Discounts/promotions
   ├── Gift cards
   ├── Customer loyalty integration
   └── End-of-day operations

3. HARDWARE INTEGRATION
   ├── Receipt printers (Epson, Star Micronics)
   ├── Barcode scanners (USB, Bluetooth)
   ├── Cash drawers (kick signals)
   ├── Payment terminals (Stripe Terminal)
   ├── Customer-facing displays
   └── RFID readers (Raptag integration)

4. OFFLINE OPERATIONS
   ├── Local SQLite database
   ├── Transaction queue
   ├── Sync engine
   ├── Conflict resolution
   └── Offline payment handling

5. DISTRIBUTION & UPDATES
   ├── Portal-based download
   ├── Registration flow
   ├── Auto-update mechanism
   └── Version management

6. CONFIGURATION SYSTEM
   ├── Drag-and-drop UI builder (Retail Pro style)
   ├── Quick-access button configuration
   ├── Receipt template customization
   ├── Per-location settings
   └── Hardware profile management

23.4 Week 14: Project Setup & Core UI

Day 1-2: .NET MAUI Blazor Hybrid Project

Objective: Create the project structure with offline-first architecture.

Claude Command:

/dev-team create .NET MAUI Blazor Hybrid project for POS client

Project Structure:

RapOS.PosClient/
├── RapOS.PosClient/
│   ├── App.xaml
│   ├── MauiProgram.cs
│   ├── Platforms/
│   │   ├── Android/
│   │   ├── iOS/
│   │   ├── MacCatalyst/
│   │   └── Windows/
│   ├── Resources/
│   └── wwwroot/
├── RapOS.PosClient.Core/
│   ├── Models/
│   ├── Services/
│   ├── Data/
│   └── Interfaces/
├── RapOS.PosClient.UI/
│   ├── Components/
│   │   ├── Layout/
│   │   ├── Sale/
│   │   ├── Products/
│   │   └── Shared/
│   ├── Pages/
│   └── Themes/
└── RapOS.PosClient.Hardware/
    ├── Printers/
    ├── Scanners/
    ├── CashDrawers/
    └── Payments/

Implementation:

// MauiProgram.cs
using Microsoft.Extensions.Logging;

namespace RapOS.PosClient;

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
    {
        var builder = MauiApp.CreateBuilder();
        builder
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            });

        builder.Services.AddMauiBlazorWebView();

#if DEBUG
        builder.Services.AddBlazorWebViewDeveloperTools();
        builder.Logging.AddDebug();
#endif

        // Core services
        builder.Services.AddSingleton<ILocalDatabase, SqliteDatabase>();
        builder.Services.AddSingleton<ISyncService, SyncService>();
        builder.Services.AddSingleton<ITerminalContext, TerminalContext>();

        // Hardware services
        builder.Services.AddSingleton<IPrinterService, EscPosPrinterService>();
        builder.Services.AddSingleton<IScannerService, BarcodeScannerService>();
        builder.Services.AddSingleton<ICashDrawerService, CashDrawerService>();

        // Sale services
        builder.Services.AddScoped<ISaleService, SaleService>();
        builder.Services.AddScoped<ICartService, CartService>();
        builder.Services.AddScoped<IPaymentService, PaymentService>();

        // Configuration
        builder.Services.AddSingleton<ILayoutService, LayoutService>();
        builder.Services.AddSingleton<ISettingsService, SettingsService>();

        return builder.Build();
    }
}

Day 3-4: SQLite Local Database Schema

Objective: Implement offline-first local database with sync support.

Claude Command:

/dev-team create SQLite schema for offline POS operations

Implementation:

// RapOS.PosClient.Core/Data/SqliteDatabase.cs
using Microsoft.Data.Sqlite;
using SQLitePCL;

namespace RapOS.PosClient.Core.Data;

public interface ILocalDatabase
{
    Task InitializeAsync();
    Task<SqliteConnection> GetConnectionAsync();
    Task ExecuteAsync(string sql, object? parameters = null);
    Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null);
    Task<List<T>> QueryAsync<T>(string sql, object? parameters = null);
}

public class SqliteDatabase : ILocalDatabase
{
    private readonly string _dbPath;
    private SqliteConnection? _connection;

    public SqliteDatabase()
    {
        _dbPath = Path.Combine(
            FileSystem.AppDataDirectory,
            "rapos_pos.db");
    }

    public async Task InitializeAsync()
    {
        Batteries.Init();

        _connection = new SqliteConnection($"Data Source={_dbPath}");
        await _connection.OpenAsync();

        await CreateTablesAsync();
    }

    private async Task CreateTablesAsync()
    {
        // Terminal configuration
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS terminal_config (
                id INTEGER PRIMARY KEY,
                tenant_code TEXT NOT NULL,
                location_id TEXT NOT NULL,
                terminal_id TEXT NOT NULL,
                terminal_name TEXT NOT NULL,
                api_endpoint TEXT NOT NULL,
                api_key TEXT NOT NULL,
                layout_config TEXT,
                last_sync_at TEXT,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
            )");

        // Products cache
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS products (
                id TEXT PRIMARY KEY,
                sku TEXT NOT NULL,
                name TEXT NOT NULL,
                description TEXT,
                category_id TEXT,
                category_name TEXT,
                base_price REAL NOT NULL,
                tax_rate REAL DEFAULT 0,
                image_url TEXT,
                barcode TEXT,
                is_active INTEGER DEFAULT 1,
                quantity_on_hand INTEGER DEFAULT 0,
                synced_at TEXT NOT NULL,
                UNIQUE(sku)
            )");

        // Categories cache
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS categories (
                id TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                parent_id TEXT,
                display_order INTEGER DEFAULT 0,
                color TEXT,
                icon TEXT,
                synced_at TEXT NOT NULL
            )");

        // Customers cache
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS customers (
                id TEXT PRIMARY KEY,
                first_name TEXT,
                last_name TEXT,
                email TEXT,
                phone TEXT,
                loyalty_points INTEGER DEFAULT 0,
                loyalty_tier TEXT,
                synced_at TEXT NOT NULL
            )");

        // Local sales (pending sync)
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS sales (
                id TEXT PRIMARY KEY,
                local_id INTEGER AUTOINCREMENT,
                status TEXT NOT NULL DEFAULT 'pending',
                customer_id TEXT,
                cashier_id TEXT NOT NULL,
                subtotal REAL NOT NULL,
                tax_total REAL NOT NULL,
                discount_total REAL DEFAULT 0,
                grand_total REAL NOT NULL,
                created_at TEXT NOT NULL,
                completed_at TEXT,
                synced_at TEXT,
                sync_attempts INTEGER DEFAULT 0,
                sync_error TEXT
            )");

        // Sale line items
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS sale_items (
                id TEXT PRIMARY KEY,
                sale_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_amount REAL DEFAULT 0,
                tax_amount REAL NOT NULL,
                line_total REAL NOT NULL,
                FOREIGN KEY (sale_id) REFERENCES sales(id)
            )");

        // Payments
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS payments (
                id TEXT PRIMARY KEY,
                sale_id TEXT NOT NULL,
                method TEXT NOT NULL,
                amount REAL NOT NULL,
                reference TEXT,
                card_last_four TEXT,
                card_brand TEXT,
                created_at TEXT NOT NULL,
                FOREIGN KEY (sale_id) REFERENCES sales(id)
            )");

        // Sync queue for offline transactions
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS sync_queue (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                entity_type TEXT NOT NULL,
                entity_id TEXT NOT NULL,
                operation TEXT NOT NULL,
                payload TEXT NOT NULL,
                priority INTEGER DEFAULT 0,
                attempts INTEGER DEFAULT 0,
                last_attempt_at TEXT,
                error TEXT,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
                UNIQUE(entity_type, entity_id, operation)
            )");

        // Quick access buttons configuration
        await ExecuteAsync(@"
            CREATE TABLE IF NOT EXISTS quick_access_buttons (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                position INTEGER NOT NULL,
                product_id TEXT,
                category_id TEXT,
                label TEXT NOT NULL,
                color TEXT,
                icon TEXT,
                action_type TEXT NOT NULL
            )");

        // Create indexes for performance
        await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_sku ON products(sku)");
        await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_barcode ON products(barcode)");
        await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id)");
        await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_sales_status ON sales(status)");
        await ExecuteAsync("CREATE INDEX IF NOT EXISTS idx_sync_queue_priority ON sync_queue(priority DESC, created_at ASC)");
    }

    public async Task<SqliteConnection> GetConnectionAsync()
    {
        if (_connection == null)
            await InitializeAsync();
        return _connection!;
    }

    public async Task ExecuteAsync(string sql, object? parameters = null)
    {
        var conn = await GetConnectionAsync();
        using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        // Add parameters...
        await cmd.ExecuteNonQueryAsync();
    }

    public async Task<T?> QuerySingleAsync<T>(string sql, object? parameters = null)
    {
        // Implementation with Dapper-style mapping
        throw new NotImplementedException();
    }

    public async Task<List<T>> QueryAsync<T>(string sql, object? parameters = null)
    {
        // Implementation with Dapper-style mapping
        throw new NotImplementedException();
    }
}

Day 5: Main Sale Screen UI

Objective: Build the primary POS interface with product grid and cart.

Claude Command:

/dev-team create main sale screen component with responsive touch layout

Implementation:

@* RapOS.PosClient.UI/Pages/SaleScreen.razor *@
@page "/sale"
@inject ICartService CartService
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject IPaymentService PaymentService
@inject ILayoutService LayoutService

<div class="sale-screen @(IsDarkMode ? "dark" : "light")">
    @* Header Bar *@
    <header class="pos-header">
        <div class="header-left">
            <img src="images/rapos-logo.svg" class="logo" alt="RapOS" />
            <span class="location-name">@CurrentLocation</span>
        </div>
        <div class="header-center">
            <div class="search-box">
                <span class="search-icon">🔍</span>
                <input type="text"
                       @bind="SearchQuery"
                       @bind:event="oninput"
                       @onkeydown="HandleSearchKeydown"
                       placeholder="Search or scan barcode..." />
            </div>
        </div>
        <div class="header-right">
            <button class="mode-toggle" @onclick="ToggleMode">
                @(IsManagerMode ? "Manager" : "Cashier")
            </button>
            <button class="theme-toggle" @onclick="ToggleTheme">
                @(IsDarkMode ? "☀️" : "🌙")
            </button>
            <span class="cashier-name">@CashierName</span>
        </div>
    </header>

    @* Main Content Area *@
    <main class="pos-main">
        @* Left Panel: Products *@
        <section class="products-panel">
            @* Category Quick Access *@
            <div class="category-bar">
                <button class="category-btn @(SelectedCategory == null ? "active" : "")"
                        @onclick="() => SelectCategory(null)">
                    All
                </button>
                @foreach (var category in Categories)
                {
                    <button class="category-btn @(SelectedCategory?.Id == category.Id ? "active" : "")"
                            style="--cat-color: @category.Color"
                            @onclick="() => SelectCategory(category)">
                        @category.Name
                    </button>
                }
            </div>

            @* Product Grid/List *@
            <div class="products-container @(IsGridView ? "grid-view" : "list-view")">
                @foreach (var product in FilteredProducts)
                {
                    <div class="product-card" @onclick="() => AddToCart(product)">
                        @if (IsGridView)
                        {
                            <div class="product-image">
                                @if (!string.IsNullOrEmpty(product.ImageUrl))
                                {
                                    <img src="@product.ImageUrl" alt="@product.Name" />
                                }
                                else
                                {
                                    <span class="placeholder-icon">📦</span>
                                }
                            </div>
                        }
                        <div class="product-info">
                            <span class="product-name">@product.Name</span>
                            <span class="product-sku">@product.Sku</span>
                            <span class="product-price">@product.BasePrice.ToString("C")</span>
                        </div>
                        @if (product.QuantityOnHand <= 3)
                        {
                            <span class="low-stock-badge">Low Stock</span>
                        }
                    </div>
                }
            </div>

            @* View Toggle *@
            <div class="view-toggle">
                <button class="@(IsGridView ? "active" : "")" @onclick="() => IsGridView = true">
                    Grid
                </button>
                <button class="@(!IsGridView ? "active" : "")" @onclick="() => IsGridView = false">
                    List
                </button>
            </div>
        </section>

        @* Right Panel: Cart *@
        <section class="cart-panel">
            @* Customer Section *@
            <div class="customer-section">
                @if (CurrentCustomer != null)
                {
                    <div class="customer-info">
                        <span class="customer-name">@CurrentCustomer.FullName</span>
                        <span class="loyalty-points">@CurrentCustomer.LoyaltyPoints pts</span>
                        <button class="remove-customer" @onclick="ClearCustomer">✕</button>
                    </div>
                }
                else
                {
                    <button class="add-customer-btn" @onclick="ShowCustomerLookup">
                        + Add Customer
                    </button>
                }
            </div>

            @* Cart Items *@
            <div class="cart-items">
                @if (!CartItems.Any())
                {
                    <div class="empty-cart">
                        <span class="empty-icon">🛒</span>
                        <p>Cart is empty</p>
                        <p class="hint">Scan or select products to add</p>
                    </div>
                }
                else
                {
                    @foreach (var item in CartItems)
                    {
                        <div class="cart-item">
                            <div class="item-info">
                                <span class="item-name">@item.Name</span>
                                <span class="item-sku">@item.Sku</span>
                            </div>
                            <div class="item-quantity">
                                <button @onclick="() => UpdateQuantity(item, -1)">−</button>
                                <span>@item.Quantity</span>
                                <button @onclick="() => UpdateQuantity(item, 1)">+</button>
                            </div>
                            <div class="item-price">
                                @item.LineTotal.ToString("C")
                            </div>
                            <button class="remove-item" @onclick="() => RemoveItem(item)">
                                🗑️
                            </button>
                        </div>
                    }
                }
            </div>

            @* Totals *@
            <div class="totals-section">
                <div class="total-row">
                    <span>Subtotal</span>
                    <span>@Subtotal.ToString("C")</span>
                </div>
                @if (DiscountTotal > 0)
                {
                    <div class="total-row discount">
                        <span>Discount</span>
                        <span>-@DiscountTotal.ToString("C")</span>
                    </div>
                }
                <div class="total-row">
                    <span>Tax</span>
                    <span>@TaxTotal.ToString("C")</span>
                </div>
                <div class="total-row grand-total">
                    <span>Total</span>
                    <span>@GrandTotal.ToString("C")</span>
                </div>
            </div>

            @* Action Buttons *@
            <div class="cart-actions">
                <button class="action-btn secondary" @onclick="ShowDiscountDialog">
                    Discount
                </button>
                <button class="action-btn secondary" @onclick="HoldSale" disabled="@(!CartItems.Any())">
                    Hold
                </button>
                <button class="action-btn primary pay-btn"
                        @onclick="ProceedToPayment"
                        disabled="@(!CartItems.Any())">
                    Pay @GrandTotal.ToString("C")
                </button>
            </div>
        </section>
    </main>

    @* Footer: Quick Access & Function Keys *@
    <footer class="pos-footer">
        <div class="quick-access">
            @foreach (var btn in QuickAccessButtons)
            {
                <button class="quick-btn"
                        style="--btn-color: @btn.Color"
                        @onclick="() => HandleQuickAction(btn)">
                    @btn.Label
                </button>
            }
        </div>
        <div class="function-keys">
            <button class="fn-key" @onclick="OpenDrawer">Open Drawer</button>
            <button class="fn-key" @onclick="ShowReturns">Returns</button>
            <button class="fn-key" @onclick="ShowHeldSales">Held Sales</button>
            <button class="fn-key" @onclick="ReprintReceipt">Reprint</button>
            @if (IsManagerMode)
            {
                <button class="fn-key manager" @onclick="ShowReports">Reports</button>
                <button class="fn-key manager" @onclick="ShowSettings">Settings</button>
            }
        </div>
    </footer>

    @* Offline Indicator *@
    @if (!IsOnline)
    {
        <div class="offline-banner">
            <span>⚡ Offline Mode - @PendingSyncCount transactions pending</span>
        </div>
    }
</div>

@code {
    private List<Product> Products = new();
    private List<Category> Categories = new();
    private List<CartItem> CartItems = new();
    private List<QuickAccessButton> QuickAccessButtons = new();

    private Category? SelectedCategory;
    private Customer? CurrentCustomer;
    private string SearchQuery = string.Empty;
    private bool IsGridView = true;
    private bool IsDarkMode = true;
    private bool IsManagerMode = false;
    private bool IsOnline = true;
    private int PendingSyncCount = 0;

    private string CurrentLocation => "Main Store";
    private string CashierName => "Jane D.";

    private IEnumerable<Product> FilteredProducts => Products
        .Where(p => SelectedCategory == null || p.CategoryId == SelectedCategory.Id)
        .Where(p => string.IsNullOrEmpty(SearchQuery) ||
                    p.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
                    p.Sku.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ||
                    p.Barcode == SearchQuery);

    private decimal Subtotal => CartItems.Sum(i => i.UnitPrice * i.Quantity);
    private decimal DiscountTotal => CartItems.Sum(i => i.DiscountAmount);
    private decimal TaxTotal => CartItems.Sum(i => i.TaxAmount);
    private decimal GrandTotal => Subtotal - DiscountTotal + TaxTotal;

    protected override async Task OnInitializedAsync()
    {
        Products = await ProductService.GetProductsAsync();
        Categories = await CategoryService.GetCategoriesAsync();
        QuickAccessButtons = await LayoutService.GetQuickAccessButtonsAsync();

        // Check connectivity
        IsOnline = await CheckConnectivity();
        PendingSyncCount = await CartService.GetPendingSyncCountAsync();
    }

    private async Task AddToCart(Product product)
    {
        await CartService.AddItemAsync(product);
        CartItems = await CartService.GetCartItemsAsync();
    }

    private async Task UpdateQuantity(CartItem item, int delta)
    {
        await CartService.UpdateQuantityAsync(item.Id, item.Quantity + delta);
        CartItems = await CartService.GetCartItemsAsync();
    }

    private async Task RemoveItem(CartItem item)
    {
        await CartService.RemoveItemAsync(item.Id);
        CartItems = await CartService.GetCartItemsAsync();
    }

    private async Task ProceedToPayment()
    {
        // Navigate to payment screen
        // PaymentService.InitiatePayment(GrandTotal, CartItems);
    }

    // Additional methods for all functionality...
}

23.5 Week 15: Sale Workflow

Objective: Implement efficient product lookup with category navigation.

Claude Command:

/dev-team create product service with caching and search

Implementation:

// RapOS.PosClient.Core/Services/ProductService.cs
namespace RapOS.PosClient.Core.Services;

public interface IProductService
{
    Task<List<Product>> GetProductsAsync();
    Task<List<Product>> SearchAsync(string query);
    Task<Product?> GetByBarcodeAsync(string barcode);
    Task<Product?> GetBySkuAsync(string sku);
    Task RefreshCacheAsync();
}

public class ProductService : IProductService
{
    private readonly ILocalDatabase _db;
    private readonly IApiClient _api;
    private readonly IConnectivityService _connectivity;
    private List<Product>? _cachedProducts;

    public ProductService(
        ILocalDatabase db,
        IApiClient api,
        IConnectivityService connectivity)
    {
        _db = db;
        _api = api;
        _connectivity = connectivity;
    }

    public async Task<List<Product>> GetProductsAsync()
    {
        if (_cachedProducts != null)
            return _cachedProducts;

        _cachedProducts = await _db.QueryAsync<Product>(
            "SELECT * FROM products WHERE is_active = 1 ORDER BY name");

        return _cachedProducts;
    }

    public async Task<List<Product>> SearchAsync(string query)
    {
        if (string.IsNullOrWhiteSpace(query))
            return await GetProductsAsync();

        var searchPattern = $"%{query}%";

        return await _db.QueryAsync<Product>(@"
            SELECT * FROM products
            WHERE is_active = 1
              AND (name LIKE @pattern
                   OR sku LIKE @pattern
                   OR barcode = @exact)
            ORDER BY
                CASE WHEN barcode = @exact THEN 0
                     WHEN sku LIKE @pattern THEN 1
                     ELSE 2 END,
                name
            LIMIT 50",
            new { pattern = searchPattern, exact = query });
    }

    public async Task<Product?> GetByBarcodeAsync(string barcode)
    {
        return await _db.QuerySingleAsync<Product>(
            "SELECT * FROM products WHERE barcode = @barcode AND is_active = 1",
            new { barcode });
    }

    public async Task<Product?> GetBySkuAsync(string sku)
    {
        return await _db.QuerySingleAsync<Product>(
            "SELECT * FROM products WHERE sku = @sku AND is_active = 1",
            new { sku });
    }

    public async Task RefreshCacheAsync()
    {
        if (!await _connectivity.IsOnlineAsync())
            return;

        var lastSync = await GetLastSyncTimeAsync();
        var products = await _api.GetAsync<List<Product>>(
            $"/api/products?modifiedAfter={lastSync:O}");

        foreach (var product in products)
        {
            await _db.ExecuteAsync(@"
                INSERT OR REPLACE INTO products
                (id, sku, name, description, category_id, category_name,
                 base_price, tax_rate, image_url, barcode, is_active,
                 quantity_on_hand, synced_at)
                VALUES
                (@Id, @Sku, @Name, @Description, @CategoryId, @CategoryName,
                 @BasePrice, @TaxRate, @ImageUrl, @Barcode, @IsActive,
                 @QuantityOnHand, @now)",
                new { product, now = DateTime.UtcNow });
        }

        _cachedProducts = null; // Invalidate cache
    }

    private async Task<DateTime> GetLastSyncTimeAsync()
    {
        var result = await _db.QuerySingleAsync<string>(
            "SELECT MAX(synced_at) FROM products");
        return DateTime.TryParse(result, out var dt) ? dt : DateTime.MinValue;
    }
}

Day 3-4: Barcode Scanning

Objective: Integrate camera and hardware barcode scanning.

Claude Command:

/dev-team implement barcode scanning with ZXing.Net.MAUI

Implementation:

// RapOS.PosClient.Hardware/Scanners/BarcodeScannerService.cs
using ZXing.Net.Maui;

namespace RapOS.PosClient.Hardware.Scanners;

public interface IScannerService
{
    event EventHandler<string>? BarcodeScanned;
    Task StartCameraScanAsync();
    Task StopCameraScanAsync();
    bool IsHardwareScannerConnected { get; }
}

public class BarcodeScannerService : IScannerService
{
    private readonly IProductService _productService;
    private readonly ICartService _cartService;

    public event EventHandler<string>? BarcodeScanned;
    public bool IsHardwareScannerConnected { get; private set; }

    public BarcodeScannerService(
        IProductService productService,
        ICartService cartService)
    {
        _productService = productService;
        _cartService = cartService;

        // Listen for USB/Bluetooth scanner input
        InitializeHardwareScanner();
    }

    private void InitializeHardwareScanner()
    {
        // USB scanners typically emit keyboard events
        // Monitor for rapid sequential character input ending with Enter
#if WINDOWS
        // Windows: Hook into keyboard events
        SetupWindowsKeyboardHook();
#elif ANDROID
        // Android: Use USB Host API for dedicated scanners
        SetupAndroidUsbScanner();
#endif
    }

    public async Task StartCameraScanAsync()
    {
        // Camera scanning is handled by ZXing component in UI
        // This just signals to show the camera overlay
    }

    public async Task StopCameraScanAsync()
    {
        // Hide camera overlay
    }

    public async Task ProcessBarcodeAsync(string barcode)
    {
        BarcodeScanned?.Invoke(this, barcode);

        var product = await _productService.GetByBarcodeAsync(barcode);
        if (product != null)
        {
            await _cartService.AddItemAsync(product);
        }
        else
        {
            // Play error sound, show "Product not found" notification
            await PlayErrorSoundAsync();
        }
    }

    private async Task PlayErrorSoundAsync()
    {
        // Platform-specific audio playback
    }

#if WINDOWS
    private void SetupWindowsKeyboardHook()
    {
        // Implementation for Windows keyboard hook
        // Detect scanner input pattern (rapid keys + Enter)
    }
#endif

#if ANDROID
    private void SetupAndroidUsbScanner()
    {
        // Implementation for Android USB Host API
    }
#endif
}

Camera Scanner Component:

@* RapOS.PosClient.UI/Components/Shared/CameraScannerOverlay.razor *@
@using ZXing.Net.Maui.Controls

<div class="scanner-overlay @(IsVisible ? "visible" : "")">
    <div class="scanner-container">
        <CameraBarcodeReaderView
            x:Name="barcodeReader"
            IsDetecting="true"
            BarcodesDetected="OnBarcodesDetected"
            Options="@(new BarcodeReaderOptions
            {
                Formats = BarcodeFormat.Ean13 | BarcodeFormat.Code128 | BarcodeFormat.QrCode,
                AutoRotate = true,
                TryHarder = true
            })" />

        <div class="scanner-frame">
            <div class="corner top-left"></div>
            <div class="corner top-right"></div>
            <div class="corner bottom-left"></div>
            <div class="corner bottom-right"></div>
        </div>

        <button class="close-scanner" @onclick="Close">✕ Close</button>
    </div>
</div>

@code {
    [Parameter] public bool IsVisible { get; set; }
    [Parameter] public EventCallback<string> OnScanned { get; set; }
    [Parameter] public EventCallback OnClose { get; set; }

    private void OnBarcodesDetected(object? sender, BarcodeDetectionEventArgs e)
    {
        var barcode = e.Results.FirstOrDefault()?.Value;
        if (!string.IsNullOrEmpty(barcode))
        {
            OnScanned.InvokeAsync(barcode);
        }
    }

    private async Task Close()
    {
        await OnClose.InvokeAsync();
    }
}

Day 5: Cart Operations

Objective: Implement full cart management with discounts.

Claude Command:

/dev-team create cart service with quantity controls and line discounts

Implementation:

// RapOS.PosClient.Core/Services/CartService.cs
namespace RapOS.PosClient.Core.Services;

public interface ICartService
{
    Task<List<CartItem>> GetCartItemsAsync();
    Task AddItemAsync(Product product, int quantity = 1);
    Task UpdateQuantityAsync(Guid itemId, int newQuantity);
    Task RemoveItemAsync(Guid itemId);
    Task ApplyLineDiscountAsync(Guid itemId, decimal amount, DiscountType type);
    Task ApplyCartDiscountAsync(decimal amount, DiscountType type);
    Task ClearCartAsync();
    Task<int> GetPendingSyncCountAsync();
    CartTotals GetTotals();
}

public class CartService : ICartService
{
    private readonly List<CartItem> _items = new();
    private decimal _cartDiscount = 0;
    private DiscountType _cartDiscountType = DiscountType.Amount;

    public Task<List<CartItem>> GetCartItemsAsync()
    {
        return Task.FromResult(_items.ToList());
    }

    public Task AddItemAsync(Product product, int quantity = 1)
    {
        var existing = _items.FirstOrDefault(i => i.ProductId == product.Id);

        if (existing != null)
        {
            existing.Quantity += quantity;
            RecalculateItem(existing);
        }
        else
        {
            var item = new CartItem
            {
                Id = Guid.NewGuid(),
                ProductId = product.Id,
                Sku = product.Sku,
                Name = product.Name,
                Quantity = quantity,
                UnitPrice = product.BasePrice,
                TaxRate = product.TaxRate
            };
            RecalculateItem(item);
            _items.Add(item);
        }

        return Task.CompletedTask;
    }

    public Task UpdateQuantityAsync(Guid itemId, int newQuantity)
    {
        var item = _items.FirstOrDefault(i => i.Id == itemId);
        if (item == null) return Task.CompletedTask;

        if (newQuantity <= 0)
        {
            _items.Remove(item);
        }
        else
        {
            item.Quantity = newQuantity;
            RecalculateItem(item);
        }

        return Task.CompletedTask;
    }

    public Task RemoveItemAsync(Guid itemId)
    {
        _items.RemoveAll(i => i.Id == itemId);
        return Task.CompletedTask;
    }

    public Task ApplyLineDiscountAsync(Guid itemId, decimal amount, DiscountType type)
    {
        var item = _items.FirstOrDefault(i => i.Id == itemId);
        if (item == null) return Task.CompletedTask;

        item.DiscountType = type;
        item.DiscountValue = amount;
        RecalculateItem(item);

        return Task.CompletedTask;
    }

    public Task ApplyCartDiscountAsync(decimal amount, DiscountType type)
    {
        _cartDiscount = amount;
        _cartDiscountType = type;
        return Task.CompletedTask;
    }

    public Task ClearCartAsync()
    {
        _items.Clear();
        _cartDiscount = 0;
        return Task.CompletedTask;
    }

    public CartTotals GetTotals()
    {
        var subtotal = _items.Sum(i => i.UnitPrice * i.Quantity);
        var lineDiscounts = _items.Sum(i => i.DiscountAmount);

        var cartDiscountAmount = _cartDiscountType == DiscountType.Percentage
            ? subtotal * (_cartDiscount / 100)
            : _cartDiscount;

        var taxableAmount = subtotal - lineDiscounts - cartDiscountAmount;
        var taxTotal = _items.Sum(i =>
            ((i.UnitPrice * i.Quantity - i.DiscountAmount) / subtotal) * taxableAmount * i.TaxRate);

        return new CartTotals
        {
            Subtotal = subtotal,
            LineDiscounts = lineDiscounts,
            CartDiscount = cartDiscountAmount,
            TotalDiscount = lineDiscounts + cartDiscountAmount,
            TaxTotal = taxTotal,
            GrandTotal = taxableAmount + taxTotal
        };
    }

    private void RecalculateItem(CartItem item)
    {
        var lineSubtotal = item.UnitPrice * item.Quantity;

        item.DiscountAmount = item.DiscountType == DiscountType.Percentage
            ? lineSubtotal * (item.DiscountValue / 100)
            : item.DiscountValue;

        var taxableAmount = lineSubtotal - item.DiscountAmount;
        item.TaxAmount = taxableAmount * item.TaxRate;
        item.LineTotal = taxableAmount + item.TaxAmount;
    }

    public async Task<int> GetPendingSyncCountAsync()
    {
        // Query sync_queue table for pending count
        return 0;
    }
}

public enum DiscountType
{
    Amount,
    Percentage
}

public class CartTotals
{
    public decimal Subtotal { get; set; }
    public decimal LineDiscounts { get; set; }
    public decimal CartDiscount { get; set; }
    public decimal TotalDiscount { get; set; }
    public decimal TaxTotal { get; set; }
    public decimal GrandTotal { get; set; }
}

23.6 Week 16: Payments & Transactions

Day 1-2: Cash & Card Payments

Objective: Implement payment handling with multiple methods.

Claude Command:

/dev-team create payment service supporting cash, card, and split payments

Implementation:

// RapOS.PosClient.Core/Services/PaymentService.cs
namespace RapOS.PosClient.Core.Services;

public interface IPaymentService
{
    Task<PaymentResult> ProcessCashPaymentAsync(decimal amount, decimal tendered);
    Task<PaymentResult> ProcessCardPaymentAsync(decimal amount);
    Task<SaleCompletionResult> CompleteSaleAsync(List<Payment> payments);
    Task<bool> CanProcessOfflineAsync(PaymentMethod method);
}

public class PaymentService : IPaymentService
{
    private readonly ILocalDatabase _db;
    private readonly ISyncService _sync;
    private readonly IStripeTerminalService _stripeTerminal;
    private readonly IPrinterService _printer;
    private readonly ICashDrawerService _cashDrawer;

    public PaymentService(
        ILocalDatabase db,
        ISyncService sync,
        IStripeTerminalService stripeTerminal,
        IPrinterService printer,
        ICashDrawerService cashDrawer)
    {
        _db = db;
        _sync = sync;
        _stripeTerminal = stripeTerminal;
        _printer = printer;
        _cashDrawer = cashDrawer;
    }

    public async Task<PaymentResult> ProcessCashPaymentAsync(decimal amount, decimal tendered)
    {
        if (tendered < amount)
        {
            return PaymentResult.Failed("Insufficient payment amount");
        }

        var change = tendered - amount;

        // Open cash drawer
        await _cashDrawer.OpenAsync();

        return PaymentResult.Success(new Payment
        {
            Id = Guid.NewGuid(),
            Method = PaymentMethod.Cash,
            Amount = amount,
            Tendered = tendered,
            Change = change,
            CreatedAt = DateTime.UtcNow
        });
    }

    public async Task<PaymentResult> ProcessCardPaymentAsync(decimal amount)
    {
        try
        {
            // Check if Stripe Terminal is connected
            if (!_stripeTerminal.IsConnected)
            {
                return PaymentResult.Failed("Payment terminal not connected");
            }

            // Create payment intent
            var intent = await _stripeTerminal.CreatePaymentIntentAsync(amount);

            // Collect payment
            var result = await _stripeTerminal.CollectPaymentAsync(intent);

            if (!result.Success)
            {
                return PaymentResult.Failed(result.ErrorMessage ?? "Payment declined");
            }

            return PaymentResult.Success(new Payment
            {
                Id = Guid.NewGuid(),
                Method = PaymentMethod.Card,
                Amount = amount,
                Reference = result.PaymentIntentId,
                CardLastFour = result.CardLastFour,
                CardBrand = result.CardBrand,
                CreatedAt = DateTime.UtcNow
            });
        }
        catch (Exception ex)
        {
            return PaymentResult.Failed($"Payment error: {ex.Message}");
        }
    }

    public async Task<SaleCompletionResult> CompleteSaleAsync(List<Payment> payments)
    {
        var sale = await CreateSaleRecordAsync(payments);

        // Save to local database
        await SaveSaleLocallyAsync(sale);

        // Queue for sync
        await _sync.QueueForSyncAsync("sale", sale.Id, SyncOperation.Create, sale);

        // Print receipt
        await _printer.PrintReceiptAsync(sale);

        // If cash payment, drawer is already open
        // Clear cart happens in calling code

        return new SaleCompletionResult
        {
            Success = true,
            SaleId = sale.Id,
            ReceiptNumber = sale.ReceiptNumber
        };
    }

    public Task<bool> CanProcessOfflineAsync(PaymentMethod method)
    {
        // Cash is always available offline
        // Cards require terminal which needs connectivity for most operations
        return Task.FromResult(method == PaymentMethod.Cash);
    }

    private async Task<Sale> CreateSaleRecordAsync(List<Payment> payments)
    {
        // Implementation to create sale from current cart state
        throw new NotImplementedException();
    }

    private async Task SaveSaleLocallyAsync(Sale sale)
    {
        await _db.ExecuteAsync(@"
            INSERT INTO sales (id, status, customer_id, cashier_id, subtotal,
                              tax_total, discount_total, grand_total, created_at)
            VALUES (@Id, 'completed', @CustomerId, @CashierId, @Subtotal,
                    @TaxTotal, @DiscountTotal, @GrandTotal, @CreatedAt)",
            sale);

        foreach (var item in sale.Items)
        {
            await _db.ExecuteAsync(@"
                INSERT INTO sale_items (id, sale_id, product_id, sku, name,
                                       quantity, unit_price, discount_amount,
                                       tax_amount, line_total)
                VALUES (@Id, @SaleId, @ProductId, @Sku, @Name, @Quantity,
                        @UnitPrice, @DiscountAmount, @TaxAmount, @LineTotal)",
                item);
        }

        foreach (var payment in sale.Payments)
        {
            await _db.ExecuteAsync(@"
                INSERT INTO payments (id, sale_id, method, amount, reference,
                                     card_last_four, card_brand, created_at)
                VALUES (@Id, @SaleId, @Method, @Amount, @Reference,
                        @CardLastFour, @CardBrand, @CreatedAt)",
                payment);
        }
    }
}

Day 3-4: Stripe Terminal Integration

Objective: Integrate Stripe Terminal for card payments.

Claude Command:

/dev-team implement Stripe Terminal SDK integration for MAUI

Implementation:

// RapOS.PosClient.Hardware/Payments/StripeTerminalService.cs
namespace RapOS.PosClient.Hardware.Payments;

public interface IStripeTerminalService
{
    bool IsConnected { get; }
    Task InitializeAsync(string locationId);
    Task<TerminalReader?> DiscoverAndConnectAsync();
    Task<PaymentIntentResult> CreatePaymentIntentAsync(decimal amount);
    Task<CollectPaymentResult> CollectPaymentAsync(string paymentIntentId);
    Task DisconnectAsync();
}

public class StripeTerminalService : IStripeTerminalService
{
    private readonly IConfiguration _config;
    private readonly IApiClient _api;
    private bool _initialized;
    private TerminalReader? _connectedReader;

    public bool IsConnected => _connectedReader != null;

    public StripeTerminalService(IConfiguration config, IApiClient api)
    {
        _config = config;
        _api = api;
    }

    public async Task InitializeAsync(string locationId)
    {
        if (_initialized) return;

        // Get connection token from backend
        var tokenResponse = await _api.PostAsync<ConnectionTokenResponse>(
            "/api/terminals/stripe/connection-token",
            new { locationId });

        // Initialize Stripe Terminal SDK
        // Note: Actual implementation depends on platform
#if ANDROID
        await InitializeAndroidAsync(tokenResponse.Secret);
#elif WINDOWS
        await InitializeWindowsAsync(tokenResponse.Secret);
#endif

        _initialized = true;
    }

    public async Task<TerminalReader?> DiscoverAndConnectAsync()
    {
        // Discover available readers
        var readers = await DiscoverReadersAsync();

        if (!readers.Any())
        {
            return null;
        }

        // Auto-connect to first reader (or show picker)
        var reader = readers.First();
        await ConnectToReaderAsync(reader);

        _connectedReader = reader;
        return reader;
    }

    public async Task<PaymentIntentResult> CreatePaymentIntentAsync(decimal amount)
    {
        // Create payment intent on backend
        var response = await _api.PostAsync<PaymentIntentResult>(
            "/api/payments/create-intent",
            new
            {
                amount = (long)(amount * 100), // Convert to cents
                currency = "usd"
            });

        return response;
    }

    public async Task<CollectPaymentResult> CollectPaymentAsync(string paymentIntentId)
    {
        // This triggers the reader to collect card
#if ANDROID
        return await CollectPaymentAndroidAsync(paymentIntentId);
#elif WINDOWS
        return await CollectPaymentWindowsAsync(paymentIntentId);
#else
        throw new PlatformNotSupportedException();
#endif
    }

    public async Task DisconnectAsync()
    {
        if (_connectedReader != null)
        {
            // Disconnect from reader
            _connectedReader = null;
        }
    }

    // Platform-specific implementations...
}

public class TerminalReader
{
    public string Id { get; set; } = string.Empty;
    public string SerialNumber { get; set; } = string.Empty;
    public string Label { get; set; } = string.Empty;
    public TerminalReaderType Type { get; set; }
    public bool IsOnline { get; set; }
}

public enum TerminalReaderType
{
    Bluetooth,
    Internet,
    USB
}

public class CollectPaymentResult
{
    public bool Success { get; set; }
    public string? PaymentIntentId { get; set; }
    public string? CardLastFour { get; set; }
    public string? CardBrand { get; set; }
    public string? ErrorMessage { get; set; }
}

Day 5: Receipt Generation

Objective: Generate and print receipts.

Claude Command:

/dev-team create receipt generator with ESC/POS printer support

Implementation:

// RapOS.PosClient.Hardware/Printers/ReceiptGenerator.cs
namespace RapOS.PosClient.Hardware.Printers;

public interface IReceiptGenerator
{
    byte[] GenerateReceipt(Sale sale);
    byte[] GenerateReprint(Sale sale);
    byte[] GenerateVoid(Sale sale, string reason);
}

public class EscPosReceiptGenerator : IReceiptGenerator
{
    private readonly ITerminalContext _terminal;
    private readonly ISettingsService _settings;

    public EscPosReceiptGenerator(
        ITerminalContext terminal,
        ISettingsService settings)
    {
        _terminal = terminal;
        _settings = settings;
    }

    public byte[] GenerateReceipt(Sale sale)
    {
        using var ms = new MemoryStream();
        using var writer = new EscPosWriter(ms);

        // Initialize printer
        writer.Initialize();

        // Header - Store Info
        writer.SetAlignment(Alignment.Center);
        writer.SetBold(true);
        writer.WriteLine(_settings.StoreName);
        writer.SetBold(false);
        writer.WriteLine(_settings.StoreAddress);
        writer.WriteLine(_settings.StorePhone);
        writer.LineFeed();

        // Transaction Info
        writer.SetAlignment(Alignment.Left);
        writer.WriteLine($"Receipt: {sale.ReceiptNumber}");
        writer.WriteLine($"Date: {sale.CreatedAt:MM/dd/yyyy HH:mm}");
        writer.WriteLine($"Cashier: {sale.CashierName}");
        if (sale.Customer != null)
        {
            writer.WriteLine($"Customer: {sale.Customer.FullName}");
        }
        writer.PrintDivider();

        // Line Items
        foreach (var item in sale.Items)
        {
            // Product name
            writer.WriteLine(item.Name);

            // Quantity x Price = Total
            var priceStr = item.UnitPrice.ToString("F2");
            var qtyStr = $"  {item.Quantity} x {priceStr}";
            var totalStr = item.LineTotal.ToString("F2");

            writer.WriteColumns(qtyStr, totalStr);

            if (item.DiscountAmount > 0)
            {
                writer.WriteColumns("    Discount:", $"-{item.DiscountAmount:F2}");
            }
        }

        writer.PrintDivider();

        // Totals
        writer.WriteColumns("Subtotal:", sale.Subtotal.ToString("F2"));

        if (sale.DiscountTotal > 0)
        {
            writer.WriteColumns("Discount:", $"-{sale.DiscountTotal:F2}");
        }

        writer.WriteColumns("Tax:", sale.TaxTotal.ToString("F2"));

        writer.SetBold(true);
        writer.WriteColumns("TOTAL:", sale.GrandTotal.ToString("F2"));
        writer.SetBold(false);

        writer.LineFeed();

        // Payments
        foreach (var payment in sale.Payments)
        {
            var methodName = payment.Method switch
            {
                PaymentMethod.Cash => "Cash",
                PaymentMethod.Card => $"Card ({payment.CardBrand} ...{payment.CardLastFour})",
                _ => payment.Method.ToString()
            };

            writer.WriteColumns(methodName, payment.Amount.ToString("F2"));

            if (payment.Method == PaymentMethod.Cash && payment.Change > 0)
            {
                writer.WriteColumns("Change:", payment.Change.ToString("F2"));
            }
        }

        writer.LineFeed();

        // Footer
        writer.SetAlignment(Alignment.Center);
        writer.WriteLine(_settings.ReceiptFooterMessage ?? "Thank you for your purchase!");

        if (sale.Customer?.LoyaltyPoints > 0)
        {
            writer.LineFeed();
            writer.WriteLine($"Loyalty Points Earned: {sale.LoyaltyPointsEarned}");
            writer.WriteLine($"Total Points: {sale.Customer.LoyaltyPoints + sale.LoyaltyPointsEarned}");
        }

        // Barcode for receipt lookup
        writer.LineFeed();
        writer.PrintBarcode(sale.ReceiptNumber, BarcodeType.Code128);

        // Cut paper
        writer.CutPaper();

        return ms.ToArray();
    }

    public byte[] GenerateReprint(Sale sale)
    {
        // Same as receipt but with "REPRINT" header
        using var ms = new MemoryStream();
        using var writer = new EscPosWriter(ms);

        writer.Initialize();
        writer.SetAlignment(Alignment.Center);
        writer.SetBold(true);
        writer.WriteLine("*** REPRINT ***");
        writer.SetBold(false);
        writer.LineFeed();

        // Rest is same as GenerateReceipt...
        // Could refactor to share code

        return ms.ToArray();
    }

    public byte[] GenerateVoid(Sale sale, string reason)
    {
        // Void receipt
        using var ms = new MemoryStream();
        using var writer = new EscPosWriter(ms);

        writer.Initialize();
        writer.SetAlignment(Alignment.Center);
        writer.SetBold(true);
        writer.WriteLine("*** VOID ***");
        writer.SetBold(false);
        writer.WriteLine($"Original Receipt: {sale.ReceiptNumber}");
        writer.WriteLine($"Void Reason: {reason}");
        writer.LineFeed();

        writer.CutPaper();

        return ms.ToArray();
    }
}

public class EscPosWriter : IDisposable
{
    private readonly Stream _stream;

    // ESC/POS command constants
    private static readonly byte[] CMD_INIT = { 0x1B, 0x40 };
    private static readonly byte[] CMD_ALIGN_LEFT = { 0x1B, 0x61, 0x00 };
    private static readonly byte[] CMD_ALIGN_CENTER = { 0x1B, 0x61, 0x01 };
    private static readonly byte[] CMD_ALIGN_RIGHT = { 0x1B, 0x61, 0x02 };
    private static readonly byte[] CMD_BOLD_ON = { 0x1B, 0x45, 0x01 };
    private static readonly byte[] CMD_BOLD_OFF = { 0x1B, 0x45, 0x00 };
    private static readonly byte[] CMD_CUT = { 0x1D, 0x56, 0x41, 0x00 };

    private const int LINE_WIDTH = 42; // Standard 80mm receipt width

    public EscPosWriter(Stream stream)
    {
        _stream = stream;
    }

    public void Initialize() => _stream.Write(CMD_INIT);

    public void SetAlignment(Alignment align)
    {
        _stream.Write(align switch
        {
            Alignment.Left => CMD_ALIGN_LEFT,
            Alignment.Center => CMD_ALIGN_CENTER,
            Alignment.Right => CMD_ALIGN_RIGHT,
            _ => CMD_ALIGN_LEFT
        });
    }

    public void SetBold(bool bold) =>
        _stream.Write(bold ? CMD_BOLD_ON : CMD_BOLD_OFF);

    public void WriteLine(string text)
    {
        var bytes = Encoding.UTF8.GetBytes(text + "\n");
        _stream.Write(bytes);
    }

    public void WriteColumns(string left, string right)
    {
        var padding = LINE_WIDTH - left.Length - right.Length;
        var line = left + new string(' ', Math.Max(1, padding)) + right;
        WriteLine(line);
    }

    public void LineFeed() => _stream.WriteByte(0x0A);

    public void PrintDivider() =>
        WriteLine(new string('-', LINE_WIDTH));

    public void PrintBarcode(string data, BarcodeType type)
    {
        // ESC/POS barcode commands
        _stream.Write(new byte[] { 0x1D, 0x68, 50 }); // Height
        _stream.Write(new byte[] { 0x1D, 0x77, 2 });  // Width
        _stream.Write(new byte[] { 0x1D, 0x6B, (byte)type });
        var bytes = Encoding.ASCII.GetBytes(data);
        _stream.WriteByte((byte)bytes.Length);
        _stream.Write(bytes);
    }

    public void CutPaper() => _stream.Write(CMD_CUT);

    public void Dispose() { }
}

public enum Alignment { Left, Center, Right }
public enum BarcodeType { Code128 = 73 }

23.7 Week 17: Offline & Sync

Day 1-2: Local Transaction Queue

Objective: Queue transactions for sync when offline.

Claude Command:

/dev-team create sync queue service for offline transaction handling

Implementation:

// RapOS.PosClient.Core/Services/SyncService.cs
namespace RapOS.PosClient.Core.Services;

public interface ISyncService
{
    Task QueueForSyncAsync<T>(string entityType, Guid entityId, SyncOperation operation, T payload);
    Task ProcessQueueAsync();
    Task<int> GetPendingCountAsync();
    event EventHandler<SyncProgressEventArgs>? SyncProgress;
}

public class SyncService : ISyncService
{
    private readonly ILocalDatabase _db;
    private readonly IApiClient _api;
    private readonly IConnectivityService _connectivity;
    private readonly ILogger<SyncService> _logger;
    private bool _isSyncing;

    public event EventHandler<SyncProgressEventArgs>? SyncProgress;

    public SyncService(
        ILocalDatabase db,
        IApiClient api,
        IConnectivityService connectivity,
        ILogger<SyncService> logger)
    {
        _db = db;
        _api = api;
        _connectivity = connectivity;
        _logger = logger;
    }

    public async Task QueueForSyncAsync<T>(
        string entityType,
        Guid entityId,
        SyncOperation operation,
        T payload)
    {
        var priority = GetPriority(entityType);
        var json = JsonSerializer.Serialize(payload);

        await _db.ExecuteAsync(@"
            INSERT OR REPLACE INTO sync_queue
            (entity_type, entity_id, operation, payload, priority, created_at)
            VALUES (@entityType, @entityId, @operation, @payload, @priority, @now)",
            new
            {
                entityType,
                entityId = entityId.ToString(),
                operation = operation.ToString(),
                payload = json,
                priority,
                now = DateTime.UtcNow.ToString("O")
            });

        // Try immediate sync if online
        if (await _connectivity.IsOnlineAsync() && !_isSyncing)
        {
            _ = ProcessQueueAsync(); // Fire and forget
        }
    }

    public async Task ProcessQueueAsync()
    {
        if (_isSyncing || !await _connectivity.IsOnlineAsync())
            return;

        _isSyncing = true;

        try
        {
            var pending = await _db.QueryAsync<SyncQueueItem>(@"
                SELECT * FROM sync_queue
                WHERE attempts < 5
                ORDER BY priority DESC, created_at ASC
                LIMIT 50");

            var total = pending.Count;
            var processed = 0;

            foreach (var item in pending)
            {
                try
                {
                    await SyncItemAsync(item);

                    // Remove from queue on success
                    await _db.ExecuteAsync(
                        "DELETE FROM sync_queue WHERE id = @id",
                        new { item.Id });

                    processed++;
                    RaiseSyncProgress(processed, total, null);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex,
                        "Failed to sync {EntityType} {EntityId}",
                        item.EntityType, item.EntityId);

                    // Update attempts and error
                    await _db.ExecuteAsync(@"
                        UPDATE sync_queue
                        SET attempts = attempts + 1,
                            last_attempt_at = @now,
                            error = @error
                        WHERE id = @id",
                        new
                        {
                            item.Id,
                            now = DateTime.UtcNow.ToString("O"),
                            error = ex.Message
                        });

                    RaiseSyncProgress(processed, total, ex.Message);
                }
            }
        }
        finally
        {
            _isSyncing = false;
        }
    }

    private async Task SyncItemAsync(SyncQueueItem item)
    {
        var endpoint = GetEndpointForEntity(item.EntityType);

        switch (item.Operation)
        {
            case "Create":
                await _api.PostAsync(endpoint, item.Payload);
                break;
            case "Update":
                await _api.PutAsync($"{endpoint}/{item.EntityId}", item.Payload);
                break;
            case "Delete":
                await _api.DeleteAsync($"{endpoint}/{item.EntityId}");
                break;
        }

        // Update local record with synced timestamp
        await MarkAsSyncedAsync(item.EntityType, item.EntityId);
    }

    private int GetPriority(string entityType)
    {
        // Sales have highest priority (revenue!)
        return entityType switch
        {
            "sale" => 100,
            "payment" => 90,
            "customer" => 50,
            "inventory_adjustment" => 40,
            _ => 10
        };
    }

    private string GetEndpointForEntity(string entityType)
    {
        return entityType switch
        {
            "sale" => "/api/sales",
            "payment" => "/api/payments",
            "customer" => "/api/customers",
            _ => $"/api/{entityType}"
        };
    }

    private async Task MarkAsSyncedAsync(string entityType, string entityId)
    {
        var table = entityType switch
        {
            "sale" => "sales",
            _ => entityType
        };

        await _db.ExecuteAsync(
            $"UPDATE {table} SET synced_at = @now WHERE id = @id",
            new { id = entityId, now = DateTime.UtcNow.ToString("O") });
    }

    public async Task<int> GetPendingCountAsync()
    {
        var result = await _db.QuerySingleAsync<int>(
            "SELECT COUNT(*) FROM sync_queue WHERE attempts < 5");
        return result;
    }

    private void RaiseSyncProgress(int current, int total, string? error)
    {
        SyncProgress?.Invoke(this, new SyncProgressEventArgs
        {
            Current = current,
            Total = total,
            Error = error
        });
    }
}

public enum SyncOperation
{
    Create,
    Update,
    Delete
}

public class SyncQueueItem
{
    public int Id { get; set; }
    public string EntityType { get; set; } = string.Empty;
    public string EntityId { get; set; } = string.Empty;
    public string Operation { get; set; } = string.Empty;
    public string Payload { get; set; } = string.Empty;
    public int Priority { get; set; }
    public int Attempts { get; set; }
    public string? LastAttemptAt { get; set; }
    public string? Error { get; set; }
    public string CreatedAt { get; set; } = string.Empty;
}

public class SyncProgressEventArgs : EventArgs
{
    public int Current { get; set; }
    public int Total { get; set; }
    public string? Error { get; set; }
}

Day 3-4: Product & Customer Cache

Objective: Sync and cache products/customers for offline access.

Claude Command:

/dev-team create background sync service for product and customer data

Implementation:

// RapOS.PosClient.Core/Services/BackgroundSyncService.cs
namespace RapOS.PosClient.Core.Services;

public interface IBackgroundSyncService
{
    Task StartAsync();
    Task StopAsync();
    Task ForceSyncAsync();
}

public class BackgroundSyncService : IBackgroundSyncService
{
    private readonly ILocalDatabase _db;
    private readonly IApiClient _api;
    private readonly IConnectivityService _connectivity;
    private readonly IProductService _products;
    private readonly ICategoryService _categories;
    private readonly ICustomerService _customers;
    private readonly ILogger<BackgroundSyncService> _logger;

    private CancellationTokenSource? _cts;
    private Timer? _syncTimer;

    private const int SYNC_INTERVAL_MINUTES = 5;

    public BackgroundSyncService(
        ILocalDatabase db,
        IApiClient api,
        IConnectivityService connectivity,
        IProductService products,
        ICategoryService categories,
        ICustomerService customers,
        ILogger<BackgroundSyncService> logger)
    {
        _db = db;
        _api = api;
        _connectivity = connectivity;
        _products = products;
        _categories = categories;
        _customers = customers;
        _logger = logger;
    }

    public Task StartAsync()
    {
        _cts = new CancellationTokenSource();

        // Immediate sync on start
        _ = RunSyncAsync(_cts.Token);

        // Periodic sync
        _syncTimer = new Timer(
            async _ => await RunSyncAsync(_cts.Token),
            null,
            TimeSpan.FromMinutes(SYNC_INTERVAL_MINUTES),
            TimeSpan.FromMinutes(SYNC_INTERVAL_MINUTES));

        return Task.CompletedTask;
    }

    public async Task StopAsync()
    {
        _cts?.Cancel();

        if (_syncTimer != null)
        {
            await _syncTimer.DisposeAsync();
        }
    }

    public async Task ForceSyncAsync()
    {
        await RunSyncAsync(CancellationToken.None);
    }

    private async Task RunSyncAsync(CancellationToken ct)
    {
        if (!await _connectivity.IsOnlineAsync())
        {
            _logger.LogInformation("Skipping sync - offline");
            return;
        }

        _logger.LogInformation("Starting background sync...");

        try
        {
            // Sync in parallel
            await Task.WhenAll(
                SyncCategoriesAsync(ct),
                SyncProductsAsync(ct),
                SyncCustomersAsync(ct));

            _logger.LogInformation("Background sync completed");
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("Background sync cancelled");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Background sync failed");
        }
    }

    private async Task SyncCategoriesAsync(CancellationToken ct)
    {
        var lastSync = await GetLastSyncAsync("categories");

        var categories = await _api.GetAsync<List<Category>>(
            $"/api/categories?modifiedAfter={lastSync:O}", ct);

        foreach (var category in categories)
        {
            ct.ThrowIfCancellationRequested();

            await _db.ExecuteAsync(@"
                INSERT OR REPLACE INTO categories
                (id, name, parent_id, display_order, color, icon, synced_at)
                VALUES (@Id, @Name, @ParentId, @DisplayOrder, @Color, @Icon, @now)",
                new { category, now = DateTime.UtcNow.ToString("O") });
        }

        await UpdateLastSyncAsync("categories");
    }

    private async Task SyncProductsAsync(CancellationToken ct)
    {
        // Products synced by ProductService.RefreshCacheAsync()
        await _products.RefreshCacheAsync();
    }

    private async Task SyncCustomersAsync(CancellationToken ct)
    {
        var lastSync = await GetLastSyncAsync("customers");

        // Only sync active customers from this location
        var customers = await _api.GetAsync<List<Customer>>(
            $"/api/customers?modifiedAfter={lastSync:O}&limit=1000", ct);

        foreach (var customer in customers)
        {
            ct.ThrowIfCancellationRequested();

            await _db.ExecuteAsync(@"
                INSERT OR REPLACE INTO customers
                (id, first_name, last_name, email, phone,
                 loyalty_points, loyalty_tier, synced_at)
                VALUES (@Id, @FirstName, @LastName, @Email, @Phone,
                        @LoyaltyPoints, @LoyaltyTier, @now)",
                new { customer, now = DateTime.UtcNow.ToString("O") });
        }

        await UpdateLastSyncAsync("customers");
    }

    private async Task<DateTime> GetLastSyncAsync(string entityType)
    {
        var result = await _db.QuerySingleAsync<string>(
            "SELECT MAX(synced_at) FROM @table",
            new { table = entityType });
        return DateTime.TryParse(result, out var dt) ? dt : DateTime.MinValue;
    }

    private async Task UpdateLastSyncAsync(string entityType)
    {
        await _db.ExecuteAsync(@"
            INSERT OR REPLACE INTO sync_metadata (entity_type, last_sync)
            VALUES (@entityType, @now)",
            new { entityType, now = DateTime.UtcNow.ToString("O") });
    }
}

Day 5: Conflict Resolution

Objective: Handle sync conflicts with server-wins strategy.

Claude Command:

/dev-team implement conflict resolution for offline sync

Implementation:

// RapOS.PosClient.Core/Services/ConflictResolver.cs
namespace RapOS.PosClient.Core.Services;

public interface IConflictResolver
{
    Task<ConflictResolution> ResolveAsync(SyncConflict conflict);
}

public class ConflictResolver : IConflictResolver
{
    private readonly ILogger<ConflictResolver> _logger;

    public ConflictResolver(ILogger<ConflictResolver> logger)
    {
        _logger = logger;
    }

    public async Task<ConflictResolution> ResolveAsync(SyncConflict conflict)
    {
        _logger.LogWarning(
            "Conflict detected for {EntityType} {EntityId}: Local={LocalVersion}, Server={ServerVersion}",
            conflict.EntityType,
            conflict.EntityId,
            conflict.LocalVersion,
            conflict.ServerVersion);

        // Resolution strategy depends on entity type
        return conflict.EntityType switch
        {
            // Sales: Local wins (transaction already happened)
            "sale" => ConflictResolution.KeepLocal,

            // Inventory: Server wins (authoritative count)
            "product" => ConflictResolution.UseServer,

            // Customer: Merge (combine changes if possible)
            "customer" => await MergeCustomerAsync(conflict),

            // Default: Server wins
            _ => ConflictResolution.UseServer
        };
    }

    private async Task<ConflictResolution> MergeCustomerAsync(SyncConflict conflict)
    {
        // For customers, we can merge if changes don't overlap
        var local = JsonSerializer.Deserialize<Customer>(conflict.LocalData);
        var server = JsonSerializer.Deserialize<Customer>(conflict.ServerData);

        if (local == null || server == null)
            return ConflictResolution.UseServer;

        // If only loyalty points differ, add them (they're additive)
        if (OnlyLoyaltyPointsDiffer(local, server))
        {
            // Keep server points (they include all synced transactions)
            return ConflictResolution.UseServer;
        }

        // Otherwise, server wins
        return ConflictResolution.UseServer;
    }

    private bool OnlyLoyaltyPointsDiffer(Customer local, Customer server)
    {
        return local.FirstName == server.FirstName &&
               local.LastName == server.LastName &&
               local.Email == server.Email &&
               local.Phone == server.Phone &&
               local.LoyaltyPoints != server.LoyaltyPoints;
    }
}

public class SyncConflict
{
    public string EntityType { get; set; } = string.Empty;
    public string EntityId { get; set; } = string.Empty;
    public int LocalVersion { get; set; }
    public int ServerVersion { get; set; }
    public string LocalData { get; set; } = string.Empty;
    public string ServerData { get; set; } = string.Empty;
}

public enum ConflictResolution
{
    KeepLocal,
    UseServer,
    Merge
}

23.8 Week 18: Hardware & Configuration

Day 1-2: Receipt Printer Integration

Objective: Connect to ESC/POS printers via USB, Bluetooth, and network.

Claude Command:

/dev-team implement multi-platform printer service for ESC/POS printers

Implementation:

// RapOS.PosClient.Hardware/Printers/PrinterService.cs
namespace RapOS.PosClient.Hardware.Printers;

public interface IPrinterService
{
    Task<List<PrinterInfo>> DiscoverPrintersAsync();
    Task<bool> ConnectAsync(PrinterInfo printer);
    Task PrintAsync(byte[] data);
    Task PrintReceiptAsync(Sale sale);
    Task<bool> TestPrintAsync();
    bool IsConnected { get; }
    PrinterInfo? ConnectedPrinter { get; }
}

public class EscPosPrinterService : IPrinterService
{
    private readonly IReceiptGenerator _receiptGenerator;
    private readonly ISettingsService _settings;
    private readonly ILogger<EscPosPrinterService> _logger;

    private IPrinterConnection? _connection;

    public bool IsConnected => _connection?.IsConnected ?? false;
    public PrinterInfo? ConnectedPrinter { get; private set; }

    public EscPosPrinterService(
        IReceiptGenerator receiptGenerator,
        ISettingsService settings,
        ILogger<EscPosPrinterService> logger)
    {
        _receiptGenerator = receiptGenerator;
        _settings = settings;
        _logger = logger;
    }

    public async Task<List<PrinterInfo>> DiscoverPrintersAsync()
    {
        var printers = new List<PrinterInfo>();

#if WINDOWS
        // Discover Windows printers
        printers.AddRange(await DiscoverWindowsPrintersAsync());
#elif ANDROID
        // Discover Bluetooth and USB printers
        printers.AddRange(await DiscoverBluetoothPrintersAsync());
        printers.AddRange(await DiscoverUsbPrintersAsync());
#endif

        // Discover network printers (common across platforms)
        printers.AddRange(await DiscoverNetworkPrintersAsync());

        return printers;
    }

    public async Task<bool> ConnectAsync(PrinterInfo printer)
    {
        try
        {
            _connection = printer.ConnectionType switch
            {
                PrinterConnectionType.USB => new UsbPrinterConnection(printer),
                PrinterConnectionType.Bluetooth => new BluetoothPrinterConnection(printer),
                PrinterConnectionType.Network => new NetworkPrinterConnection(printer),
                _ => throw new NotSupportedException($"Connection type {printer.ConnectionType} not supported")
            };

            await _connection.OpenAsync();
            ConnectedPrinter = printer;

            _logger.LogInformation("Connected to printer: {PrinterName}", printer.Name);
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to connect to printer: {PrinterName}", printer.Name);
            return false;
        }
    }

    public async Task PrintAsync(byte[] data)
    {
        if (_connection == null || !_connection.IsConnected)
        {
            throw new InvalidOperationException("Printer not connected");
        }

        await _connection.WriteAsync(data);
    }

    public async Task PrintReceiptAsync(Sale sale)
    {
        var receiptData = _receiptGenerator.GenerateReceipt(sale);
        await PrintAsync(receiptData);
    }

    public async Task<bool> TestPrintAsync()
    {
        try
        {
            using var ms = new MemoryStream();
            using var writer = new EscPosWriter(ms);

            writer.Initialize();
            writer.SetAlignment(Alignment.Center);
            writer.WriteLine("*** TEST PRINT ***");
            writer.LineFeed();
            writer.WriteLine($"Printer: {ConnectedPrinter?.Name}");
            writer.WriteLine($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            writer.LineFeed();
            writer.WriteLine("If you can read this,");
            writer.WriteLine("the printer is working!");
            writer.LineFeed();
            writer.CutPaper();

            await PrintAsync(ms.ToArray());
            return true;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Test print failed");
            return false;
        }
    }

    private async Task<List<PrinterInfo>> DiscoverNetworkPrintersAsync()
    {
        // Common receipt printer ports: 9100 (raw), 515 (LPR)
        var printers = new List<PrinterInfo>();

        // Check saved network printers
        var savedPrinters = await _settings.GetNetworkPrintersAsync();
        foreach (var saved in savedPrinters)
        {
            if (await IsReachableAsync(saved.IpAddress, saved.Port))
            {
                printers.Add(saved);
            }
        }

        return printers;
    }

    private async Task<bool> IsReachableAsync(string host, int port)
    {
        try
        {
            using var client = new TcpClient();
            var connectTask = client.ConnectAsync(host, port);
            var timeoutTask = Task.Delay(TimeSpan.FromSeconds(2));

            if (await Task.WhenAny(connectTask, timeoutTask) == connectTask)
            {
                return client.Connected;
            }
            return false;
        }
        catch
        {
            return false;
        }
    }

#if WINDOWS
    private async Task<List<PrinterInfo>> DiscoverWindowsPrintersAsync()
    {
        // Use Windows printing API
        return new List<PrinterInfo>();
    }
#endif

#if ANDROID
    private async Task<List<PrinterInfo>> DiscoverBluetoothPrintersAsync()
    {
        // Use Android Bluetooth API
        return new List<PrinterInfo>();
    }

    private async Task<List<PrinterInfo>> DiscoverUsbPrintersAsync()
    {
        // Use Android USB Host API
        return new List<PrinterInfo>();
    }
#endif
}

public class PrinterInfo
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public PrinterConnectionType ConnectionType { get; set; }
    public string Address { get; set; } = string.Empty;
    public string? IpAddress { get; set; }
    public int Port { get; set; } = 9100;
}

public enum PrinterConnectionType
{
    USB,
    Bluetooth,
    Network
}

Day 3-4: Drag-and-Drop Layout Builder

Objective: Implement Retail Pro-style configurable UI layouts.

Claude Command:

/dev-team create drag-and-drop layout designer for POS screen customization

Layout Designer Component:

@* RapOS.PosClient.UI/Components/Layout/LayoutDesigner.razor *@
@inject ILayoutService LayoutService
@inject IJSRuntime JS

<div class="layout-designer">
    <aside class="widget-palette">
        <h3>Available Widgets</h3>

        @foreach (var widget in AvailableWidgets)
        {
            <div class="widget-item"
                 draggable="true"
                 @ondragstart="() => StartDrag(widget)">
                <span class="widget-icon">@widget.Icon</span>
                <span class="widget-name">@widget.Name</span>
            </div>
        }
    </aside>

    <main class="layout-canvas">
        <div class="canvas-header">
            <h3>Layout Canvas</h3>
            <div class="canvas-actions">
                <button @onclick="ResetLayout">Reset</button>
                <button @onclick="SaveLayout" class="primary">Save Layout</button>
            </div>
        </div>

        <div class="canvas-grid"
             @ondragover="HandleDragOver"
             @ondrop="HandleDrop">
            @foreach (var cell in LayoutCells)
            {
                <div class="grid-cell @(cell.Widget != null ? "occupied" : "")"
                     data-row="@cell.Row"
                     data-col="@cell.Column"
                     style="grid-row: @(cell.Row + 1); grid-column: @(cell.Column + 1) / span @cell.ColSpan;">

                    @if (cell.Widget != null)
                    {
                        <div class="placed-widget"
                             style="background-color: @cell.Widget.Color">
                            <span class="widget-icon">@cell.Widget.Icon</span>
                            <span class="widget-name">@cell.Widget.Name</span>
                            <button class="remove-widget" @onclick="() => RemoveWidget(cell)">
                                ✕
                            </button>
                            <div class="resize-handle" @onmousedown="() => StartResize(cell)"></div>
                        </div>
                    }
                    else
                    {
                        <span class="drop-hint">Drop widget here</span>
                    }
                </div>
            }
        </div>
    </main>

    <aside class="layout-preview">
        <h3>Preview</h3>
        <div class="preview-container">
            @* Miniature preview of the layout *@
            <div class="preview-grid">
                @foreach (var cell in LayoutCells.Where(c => c.Widget != null))
                {
                    <div class="preview-widget"
                         style="grid-row: @(cell.Row + 1);
                                grid-column: @(cell.Column + 1) / span @cell.ColSpan;
                                background-color: @cell.Widget!.Color;">
                    </div>
                }
            </div>
        </div>
    </aside>
</div>

@code {
    private List<WidgetDefinition> AvailableWidgets = new()
    {
        new() { Id = "product-grid", Name = "Product Grid", Icon = "📦", Color = "#4A90D9", DefaultColSpan = 2 },
        new() { Id = "cart", Name = "Cart Panel", Icon = "🛒", Color = "#7B68EE", DefaultColSpan = 1 },
        new() { Id = "quick-access", Name = "Quick Access", Icon = "⚡", Color = "#FFB347", DefaultColSpan = 3 },
        new() { Id = "totals", Name = "Totals Panel", Icon = "💰", Color = "#77DD77", DefaultColSpan = 1 },
        new() { Id = "customer", Name = "Customer Info", Icon = "👤", Color = "#FF6B6B", DefaultColSpan = 1 },
        new() { Id = "categories", Name = "Category Bar", Icon = "📁", Color = "#DDA0DD", DefaultColSpan = 3 },
        new() { Id = "search", Name = "Search Bar", Icon = "🔍", Color = "#87CEEB", DefaultColSpan = 2 }
    };

    private List<LayoutCell> LayoutCells = new();
    private WidgetDefinition? DraggedWidget;

    private const int GRID_ROWS = 4;
    private const int GRID_COLS = 3;

    protected override async Task OnInitializedAsync()
    {
        // Initialize grid cells
        for (int row = 0; row < GRID_ROWS; row++)
        {
            for (int col = 0; col < GRID_COLS; col++)
            {
                LayoutCells.Add(new LayoutCell { Row = row, Column = col, ColSpan = 1 });
            }
        }

        // Load existing layout
        var savedLayout = await LayoutService.GetCurrentLayoutAsync();
        if (savedLayout != null)
        {
            ApplyLayout(savedLayout);
        }
    }

    private void StartDrag(WidgetDefinition widget)
    {
        DraggedWidget = widget;
    }

    private void HandleDragOver(DragEventArgs e)
    {
        // Allow drop
    }

    private void HandleDrop(DragEventArgs e)
    {
        if (DraggedWidget == null) return;

        // Get drop target from event
        // Place widget in cell
        // This is simplified - real implementation needs JS interop for accurate drop position
    }

    private void RemoveWidget(LayoutCell cell)
    {
        cell.Widget = null;
        cell.ColSpan = 1;
    }

    private void StartResize(LayoutCell cell)
    {
        // Start resize operation
    }

    private void ResetLayout()
    {
        foreach (var cell in LayoutCells)
        {
            cell.Widget = null;
            cell.ColSpan = 1;
        }
    }

    private async Task SaveLayout()
    {
        var layout = new PosLayout
        {
            Id = Guid.NewGuid(),
            Name = "Custom Layout",
            Cells = LayoutCells.Where(c => c.Widget != null).Select(c => new LayoutCellConfig
            {
                WidgetId = c.Widget!.Id,
                Row = c.Row,
                Column = c.Column,
                ColSpan = c.ColSpan
            }).ToList()
        };

        await LayoutService.SaveLayoutAsync(layout);
    }

    private void ApplyLayout(PosLayout layout)
    {
        foreach (var cell in layout.Cells)
        {
            var gridCell = LayoutCells.FirstOrDefault(c => c.Row == cell.Row && c.Column == cell.Column);
            if (gridCell != null)
            {
                gridCell.Widget = AvailableWidgets.FirstOrDefault(w => w.Id == cell.WidgetId);
                gridCell.ColSpan = cell.ColSpan;
            }
        }
    }
}

public class WidgetDefinition
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string Icon { get; set; } = string.Empty;
    public string Color { get; set; } = string.Empty;
    public int DefaultColSpan { get; set; } = 1;
}

public class LayoutCell
{
    public int Row { get; set; }
    public int Column { get; set; }
    public int ColSpan { get; set; } = 1;
    public WidgetDefinition? Widget { get; set; }
}

public class PosLayout
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public List<LayoutCellConfig> Cells { get; set; } = new();
}

public class LayoutCellConfig
{
    public string WidgetId { get; set; } = string.Empty;
    public int Row { get; set; }
    public int Column { get; set; }
    public int ColSpan { get; set; }
}

Day 5: Quick Access Button Editor

Objective: Allow customization of quick-access buttons.

Claude Command:

/dev-team create quick access button configuration editor

Implementation:

@* RapOS.PosClient.UI/Components/Layout/QuickAccessEditor.razor *@
@inject IProductService ProductService
@inject ICategoryService CategoryService
@inject ILayoutService LayoutService

<div class="quick-access-editor">
    <h3>Quick Access Buttons</h3>
    <p class="hint">Configure up to 12 quick access buttons for fast product selection.</p>

    <div class="button-grid">
        @for (int i = 0; i < 12; i++)
        {
            var index = i;
            var button = Buttons.ElementAtOrDefault(index);

            <div class="button-slot @(button != null ? "configured" : "")"
                 @onclick="() => EditButton(index)">
                @if (button != null)
                {
                    <div class="button-preview" style="background-color: @button.Color">
                        <span class="button-icon">@button.Icon</span>
                        <span class="button-label">@button.Label</span>
                    </div>
                    <button class="remove-btn" @onclick:stopPropagation @onclick="() => RemoveButton(index)">
                        ✕
                    </button>
                }
                else
                {
                    <span class="empty-slot">+ Add Button</span>
                }
            </div>
        }
    </div>

    @* Edit Modal *@
    @if (IsEditing)
    {
        <div class="modal-overlay" @onclick="CancelEdit">
            <div class="modal-content" @onclick:stopPropagation>
                <h4>Configure Quick Access Button</h4>

                <div class="form-group">
                    <label>Button Type</label>
                    <select @bind="EditingButton.ActionType">
                        <option value="product">Product</option>
                        <option value="category">Category</option>
                        <option value="function">Function</option>
                    </select>
                </div>

                @if (EditingButton.ActionType == "product")
                {
                    <div class="form-group">
                        <label>Select Product</label>
                        <input type="text" @bind="ProductSearch" @bind:event="oninput"
                               placeholder="Search products..." />
                        @if (FilteredProducts.Any())
                        {
                            <div class="search-results">
                                @foreach (var product in FilteredProducts.Take(10))
                                {
                                    <div class="search-result" @onclick="() => SelectProduct(product)">
                                        @product.Name - @product.Sku
                                    </div>
                                }
                            </div>
                        }
                    </div>
                }
                else if (EditingButton.ActionType == "category")
                {
                    <div class="form-group">
                        <label>Select Category</label>
                        <select @bind="EditingButton.CategoryId">
                            @foreach (var category in Categories)
                            {
                                <option value="@category.Id">@category.Name</option>
                            }
                        </select>
                    </div>
                }
                else if (EditingButton.ActionType == "function")
                {
                    <div class="form-group">
                        <label>Select Function</label>
                        <select @bind="EditingButton.FunctionId">
                            <option value="open_drawer">Open Cash Drawer</option>
                            <option value="no_sale">No Sale</option>
                            <option value="discount">Apply Discount</option>
                            <option value="held_sales">Held Sales</option>
                            <option value="returns">Returns</option>
                        </select>
                    </div>
                }

                <div class="form-group">
                    <label>Button Label</label>
                    <input type="text" @bind="EditingButton.Label" maxlength="15" />
                </div>

                <div class="form-group">
                    <label>Button Color</label>
                    <div class="color-picker">
                        @foreach (var color in AvailableColors)
                        {
                            <div class="color-option @(EditingButton.Color == color ? "selected" : "")"
                                 style="background-color: @color"
                                 @onclick="() => EditingButton.Color = color">
                            </div>
                        }
                    </div>
                </div>

                <div class="modal-actions">
                    <button @onclick="CancelEdit">Cancel</button>
                    <button class="primary" @onclick="SaveButton">Save</button>
                </div>
            </div>
        </div>
    }
</div>

@code {
    private List<QuickAccessButton> Buttons = new();
    private List<Product> Products = new();
    private List<Category> Categories = new();

    private bool IsEditing;
    private int EditingIndex;
    private QuickAccessButton EditingButton = new();
    private string ProductSearch = string.Empty;

    private readonly string[] AvailableColors =
    {
        "#4A90D9", "#7B68EE", "#FFB347", "#77DD77",
        "#FF6B6B", "#DDA0DD", "#87CEEB", "#F0E68C"
    };

    private IEnumerable<Product> FilteredProducts =>
        string.IsNullOrEmpty(ProductSearch)
            ? Enumerable.Empty<Product>()
            : Products.Where(p =>
                p.Name.Contains(ProductSearch, StringComparison.OrdinalIgnoreCase) ||
                p.Sku.Contains(ProductSearch, StringComparison.OrdinalIgnoreCase));

    protected override async Task OnInitializedAsync()
    {
        Buttons = await LayoutService.GetQuickAccessButtonsAsync();
        Products = await ProductService.GetProductsAsync();
        Categories = await CategoryService.GetCategoriesAsync();
    }

    private void EditButton(int index)
    {
        EditingIndex = index;
        EditingButton = Buttons.ElementAtOrDefault(index)?.Clone() ?? new QuickAccessButton
        {
            Position = index,
            ActionType = "product",
            Color = AvailableColors[0]
        };
        IsEditing = true;
    }

    private void SelectProduct(Product product)
    {
        EditingButton.ProductId = product.Id;
        EditingButton.Label = product.Name.Length > 15
            ? product.Name.Substring(0, 15)
            : product.Name;
        ProductSearch = string.Empty;
    }

    private async Task SaveButton()
    {
        EditingButton.Position = EditingIndex;

        if (EditingIndex < Buttons.Count)
        {
            Buttons[EditingIndex] = EditingButton;
        }
        else
        {
            while (Buttons.Count <= EditingIndex)
            {
                Buttons.Add(null!);
            }
            Buttons[EditingIndex] = EditingButton;
        }

        await LayoutService.SaveQuickAccessButtonsAsync(Buttons.Where(b => b != null).ToList());
        IsEditing = false;
    }

    private void CancelEdit()
    {
        IsEditing = false;
    }

    private async Task RemoveButton(int index)
    {
        if (index < Buttons.Count)
        {
            Buttons.RemoveAt(index);
            await LayoutService.SaveQuickAccessButtonsAsync(Buttons);
        }
    }
}

23.9 Week 19: Distribution & Polish

Day 1-2: Update Server (Adapt Raptag Pattern)

Objective: Deploy update distribution service based on existing Raptag pattern.

Claude Command:

/dev-team create POS client update server adapting Raptag pattern

Service Location: /volume1/docker/pos-platform/update-server/

Implementation: See existing Raptag update-server at /volume1/docker/raptag/update-server/ - adapt for multi-platform support.

Key additions:

  • Platform detection (windows, android, macos)
  • Tenant-specific version channels
  • Rollback support

Day 3-4: Terminal Registration Flow

Objective: Implement first-launch registration experience.

Claude Command:

/dev-team create terminal registration flow with QR code provisioning

Implementation:

@* RapOS.PosClient.UI/Pages/Registration.razor *@
@page "/register"
@inject ITerminalService TerminalService
@inject ILocalDatabase Database
@inject NavigationManager Navigation

<div class="registration-screen">
    <div class="registration-card">
        <img src="images/rapos-logo.svg" class="logo" alt="RapOS" />
        <h1>Welcome to RapOS POS</h1>

        @if (!IsRegistered)
        {
            @if (!IsConnecting)
            {
                <p class="instruction">
                    Enter the 6-digit registration code provided by your administrator.
                </p>

                <div class="code-input">
                    @for (int i = 0; i < 6; i++)
                    {
                        var index = i;
                        <input type="text"
                               maxlength="1"
                               class="code-digit"
                               @bind="CodeDigits[index]"
                               @oninput="(e) => HandleDigitInput(index, e)"
                               @ref="DigitInputs[index]" />
                    }
                </div>

                @if (!string.IsNullOrEmpty(ErrorMessage))
                {
                    <div class="error-message">@ErrorMessage</div>
                }

                <button class="connect-btn"
                        @onclick="ConnectTerminal"
                        disabled="@(!IsCodeComplete)">
                    Connect Terminal
                </button>

                <p class="help-text">
                    Don't have a code? Contact your store administrator.
                </p>
            }
            else
            {
                <div class="connecting">
                    <div class="spinner"></div>
                    <p>Connecting to @TenantName...</p>
                    <p class="status">@ConnectionStatus</p>
                </div>
            }
        }
        else
        {
            <div class="success">
                <span class="success-icon">✓</span>
                <h2>Terminal Registered!</h2>
                <p>Connected to @TenantName</p>
                <p>Location: @LocationName</p>

                <div class="sync-status">
                    <div class="progress-bar">
                        <div class="progress" style="width: @(SyncProgress)%"></div>
                    </div>
                    <p>Syncing data... @SyncProgress%</p>
                </div>
            </div>
        }
    </div>
</div>

@code {
    private string[] CodeDigits = new string[6];
    private ElementReference[] DigitInputs = new ElementReference[6];

    private bool IsConnecting;
    private bool IsRegistered;
    private string? ErrorMessage;
    private string? TenantName;
    private string? LocationName;
    private string ConnectionStatus = "Verifying code...";
    private int SyncProgress;

    private bool IsCodeComplete => CodeDigits.All(d => !string.IsNullOrEmpty(d));
    private string RegistrationCode => string.Join("", CodeDigits);

    private void HandleDigitInput(int index, ChangeEventArgs e)
    {
        var value = e.Value?.ToString() ?? "";

        if (value.Length > 0 && index < 5)
        {
            // Auto-advance to next input
            // Requires JS interop to focus next input
        }
    }

    private async Task ConnectTerminal()
    {
        if (!IsCodeComplete) return;

        IsConnecting = true;
        ErrorMessage = null;

        try
        {
            // Step 1: Verify code with server
            ConnectionStatus = "Verifying code...";
            var result = await TerminalService.RegisterAsync(RegistrationCode);

            if (!result.Success)
            {
                ErrorMessage = result.Error ?? "Invalid or expired registration code";
                IsConnecting = false;
                return;
            }

            TenantName = result.TenantName;
            LocationName = result.LocationName;

            // Step 2: Save configuration locally
            ConnectionStatus = "Saving configuration...";
            await SaveTerminalConfigAsync(result);

            // Step 3: Initial data sync
            ConnectionStatus = "Downloading products...";
            await SyncInitialDataAsync();

            IsRegistered = true;

            // Navigate to main screen after delay
            await Task.Delay(2000);
            Navigation.NavigateTo("/sale");
        }
        catch (Exception ex)
        {
            ErrorMessage = $"Connection failed: {ex.Message}";
            IsConnecting = false;
        }
    }

    private async Task SaveTerminalConfigAsync(RegistrationResult result)
    {
        await Database.ExecuteAsync(@"
            INSERT OR REPLACE INTO terminal_config
            (id, tenant_code, location_id, terminal_id, terminal_name,
             api_endpoint, api_key, created_at)
            VALUES (1, @TenantCode, @LocationId, @TerminalId, @TerminalName,
                    @ApiEndpoint, @ApiKey, @now)",
            new
            {
                result.TenantCode,
                result.LocationId,
                result.TerminalId,
                result.TerminalName,
                result.ApiEndpoint,
                result.ApiKey,
                now = DateTime.UtcNow.ToString("O")
            });

        // Store API key in secure storage
        await SecureStorage.SetAsync("api_key", result.ApiKey);
    }

    private async Task SyncInitialDataAsync()
    {
        // Sync categories
        SyncProgress = 10;
        ConnectionStatus = "Syncing categories...";
        await Task.Delay(500); // Simulated

        // Sync products
        SyncProgress = 30;
        ConnectionStatus = "Syncing products...";
        await Task.Delay(1000); // Simulated

        // Sync customers
        SyncProgress = 60;
        ConnectionStatus = "Syncing customers...";
        await Task.Delay(500); // Simulated

        // Sync settings
        SyncProgress = 80;
        ConnectionStatus = "Applying settings...";
        await Task.Delay(500); // Simulated

        SyncProgress = 100;
        ConnectionStatus = "Ready!";
    }
}

Day 5: End-to-End Testing & Performance

Objective: Validate complete workflows and optimize performance.

Claude Command:

/qa-team run end-to-end testing for POS client workflows

Test Scenarios:

// RapOS.PosClient.Tests/E2E/SaleWorkflowTests.cs
namespace RapOS.PosClient.Tests.E2E;

[TestClass]
public class SaleWorkflowTests
{
    [TestMethod]
    public async Task CompleteSale_WithCash_PrintsReceipt()
    {
        // Arrange
        var cart = new CartService();
        await cart.AddItemAsync(TestProducts.Shirt);
        await cart.AddItemAsync(TestProducts.Jeans);

        var payment = new PaymentService(/*...*/);
        var sale = new SaleService(/*...*/);

        // Act
        var cashResult = await payment.ProcessCashPaymentAsync(99.99m, 100.00m);
        var saleResult = await sale.CompleteSaleAsync(new[] { cashResult.Payment });

        // Assert
        Assert.IsTrue(saleResult.Success);
        Assert.IsNotNull(saleResult.ReceiptNumber);
        Assert.AreEqual(0.01m, cashResult.Payment.Change);
    }

    [TestMethod]
    public async Task OfflineSale_QueuesForSync()
    {
        // Arrange
        var connectivity = new Mock<IConnectivityService>();
        connectivity.Setup(c => c.IsOnlineAsync()).ReturnsAsync(false);

        var sync = new SyncService(/*...*/);
        var cart = new CartService();
        await cart.AddItemAsync(TestProducts.Shirt);

        var sale = new SaleService(/*...*/);

        // Act
        var result = await sale.CompleteSaleAsync(/*...*/);
        var pendingCount = await sync.GetPendingCountAsync();

        // Assert
        Assert.IsTrue(result.Success);
        Assert.AreEqual(1, pendingCount);
    }

    [TestMethod]
    public async Task BarcodeScanner_AddsProductToCart()
    {
        // Arrange
        var scanner = new BarcodeScannerService(/*...*/);
        var cart = new CartService();

        // Act
        await scanner.ProcessBarcodeAsync("1234567890123");

        // Assert
        var items = await cart.GetCartItemsAsync();
        Assert.AreEqual(1, items.Count);
    }
}

23.10 Phase 4 Deliverables Checklist

Week 14: Project Setup & Core UI

  • .NET MAUI Blazor Hybrid project structure
  • SQLite local database schema
  • Main sale screen with product grid and cart

Week 15: Sale Workflow

  • Product browsing and search
  • Barcode scanning (camera + hardware)
  • Cart operations with discounts

Week 16: Payments & Transactions

  • Cash payment processing
  • Card payment (Stripe Terminal)
  • Receipt generation and printing

Week 17: Offline & Sync

  • Local transaction queue
  • Product/customer cache
  • Background sync service
  • Conflict resolution

Week 18: Hardware & Configuration

  • Multi-platform printer support
  • Drag-and-drop layout builder
  • Quick access button editor
  • Hardware profile management

Week 19: Distribution & Polish

  • Update server deployment
  • Terminal registration flow
  • End-to-end testing
  • Performance optimization

23.11 Success Criteria

MetricTarget
Offline Duration72+ hours fully functional
Transaction Speed< 2 seconds from scan to receipt
Sync Reliability99.9% successful sync rate
App Startup< 3 seconds cold start
Hardware SupportEpson, Star Micronics, Zebra printers
Platform CoverageWindows, Android, macOS

23.12 Next Steps

Phase 4 is the final implementation phase. After completion:

  1. Part VII: Operations (Chapters 24-28)
    • Deployment procedures (Chapter 24)
    • Monitoring and alerting (Chapter 25)
    • Security compliance (Chapter 26)
    • Disaster recovery (Chapter 27)
    • Tenant lifecycle management (Chapter 28)


Document Information

AttributeValue
Version5.0.0
Created2025-12-29
Updated2026-02-25
AuthorClaude Code
StatusActive
PartVI - Implementation Guide
Chapter23 of 32

This chapter is part of the POS Blueprint Book. All content is self-contained.