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