Chapter 27: Phase 3 - Support Implementation
Overview
Phase 3 adds support capabilities that enhance the core POS system: customer management with loyalty programs, offline operation support, and optional RFID integration. This 4-week phase (Weeks 11-14) builds features that differentiate the platform.
Week 11-12: Customer Domain with Loyalty
Day 1-2: Customer Entity
Objective: Create customer entity with profile and contact information.
Claude Command:
/dev-team create customer entity with contact information and profile
Implementation:
// src/PosPlatform.Core/Entities/Customers/Customer.cs
namespace PosPlatform.Core.Entities.Customers;
public class Customer
{
public Guid Id { get; private set; }
public string? CustomerNumber { get; private set; }
public string FirstName { get; private set; } = string.Empty;
public string LastName { get; private set; } = string.Empty;
public string FullName => $"{FirstName} {LastName}".Trim();
// Contact info
public string? Email { get; private set; }
public string? Phone { get; private set; }
public CustomerAddress? Address { get; private set; }
// Loyalty
public string? LoyaltyId { get; private set; }
public int LoyaltyPoints { get; private set; }
public CustomerTier Tier { get; private set; }
// Marketing
public bool EmailOptIn { get; private set; }
public bool SmsOptIn { get; private set; }
// Stats
public int TotalOrders { get; private set; }
public decimal TotalSpent { get; private set; }
public DateTime? LastPurchaseAt { get; private set; }
// Metadata
public bool IsActive { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
private readonly List<CustomerNote> _notes = new();
public IReadOnlyList<CustomerNote> Notes => _notes.AsReadOnly();
private Customer() { }
public static Customer Create(
string firstName,
string lastName,
string? email = null,
string? phone = null)
{
var customer = new Customer
{
Id = Guid.NewGuid(),
CustomerNumber = GenerateCustomerNumber(),
FirstName = firstName,
LastName = lastName,
Email = email?.ToLowerInvariant(),
Phone = NormalizePhone(phone),
Tier = CustomerTier.Bronze,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
// Auto-generate loyalty ID
customer.LoyaltyId = GenerateLoyaltyId();
return customer;
}
public void UpdateContact(string? email, string? phone)
{
Email = email?.ToLowerInvariant();
Phone = NormalizePhone(phone);
UpdatedAt = DateTime.UtcNow;
}
public void UpdateAddress(CustomerAddress address)
{
Address = address;
UpdatedAt = DateTime.UtcNow;
}
public void SetMarketingPreferences(bool emailOptIn, bool smsOptIn)
{
EmailOptIn = emailOptIn;
SmsOptIn = smsOptIn;
UpdatedAt = DateTime.UtcNow;
}
public void RecordPurchase(decimal amount, int pointsEarned)
{
TotalOrders++;
TotalSpent += amount;
LoyaltyPoints += pointsEarned;
LastPurchaseAt = DateTime.UtcNow;
// Update tier based on total spent
Tier = TotalSpent switch
{
>= 10000 => CustomerTier.Platinum,
>= 5000 => CustomerTier.Gold,
>= 1000 => CustomerTier.Silver,
_ => CustomerTier.Bronze
};
UpdatedAt = DateTime.UtcNow;
}
public bool RedeemPoints(int points)
{
if (points > LoyaltyPoints)
return false;
LoyaltyPoints -= points;
UpdatedAt = DateTime.UtcNow;
return true;
}
public void AddNote(string content, Guid userId)
{
_notes.Add(new CustomerNote(Id, content, userId));
UpdatedAt = DateTime.UtcNow;
}
public void Deactivate() => IsActive = false;
public void Reactivate() => IsActive = true;
private static string GenerateCustomerNumber()
=> $"C{DateTime.UtcNow:yyMMdd}{new Random().Next(1000, 9999)}";
private static string GenerateLoyaltyId()
=> $"LYL{Guid.NewGuid().ToString()[..8].ToUpper()}";
private static string? NormalizePhone(string? phone)
{
if (string.IsNullOrWhiteSpace(phone))
return null;
// Remove non-digits
var digits = new string(phone.Where(char.IsDigit).ToArray());
// Format as (XXX) XXX-XXXX for US numbers
if (digits.Length == 10)
return $"({digits[..3]}) {digits[3..6]}-{digits[6..]}";
if (digits.Length == 11 && digits[0] == '1')
return $"({digits[1..4]}) {digits[4..7]}-{digits[7..]}";
return digits;
}
}
public class CustomerAddress
{
public string Street1 { get; set; } = string.Empty;
public string? Street2 { get; set; }
public string City { get; set; } = string.Empty;
public string State { get; set; } = string.Empty;
public string PostalCode { get; set; } = string.Empty;
public string Country { get; set; } = "US";
}
public class CustomerNote
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public string Content { get; private set; }
public Guid CreatedByUserId { get; private set; }
public DateTime CreatedAt { get; private set; }
public CustomerNote(Guid customerId, string content, Guid userId)
{
Id = Guid.NewGuid();
CustomerId = customerId;
Content = content;
CreatedByUserId = userId;
CreatedAt = DateTime.UtcNow;
}
}
public enum CustomerTier
{
Bronze,
Silver,
Gold,
Platinum
}
Day 3-4: Customer Lookup Service
Objective: Fast customer lookup by multiple identifiers.
Claude Command:
/dev-team implement customer lookup by phone, email, and loyalty ID
Implementation:
// src/PosPlatform.Core/Interfaces/ICustomerRepository.cs
using PosPlatform.Core.Entities.Customers;
namespace PosPlatform.Core.Interfaces;
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default);
Task<Customer?> GetByPhoneAsync(string phone, CancellationToken ct = default);
Task<Customer?> GetByLoyaltyIdAsync(string loyaltyId, CancellationToken ct = default);
Task<Customer?> GetByCustomerNumberAsync(string customerNumber, CancellationToken ct = default);
Task<IReadOnlyList<Customer>> SearchAsync(
string searchTerm,
int limit = 20,
CancellationToken ct = default);
Task<Customer> AddAsync(Customer customer, CancellationToken ct = default);
Task UpdateAsync(Customer customer, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/Repositories/CustomerRepository.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;
namespace PosPlatform.Infrastructure.Repositories;
public class CustomerRepository : ICustomerRepository
{
private readonly TenantDbContext _context;
public CustomerRepository(TenantDbContext context)
{
_context = context;
}
public async Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _context.Customers
.Include(c => c.Notes)
.FirstOrDefaultAsync(c => c.Id == id, ct);
public async Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default)
=> await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email.ToLowerInvariant(), ct);
public async Task<Customer?> GetByPhoneAsync(string phone, CancellationToken ct = default)
{
// Normalize phone for comparison
var normalizedPhone = NormalizePhoneForSearch(phone);
return await _context.Customers
.FirstOrDefaultAsync(c => c.Phone != null &&
EF.Functions.Like(c.Phone, $"%{normalizedPhone}%"), ct);
}
public async Task<Customer?> GetByLoyaltyIdAsync(string loyaltyId, CancellationToken ct = default)
=> await _context.Customers
.FirstOrDefaultAsync(c => c.LoyaltyId == loyaltyId.ToUpperInvariant(), ct);
public async Task<Customer?> GetByCustomerNumberAsync(string customerNumber, CancellationToken ct = default)
=> await _context.Customers
.FirstOrDefaultAsync(c => c.CustomerNumber == customerNumber, ct);
public async Task<IReadOnlyList<Customer>> SearchAsync(
string searchTerm,
int limit = 20,
CancellationToken ct = default)
{
var term = searchTerm.ToLowerInvariant();
return await _context.Customers
.Where(c => c.IsActive &&
(c.FirstName.ToLower().Contains(term) ||
c.LastName.ToLower().Contains(term) ||
(c.Email != null && c.Email.Contains(term)) ||
(c.Phone != null && c.Phone.Contains(term)) ||
(c.LoyaltyId != null && c.LoyaltyId.Contains(term.ToUpper())) ||
(c.CustomerNumber != null && c.CustomerNumber.Contains(term))))
.OrderBy(c => c.LastName)
.ThenBy(c => c.FirstName)
.Take(limit)
.ToListAsync(ct);
}
public async Task<Customer> AddAsync(Customer customer, CancellationToken ct = default)
{
await _context.Customers.AddAsync(customer, ct);
await _context.SaveChangesAsync(ct);
return customer;
}
public async Task UpdateAsync(Customer customer, CancellationToken ct = default)
{
_context.Customers.Update(customer);
await _context.SaveChangesAsync(ct);
}
private static string NormalizePhoneForSearch(string phone)
=> new string(phone.Where(char.IsDigit).ToArray());
}
// src/PosPlatform.Core/Services/CustomerLookupService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
namespace PosPlatform.Core.Services;
public interface ICustomerLookupService
{
Task<Customer?> LookupAsync(string identifier, CancellationToken ct = default);
Task<IReadOnlyList<Customer>> QuickSearchAsync(string term, CancellationToken ct = default);
}
public class CustomerLookupService : ICustomerLookupService
{
private readonly ICustomerRepository _repository;
public CustomerLookupService(ICustomerRepository repository)
{
_repository = repository;
}
public async Task<Customer?> LookupAsync(string identifier, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(identifier))
return null;
identifier = identifier.Trim();
// Try loyalty ID first (fast, unique)
if (identifier.StartsWith("LYL", StringComparison.OrdinalIgnoreCase))
{
return await _repository.GetByLoyaltyIdAsync(identifier, ct);
}
// Try customer number
if (identifier.StartsWith("C", StringComparison.OrdinalIgnoreCase) &&
identifier.Length == 11)
{
return await _repository.GetByCustomerNumberAsync(identifier, ct);
}
// Try email
if (identifier.Contains('@'))
{
return await _repository.GetByEmailAsync(identifier, ct);
}
// Try phone (if mostly digits)
var digits = identifier.Count(char.IsDigit);
if (digits >= 7)
{
return await _repository.GetByPhoneAsync(identifier, ct);
}
// Fall back to search
var results = await _repository.SearchAsync(identifier, 1, ct);
return results.FirstOrDefault();
}
public async Task<IReadOnlyList<Customer>> QuickSearchAsync(
string term,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Array.Empty<Customer>();
return await _repository.SearchAsync(term, 10, ct);
}
}
Day 5-6: Purchase History
Objective: Track and query customer purchase history.
Claude Command:
/dev-team create purchase history tracking and queries
Implementation:
// src/PosPlatform.Core/Entities/Customers/CustomerPurchase.cs
namespace PosPlatform.Core.Entities.Customers;
public class CustomerPurchase
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public Guid SaleId { get; private set; }
public string SaleNumber { get; private set; } = string.Empty;
public Guid LocationId { get; private set; }
public decimal TotalAmount { get; private set; }
public int ItemCount { get; private set; }
public int PointsEarned { get; private set; }
public int PointsRedeemed { get; private set; }
public DateTime PurchasedAt { get; private set; }
private readonly List<CustomerPurchaseItem> _items = new();
public IReadOnlyList<CustomerPurchaseItem> Items => _items.AsReadOnly();
private CustomerPurchase() { }
public static CustomerPurchase Create(
Guid customerId,
Guid saleId,
string saleNumber,
Guid locationId,
decimal totalAmount,
int pointsEarned,
int pointsRedeemed,
IEnumerable<CustomerPurchaseItem> items)
{
var purchase = new CustomerPurchase
{
Id = Guid.NewGuid(),
CustomerId = customerId,
SaleId = saleId,
SaleNumber = saleNumber,
LocationId = locationId,
TotalAmount = totalAmount,
PointsEarned = pointsEarned,
PointsRedeemed = pointsRedeemed,
PurchasedAt = DateTime.UtcNow
};
foreach (var item in items)
{
purchase._items.Add(item);
}
purchase.ItemCount = purchase._items.Sum(i => i.Quantity);
return purchase;
}
}
public class CustomerPurchaseItem
{
public Guid Id { get; set; }
public Guid PurchaseId { get; set; }
public string Sku { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
// src/PosPlatform.Core/Services/PurchaseHistoryService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Entities.Sales;
using PosPlatform.Core.Interfaces;
namespace PosPlatform.Core.Services;
public interface IPurchaseHistoryService
{
Task RecordPurchaseAsync(Guid customerId, Sale sale, CancellationToken ct = default);
Task<IReadOnlyList<CustomerPurchase>> GetHistoryAsync(
Guid customerId,
DateTime? fromDate = null,
DateTime? toDate = null,
int limit = 50,
CancellationToken ct = default);
Task<CustomerPurchaseStats> GetStatsAsync(Guid customerId, CancellationToken ct = default);
}
public class PurchaseHistoryService : IPurchaseHistoryService
{
private readonly IPurchaseHistoryRepository _repository;
private readonly ICustomerRepository _customerRepository;
private readonly ILoyaltyService _loyaltyService;
public PurchaseHistoryService(
IPurchaseHistoryRepository repository,
ICustomerRepository customerRepository,
ILoyaltyService loyaltyService)
{
_repository = repository;
_customerRepository = customerRepository;
_loyaltyService = loyaltyService;
}
public async Task RecordPurchaseAsync(
Guid customerId,
Sale sale,
CancellationToken ct = default)
{
var customer = await _customerRepository.GetByIdAsync(customerId, ct)
?? throw new InvalidOperationException("Customer not found");
var pointsEarned = _loyaltyService.CalculatePoints(sale.Total, customer.Tier);
var items = sale.Items.Select(i => new CustomerPurchaseItem
{
Id = Guid.NewGuid(),
Sku = i.Sku,
ProductName = i.Name,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.ExtendedPrice
});
var purchase = CustomerPurchase.Create(
customerId,
sale.Id,
sale.SaleNumber,
sale.LocationId,
sale.Total,
pointsEarned,
0, // Points redeemed tracked separately
items);
await _repository.AddAsync(purchase, ct);
customer.RecordPurchase(sale.Total, pointsEarned);
await _customerRepository.UpdateAsync(customer, ct);
}
public async Task<IReadOnlyList<CustomerPurchase>> GetHistoryAsync(
Guid customerId,
DateTime? fromDate = null,
DateTime? toDate = null,
int limit = 50,
CancellationToken ct = default)
{
return await _repository.GetByCustomerAsync(customerId, fromDate, toDate, limit, ct);
}
public async Task<CustomerPurchaseStats> GetStatsAsync(
Guid customerId,
CancellationToken ct = default)
{
return await _repository.GetStatsAsync(customerId, ct);
}
}
public record CustomerPurchaseStats(
int TotalOrders,
decimal TotalSpent,
decimal AverageOrderValue,
int TotalItems,
string? TopCategory,
string? TopProduct,
DateTime? FirstPurchase,
DateTime? LastPurchase);
Day 7-8: Loyalty Points System
Objective: Implement point earning and redemption.
Claude Command:
/dev-team implement loyalty points earning and redemption system
Implementation:
// src/PosPlatform.Core/Services/LoyaltyService.cs
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
namespace PosPlatform.Core.Services;
public interface ILoyaltyService
{
int CalculatePoints(decimal purchaseAmount, CustomerTier tier);
decimal CalculateRedemptionValue(int points);
Task<PointsRedemptionResult> RedeemPointsAsync(
Guid customerId,
int points,
Guid saleId,
CancellationToken ct = default);
LoyaltyTierBenefits GetTierBenefits(CustomerTier tier);
}
public class LoyaltyService : ILoyaltyService
{
private readonly ICustomerRepository _customerRepository;
private readonly ILoyaltyTransactionRepository _transactionRepository;
// Configuration (in production, load from settings)
private const decimal BasePointsPerDollar = 1.0m;
private const decimal PointValue = 0.01m; // Each point = $0.01
private static readonly Dictionary<CustomerTier, decimal> TierMultipliers = new()
{
{ CustomerTier.Bronze, 1.0m },
{ CustomerTier.Silver, 1.25m },
{ CustomerTier.Gold, 1.5m },
{ CustomerTier.Platinum, 2.0m }
};
public LoyaltyService(
ICustomerRepository customerRepository,
ILoyaltyTransactionRepository transactionRepository)
{
_customerRepository = customerRepository;
_transactionRepository = transactionRepository;
}
public int CalculatePoints(decimal purchaseAmount, CustomerTier tier)
{
var multiplier = TierMultipliers.GetValueOrDefault(tier, 1.0m);
var points = purchaseAmount * BasePointsPerDollar * multiplier;
return (int)Math.Floor(points);
}
public decimal CalculateRedemptionValue(int points)
{
return points * PointValue;
}
public async Task<PointsRedemptionResult> RedeemPointsAsync(
Guid customerId,
int points,
Guid saleId,
CancellationToken ct = default)
{
var customer = await _customerRepository.GetByIdAsync(customerId, ct)
?? throw new InvalidOperationException("Customer not found");
if (points > customer.LoyaltyPoints)
{
return PointsRedemptionResult.Failed(
$"Insufficient points. Available: {customer.LoyaltyPoints}");
}
var value = CalculateRedemptionValue(points);
if (!customer.RedeemPoints(points))
{
return PointsRedemptionResult.Failed("Failed to redeem points");
}
await _customerRepository.UpdateAsync(customer, ct);
// Record transaction
var transaction = new LoyaltyTransaction
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Type = LoyaltyTransactionType.Redemption,
Points = -points,
BalanceAfter = customer.LoyaltyPoints,
Reference = saleId.ToString(),
CreatedAt = DateTime.UtcNow
};
await _transactionRepository.AddAsync(transaction, ct);
return PointsRedemptionResult.Success(points, value);
}
public LoyaltyTierBenefits GetTierBenefits(CustomerTier tier)
{
return tier switch
{
CustomerTier.Bronze => new LoyaltyTierBenefits(
"Bronze", 1.0m, 0, false, false),
CustomerTier.Silver => new LoyaltyTierBenefits(
"Silver", 1.25m, 5, true, false),
CustomerTier.Gold => new LoyaltyTierBenefits(
"Gold", 1.5m, 10, true, true),
CustomerTier.Platinum => new LoyaltyTierBenefits(
"Platinum", 2.0m, 15, true, true),
_ => new LoyaltyTierBenefits("Unknown", 1.0m, 0, false, false)
};
}
}
public record PointsRedemptionResult(
bool IsSuccess,
int PointsRedeemed,
decimal DiscountValue,
string? ErrorMessage)
{
public static PointsRedemptionResult Success(int points, decimal value)
=> new(true, points, value, null);
public static PointsRedemptionResult Failed(string error)
=> new(false, 0, 0, error);
}
public record LoyaltyTierBenefits(
string TierName,
decimal PointsMultiplier,
int DiscountPercentage,
bool FreeShipping,
bool EarlyAccess);
public class LoyaltyTransaction
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public LoyaltyTransactionType Type { get; set; }
public int Points { get; set; }
public int BalanceAfter { get; set; }
public string Reference { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
public enum LoyaltyTransactionType
{
Earn,
Redemption,
Adjustment,
Expiration
}
Day 9-10: Customer API
Claude Command:
/dev-team create customer API endpoints with search and CRUD
Implementation:
// src/PosPlatform.Api/Controllers/CustomersController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Entities.Customers;
using PosPlatform.Core.Interfaces;
using PosPlatform.Core.Services;
namespace PosPlatform.Api.Controllers;
[ApiController]
[Route("api/customers")]
[Authorize]
public class CustomersController : ControllerBase
{
private readonly ICustomerRepository _repository;
private readonly ICustomerLookupService _lookupService;
private readonly IPurchaseHistoryService _historyService;
private readonly ILoyaltyService _loyaltyService;
public CustomersController(
ICustomerRepository repository,
ICustomerLookupService lookupService,
IPurchaseHistoryService historyService,
ILoyaltyService loyaltyService)
{
_repository = repository;
_lookupService = lookupService;
_historyService = historyService;
_loyaltyService = loyaltyService;
}
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<CustomerSummaryDto>>> Search(
[FromQuery] string q,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(q))
return BadRequest("Search term required");
var customers = await _lookupService.QuickSearchAsync(q, ct);
return Ok(customers.Select(CustomerSummaryDto.FromEntity));
}
[HttpGet("lookup")]
public async Task<ActionResult<CustomerDto>> Lookup(
[FromQuery] string identifier,
CancellationToken ct)
{
var customer = await _lookupService.LookupAsync(identifier, ct);
if (customer == null)
return NotFound();
var benefits = _loyaltyService.GetTierBenefits(customer.Tier);
return Ok(CustomerDto.FromEntity(customer, benefits));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<CustomerDto>> GetById(Guid id, CancellationToken ct)
{
var customer = await _repository.GetByIdAsync(id, ct);
if (customer == null)
return NotFound();
var benefits = _loyaltyService.GetTierBenefits(customer.Tier);
return Ok(CustomerDto.FromEntity(customer, benefits));
}
[HttpPost]
public async Task<ActionResult<CustomerDto>> Create(
[FromBody] CreateCustomerRequest request,
CancellationToken ct)
{
// Check for existing customer
if (!string.IsNullOrEmpty(request.Email))
{
var existing = await _repository.GetByEmailAsync(request.Email, ct);
if (existing != null)
return Conflict(new { error = "Email already registered" });
}
var customer = Customer.Create(
request.FirstName,
request.LastName,
request.Email,
request.Phone);
if (request.Address != null)
customer.UpdateAddress(request.Address);
customer.SetMarketingPreferences(
request.EmailOptIn ?? false,
request.SmsOptIn ?? false);
await _repository.AddAsync(customer, ct);
var benefits = _loyaltyService.GetTierBenefits(customer.Tier);
return CreatedAtAction(
nameof(GetById),
new { id = customer.Id },
CustomerDto.FromEntity(customer, benefits));
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(
Guid id,
[FromBody] UpdateCustomerRequest request,
CancellationToken ct)
{
var customer = await _repository.GetByIdAsync(id, ct);
if (customer == null)
return NotFound();
if (request.Email != null || request.Phone != null)
customer.UpdateContact(request.Email ?? customer.Email, request.Phone ?? customer.Phone);
if (request.Address != null)
customer.UpdateAddress(request.Address);
if (request.EmailOptIn.HasValue || request.SmsOptIn.HasValue)
customer.SetMarketingPreferences(
request.EmailOptIn ?? customer.EmailOptIn,
request.SmsOptIn ?? customer.SmsOptIn);
await _repository.UpdateAsync(customer, ct);
return NoContent();
}
[HttpGet("{id:guid}/purchases")]
public async Task<ActionResult<IEnumerable<PurchaseDto>>> GetPurchases(
Guid id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 50,
CancellationToken ct)
{
var purchases = await _historyService.GetHistoryAsync(id, from, to, limit, ct);
return Ok(purchases.Select(PurchaseDto.FromEntity));
}
[HttpGet("{id:guid}/stats")]
public async Task<ActionResult<CustomerPurchaseStats>> GetStats(
Guid id,
CancellationToken ct)
{
var stats = await _historyService.GetStatsAsync(id, ct);
return Ok(stats);
}
[HttpPost("{id:guid}/redeem-points")]
public async Task<ActionResult<PointsRedemptionResult>> RedeemPoints(
Guid id,
[FromBody] RedeemPointsRequest request,
CancellationToken ct)
{
var result = await _loyaltyService.RedeemPointsAsync(
id, request.Points, request.SaleId, ct);
if (!result.IsSuccess)
return BadRequest(result);
return Ok(result);
}
}
// DTOs
public record CreateCustomerRequest(
string FirstName,
string LastName,
string? Email,
string? Phone,
CustomerAddress? Address,
bool? EmailOptIn,
bool? SmsOptIn);
public record UpdateCustomerRequest(
string? Email,
string? Phone,
CustomerAddress? Address,
bool? EmailOptIn,
bool? SmsOptIn);
public record RedeemPointsRequest(int Points, Guid SaleId);
public record CustomerSummaryDto(
Guid Id,
string FullName,
string? Email,
string? Phone,
string? LoyaltyId,
int LoyaltyPoints,
string Tier);
public record CustomerDto(
Guid Id,
string CustomerNumber,
string FirstName,
string LastName,
string FullName,
string? Email,
string? Phone,
CustomerAddress? Address,
string? LoyaltyId,
int LoyaltyPoints,
string Tier,
LoyaltyTierBenefits TierBenefits,
int TotalOrders,
decimal TotalSpent,
DateTime? LastPurchaseAt)
{
public static CustomerDto FromEntity(Customer c, LoyaltyTierBenefits benefits) => new(
c.Id, c.CustomerNumber ?? "", c.FirstName, c.LastName, c.FullName,
c.Email, c.Phone, c.Address, c.LoyaltyId, c.LoyaltyPoints,
c.Tier.ToString(), benefits, c.TotalOrders, c.TotalSpent, c.LastPurchaseAt);
}
public record PurchaseDto(
Guid Id,
string SaleNumber,
decimal TotalAmount,
int ItemCount,
int PointsEarned,
DateTime PurchasedAt);
Week 12-13: Offline Sync Infrastructure
Day 1-2: Local SQLite Storage
Objective: Implement local storage for offline operation.
Claude Command:
/dev-team implement local SQLite storage for offline mode
Implementation:
// src/PosPlatform.Core/Offline/OfflineStorage.cs
using Microsoft.Data.Sqlite;
using System.Text.Json;
namespace PosPlatform.Core.Offline;
public interface IOfflineStorage
{
Task InitializeAsync(CancellationToken ct = default);
Task StoreTransactionAsync(OfflineTransaction transaction, CancellationToken ct = default);
Task<IReadOnlyList<OfflineTransaction>> GetPendingTransactionsAsync(CancellationToken ct = default);
Task MarkSyncedAsync(Guid transactionId, CancellationToken ct = default);
Task DeleteSyncedAsync(CancellationToken ct = default);
}
public class SqliteOfflineStorage : IOfflineStorage
{
private readonly string _connectionString;
public SqliteOfflineStorage(string databasePath)
{
_connectionString = $"Data Source={databasePath}";
}
public async Task InitializeAsync(CancellationToken ct = default)
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = @"
CREATE TABLE IF NOT EXISTS offline_transactions (
id TEXT PRIMARY KEY,
transaction_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
synced_at TEXT,
retry_count INTEGER DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_offline_synced
ON offline_transactions(synced_at);
";
await using var cmd = new SqliteCommand(sql, conn);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task StoreTransactionAsync(
OfflineTransaction transaction,
CancellationToken ct = default)
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = @"
INSERT INTO offline_transactions (id, transaction_type, payload, created_at)
VALUES (@id, @type, @payload, @created)
";
await using var cmd = new SqliteCommand(sql, conn);
cmd.Parameters.AddWithValue("@id", transaction.Id.ToString());
cmd.Parameters.AddWithValue("@type", transaction.Type.ToString());
cmd.Parameters.AddWithValue("@payload", transaction.PayloadJson);
cmd.Parameters.AddWithValue("@created", transaction.CreatedAt.ToString("O"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<IReadOnlyList<OfflineTransaction>> GetPendingTransactionsAsync(
CancellationToken ct = default)
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = @"
SELECT id, transaction_type, payload, created_at, retry_count, last_error
FROM offline_transactions
WHERE synced_at IS NULL
ORDER BY created_at ASC
";
await using var cmd = new SqliteCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var transactions = new List<OfflineTransaction>();
while (await reader.ReadAsync(ct))
{
transactions.Add(new OfflineTransaction
{
Id = Guid.Parse(reader.GetString(0)),
Type = Enum.Parse<OfflineTransactionType>(reader.GetString(1)),
PayloadJson = reader.GetString(2),
CreatedAt = DateTime.Parse(reader.GetString(3)),
RetryCount = reader.GetInt32(4),
LastError = reader.IsDBNull(5) ? null : reader.GetString(5)
});
}
return transactions;
}
public async Task MarkSyncedAsync(Guid transactionId, CancellationToken ct = default)
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = "UPDATE offline_transactions SET synced_at = @synced WHERE id = @id";
await using var cmd = new SqliteCommand(sql, conn);
cmd.Parameters.AddWithValue("@id", transactionId.ToString());
cmd.Parameters.AddWithValue("@synced", DateTime.UtcNow.ToString("O"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteSyncedAsync(CancellationToken ct = default)
{
await using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = "DELETE FROM offline_transactions WHERE synced_at IS NOT NULL";
await using var cmd = new SqliteCommand(sql, conn);
await cmd.ExecuteNonQueryAsync(ct);
}
}
public class OfflineTransaction
{
public Guid Id { get; set; }
public OfflineTransactionType Type { get; set; }
public string PayloadJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? SyncedAt { get; set; }
public int RetryCount { get; set; }
public string? LastError { get; set; }
public T? GetPayload<T>() where T : class
{
return JsonSerializer.Deserialize<T>(PayloadJson);
}
}
public enum OfflineTransactionType
{
Sale,
Payment,
InventoryAdjustment,
CustomerCreate,
DrawerTransaction
}
Day 3-4: Offline Queue Service
Claude Command:
/dev-team create offline transaction queue service
Implementation:
// src/PosPlatform.Core/Offline/OfflineQueueService.cs
using System.Text.Json;
namespace PosPlatform.Core.Offline;
public interface IOfflineQueueService
{
Task<bool> IsOnlineAsync(CancellationToken ct = default);
Task EnqueueAsync<T>(OfflineTransactionType type, T payload, CancellationToken ct = default);
Task<int> GetPendingCountAsync(CancellationToken ct = default);
Task ProcessQueueAsync(CancellationToken ct = default);
}
public class OfflineQueueService : IOfflineQueueService
{
private readonly IOfflineStorage _storage;
private readonly IConnectivityService _connectivity;
private readonly ISyncProcessor _syncProcessor;
private readonly ILogger<OfflineQueueService> _logger;
public OfflineQueueService(
IOfflineStorage storage,
IConnectivityService connectivity,
ISyncProcessor syncProcessor,
ILogger<OfflineQueueService> logger)
{
_storage = storage;
_connectivity = connectivity;
_syncProcessor = syncProcessor;
_logger = logger;
}
public async Task<bool> IsOnlineAsync(CancellationToken ct = default)
{
return await _connectivity.CheckConnectionAsync(ct);
}
public async Task EnqueueAsync<T>(
OfflineTransactionType type,
T payload,
CancellationToken ct = default)
{
var transaction = new OfflineTransaction
{
Id = Guid.NewGuid(),
Type = type,
PayloadJson = JsonSerializer.Serialize(payload),
CreatedAt = DateTime.UtcNow
};
await _storage.StoreTransactionAsync(transaction, ct);
_logger.LogInformation(
"Transaction queued for offline sync: {Type} {Id}",
type, transaction.Id);
}
public async Task<int> GetPendingCountAsync(CancellationToken ct = default)
{
var pending = await _storage.GetPendingTransactionsAsync(ct);
return pending.Count;
}
public async Task ProcessQueueAsync(CancellationToken ct = default)
{
if (!await IsOnlineAsync(ct))
{
_logger.LogDebug("Cannot process queue - offline");
return;
}
var pending = await _storage.GetPendingTransactionsAsync(ct);
if (pending.Count == 0)
return;
_logger.LogInformation("Processing {Count} pending transactions", pending.Count);
foreach (var transaction in pending)
{
try
{
await _syncProcessor.ProcessAsync(transaction, ct);
await _storage.MarkSyncedAsync(transaction.Id, ct);
_logger.LogInformation(
"Transaction synced: {Type} {Id}",
transaction.Type, transaction.Id);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to sync transaction {Id}. Retry count: {RetryCount}",
transaction.Id, transaction.RetryCount);
// Will retry on next sync cycle
}
}
// Clean up synced transactions older than 24 hours
await _storage.DeleteSyncedAsync(ct);
}
}
Day 5-6: Sync Protocol with Conflict Resolution
Claude Command:
/dev-team implement sync protocol with conflict resolution
Implementation:
// src/PosPlatform.Core/Offline/SyncProcessor.cs
namespace PosPlatform.Core.Offline;
public interface ISyncProcessor
{
Task ProcessAsync(OfflineTransaction transaction, CancellationToken ct = default);
}
public class SyncProcessor : ISyncProcessor
{
private readonly ISaleRepository _saleRepository;
private readonly IInventoryService _inventoryService;
private readonly ICustomerRepository _customerRepository;
private readonly IConflictResolver _conflictResolver;
private readonly ILogger<SyncProcessor> _logger;
public SyncProcessor(
ISaleRepository saleRepository,
IInventoryService inventoryService,
ICustomerRepository customerRepository,
IConflictResolver conflictResolver,
ILogger<SyncProcessor> logger)
{
_saleRepository = saleRepository;
_inventoryService = inventoryService;
_customerRepository = customerRepository;
_conflictResolver = conflictResolver;
_logger = logger;
}
public async Task ProcessAsync(
OfflineTransaction transaction,
CancellationToken ct = default)
{
switch (transaction.Type)
{
case OfflineTransactionType.Sale:
await ProcessSaleAsync(transaction, ct);
break;
case OfflineTransactionType.InventoryAdjustment:
await ProcessInventoryAsync(transaction, ct);
break;
case OfflineTransactionType.CustomerCreate:
await ProcessCustomerAsync(transaction, ct);
break;
default:
_logger.LogWarning("Unknown transaction type: {Type}", transaction.Type);
break;
}
}
private async Task ProcessSaleAsync(
OfflineTransaction transaction,
CancellationToken ct)
{
var payload = transaction.GetPayload<OfflineSalePayload>();
if (payload == null) return;
// Check if sale already exists (idempotency)
var existing = await _saleRepository.GetByIdAsync(payload.SaleId, ct);
if (existing != null)
{
_logger.LogInformation("Sale {Id} already synced, skipping", payload.SaleId);
return;
}
// Validate inventory availability
foreach (var item in payload.Items)
{
var available = await _inventoryService.GetAvailableAsync(
item.Sku, payload.LocationId, ct);
if (available < item.Quantity)
{
// Conflict: inventory no longer available
var resolution = await _conflictResolver.ResolveInventoryConflictAsync(
item.Sku, item.Quantity, available, ct);
if (resolution.Action == ConflictAction.Reject)
{
throw new SyncConflictException(
$"Insufficient inventory for {item.Sku}");
}
// Adjust quantity if partial fulfillment allowed
item.Quantity = resolution.AdjustedQuantity;
}
}
// Create the sale
await _saleRepository.AddFromOfflineAsync(payload, ct);
}
private async Task ProcessInventoryAsync(
OfflineTransaction transaction,
CancellationToken ct)
{
var payload = transaction.GetPayload<OfflineInventoryPayload>();
if (payload == null) return;
// Get current server state
var currentQuantity = await _inventoryService.GetQuantityAsync(
payload.Sku, payload.LocationId, ct);
// Apply delta (relative adjustment)
var newQuantity = currentQuantity + payload.QuantityDelta;
if (newQuantity < 0)
{
// Last-write-wins for negative inventory
_logger.LogWarning(
"Inventory for {Sku} would go negative, clamping to 0", payload.Sku);
newQuantity = 0;
}
await _inventoryService.SetQuantityAsync(
payload.Sku, payload.LocationId, newQuantity, payload.Reason, payload.UserId, ct);
}
private async Task ProcessCustomerAsync(
OfflineTransaction transaction,
CancellationToken ct)
{
var payload = transaction.GetPayload<OfflineCustomerPayload>();
if (payload == null) return;
// Check for duplicate by email
if (!string.IsNullOrEmpty(payload.Email))
{
var existing = await _customerRepository.GetByEmailAsync(payload.Email, ct);
if (existing != null)
{
_logger.LogInformation(
"Customer with email {Email} already exists, merging",
payload.Email);
// Merge: update existing customer
existing.UpdateContact(payload.Email, payload.Phone);
await _customerRepository.UpdateAsync(existing, ct);
return;
}
}
// Create new customer
var customer = Customer.Create(
payload.FirstName,
payload.LastName,
payload.Email,
payload.Phone);
await _customerRepository.AddAsync(customer, ct);
}
}
public interface IConflictResolver
{
Task<ConflictResolution> ResolveInventoryConflictAsync(
string sku,
int requested,
int available,
CancellationToken ct = default);
}
public class ConflictResolver : IConflictResolver
{
public Task<ConflictResolution> ResolveInventoryConflictAsync(
string sku,
int requested,
int available,
CancellationToken ct = default)
{
// Strategy: Partial fulfillment if any stock available
if (available > 0)
{
return Task.FromResult(new ConflictResolution(
ConflictAction.AdjustAndContinue,
available));
}
// No stock: reject the item
return Task.FromResult(new ConflictResolution(
ConflictAction.Reject,
0));
}
}
public record ConflictResolution(ConflictAction Action, int AdjustedQuantity);
public enum ConflictAction
{
Continue,
AdjustAndContinue,
Reject
}
public class SyncConflictException : Exception
{
public SyncConflictException(string message) : base(message) { }
}
// Payload models
public class OfflineSalePayload
{
public Guid SaleId { get; set; }
public Guid LocationId { get; set; }
public Guid CashierId { get; set; }
public List<OfflineSaleItem> Items { get; set; } = new();
public List<OfflinePayment> Payments { get; set; } = new();
public DateTime CreatedAt { get; set; }
}
public class OfflineSaleItem
{
public string Sku { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class OfflinePayment
{
public string Method { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
public class OfflineInventoryPayload
{
public string Sku { get; set; } = string.Empty;
public Guid LocationId { get; set; }
public int QuantityDelta { get; set; }
public string Reason { get; set; } = string.Empty;
public Guid UserId { get; set; }
}
public class OfflineCustomerPayload
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? Phone { get; set; }
}
Day 7-8: Connectivity Detection
Claude Command:
/dev-team create connectivity detection service
Implementation:
// src/PosPlatform.Core/Offline/ConnectivityService.cs
namespace PosPlatform.Core.Offline;
public interface IConnectivityService
{
event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;
bool IsOnline { get; }
Task<bool> CheckConnectionAsync(CancellationToken ct = default);
void StartMonitoring();
void StopMonitoring();
}
public class ConnectivityService : IConnectivityService, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<ConnectivityService> _logger;
private readonly string _healthCheckUrl;
private readonly TimeSpan _checkInterval;
private Timer? _timer;
private bool _isOnline = true;
public event EventHandler<ConnectivityChangedEventArgs>? ConnectivityChanged;
public bool IsOnline => _isOnline;
public ConnectivityService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<ConnectivityService> logger)
{
_httpClient = httpClient;
_logger = logger;
_healthCheckUrl = configuration["Api:HealthCheckUrl"] ?? "/health";
_checkInterval = TimeSpan.FromSeconds(
configuration.GetValue<int>("Connectivity:CheckIntervalSeconds", 30));
}
public async Task<bool> CheckConnectionAsync(CancellationToken ct = default)
{
try
{
var response = await _httpClient.GetAsync(_healthCheckUrl, ct);
var isOnline = response.IsSuccessStatusCode;
if (isOnline != _isOnline)
{
var previousState = _isOnline;
_isOnline = isOnline;
_logger.LogInformation(
"Connectivity changed: {Previous} -> {Current}",
previousState ? "Online" : "Offline",
isOnline ? "Online" : "Offline");
ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(isOnline));
}
return isOnline;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Connectivity check failed");
if (_isOnline)
{
_isOnline = false;
ConnectivityChanged?.Invoke(this, new ConnectivityChangedEventArgs(false));
}
return false;
}
}
public void StartMonitoring()
{
_timer = new Timer(
async _ => await CheckConnectionAsync(),
null,
TimeSpan.Zero,
_checkInterval);
_logger.LogInformation(
"Connectivity monitoring started. Check interval: {Interval}s",
_checkInterval.TotalSeconds);
}
public void StopMonitoring()
{
_timer?.Dispose();
_timer = null;
_logger.LogInformation("Connectivity monitoring stopped");
}
public void Dispose()
{
StopMonitoring();
}
}
public class ConnectivityChangedEventArgs : EventArgs
{
public bool IsOnline { get; }
public ConnectivityChangedEventArgs(bool isOnline)
{
IsOnline = isOnline;
}
}
Day 9-10: Background Sync Service
Claude Command:
/dev-team implement background sync with retry logic
Implementation:
// src/PosPlatform.Infrastructure/Services/BackgroundSyncService.cs
using Microsoft.Extensions.Hosting;
namespace PosPlatform.Infrastructure.Services;
public class BackgroundSyncService : BackgroundService
{
private readonly IOfflineQueueService _queueService;
private readonly IConnectivityService _connectivity;
private readonly ILogger<BackgroundSyncService> _logger;
private readonly TimeSpan _syncInterval;
public BackgroundSyncService(
IOfflineQueueService queueService,
IConnectivityService connectivity,
IConfiguration configuration,
ILogger<BackgroundSyncService> logger)
{
_queueService = queueService;
_connectivity = connectivity;
_logger = logger;
_syncInterval = TimeSpan.FromSeconds(
configuration.GetValue<int>("Sync:IntervalSeconds", 60));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Background sync service started");
// Subscribe to connectivity changes for immediate sync
_connectivity.ConnectivityChanged += OnConnectivityChanged;
_connectivity.StartMonitoring();
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _queueService.ProcessQueueAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing sync queue");
}
await Task.Delay(_syncInterval, stoppingToken);
}
_connectivity.StopMonitoring();
_connectivity.ConnectivityChanged -= OnConnectivityChanged;
_logger.LogInformation("Background sync service stopped");
}
private async void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
{
if (e.IsOnline)
{
_logger.LogInformation("Connection restored, triggering immediate sync");
try
{
await _queueService.ProcessQueueAsync(CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during immediate sync after reconnection");
}
}
}
}
Week 13-14: RFID Module (Optional)
Day 1-2: RFID Reader Abstraction
Claude Command:
/dev-team create RFID reader abstraction interface
Implementation:
// src/PosPlatform.Core/RFID/IRfidReader.cs
namespace PosPlatform.Core.RFID;
public interface IRfidReader : IDisposable
{
event EventHandler<TagReadEventArgs>? TagRead;
event EventHandler<ReaderStatusEventArgs>? StatusChanged;
string ReaderId { get; }
ReaderStatus Status { get; }
Task ConnectAsync(CancellationToken ct = default);
Task DisconnectAsync(CancellationToken ct = default);
Task StartInventoryAsync(CancellationToken ct = default);
Task StopInventoryAsync(CancellationToken ct = default);
Task<IReadOnlyList<RfidTag>> ReadTagsAsync(TimeSpan timeout, CancellationToken ct = default);
Task<bool> WriteTagAsync(string epc, byte[] data, CancellationToken ct = default);
}
public class TagReadEventArgs : EventArgs
{
public RfidTag Tag { get; }
public DateTime ReadAt { get; }
public TagReadEventArgs(RfidTag tag)
{
Tag = tag;
ReadAt = DateTime.UtcNow;
}
}
public class ReaderStatusEventArgs : EventArgs
{
public ReaderStatus Status { get; }
public string? Message { get; }
public ReaderStatusEventArgs(ReaderStatus status, string? message = null)
{
Status = status;
Message = message;
}
}
public class RfidTag
{
public string Epc { get; set; } = string.Empty;
public string? Tid { get; set; }
public int Rssi { get; set; }
public int ReadCount { get; set; }
public byte[]? UserData { get; set; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
// Parsed product info (if encoded)
public string? Sku { get; set; }
public string? SerialNumber { get; set; }
}
public enum ReaderStatus
{
Disconnected,
Connecting,
Connected,
Reading,
Error
}
Day 3-4: Bulk Inventory Scanning
Claude Command:
/dev-team implement bulk inventory scanning with RFID
Implementation:
// src/PosPlatform.Core/RFID/RfidInventoryService.cs
namespace PosPlatform.Core.RFID;
public interface IRfidInventoryService
{
Task<InventoryScanResult> ScanInventoryAsync(
Guid locationId,
TimeSpan scanDuration,
CancellationToken ct = default);
Task<InventoryComparisonResult> CompareWithSystemAsync(
Guid locationId,
IReadOnlyList<RfidTag> scannedTags,
CancellationToken ct = default);
}
public class RfidInventoryService : IRfidInventoryService
{
private readonly IRfidReader _reader;
private readonly IInventoryRepository _inventoryRepository;
private readonly IRfidTagDecoder _tagDecoder;
private readonly ILogger<RfidInventoryService> _logger;
public RfidInventoryService(
IRfidReader reader,
IInventoryRepository inventoryRepository,
IRfidTagDecoder tagDecoder,
ILogger<RfidInventoryService> logger)
{
_reader = reader;
_inventoryRepository = inventoryRepository;
_tagDecoder = tagDecoder;
_logger = logger;
}
public async Task<InventoryScanResult> ScanInventoryAsync(
Guid locationId,
TimeSpan scanDuration,
CancellationToken ct = default)
{
var startTime = DateTime.UtcNow;
var allTags = new Dictionary<string, RfidTag>();
_logger.LogInformation(
"Starting RFID inventory scan at location {Location} for {Duration}s",
locationId, scanDuration.TotalSeconds);
await _reader.StartInventoryAsync(ct);
var endTime = DateTime.UtcNow.Add(scanDuration);
while (DateTime.UtcNow < endTime && !ct.IsCancellationRequested)
{
var tags = await _reader.ReadTagsAsync(TimeSpan.FromSeconds(1), ct);
foreach (var tag in tags)
{
if (allTags.TryGetValue(tag.Epc, out var existing))
{
existing.ReadCount += tag.ReadCount;
existing.LastSeen = tag.LastSeen;
existing.Rssi = Math.Max(existing.Rssi, tag.Rssi);
}
else
{
// Decode SKU from tag
tag.Sku = await _tagDecoder.DecodeSkuAsync(tag.Epc, ct);
allTags[tag.Epc] = tag;
}
}
}
await _reader.StopInventoryAsync(ct);
var elapsed = DateTime.UtcNow - startTime;
_logger.LogInformation(
"RFID scan complete. Found {Count} unique tags in {Elapsed}s",
allTags.Count, elapsed.TotalSeconds);
return new InventoryScanResult(
locationId,
allTags.Values.ToList(),
startTime,
elapsed);
}
public async Task<InventoryComparisonResult> CompareWithSystemAsync(
Guid locationId,
IReadOnlyList<RfidTag> scannedTags,
CancellationToken ct = default)
{
// Group scanned tags by SKU
var scannedBySku = scannedTags
.Where(t => !string.IsNullOrEmpty(t.Sku))
.GroupBy(t => t.Sku!)
.ToDictionary(g => g.Key, g => g.Count());
// Get system inventory
var systemInventory = await _inventoryRepository
.GetByLocationAsync(locationId, ct);
var systemBySku = systemInventory
.ToDictionary(i => i.Sku, i => i.QuantityOnHand);
var discrepancies = new List<InventoryDiscrepancy>();
// Find discrepancies
var allSkus = scannedBySku.Keys.Union(systemBySku.Keys);
foreach (var sku in allSkus)
{
var scanned = scannedBySku.GetValueOrDefault(sku, 0);
var system = systemBySku.GetValueOrDefault(sku, 0);
if (scanned != system)
{
discrepancies.Add(new InventoryDiscrepancy(
sku,
system,
scanned,
scanned - system));
}
}
return new InventoryComparisonResult(
locationId,
scannedTags.Count,
systemInventory.Sum(i => i.QuantityOnHand),
discrepancies);
}
}
public record InventoryScanResult(
Guid LocationId,
IReadOnlyList<RfidTag> Tags,
DateTime StartedAt,
TimeSpan Duration)
{
public int TotalTags => Tags.Count;
public int UniqueSkus => Tags.Where(t => t.Sku != null).Select(t => t.Sku).Distinct().Count();
}
public record InventoryComparisonResult(
Guid LocationId,
int ScannedCount,
int SystemCount,
IReadOnlyList<InventoryDiscrepancy> Discrepancies)
{
public int MatchCount => ScannedCount - Discrepancies.Sum(d => Math.Abs(d.Variance));
public decimal AccuracyPercent => SystemCount > 0
? (decimal)MatchCount / SystemCount * 100
: 100;
}
public record InventoryDiscrepancy(
string Sku,
int SystemQuantity,
int ScannedQuantity,
int Variance);
Integration Testing
Customer Domain Tests
# Run customer tests
dotnet test --filter "FullyQualifiedName~Customer"
# Manual API test
curl -X POST http://localhost:5100/api/customers \
-H "Content-Type: application/json" \
-H "X-Tenant-Code: DEMO" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "555-123-4567"
}'
Offline Sync Tests
# Simulate offline mode
# 1. Create sale while "offline"
# 2. Check pending queue
# 3. Restore connection
# 4. Verify sync completed
dotnet test --filter "FullyQualifiedName~OfflineSync"
Performance Testing
Customer Lookup Performance
// tests/PosPlatform.Api.Tests/CustomerLookupPerformanceTests.cs
[Fact]
public async Task CustomerLookup_ShouldCompleteIn200ms()
{
var stopwatch = Stopwatch.StartNew();
var result = await _lookupService.LookupAsync("555-123-4567");
stopwatch.Stop();
Assert.NotNull(result);
Assert.True(stopwatch.ElapsedMilliseconds < 200,
$"Lookup took {stopwatch.ElapsedMilliseconds}ms, expected < 200ms");
}
Next Steps
Proceed to Chapter 28: Phase 4 - Production Implementation for:
- Monitoring and alerting setup
- Security hardening
- Production deployment
- Go-live procedures
Chapter 27 Complete - Phase 3 Support Implementation