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 25: Phase 1 - Foundation Implementation

Overview

Phase 1 establishes the foundational infrastructure: multi-tenant isolation, authentication, and catalog management. This 4-week phase creates the base upon which all other modules build.


Week 1-2: Multi-Tenant Infrastructure

Day 1-2: Tenant Entity and Repository

Objective: Create the tenant domain model with repository pattern.

Claude Command:

/dev-team implement tenant entity with repository pattern

Implementation:

// src/PosPlatform.Core/Entities/Tenant.cs
using System;
using System.Collections.Generic;

namespace PosPlatform.Core.Entities;

public class Tenant
{
    public Guid Id { get; private set; }
    public string Code { get; private set; } = string.Empty;
    public string Name { get; private set; } = string.Empty;
    public string? Domain { get; private set; }
    public TenantStatus Status { get; private set; }
    public TenantSettings Settings { get; private set; } = new();
    public DateTime CreatedAt { get; private set; }
    public DateTime? UpdatedAt { get; private set; }

    private Tenant() { } // EF Core

    public static Tenant Create(string code, string name, string? domain = null)
    {
        if (string.IsNullOrWhiteSpace(code) || code.Length > 10)
            throw new ArgumentException("Code must be 1-10 characters", nameof(code));

        return new Tenant
        {
            Id = Guid.NewGuid(),
            Code = code.ToUpperInvariant(),
            Name = name,
            Domain = domain,
            Status = TenantStatus.Active,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void UpdateSettings(TenantSettings settings)
    {
        Settings = settings ?? throw new ArgumentNullException(nameof(settings));
        UpdatedAt = DateTime.UtcNow;
    }

    public void Suspend() => Status = TenantStatus.Suspended;
    public void Activate() => Status = TenantStatus.Active;
}

public enum TenantStatus
{
    Active,
    Suspended,
    Pending
}

public class TenantSettings
{
    public string Timezone { get; set; } = "UTC";
    public string Currency { get; set; } = "USD";
    public decimal TaxRate { get; set; } = 0.0m;
    public string? LogoUrl { get; set; }
    public Dictionary<string, string> Custom { get; set; } = new();
}
// src/PosPlatform.Core/Interfaces/ITenantRepository.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantRepository
{
    Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default);
    Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default);
    Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default);
    Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default);
    Task UpdateAsync(Tenant tenant, CancellationToken ct = default);
    Task<bool> ExistsAsync(string code, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/Repositories/TenantRepository.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Data;

namespace PosPlatform.Infrastructure.Repositories;

public class TenantRepository : ITenantRepository
{
    private readonly PlatformDbContext _context;

    public TenantRepository(PlatformDbContext context)
    {
        _context = context;
    }

    public async Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => await _context.Tenants.FindAsync(new object[] { id }, ct);

    public async Task<Tenant?> GetByCodeAsync(string code, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Code == code.ToUpperInvariant(), ct);

    public async Task<Tenant?> GetByDomainAsync(string domain, CancellationToken ct = default)
        => await _context.Tenants
            .FirstOrDefaultAsync(t => t.Domain == domain.ToLowerInvariant(), ct);

    public async Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default)
        => await _context.Tenants.ToListAsync(ct);

    public async Task<Tenant> AddAsync(Tenant tenant, CancellationToken ct = default)
    {
        await _context.Tenants.AddAsync(tenant, ct);
        await _context.SaveChangesAsync(ct);
        return tenant;
    }

    public async Task UpdateAsync(Tenant tenant, CancellationToken ct = default)
    {
        _context.Tenants.Update(tenant);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<bool> ExistsAsync(string code, CancellationToken ct = default)
        => await _context.Tenants.AnyAsync(t => t.Code == code.ToUpperInvariant(), ct);
}

Test Command:

# Run unit tests for tenant entity
dotnet test --filter "FullyQualifiedName~TenantTests"

Day 3-4: Schema Provisioning Service

Objective: Automatically create tenant-specific database schemas.

Claude Command:

/dev-team create tenant provisioning service with schema isolation

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantProvisioningService.cs
namespace PosPlatform.Core.Interfaces;

public interface ITenantProvisioningService
{
    Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default);
    Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default);
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantProvisioningService.cs
using Microsoft.Extensions.Logging;
using Npgsql;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantProvisioningService : ITenantProvisioningService
{
    private readonly string _connectionString;
    private readonly ILogger<TenantProvisioningService> _logger;

    public TenantProvisioningService(
        string connectionString,
        ILogger<TenantProvisioningService> logger)
    {
        _connectionString = connectionString;
        _logger = logger;
    }

    public async Task ProvisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogInformation("Provisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var transaction = await conn.BeginTransactionAsync(ct);

        try
        {
            // Create schema
            await ExecuteAsync(conn, $"CREATE SCHEMA IF NOT EXISTS {schemaName}", ct);

            // Create locations table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.locations (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    code VARCHAR(10) NOT NULL,
                    name VARCHAR(100) NOT NULL,
                    address JSONB,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_locations_code UNIQUE (code)
                )", ct);

            // Create users table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.users (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    employee_id VARCHAR(20),
                    full_name VARCHAR(100) NOT NULL,
                    email VARCHAR(255),
                    password_hash VARCHAR(255),
                    pin_hash VARCHAR(255),
                    role VARCHAR(50) NOT NULL,
                    location_id UUID REFERENCES {schemaName}.locations(id),
                    is_active BOOLEAN DEFAULT true,
                    last_login_at TIMESTAMPTZ,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_users_email UNIQUE (email),
                    CONSTRAINT uk_{schemaName}_users_employee_id UNIQUE (employee_id)
                )", ct);

            // Create categories table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.categories (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    name VARCHAR(100) NOT NULL,
                    parent_id UUID REFERENCES {schemaName}.categories(id),
                    sort_order INT DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW()
                )", ct);

            // Create products table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.products (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    description TEXT,
                    category_id UUID REFERENCES {schemaName}.categories(id),
                    base_price DECIMAL(10,2) NOT NULL,
                    cost DECIMAL(10,2),
                    tax_rate DECIMAL(5,4) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    updated_at TIMESTAMPTZ,
                    CONSTRAINT uk_{schemaName}_products_sku UNIQUE (sku)
                )", ct);

            // Create product_variants table
            await ExecuteAsync(conn, $@"
                CREATE TABLE IF NOT EXISTS {schemaName}.product_variants (
                    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
                    product_id UUID NOT NULL REFERENCES {schemaName}.products(id),
                    sku VARCHAR(50) NOT NULL,
                    name VARCHAR(255) NOT NULL,
                    attributes JSONB NOT NULL DEFAULT '{{}}',
                    price_adjustment DECIMAL(10,2) DEFAULT 0,
                    is_active BOOLEAN DEFAULT true,
                    created_at TIMESTAMPTZ DEFAULT NOW(),
                    CONSTRAINT uk_{schemaName}_variants_sku UNIQUE (sku)
                )", ct);

            // Create indexes
            await ExecuteAsync(conn, $@"
                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_category
                ON {schemaName}.products(category_id);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_products_name
                ON {schemaName}.products USING gin(name gin_trgm_ops);

                CREATE INDEX IF NOT EXISTS idx_{schemaName}_variants_product
                ON {schemaName}.product_variants(product_id);
            ", ct);

            await transaction.CommitAsync(ct);
            _logger.LogInformation("Tenant schema provisioned: {Schema}", schemaName);
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync(ct);
            _logger.LogError(ex, "Failed to provision tenant schema: {Schema}", schemaName);
            throw;
        }
    }

    public async Task DeprovisionTenantAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);
        _logger.LogWarning("Deprovisioning tenant schema: {Schema}", schemaName);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await ExecuteAsync(conn, $"DROP SCHEMA IF EXISTS {schemaName} CASCADE", ct);
    }

    public async Task<bool> IsProvisionedAsync(string tenantCode, CancellationToken ct = default)
    {
        var schemaName = GetSchemaName(tenantCode);

        await using var conn = new NpgsqlConnection(_connectionString);
        await conn.OpenAsync(ct);

        await using var cmd = new NpgsqlCommand(
            "SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = @schema)",
            conn);
        cmd.Parameters.AddWithValue("schema", schemaName);

        var result = await cmd.ExecuteScalarAsync(ct);
        return result is true;
    }

    private static string GetSchemaName(string tenantCode)
        => $"tenant_{tenantCode.ToLowerInvariant()}";

    private static async Task ExecuteAsync(NpgsqlConnection conn, string sql, CancellationToken ct)
    {
        await using var cmd = new NpgsqlCommand(sql, conn);
        await cmd.ExecuteNonQueryAsync(ct);
    }
}

