Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 26: Phase 2 - Core Implementation

Overview

Phase 2 builds the core transactional capabilities: inventory management, sales processing with event sourcing, payment handling, and cash drawer operations. This 6-week phase (Weeks 5-10) delivers the heart of the POS system.


Week 5-6: Inventory Domain

Day 1-2: Inventory Item Entity

Objective: Create inventory entity with multi-location stock tracking.

Claude Command:

/dev-team create inventory item entity with location quantities

Implementation:

// src/PosPlatform.Core/Entities/Inventory/InventoryItem.cs
namespace PosPlatform.Core.Entities.Inventory;

public class InventoryItem
{
    public Guid Id { get; private set; }
    public Guid ProductId { get; private set; }
    public Guid? VariantId { get; private set; }
    public string Sku { get; private set; } = string.Empty;
    public Guid LocationId { get; private set; }

    // Stock levels
    public int QuantityOnHand { get; private set; }
    public int QuantityReserved { get; private set; }
    public int QuantityAvailable => QuantityOnHand - QuantityReserved;

    // Thresholds
    public int ReorderPoint { get; private set; }
    public int ReorderQuantity { get; private set; }
    public int MaxQuantity { get; private set; }

    // Tracking
    public DateTime LastCountedAt { get; private set; }
    public DateTime LastReceivedAt { get; private set; }
    public DateTime LastSoldAt { get; private set; }
    public DateTime UpdatedAt { get; private set; }

    private readonly List<StockMovement> _movements = new();
    public IReadOnlyList<StockMovement> Movements => _movements.AsReadOnly();

    private InventoryItem() { }

    public static InventoryItem Create(
        Guid productId,
        Guid locationId,
        string sku,
        Guid? variantId = null)
    {
        return new InventoryItem
        {
            Id = Guid.NewGuid(),
            ProductId = productId,
            VariantId = variantId,
            Sku = sku,
            LocationId = locationId,
            QuantityOnHand = 0,
            QuantityReserved = 0,
            ReorderPoint = 10,
            ReorderQuantity = 50,
            MaxQuantity = 200,
            LastCountedAt = DateTime.MinValue,
            LastReceivedAt = DateTime.MinValue,
            LastSoldAt = DateTime.MinValue,
            UpdatedAt = DateTime.UtcNow
        };
    }

    public void ReceiveStock(int quantity, string reference, Guid userId)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));

        var movement = StockMovement.Create(
            Id, MovementType.Receipt, quantity, QuantityOnHand, reference, userId);

        QuantityOnHand += quantity;
        LastReceivedAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void SellStock(int quantity, string saleReference, Guid userId)
    {
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));

        if (quantity > QuantityAvailable)
            throw new InvalidOperationException($"Insufficient stock. Available: {QuantityAvailable}");

        var movement = StockMovement.Create(
            Id, MovementType.Sale, -quantity, QuantityOnHand, saleReference, userId);

        QuantityOnHand -= quantity;
        QuantityReserved = Math.Max(0, QuantityReserved - quantity);
        LastSoldAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void ReserveStock(int quantity)
    {
        if (quantity > QuantityAvailable)
            throw new InvalidOperationException($"Cannot reserve {quantity}. Available: {QuantityAvailable}");

        QuantityReserved += quantity;
        UpdatedAt = DateTime.UtcNow;
    }

    public void ReleaseReservation(int quantity)
    {
        QuantityReserved = Math.Max(0, QuantityReserved - quantity);
        UpdatedAt = DateTime.UtcNow;
    }

    public void Adjust(int newQuantity, string reason, Guid userId)
    {
        var difference = newQuantity - QuantityOnHand;

        var movement = StockMovement.Create(
            Id, MovementType.Adjustment, difference, QuantityOnHand, reason, userId);

        QuantityOnHand = newQuantity;
        UpdatedAt = DateTime.UtcNow;

        _movements.Add(movement);
    }

    public void RecordCount(int countedQuantity, Guid userId)
    {
        if (countedQuantity != QuantityOnHand)
        {
            var variance = countedQuantity - QuantityOnHand;
            var movement = StockMovement.Create(
                Id, MovementType.Count, variance, QuantityOnHand,
                $"Physical count variance: {variance}", userId);

            QuantityOnHand = countedQuantity;
            _movements.Add(movement);
        }

        LastCountedAt = DateTime.UtcNow;
        UpdatedAt = DateTime.UtcNow;
    }

    public bool NeedsReorder => QuantityOnHand <= ReorderPoint;
    public bool IsOverstocked => QuantityOnHand > MaxQuantity;
}
// src/PosPlatform.Core/Entities/Inventory/StockMovement.cs
namespace PosPlatform.Core.Entities.Inventory;

public class StockMovement
{
    public Guid Id { get; private set; }
    public Guid InventoryItemId { get; private set; }
    public MovementType Type { get; private set; }
    public int Quantity { get; private set; }
    public int QuantityBefore { get; private set; }
    public int QuantityAfter { get; private set; }
    public string Reference { get; private set; } = string.Empty;
    public Guid UserId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private StockMovement() { }

