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