Test Command:

# Test schema provisioning
curl -X POST http://localhost:5100/api/admin/tenants \
  -H "Content-Type: application/json" \
  -d '{"code": "TEST", "name": "Test Store"}'

# Verify schema exists
docker exec -it pos-postgres psql -U pos_admin -d pos_platform -c "\dn"

Day 5: Tenant Resolution Middleware

Objective: Resolve tenant from request and establish context.

Claude Command:

/dev-team implement tenant resolution middleware with request context

Implementation:

// src/PosPlatform.Core/Interfaces/ITenantContext.cs
using PosPlatform.Core.Entities;

namespace PosPlatform.Core.Interfaces;

public interface ITenantContext
{
    Tenant? CurrentTenant { get; }
    string? TenantCode { get; }
    bool HasTenant { get; }
}

public interface ITenantContextSetter
{
    void SetTenant(Tenant tenant);
    void ClearTenant();
}
// src/PosPlatform.Infrastructure/MultiTenant/TenantContext.cs
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.MultiTenant;

public class TenantContext : ITenantContext, ITenantContextSetter
{
    private Tenant? _tenant;

    public Tenant? CurrentTenant => _tenant;
    public string? TenantCode => _tenant?.Code;
    public bool HasTenant => _tenant != null;

    public void SetTenant(Tenant tenant)
    {
        _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant));
    }

    public void ClearTenant()
    {
        _tenant = null;
    }
}
// src/PosPlatform.Api/Middleware/TenantResolutionMiddleware.cs
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Middleware;

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

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

    public async Task InvokeAsync(
        HttpContext context,
        ITenantRepository tenantRepository,
        ITenantContextSetter tenantContext)
    {
        // Skip tenant resolution for platform endpoints
        if (context.Request.Path.StartsWithSegments("/api/admin") ||
            context.Request.Path.StartsWithSegments("/health"))
        {
            await _next(context);
            return;
        }

        var tenantCode = ResolveTenantCode(context);

        if (string.IsNullOrEmpty(tenantCode))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant not specified" });
            return;
        }

        var tenant = await tenantRepository.GetByCodeAsync(tenantCode);

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

        if (tenant.Status != TenantStatus.Active)
        {
            context.Response.StatusCode = 403;
            await context.Response.WriteAsJsonAsync(new { error = "Tenant is not active" });
            return;
        }

        tenantContext.SetTenant(tenant);
        _logger.LogDebug("Tenant resolved: {TenantCode}", tenant.Code);

        try
        {
            await _next(context);
        }
        finally
        {
            tenantContext.ClearTenant();
        }
    }

    private static string? ResolveTenantCode(HttpContext context)
    {
        // Priority 1: Header
        if (context.Request.Headers.TryGetValue("X-Tenant-Code", out var headerValue))
            return headerValue.ToString();

        // Priority 2: Query string
        if (context.Request.Query.TryGetValue("tenant", out var queryValue))
            return queryValue.ToString();

        // Priority 3: Subdomain (e.g., tenant1.posplatform.com)
        var host = context.Request.Host.Host;
        var parts = host.Split('.');
        if (parts.Length >= 3)
            return parts[0];

        // Priority 4: JWT claim (if authenticated)
        var tenantClaim = context.User?.FindFirst("tenant_code");
        if (tenantClaim != null)
            return tenantClaim.Value;

        return null;
    }
}