    public static StockMovement Create(
        Guid inventoryItemId,
        MovementType type,
        int quantity,
        int quantityBefore,
        string reference,
        Guid userId)
    {
        return new StockMovement
        {
            Id = Guid.NewGuid(),
            InventoryItemId = inventoryItemId,
            Type = type,
            Quantity = quantity,
            QuantityBefore = quantityBefore,
            QuantityAfter = quantityBefore + quantity,
            Reference = reference,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };
    }
}

public enum MovementType
{
    Receipt,      // Stock received from vendor
    Sale,         // Stock sold to customer
    Return,       // Customer return
    Adjustment,   // Manual adjustment
    Transfer,     // Inter-store transfer
    Count,        // Physical count variance
    Damage,       // Damaged/written off
    Reserved      // Reserved for order
}

Day 3-4: Stock Movement Event Sourcing

Objective: Implement event-sourced stock movements for complete audit trail.

Claude Command:

/dev-team implement stock movement event sourcing

Implementation:

// src/PosPlatform.Core/Events/Inventory/InventoryEvents.cs
namespace PosPlatform.Core.Events.Inventory;

public abstract record InventoryEvent(
    Guid InventoryItemId,
    Guid UserId,
    DateTime OccurredAt);

public record StockReceivedEvent(
    Guid InventoryItemId,
    int Quantity,
    string ReceiptReference,
    decimal UnitCost,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockSoldEvent(
    Guid InventoryItemId,
    int Quantity,
    Guid SaleId,
    decimal UnitPrice,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockAdjustedEvent(
    Guid InventoryItemId,
    int QuantityChange,
    int NewQuantity,
    string Reason,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);

public record StockTransferredEvent(
    Guid SourceInventoryItemId,
    Guid DestinationInventoryItemId,
    int Quantity,
    string TransferReference,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(SourceInventoryItemId, UserId, OccurredAt);

public record StockCountedEvent(
    Guid InventoryItemId,
    int CountedQuantity,
    int SystemQuantity,
    int Variance,
    Guid UserId,
    DateTime OccurredAt) : InventoryEvent(InventoryItemId, UserId, OccurredAt);
// src/PosPlatform.Infrastructure/Data/InventoryEventStore.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Events.Inventory;
using System.Text.Json;

namespace PosPlatform.Infrastructure.Data;

public interface IInventoryEventStore
{
    Task AppendAsync(InventoryEvent @event, CancellationToken ct = default);
    Task<IReadOnlyList<InventoryEvent>> GetEventsAsync(
        Guid inventoryItemId,
        DateTime? fromDate = null,
        CancellationToken ct = default);
}

public class InventoryEventStore : IInventoryEventStore
{
    private readonly TenantDbContext _context;

    public InventoryEventStore(TenantDbContext context)
    {
        _context = context;
    }

    public async Task AppendAsync(InventoryEvent @event, CancellationToken ct = default)
    {
        var storedEvent = new StoredInventoryEvent
        {
            Id = Guid.NewGuid(),
            InventoryItemId = @event.InventoryItemId,
            EventType = @event.GetType().Name,
            EventData = JsonSerializer.Serialize(@event, @event.GetType()),
            UserId = @event.UserId,
            CreatedAt = @event.OccurredAt
        };

        _context.Set<StoredInventoryEvent>().Add(storedEvent);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<IReadOnlyList<InventoryEvent>> GetEventsAsync(
        Guid inventoryItemId,
        DateTime? fromDate = null,
        CancellationToken ct = default)
    {
        var query = _context.Set<StoredInventoryEvent>()
            .Where(e => e.InventoryItemId == inventoryItemId);

        if (fromDate.HasValue)
            query = query.Where(e => e.CreatedAt >= fromDate.Value);

        var stored = await query
            .OrderBy(e => e.CreatedAt)
            .ToListAsync(ct);

        return stored
            .Select(DeserializeEvent)
            .Where(e => e != null)
            .Cast<InventoryEvent>()
            .ToList();
    }

    private static InventoryEvent? DeserializeEvent(StoredInventoryEvent stored)
    {
        var type = stored.EventType switch
        {
            nameof(StockReceivedEvent) => typeof(StockReceivedEvent),
            nameof(StockSoldEvent) => typeof(StockSoldEvent),
            nameof(StockAdjustedEvent) => typeof(StockAdjustedEvent),
            nameof(StockTransferredEvent) => typeof(StockTransferredEvent),
            nameof(StockCountedEvent) => typeof(StockCountedEvent),
            _ => null
        };

        if (type == null) return null;

        return JsonSerializer.Deserialize(stored.EventData, type) as InventoryEvent;
    }
}

public class StoredInventoryEvent
{
    public Guid Id { get; set; }
    public Guid InventoryItemId { get; set; }
    public string EventType { get; set; } = string.Empty;
    public string EventData { get; set; } = string.Empty;
    public Guid UserId { get; set; }
    public DateTime CreatedAt { get; set; }
}

Day 5-6: Inventory Adjustment Service

Claude Command:

/dev-team create inventory adjustment service with reasons

Implementation:

// src/PosPlatform.Core/Services/InventoryAdjustmentService.cs
using PosPlatform.Core.Entities.Inventory;
using PosPlatform.Core.Events.Inventory;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Core.Services;

public interface IInventoryAdjustmentService
{
    Task<InventoryItem> AdjustQuantityAsync(
        Guid inventoryItemId,
        int newQuantity,
        AdjustmentReason reason,
        string? notes,
        Guid userId,
        CancellationToken ct = default);

    Task<InventoryItem> RecordCountAsync(
        Guid inventoryItemId,
        int countedQuantity,
        Guid userId,
        CancellationToken ct = default);
}

public class InventoryAdjustmentService : IInventoryAdjustmentService
{
    private readonly IInventoryRepository _repository;
    private readonly IInventoryEventStore _eventStore;

    public InventoryAdjustmentService(
        IInventoryRepository repository,
        IInventoryEventStore eventStore)
    {
        _repository = repository;
        _eventStore = eventStore;
    }

    public async Task<InventoryItem> AdjustQuantityAsync(
        Guid inventoryItemId,
        int newQuantity,
        AdjustmentReason reason,
        string? notes,
        Guid userId,
        CancellationToken ct = default)
    {
        var item = await _repository.GetByIdAsync(inventoryItemId, ct)
            ?? throw new InvalidOperationException("Inventory item not found");

        var oldQuantity = item.QuantityOnHand;
        var reasonText = FormatReason(reason, notes);

        item.Adjust(newQuantity, reasonText, userId);
        await _repository.UpdateAsync(item, ct);

        var @event = new StockAdjustedEvent(
            inventoryItemId,
            newQuantity - oldQuantity,
            newQuantity,
            reasonText,
            userId,
            DateTime.UtcNow);

        await _eventStore.AppendAsync(@event, ct);

        return item;
    }

    public async Task<InventoryItem> RecordCountAsync(
        Guid inventoryItemId,
        int countedQuantity,
        Guid userId,
        CancellationToken ct = default)
    {
        var item = await _repository.GetByIdAsync(inventoryItemId, ct)
            ?? throw new InvalidOperationException("Inventory item not found");

        var systemQuantity = item.QuantityOnHand;
        var variance = countedQuantity - systemQuantity;

        item.RecordCount(countedQuantity, userId);
        await _repository.UpdateAsync(item, ct);

        var @event = new StockCountedEvent(
            inventoryItemId,
            countedQuantity,
            systemQuantity,
            variance,
            userId,
            DateTime.UtcNow);

        await _eventStore.AppendAsync(@event, ct);

        return item;
    }

    private static string FormatReason(AdjustmentReason reason, string? notes)
    {
        var reasonText = reason switch
        {
            AdjustmentReason.Damaged => "Damaged merchandise",
            AdjustmentReason.Theft => "Theft/shrinkage",
            AdjustmentReason.Expired => "Expired product",
            AdjustmentReason.DataCorrection => "Data entry correction",
            AdjustmentReason.VendorReturn => "Returned to vendor",
            AdjustmentReason.Found => "Found stock",
            AdjustmentReason.Other => "Other adjustment",
            _ => "Unknown reason"
        };

        return string.IsNullOrWhiteSpace(notes)
            ? reasonText
            : $"{reasonText}: {notes}";
    }
}

public enum AdjustmentReason
{
    Damaged,
    Theft,
    Expired,
    DataCorrection,
    VendorReturn,
    Found,
    Other
}

Day 7-8: Inter-Store Transfers

Claude Command:

/dev-team implement inter-store transfer workflow

Implementation:

// src/PosPlatform.Core/Entities/Inventory/TransferRequest.cs
namespace PosPlatform.Core.Entities.Inventory;

public class TransferRequest
{
    public Guid Id { get; private set; }
    public string TransferNumber { get; private set; } = string.Empty;
    public Guid SourceLocationId { get; private set; }
    public Guid DestinationLocationId { get; private set; }
    public TransferStatus Status { get; private set; }
    public Guid RequestedByUserId { get; private set; }
    public DateTime RequestedAt { get; private set; }
    public Guid? ApprovedByUserId { get; private set; }
    public DateTime? ApprovedAt { get; private set; }
    public Guid? ShippedByUserId { get; private set; }
    public DateTime? ShippedAt { get; private set; }
    public Guid? ReceivedByUserId { get; private set; }
    public DateTime? ReceivedAt { get; private set; }
    public string? Notes { get; private set; }

    private readonly List<TransferItem> _items = new();
    public IReadOnlyList<TransferItem> Items => _items.AsReadOnly();

    private TransferRequest() { }

    public static TransferRequest Create(
        Guid sourceLocationId,
        Guid destinationLocationId,
        Guid requestedByUserId,
        string? notes = null)
    {
        return new TransferRequest
        {
            Id = Guid.NewGuid(),
            TransferNumber = GenerateTransferNumber(),
            SourceLocationId = sourceLocationId,
            DestinationLocationId = destinationLocationId,
            Status = TransferStatus.Pending,
            RequestedByUserId = requestedByUserId,
            RequestedAt = DateTime.UtcNow,
            Notes = notes
        };
    }

    public void AddItem(Guid productId, Guid? variantId, string sku, int quantity)
    {
        if (Status != TransferStatus.Pending)
            throw new InvalidOperationException("Cannot modify non-pending transfer");

        var existing = _items.FirstOrDefault(i => i.Sku == sku);
        if (existing != null)
        {
            existing.UpdateQuantity(existing.Quantity + quantity);
        }
        else
        {
            _items.Add(new TransferItem(Id, productId, variantId, sku, quantity));
        }
    }

    public void Approve(Guid userId)
    {
        if (Status != TransferStatus.Pending)
            throw new InvalidOperationException("Can only approve pending transfers");

        Status = TransferStatus.Approved;
        ApprovedByUserId = userId;
        ApprovedAt = DateTime.UtcNow;
    }

    public void Ship(Guid userId)
    {
        if (Status != TransferStatus.Approved)
            throw new InvalidOperationException("Can only ship approved transfers");

        Status = TransferStatus.InTransit;
        ShippedByUserId = userId;
        ShippedAt = DateTime.UtcNow;
    }

    public void Receive(Guid userId, IEnumerable<ReceivedQuantity> receivedQuantities)
    {
        if (Status != TransferStatus.InTransit)
            throw new InvalidOperationException("Can only receive in-transit transfers");

        foreach (var received in receivedQuantities)
        {
            var item = _items.FirstOrDefault(i => i.Sku == received.Sku);
            item?.RecordReceived(received.Quantity);
        }

        Status = TransferStatus.Completed;
        ReceivedByUserId = userId;
        ReceivedAt = DateTime.UtcNow;
    }

    public void Cancel(Guid userId, string reason)
    {
        if (Status == TransferStatus.Completed)
            throw new InvalidOperationException("Cannot cancel completed transfer");

        Status = TransferStatus.Cancelled;
        Notes = $"{Notes}\nCancelled: {reason}";
    }

    private static string GenerateTransferNumber()
        => $"TRF-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString()[..8].ToUpper()}";
}

public class TransferItem
{
    public Guid Id { get; private set; }
    public Guid TransferRequestId { get; private set; }
    public Guid ProductId { get; private set; }
    public Guid? VariantId { get; private set; }
    public string Sku { get; private set; } = string.Empty;
    public int Quantity { get; private set; }
    public int QuantityReceived { get; private set; }
    public int Variance => QuantityReceived - Quantity;

    internal TransferItem(Guid transferId, Guid productId, Guid? variantId, string sku, int quantity)
    {
        Id = Guid.NewGuid();
        TransferRequestId = transferId;
        ProductId = productId;
        VariantId = variantId;
        Sku = sku;
        Quantity = quantity;
    }

    internal void UpdateQuantity(int quantity) => Quantity = quantity;
    internal void RecordReceived(int quantity) => QuantityReceived = quantity;
}

public record ReceivedQuantity(string Sku, int Quantity);

public enum TransferStatus
{
    Pending,
    Approved,
    InTransit,
    Completed,
    Cancelled
}

Day 9-10: Low Stock Alerts

Claude Command:

/dev-team create low stock alert notification system

Implementation:

// src/PosPlatform.Core/Services/LowStockAlertService.cs
using PosPlatform.Core.Entities.Inventory;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ILowStockAlertService
{
    Task<IReadOnlyList<LowStockAlert>> GetAlertsAsync(
        Guid? locationId = null,
        CancellationToken ct = default);

    Task ProcessAlertsAsync(CancellationToken ct = default);
}

public class LowStockAlertService : ILowStockAlertService
{
    private readonly IInventoryRepository _repository;
    private readonly INotificationService _notificationService;

    public LowStockAlertService(
        IInventoryRepository repository,
        INotificationService notificationService)
    {
        _repository = repository;
        _notificationService = notificationService;
    }

    public async Task<IReadOnlyList<LowStockAlert>> GetAlertsAsync(
        Guid? locationId = null,
        CancellationToken ct = default)
    {
        var lowStockItems = await _repository.GetBelowReorderPointAsync(locationId, ct);

        return lowStockItems.Select(item => new LowStockAlert(
            item.Id,
            item.Sku,
            item.LocationId,
            item.QuantityOnHand,
            item.ReorderPoint,
            item.ReorderQuantity,
            item.QuantityOnHand == 0 ? AlertSeverity.Critical : AlertSeverity.Warning
        )).ToList();
    }

    public async Task ProcessAlertsAsync(CancellationToken ct = default)
    {
        var alerts = await GetAlertsAsync(ct: ct);

        var criticalAlerts = alerts.Where(a => a.Severity == AlertSeverity.Critical);
        foreach (var alert in criticalAlerts)
        {
            await _notificationService.SendAsync(new Notification
            {
                Type = NotificationType.LowStock,
                Priority = NotificationPriority.High,
                Title = $"Out of Stock: {alert.Sku}",
                Message = $"SKU {alert.Sku} is out of stock at location. Reorder quantity: {alert.ReorderQuantity}",
                Data = new { alert.InventoryItemId, alert.LocationId }
            }, ct);
        }
    }
}

public record LowStockAlert(
    Guid InventoryItemId,
    string Sku,
    Guid LocationId,
    int CurrentQuantity,
    int ReorderPoint,
    int SuggestedOrderQuantity,
    AlertSeverity Severity);

public enum AlertSeverity
{
    Info,
    Warning,
    Critical
}

Week 6-7: Sales Domain (Event Sourcing)

Day 1-2: Sale Aggregate Root

Objective: Create event-sourced sale entity.

Claude Command:

/dev-team create sale aggregate with event sourcing

Implementation:

// src/PosPlatform.Core/Entities/Sales/Sale.cs
using PosPlatform.Core.Events.Sales;

namespace PosPlatform.Core.Entities.Sales;

public class Sale
{
    public Guid Id { get; private set; }
    public string SaleNumber { get; private set; } = string.Empty;
    public Guid LocationId { get; private set; }
    public Guid CashierId { get; private set; }
    public Guid? CustomerId { get; private set; }
    public SaleStatus Status { get; private set; }

    // Calculated totals
    public decimal Subtotal { get; private set; }
    public decimal TotalDiscount { get; private set; }
    public decimal TaxAmount { get; private set; }
    public decimal Total { get; private set; }

    // Metadata
    public DateTime StartedAt { get; private set; }
    public DateTime? CompletedAt { get; private set; }
    public DateTime? VoidedAt { get; private set; }
    public Guid? VoidedByUserId { get; private set; }
    public string? VoidReason { get; private set; }

    private readonly List<SaleLineItem> _items = new();
    public IReadOnlyList<SaleLineItem> Items => _items.AsReadOnly();

    private readonly List<SalePayment> _payments = new();
    public IReadOnlyList<SalePayment> Payments => _payments.AsReadOnly();

    private readonly List<SaleEvent> _events = new();
    public IReadOnlyList<SaleEvent> Events => _events.AsReadOnly();

    private Sale() { }

    public static Sale Start(Guid locationId, Guid cashierId, Guid? customerId = null)
    {
        var sale = new Sale
        {
            Id = Guid.NewGuid(),
            SaleNumber = GenerateSaleNumber(),
            LocationId = locationId,
            CashierId = cashierId,
            CustomerId = customerId,
            Status = SaleStatus.InProgress,
            StartedAt = DateTime.UtcNow
        };

        sale.Apply(new SaleStartedEvent(sale.Id, locationId, cashierId, DateTime.UtcNow));

        return sale;
    }

    public void AddItem(
        Guid productId,
        Guid? variantId,
        string sku,
        string name,
        int quantity,
        decimal unitPrice,
        decimal taxRate)
    {
        EnsureInProgress();

        var existing = _items.FirstOrDefault(i => i.Sku == sku);
        if (existing != null)
        {
            existing.UpdateQuantity(existing.Quantity + quantity);
        }
        else
        {
            var item = new SaleLineItem(Id, productId, variantId, sku, name, quantity, unitPrice, taxRate);
            _items.Add(item);
        }

        RecalculateTotals();

        Apply(new ItemAddedEvent(Id, sku, name, quantity, unitPrice, DateTime.UtcNow));
    }

    public void RemoveItem(string sku)
    {
        EnsureInProgress();

        var item = _items.FirstOrDefault(i => i.Sku == sku);
        if (item != null)
        {
            _items.Remove(item);
            RecalculateTotals();

            Apply(new ItemRemovedEvent(Id, sku, DateTime.UtcNow));
        }
    }

    public void UpdateItemQuantity(string sku, int newQuantity)
    {
        EnsureInProgress();

        var item = _items.FirstOrDefault(i => i.Sku == sku)
            ?? throw new InvalidOperationException($"Item {sku} not found");

        if (newQuantity <= 0)
        {
            RemoveItem(sku);
            return;
        }

        item.UpdateQuantity(newQuantity);
        RecalculateTotals();

        Apply(new ItemQuantityChangedEvent(Id, sku, newQuantity, DateTime.UtcNow));
    }

    public void ApplyDiscount(decimal discountAmount, string discountCode)
    {
        EnsureInProgress();

        TotalDiscount = discountAmount;
        RecalculateTotals();

        Apply(new DiscountAppliedEvent(Id, discountAmount, discountCode, DateTime.UtcNow));
    }

    public void AddPayment(PaymentMethod method, decimal amount, string? reference = null)
    {
        EnsureInProgress();

        var payment = new SalePayment(Id, method, amount, reference);
        _payments.Add(payment);

        Apply(new PaymentReceivedEvent(Id, method, amount, DateTime.UtcNow));

        if (TotalPaid >= Total)
        {
            Complete();
        }
    }

    public void Complete()
    {
        if (Status != SaleStatus.InProgress)
            throw new InvalidOperationException("Sale is not in progress");

        if (TotalPaid < Total)
            throw new InvalidOperationException($"Payment incomplete. Due: {Total - TotalPaid:C}");

        Status = SaleStatus.Completed;
        CompletedAt = DateTime.UtcNow;

        Apply(new SaleCompletedEvent(Id, Total, DateTime.UtcNow));
    }

    public void Void(Guid userId, string reason)
    {
        if (Status == SaleStatus.Voided)
            throw new InvalidOperationException("Sale already voided");

        Status = SaleStatus.Voided;
        VoidedAt = DateTime.UtcNow;
        VoidedByUserId = userId;
        VoidReason = reason;

        Apply(new SaleVoidedEvent(Id, userId, reason, DateTime.UtcNow));
    }

    public decimal TotalPaid => _payments.Sum(p => p.Amount);
    public decimal BalanceDue => Total - TotalPaid;
    public decimal ChangeDue => TotalPaid > Total ? TotalPaid - Total : 0;

    private void RecalculateTotals()
    {
        Subtotal = _items.Sum(i => i.ExtendedPrice);
        TaxAmount = _items.Sum(i => i.TaxAmount);
        Total = Subtotal - TotalDiscount + TaxAmount;
    }

    private void EnsureInProgress()
    {
        if (Status != SaleStatus.InProgress)
            throw new InvalidOperationException("Sale is not in progress");
    }

    private void Apply(SaleEvent @event)
    {
        _events.Add(@event);
    }

    private static string GenerateSaleNumber()
        => $"S-{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid().ToString()[..4].ToUpper()}";
}

public enum SaleStatus
{
    InProgress,
    Completed,
    Voided,
    Suspended
}

Day 3-4: Sale Events

Claude Command:

/dev-team implement sale events (add, remove, discount, payment)

Implementation:

// src/PosPlatform.Core/Events/Sales/SaleEvents.cs
namespace PosPlatform.Core.Events.Sales;

public abstract record SaleEvent(Guid SaleId, DateTime OccurredAt);

public record SaleStartedEvent(
    Guid SaleId,
    Guid LocationId,
    Guid CashierId,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemAddedEvent(
    Guid SaleId,
    string Sku,
    string Name,
    int Quantity,
    decimal UnitPrice,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemRemovedEvent(
    Guid SaleId,
    string Sku,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record ItemQuantityChangedEvent(
    Guid SaleId,
    string Sku,
    int NewQuantity,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record DiscountAppliedEvent(
    Guid SaleId,
    decimal DiscountAmount,
    string DiscountCode,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record PaymentReceivedEvent(
    Guid SaleId,
    PaymentMethod Method,
    decimal Amount,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record SaleCompletedEvent(
    Guid SaleId,
    decimal TotalAmount,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

public record SaleVoidedEvent(
    Guid SaleId,
    Guid VoidedByUserId,
    string Reason,
    DateTime OccurredAt) : SaleEvent(SaleId, OccurredAt);

Day 7-8: Sale Completion Workflow

Claude Command:

/dev-team implement sale completion workflow

Implementation:

// src/PosPlatform.Core/Services/SaleCompletionService.cs
using PosPlatform.Core.Entities.Sales;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Core.Services;

public interface ISaleCompletionService
{
    Task<SaleCompletionResult> CompleteSaleAsync(
        Guid saleId,
        CancellationToken ct = default);
}

public class SaleCompletionService : ISaleCompletionService
{
    private readonly ISaleRepository _saleRepository;
    private readonly IInventoryService _inventoryService;
    private readonly IReceiptService _receiptService;
    private readonly IEventPublisher _eventPublisher;

    public SaleCompletionService(
        ISaleRepository saleRepository,
        IInventoryService inventoryService,
        IReceiptService receiptService,
        IEventPublisher eventPublisher)
    {
        _saleRepository = saleRepository;
        _inventoryService = inventoryService;
        _receiptService = receiptService;
        _eventPublisher = eventPublisher;
    }

    public async Task<SaleCompletionResult> CompleteSaleAsync(
        Guid saleId,
        CancellationToken ct = default)
    {
        var sale = await _saleRepository.GetByIdAsync(saleId, ct)
            ?? throw new InvalidOperationException("Sale not found");

        // Validate payment
        if (sale.BalanceDue > 0)
        {
            return SaleCompletionResult.Failed($"Balance due: {sale.BalanceDue:C}");
        }

        // Deduct inventory
        foreach (var item in sale.Items)
        {
            await _inventoryService.DeductStockAsync(
                item.Sku,
                sale.LocationId,
                item.Quantity,
                sale.SaleNumber,
                sale.CashierId,
                ct);
        }

        // Complete the sale
        sale.Complete();
        await _saleRepository.UpdateAsync(sale, ct);

        // Generate receipt
        var receipt = await _receiptService.GenerateAsync(sale, ct);

        // Publish events
        foreach (var @event in sale.Events)
        {
            await _eventPublisher.PublishAsync(@event, ct);
        }

        return SaleCompletionResult.Success(receipt.ReceiptNumber, sale.ChangeDue);
    }
}

public record SaleCompletionResult(
    bool IsSuccess,
    string? ReceiptNumber,
    decimal ChangeDue,
    string? ErrorMessage)
{
    public static SaleCompletionResult Success(string receiptNumber, decimal change)
        => new(true, receiptNumber, change, null);

    public static SaleCompletionResult Failed(string error)
        => new(false, null, 0, error);
}

Week 8-9: Payment Processing

Day 1-2: Multi-Tender Payment Entity

Claude Command:

/dev-team create payment entity with multi-tender support

Implementation:

// src/PosPlatform.Core/Entities/Sales/SalePayment.cs
namespace PosPlatform.Core.Entities.Sales;

public class SalePayment
{
    public Guid Id { get; private set; }
    public Guid SaleId { get; private set; }
    public PaymentMethod Method { get; private set; }
    public decimal Amount { get; private set; }
    public string? Reference { get; private set; }
    public PaymentStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public string? ProcessorResponse { get; private set; }

    internal SalePayment(
        Guid saleId,
        PaymentMethod method,
        decimal amount,
        string? reference = null)
    {
        Id = Guid.NewGuid();
        SaleId = saleId;
        Method = method;
        Amount = amount;
        Reference = reference;
        Status = PaymentStatus.Pending;
        CreatedAt = DateTime.UtcNow;
    }

    public void MarkApproved(string? processorResponse = null)
    {
        Status = PaymentStatus.Approved;
        ProcessorResponse = processorResponse;
    }

    public void MarkDeclined(string? reason = null)
    {
        Status = PaymentStatus.Declined;
        ProcessorResponse = reason;
    }

    public void MarkRefunded()
    {
        Status = PaymentStatus.Refunded;
    }
}

public enum PaymentMethod
{
    Cash,
    CreditCard,
    DebitCard,
    GiftCard,
    StoreCredit,
    Check,
    Other
}

public enum PaymentStatus
{
    Pending,
    Approved,
    Declined,
    Refunded,
    Voided
}

Day 3-4: Cash Payment Handler

Claude Command:

/dev-team implement cash payment handler with change calculation

Implementation:

// src/PosPlatform.Core/Services/Payments/CashPaymentHandler.cs
namespace PosPlatform.Core.Services.Payments;

public interface ICashPaymentHandler
{
    CashPaymentResult ProcessPayment(decimal amountDue, decimal amountTendered);
    IReadOnlyList<CashDenomination> CalculateOptimalChange(decimal changeAmount);
}

public class CashPaymentHandler : ICashPaymentHandler
{
    private static readonly decimal[] Denominations =
    {
        100.00m, 50.00m, 20.00m, 10.00m, 5.00m, 2.00m, 1.00m,
        0.25m, 0.10m, 0.05m, 0.01m
    };

    public CashPaymentResult ProcessPayment(decimal amountDue, decimal amountTendered)
    {
        if (amountTendered < 0)
            throw new ArgumentException("Amount tendered cannot be negative");

        if (amountTendered < amountDue)
        {
            return new CashPaymentResult(
                false,
                amountTendered,
                0,
                amountDue - amountTendered,
                Array.Empty<CashDenomination>());
        }

        var change = amountTendered - amountDue;
        var changeDenominations = CalculateOptimalChange(change);

        return new CashPaymentResult(
            true,
            amountTendered,
            change,
            0,
            changeDenominations);
    }

    public IReadOnlyList<CashDenomination> CalculateOptimalChange(decimal changeAmount)
    {
        var result = new List<CashDenomination>();
        var remaining = changeAmount;

        foreach (var denom in Denominations)
        {
            if (remaining <= 0) break;

            var count = (int)(remaining / denom);
            if (count > 0)
            {
                result.Add(new CashDenomination(denom, count));
                remaining -= count * denom;
            }
        }

        // Handle any remaining due to floating point
        remaining = Math.Round(remaining, 2);
        if (remaining > 0)
        {
            result.Add(new CashDenomination(0.01m, (int)(remaining / 0.01m)));
        }

        return result;
    }
}

public record CashPaymentResult(
    bool IsFullPayment,
    decimal AmountTendered,
    decimal ChangeAmount,
    decimal RemainingBalance,
    IReadOnlyList<CashDenomination> ChangeDenominations);

public record CashDenomination(decimal Value, int Count)
{
    public decimal Total => Value * Count;
}

Week 9-10: Cash Drawer Operations

Day 1-2: Drawer Session Entity

Claude Command:

/dev-team create drawer session entity with state machine

Implementation:

// src/PosPlatform.Core/Entities/CashDrawer/DrawerSession.cs
namespace PosPlatform.Core.Entities.CashDrawer;

public class DrawerSession
{
    public Guid Id { get; private set; }
    public Guid LocationId { get; private set; }
    public Guid TerminalId { get; private set; }
    public Guid OpenedByUserId { get; private set; }
    public Guid? ClosedByUserId { get; private set; }

    public DrawerSessionStatus Status { get; private set; }

    public decimal OpeningBalance { get; private set; }
    public decimal ExpectedBalance { get; private set; }
    public decimal? CountedBalance { get; private set; }
    public decimal? Variance { get; private set; }

    public DateTime OpenedAt { get; private set; }
    public DateTime? ClosedAt { get; private set; }

    private readonly List<DrawerTransaction> _transactions = new();
    public IReadOnlyList<DrawerTransaction> Transactions => _transactions.AsReadOnly();

    private DrawerSession() { }

    public static DrawerSession Open(
        Guid locationId,
        Guid terminalId,
        Guid userId,
        decimal openingBalance)
    {
        return new DrawerSession
        {
            Id = Guid.NewGuid(),
            LocationId = locationId,
            TerminalId = terminalId,
            OpenedByUserId = userId,
            Status = DrawerSessionStatus.Open,
            OpeningBalance = openingBalance,
            ExpectedBalance = openingBalance,
            OpenedAt = DateTime.UtcNow
        };
    }

    public void RecordCashSale(decimal amount, string saleReference)
    {
        EnsureOpen();
        var txn = DrawerTransaction.CashIn(Id, amount, saleReference);
        _transactions.Add(txn);
        ExpectedBalance += amount;
    }

    public void RecordCashRefund(decimal amount, string refundReference)
    {
        EnsureOpen();
        var txn = DrawerTransaction.CashOut(Id, amount, refundReference, "Cash Refund");
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void RecordPaidOut(decimal amount, string description, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.PaidOut(Id, amount, description, userId);
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void RecordPaidIn(decimal amount, string description, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.PaidIn(Id, amount, description, userId);
        _transactions.Add(txn);
        ExpectedBalance += amount;
    }

    public void RecordPickup(decimal amount, Guid userId)
    {
        EnsureOpen();
        var txn = DrawerTransaction.Pickup(Id, amount, userId);
        _transactions.Add(txn);
        ExpectedBalance -= amount;
    }

    public void SubmitBlindCount(decimal countedAmount, Guid userId)
    {
        EnsureOpen();
        CountedBalance = countedAmount;
        Variance = countedAmount - ExpectedBalance;
        Status = DrawerSessionStatus.Counted;
        ClosedByUserId = userId;
    }

    public void Close(Guid userId)
    {
        if (Status == DrawerSessionStatus.Open)
            throw new InvalidOperationException("Must submit count before closing");

        if (Status == DrawerSessionStatus.Closed)
            throw new InvalidOperationException("Drawer already closed");

        Status = DrawerSessionStatus.Closed;
        ClosedByUserId = userId;
        ClosedAt = DateTime.UtcNow;
    }

    public decimal TotalCashIn => _transactions
        .Where(t => t.Type == DrawerTransactionType.CashIn || t.Type == DrawerTransactionType.PaidIn)
        .Sum(t => t.Amount);

    public decimal TotalCashOut => _transactions
        .Where(t => t.Type == DrawerTransactionType.CashOut ||
                    t.Type == DrawerTransactionType.PaidOut ||
                    t.Type == DrawerTransactionType.Pickup)
        .Sum(t => t.Amount);

    private void EnsureOpen()
    {
        if (Status != DrawerSessionStatus.Open)
            throw new InvalidOperationException("Drawer is not open");
    }
}

public enum DrawerSessionStatus
{
    Open,
    Counted,
    Closed
}
// src/PosPlatform.Core/Entities/CashDrawer/DrawerTransaction.cs
namespace PosPlatform.Core.Entities.CashDrawer;

public class DrawerTransaction
{
    public Guid Id { get; private set; }
    public Guid SessionId { get; private set; }
    public DrawerTransactionType Type { get; private set; }
    public decimal Amount { get; private set; }
    public string Reference { get; private set; } = string.Empty;
    public string? Description { get; private set; }
    public Guid? UserId { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private DrawerTransaction() { }

    public static DrawerTransaction CashIn(Guid sessionId, decimal amount, string reference)
        => Create(sessionId, DrawerTransactionType.CashIn, amount, reference);

    public static DrawerTransaction CashOut(Guid sessionId, decimal amount, string reference, string description)
        => Create(sessionId, DrawerTransactionType.CashOut, amount, reference, description);

    public static DrawerTransaction PaidOut(Guid sessionId, decimal amount, string description, Guid userId)
        => Create(sessionId, DrawerTransactionType.PaidOut, amount, Guid.NewGuid().ToString(), description, userId);

    public static DrawerTransaction PaidIn(Guid sessionId, decimal amount, string description, Guid userId)
        => Create(sessionId, DrawerTransactionType.PaidIn, amount, Guid.NewGuid().ToString(), description, userId);

    public static DrawerTransaction Pickup(Guid sessionId, decimal amount, Guid userId)
        => Create(sessionId, DrawerTransactionType.Pickup, amount, $"PICKUP-{DateTime.UtcNow:yyyyMMddHHmmss}", "Cash Pickup", userId);

    private static DrawerTransaction Create(
        Guid sessionId,
        DrawerTransactionType type,
        decimal amount,
        string reference,
        string? description = null,
        Guid? userId = null)
    {
        return new DrawerTransaction
        {
            Id = Guid.NewGuid(),
            SessionId = sessionId,
            Type = type,
            Amount = Math.Abs(amount),
            Reference = reference,
            Description = description,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };
    }
}

public enum DrawerTransactionType
{
    CashIn,      // Cash received from sale
    CashOut,     // Cash returned (refund, change)
    PaidIn,      // Manual cash deposit
    PaidOut,     // Manual cash withdrawal
    Pickup       // Cash pickup during shift
}

Testing Checkpoints

Week 6 Checkpoint: Inventory

# Run inventory tests
dotnet test --filter "FullyQualifiedName~Inventory"

# Manual verification
curl -X POST http://localhost:5100/api/inventory/receive \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Code: DEMO" \
  -d '{"sku": "TEST-001", "quantity": 100, "reference": "PO-001"}'

Week 7 Checkpoint: Sales

# Create and complete a sale
curl -X POST http://localhost:5100/api/sales \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Code: DEMO" \
  -d '{"locationId": "...", "cashierId": "..."}'

# Add item
curl -X POST http://localhost:5100/api/sales/{saleId}/items \
  -d '{"sku": "TEST-001", "quantity": 1}'

# Add payment
curl -X POST http://localhost:5100/api/sales/{saleId}/payments \
  -d '{"method": "Cash", "amount": 50.00}'

Next Steps

Proceed to Chapter 27: Phase 3 - Support Implementation for:

  • Customer domain with loyalty
  • Offline sync infrastructure
  • RFID module (optional)

Chapter 26 Complete - Phase 2 Core Implementation