// Extension method for registration
public static class TenantMiddlewareExtensions
{
    public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
    {
        return app.UseMiddleware<TenantResolutionMiddleware>();
    }
}

Registration in Program.cs:

// Add to Program.cs
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<ITenantContext>(sp => sp.GetRequiredService<TenantContext>());
builder.Services.AddScoped<ITenantContextSetter>(sp => sp.GetRequiredService<TenantContext>());

// In middleware pipeline (after authentication, before controllers)
app.UseAuthentication();
app.UseTenantResolution();
app.UseAuthorization();

Day 6-7: Dynamic Connection Routing

Objective: Route database connections to tenant-specific schemas.

Claude Command:

/dev-team implement dynamic connection string routing per tenant

Implementation:

// src/PosPlatform.Infrastructure/Data/TenantDbContext.cs
using Microsoft.EntityFrameworkCore;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Infrastructure.Data;

public class TenantDbContext : DbContext
{
    private readonly ITenantContext _tenantContext;
    private readonly string _schemaName;

    public TenantDbContext(
        DbContextOptions<TenantDbContext> options,
        ITenantContext tenantContext)
        : base(options)
    {
        _tenantContext = tenantContext;
        _schemaName = tenantContext.HasTenant
            ? $"tenant_{tenantContext.TenantCode!.ToLowerInvariant()}"
            : "public";
    }

    public DbSet<Location> Locations => Set<Location>();
    public DbSet<User> Users => Set<User>();
    public DbSet<Category> Categories => Set<Category>();
    public DbSet<Product> Products => Set<Product>();
    public DbSet<ProductVariant> ProductVariants => Set<ProductVariant>();

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

        // Configure entities
        modelBuilder.Entity<Location>(entity =>
        {
            entity.ToTable("locations");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Code).HasMaxLength(10).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasIndex(e => e.Code).IsUnique();
        });

        modelBuilder.Entity<User>(entity =>
        {
            entity.ToTable("users");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.FullName).HasMaxLength(100).IsRequired();
            entity.Property(e => e.Email).HasMaxLength(255);
            entity.HasIndex(e => e.Email).IsUnique();
            entity.HasIndex(e => e.EmployeeId).IsUnique();
            entity.HasOne(e => e.Location)
                  .WithMany()
                  .HasForeignKey(e => e.LocationId);
        });

        modelBuilder.Entity<Category>(entity =>
        {
            entity.ToTable("categories");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
            entity.HasOne(e => e.Parent)
                  .WithMany(e => e.Children)
                  .HasForeignKey(e => e.ParentId)
                  .OnDelete(DeleteBehavior.Restrict);
        });

        modelBuilder.Entity<Product>(entity =>
        {
            entity.ToTable("products");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.Property(e => e.Name).HasMaxLength(255).IsRequired();
            entity.Property(e => e.BasePrice).HasPrecision(10, 2);
            entity.Property(e => e.Cost).HasPrecision(10, 2);
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Category)
                  .WithMany()
                  .HasForeignKey(e => e.CategoryId);
        });

        modelBuilder.Entity<ProductVariant>(entity =>
        {
            entity.ToTable("product_variants");
            entity.HasKey(e => e.Id);
            entity.Property(e => e.Sku).HasMaxLength(50).IsRequired();
            entity.HasIndex(e => e.Sku).IsUnique();
            entity.HasOne(e => e.Product)
                  .WithMany(p => p.Variants)
                  .HasForeignKey(e => e.ProductId);
        });
    }
}
// DI Registration in Program.cs
builder.Services.AddDbContext<TenantDbContext>((sp, options) =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    options.UseNpgsql(connectionString);
});

Day 8-9: Tenant Management API

Objective: Create REST API for tenant CRUD operations.

Claude Command:

/dev-team create tenant management API with CRUD endpoints

Implementation:

// src/PosPlatform.Api/Controllers/Admin/TenantsController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Entities;
using PosPlatform.Core.Interfaces;

namespace PosPlatform.Api.Controllers.Admin;

[ApiController]
[Route("api/admin/tenants")]
[Authorize(Roles = "super_admin")]
public class TenantsController : ControllerBase
{
    private readonly ITenantRepository _tenantRepository;
    private readonly ITenantProvisioningService _provisioningService;
    private readonly ILogger<TenantsController> _logger;

    public TenantsController(
        ITenantRepository tenantRepository,
        ITenantProvisioningService provisioningService,
        ILogger<TenantsController> logger)
    {
        _tenantRepository = tenantRepository;
        _provisioningService = provisioningService;
        _logger = logger;
    }

    [HttpGet]
    public async Task<ActionResult<IEnumerable<TenantDto>>> GetAll(CancellationToken ct)
    {
        var tenants = await _tenantRepository.GetAllAsync(ct);
        return Ok(tenants.Select(TenantDto.FromEntity));
    }

    [HttpGet("{code}")]
    public async Task<ActionResult<TenantDto>> GetByCode(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        return Ok(TenantDto.FromEntity(tenant));
    }

    [HttpPost]
    public async Task<ActionResult<TenantDto>> Create(
        [FromBody] CreateTenantRequest request,
        CancellationToken ct)
    {
        if (await _tenantRepository.ExistsAsync(request.Code, ct))
            return Conflict(new { error = "Tenant code already exists" });

        var tenant = Tenant.Create(request.Code, request.Name, request.Domain);

        if (request.Settings != null)
            tenant.UpdateSettings(request.Settings);

        await _tenantRepository.AddAsync(tenant, ct);

        // Provision database schema
        await _provisioningService.ProvisionTenantAsync(tenant.Code, ct);

        _logger.LogInformation("Tenant created: {Code}", tenant.Code);

        return CreatedAtAction(
            nameof(GetByCode),
            new { code = tenant.Code },
            TenantDto.FromEntity(tenant));
    }

    [HttpPut("{code}/settings")]
    public async Task<IActionResult> UpdateSettings(
        string code,
        [FromBody] TenantSettings settings,
        CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.UpdateSettings(settings);
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }

    [HttpPost("{code}/suspend")]
    public async Task<IActionResult> Suspend(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Suspend();
        await _tenantRepository.UpdateAsync(tenant, ct);

        _logger.LogWarning("Tenant suspended: {Code}", code);
        return NoContent();
    }

    [HttpPost("{code}/activate")]
    public async Task<IActionResult> Activate(string code, CancellationToken ct)
    {
        var tenant = await _tenantRepository.GetByCodeAsync(code, ct);
        if (tenant == null)
            return NotFound();

        tenant.Activate();
        await _tenantRepository.UpdateAsync(tenant, ct);

        return NoContent();
    }
}

// DTOs
public record CreateTenantRequest(
    string Code,
    string Name,
    string? Domain,
    TenantSettings? Settings);

public record TenantDto(
    Guid Id,
    string Code,
    string Name,
    string? Domain,
    string Status,
    TenantSettings Settings,
    DateTime CreatedAt)
{
    public static TenantDto FromEntity(Tenant tenant) => new(
        tenant.Id,
        tenant.Code,
        tenant.Name,
        tenant.Domain,
        tenant.Status.ToString(),
        tenant.Settings,
        tenant.CreatedAt);
}

Day 10: Integration Tests

Objective: Verify tenant isolation through integration tests.

Claude Command:

/qa-team write tenant isolation integration tests

Implementation:

// tests/PosPlatform.Api.Tests/TenantIsolationTests.cs
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace PosPlatform.Api.Tests;

public class TenantIsolationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public TenantIsolationTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

    [Fact]
    public async Task Request_WithoutTenant_Returns400()
    {
        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }

    [Fact]
    public async Task Request_WithInvalidTenant_Returns404()
    {
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "INVALID");

        var response = await _client.GetAsync("/api/products");

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }

    [Fact]
    public async Task Products_FromDifferentTenants_AreIsolated()
    {
        // Create product in Tenant A
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var productA = new { Sku = "SKU-A", Name = "Product A", BasePrice = 10.00m };
        await _client.PostAsJsonAsync("/api/products", productA);

        // Create product in Tenant B
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var productB = new { Sku = "SKU-B", Name = "Product B", BasePrice = 20.00m };
        await _client.PostAsJsonAsync("/api/products", productB);

        // Verify Tenant A only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_A");

        var responseA = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseA!.Items);
        Assert.Equal("SKU-A", responseA.Items[0].Sku);

        // Verify Tenant B only sees their product
        _client.DefaultRequestHeaders.Clear();
        _client.DefaultRequestHeaders.Add("X-Tenant-Code", "TENANT_B");

        var responseB = await _client.GetFromJsonAsync<ProductListResponse>("/api/products");
        Assert.Single(responseB!.Items);
        Assert.Equal("SKU-B", responseB.Items[0].Sku);
    }
}

public record ProductListResponse(List<ProductItem> Items);
public record ProductItem(string Sku, string Name, decimal BasePrice);

Test Command:

# Run integration tests
dotnet test tests/PosPlatform.Api.Tests --filter "FullyQualifiedName~TenantIsolation"

Week 2-3: Authentication System

Day 1-2: User Entity with Password Hashing

Claude Command:

/dev-team implement user entity with bcrypt password hashing

Implementation:

// src/PosPlatform.Core/Entities/User.cs
using System.Security.Cryptography;
using BCrypt.Net;

namespace PosPlatform.Core.Entities;

public class User
{
    public Guid Id { get; private set; }
    public string? EmployeeId { get; private set; }
    public string FullName { get; private set; } = string.Empty;
    public string? Email { get; private set; }
    public string? PasswordHash { get; private set; }
    public string? PinHash { get; private set; }
    public UserRole Role { get; private set; }
    public Guid? LocationId { get; private set; }
    public Location? Location { get; private set; }
    public bool IsActive { get; private set; }
    public DateTime? LastLoginAt { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private User() { }

    public static User Create(
        string fullName,
        UserRole role,
        string? email = null,
        string? employeeId = null)
    {
        return new User
        {
            Id = Guid.NewGuid(),
            FullName = fullName,
            Email = email?.ToLowerInvariant(),
            EmployeeId = employeeId,
            Role = role,
            IsActive = true,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void SetPassword(string password)
    {
        if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters");

        PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, 12);
    }

    public bool VerifyPassword(string password)
    {
        if (string.IsNullOrEmpty(PasswordHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(password, PasswordHash);
    }

    public void SetPin(string pin)
    {
        if (string.IsNullOrWhiteSpace(pin) || !pin.All(char.IsDigit) || pin.Length < 4 || pin.Length > 6)
            throw new ArgumentException("PIN must be 4-6 digits");

        PinHash = BCrypt.Net.BCrypt.HashPassword(pin, 10);
    }

    public bool VerifyPin(string pin)
    {
        if (string.IsNullOrEmpty(PinHash))
            return false;

        return BCrypt.Net.BCrypt.Verify(pin, PinHash);
    }

    public void AssignLocation(Guid locationId) => LocationId = locationId;
    public void RecordLogin() => LastLoginAt = DateTime.UtcNow;
    public void Deactivate() => IsActive = false;
    public void Activate() => IsActive = true;
}

public enum UserRole
{
    Cashier,
    Supervisor,
    Manager,
    Admin
}

Day 3-4: JWT Token Service

Claude Command:

/dev-team create JWT token service with refresh token support

Implementation:

// src/PosPlatform.Infrastructure/Services/JwtTokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using PosPlatform.Core.Entities;

namespace PosPlatform.Infrastructure.Services;

public interface IJwtTokenService
{
    TokenResult GenerateTokens(User user, string tenantCode);
    ClaimsPrincipal? ValidateToken(string token);
    string GenerateRefreshToken();
}

public record TokenResult(
    string AccessToken,
    string RefreshToken,
    DateTime ExpiresAt);

public class JwtTokenService : IJwtTokenService
{
    private readonly JwtSettings _settings;
    private readonly byte[] _key;

    public JwtTokenService(IOptions<JwtSettings> settings)
    {
        _settings = settings.Value;
        _key = Encoding.UTF8.GetBytes(_settings.SecretKey);
    }

    public TokenResult GenerateTokens(User user, string tenantCode)
    {
        var expiresAt = DateTime.UtcNow.AddMinutes(_settings.AccessTokenExpirationMinutes);

        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, user.Id.ToString()),
            new(ClaimTypes.Name, user.FullName),
            new(ClaimTypes.Role, user.Role.ToString()),
            new("tenant_code", tenantCode),
            new("jti", Guid.NewGuid().ToString())
        };

        if (!string.IsNullOrEmpty(user.Email))
            claims.Add(new Claim(ClaimTypes.Email, user.Email));

        if (user.LocationId.HasValue)
            claims.Add(new Claim("location_id", user.LocationId.Value.ToString()));

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(claims),
            Expires = expiresAt,
            Issuer = _settings.Issuer,
            Audience = _settings.Audience,
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(_key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateToken(tokenDescriptor);

        return new TokenResult(
            tokenHandler.WriteToken(token),
            GenerateRefreshToken(),
            expiresAt);
    }

    public ClaimsPrincipal? ValidateToken(string token)
    {
        var tokenHandler = new JwtSecurityTokenHandler();

        try
        {
            var principal = tokenHandler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(_key),
                ValidateIssuer = true,
                ValidIssuer = _settings.Issuer,
                ValidateAudience = true,
                ValidAudience = _settings.Audience,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.Zero
            }, out _);

            return principal;
        }
        catch
        {
            return null;
        }
    }

    public string GenerateRefreshToken()
    {
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }
}

public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = "PosPlatform";
    public string Audience { get; set; } = "PosPlatform";
    public int AccessTokenExpirationMinutes { get; set; } = 60;
    public int RefreshTokenExpirationDays { get; set; } = 7;
}

Day 5-6: PIN Authentication

Claude Command:

/dev-team implement PIN authentication for POS terminals

Implementation:

// src/PosPlatform.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PosPlatform.Core.Interfaces;
using PosPlatform.Infrastructure.Services;

namespace PosPlatform.Api.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtTokenService _tokenService;
    private readonly ITenantContext _tenantContext;

    public AuthController(
        IUserRepository userRepository,
        IJwtTokenService tokenService,
        ITenantContext tenantContext)
    {
        _userRepository = userRepository;
        _tokenService = tokenService;
        _tenantContext = tenantContext;
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmailAsync(request.Email, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPassword(request.Password))
            return Unauthorized(new { error = "Invalid credentials" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("login/pin")]
    [AllowAnonymous]
    public async Task<ActionResult<LoginResponse>> LoginWithPin(
        [FromBody] PinLoginRequest request,
        CancellationToken ct)
    {
        var user = await _userRepository.GetByEmployeeIdAsync(request.EmployeeId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized(new { error = "Invalid credentials" });

        if (!user.VerifyPin(request.Pin))
            return Unauthorized(new { error = "Invalid PIN" });

        user.RecordLogin();
        await _userRepository.UpdateAsync(user, ct);

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new LoginResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt,
            UserDto.FromEntity(user)));
    }

    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<ActionResult<TokenResponse>> Refresh(
        [FromBody] RefreshRequest request,
        CancellationToken ct)
    {
        // In production, validate refresh token from database
        var principal = _tokenService.ValidateToken(request.AccessToken);
        if (principal == null)
            return Unauthorized();

        var userId = Guid.Parse(principal.FindFirst(ClaimTypes.NameIdentifier)!.Value);
        var user = await _userRepository.GetByIdAsync(userId, ct);

        if (user == null || !user.IsActive)
            return Unauthorized();

        var tokens = _tokenService.GenerateTokens(user, _tenantContext.TenantCode!);

        return Ok(new TokenResponse(
            tokens.AccessToken,
            tokens.RefreshToken,
            tokens.ExpiresAt));
    }

    [HttpPost("logout")]
    [Authorize]
    public IActionResult Logout()
    {
        // In production, invalidate refresh token in database
        return NoContent();
    }
}

// DTOs
public record LoginRequest(string Email, string Password);
public record PinLoginRequest(string EmployeeId, string Pin);
public record RefreshRequest(string AccessToken, string RefreshToken);
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, UserDto User);
public record TokenResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);

public record UserDto(Guid Id, string FullName, string? Email, string Role, Guid? LocationId)
{
    public static UserDto FromEntity(User user) => new(
        user.Id, user.FullName, user.Email, user.Role.ToString(), user.LocationId);
}

Week 3-4: Catalog Domain

Day 1-2: Product and Category Entities

Claude Command:

/dev-team create product entity with variant support

See entities defined in TenantDbContext above.

Day 5-6: Pricing Rules Engine

Claude Command:

/dev-team create pricing rules engine

Implementation:

// src/PosPlatform.Core/Services/PricingService.cs
namespace PosPlatform.Core.Services;

public interface IPricingService
{
    decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context);
}

public class PricingService : IPricingService
{
    public decimal CalculatePrice(Product product, ProductVariant? variant, PricingContext context)
    {
        var basePrice = product.BasePrice;

        // Apply variant adjustment
        if (variant != null)
            basePrice += variant.PriceAdjustment;

        // Apply promotions
        foreach (var promo in context.ActivePromotions)
        {
            if (promo.AppliesTo(product))
                basePrice = promo.Apply(basePrice);
        }

        // Apply customer discount
        if (context.CustomerDiscount > 0)
            basePrice *= (1 - context.CustomerDiscount);

        return Math.Round(basePrice, 2);
    }
}

public class PricingContext
{
    public List<Promotion> ActivePromotions { get; set; } = new();
    public decimal CustomerDiscount { get; set; }
    public DateTime PriceDate { get; set; } = DateTime.UtcNow;
}

public abstract class Promotion
{
    public abstract bool AppliesTo(Product product);
    public abstract decimal Apply(decimal price);
}

public class PercentagePromotion : Promotion
{
    public decimal DiscountPercent { get; set; }
    public Guid? CategoryId { get; set; }

    public override bool AppliesTo(Product product)
        => !CategoryId.HasValue || product.CategoryId == CategoryId;

    public override decimal Apply(decimal price)
        => price * (1 - DiscountPercent / 100);
}

Week 1-2 Review Checkpoint

Claude Command:

/architect-review multi-tenant isolation and authentication implementation

Checklist:

  • Tenant CRUD API functional
  • Schema provisioning creates tables correctly
  • Tenant middleware resolves from header/subdomain
  • JWT authentication working
  • PIN login functional for cashiers
  • Integration tests pass

Next Steps

Proceed to Chapter 26: Phase 2 - Core Implementation for:

  • Inventory domain with stock tracking
  • Sales domain with event sourcing
  • Payment processing
  • Cash drawer operations

Chapter 25 Complete - Phase 1 Foundation Implementation