diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs
new file mode 100644
index 0000000..ff7cda4
--- /dev/null
+++ b/Backend/ERPCore/Controllers/CustomersController.cs
@@ -0,0 +1,69 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Customer master endpoints for Phase 1 sales.
+[Route("api/v1/customers")]
+public sealed class CustomersController : ApiControllerBase
+{
+ private readonly ICustomerService _customers;
+
+ public CustomersController(ICustomerService customers) => _customers = customers;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query,
+ [FromQuery] EntityStatus? status,
+ [FromQuery] CustomerType? customerType,
+ CancellationToken ct)
+ => Ok(await _customers.ListAsync(query, status, customerType, ct));
+
+ [HttpGet("{customerId:int}")]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int customerId, CancellationToken ct)
+ {
+ var result = await _customers.GetAsync(customerId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
+ {
+ var result = await _customers.CreateAsync(request, ct);
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
+ }
+
+ [HttpPut("{customerId:int}")]
+ [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _customers.UpdateAsync(customerId, request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPatch("{customerId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
+ {
+ await _customers.SetStatusAsync(customerId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Customer.cs b/Backend/ERPCore/Domain/Entities/Customer.cs
new file mode 100644
index 0000000..916a1b9
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Customer.cs
@@ -0,0 +1,38 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Customer master for both B2B and B2C sales.
+/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
+///
+public class Customer
+{
+ public int CustomerId { get; set; }
+ public string CustomerCode { get; set; } = string.Empty;
+ public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+
+ public string Name { get; set; } = string.Empty;
+ public string? DisplayName { get; set; }
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+
+ public string? AddressLine1 { get; set; }
+ public string? AddressLine2 { get; set; }
+ public string? City { get; set; }
+ public string? Country { get; set; }
+
+ public string? TaxRegistrationNo { get; set; }
+ public decimal CreditLimit { get; set; }
+ public int CreditDays { get; set; }
+
+ public int? DefaultWarehouseId { get; set; }
+ public Warehouse? DefaultWarehouse { get; set; }
+
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Enums/CustomerType.cs b/Backend/ERPCore/Domain/Enums/CustomerType.cs
new file mode 100644
index 0000000..5928ba5
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CustomerType.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum CustomerType
+{
+ B2B = 1,
+ B2C = 2,
+ WalkIn = 3
+}
diff --git a/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
new file mode 100644
index 0000000..3b7175d
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Customers/CustomerDtos.cs
@@ -0,0 +1,66 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Customers;
+
+/// Customer resource used by sales documents.
+public sealed record CustomerDto(
+ int CustomerId,
+ string CustomerCode,
+ CustomerType CustomerType,
+ string Name,
+ string? DisplayName,
+ string? Phone,
+ string? Email,
+ string? AddressLine1,
+ string? AddressLine2,
+ string? City,
+ string? Country,
+ string? TaxRegistrationNo,
+ decimal CreditLimit,
+ int CreditDays,
+ int? DefaultWarehouseId,
+ EntityStatus Status,
+ DateTime CreatedAt,
+ DateTime? UpdatedAt);
+
+public sealed class CreateCustomerRequest
+{
+ [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
+ [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(200)] public string? DisplayName { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
+ [StringLength(100)] public string? Email { get; set; }
+ [StringLength(250)] public string? AddressLine1 { get; set; }
+ [StringLength(250)] public string? AddressLine2 { get; set; }
+ [StringLength(100)] public string? City { get; set; }
+ [StringLength(100)] public string? Country { get; set; }
+ [StringLength(50)] public string? TaxRegistrationNo { get; set; }
+ [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
+ [Range(0, int.MaxValue)] public int CreditDays { get; set; }
+ public int? DefaultWarehouseId { get; set; }
+}
+
+public sealed class UpdateCustomerRequest
+{
+ [Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
+ [Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+ [StringLength(200)] public string? DisplayName { get; set; }
+ [StringLength(30)] public string? Phone { get; set; }
+ [StringLength(100)] public string? Email { get; set; }
+ [StringLength(250)] public string? AddressLine1 { get; set; }
+ [StringLength(250)] public string? AddressLine2 { get; set; }
+ [StringLength(100)] public string? City { get; set; }
+ [StringLength(100)] public string? Country { get; set; }
+ [StringLength(50)] public string? TaxRegistrationNo { get; set; }
+ [Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
+ [Range(0, int.MaxValue)] public int CreditDays { get; set; }
+ public int? DefaultWarehouseId { get; set; }
+}
+
+public sealed class UpdateCustomerStatusRequest
+{
+ [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
new file mode 100644
index 0000000..a52b327
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/CustomerConfiguration.cs
@@ -0,0 +1,50 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class CustomerConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("customers");
+ builder.HasKey(c => c.CustomerId);
+
+ builder.Property(c => c.CustomerCode).IsRequired().HasMaxLength(50);
+ builder.HasIndex(c => c.CustomerCode).IsUnique();
+
+ builder.Property(c => c.CustomerType)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(CustomerType.B2C);
+
+ builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
+ builder.Property(c => c.DisplayName).HasMaxLength(200);
+ builder.Property(c => c.Phone).HasMaxLength(30);
+ builder.Property(c => c.Email).HasMaxLength(100);
+ builder.Property(c => c.AddressLine1).HasMaxLength(250);
+ builder.Property(c => c.AddressLine2).HasMaxLength(250);
+ builder.Property(c => c.City).HasMaxLength(100);
+ builder.Property(c => c.Country).HasMaxLength(100);
+ builder.Property(c => c.TaxRegistrationNo).HasMaxLength(50);
+
+ builder.Property(c => c.CreditLimit).HasPrecision(18, 4);
+ builder.Property(c => c.CreditDays).IsRequired();
+
+ builder.HasOne(c => c.DefaultWarehouse)
+ .WithMany()
+ .HasForeignKey(c => c.DefaultWarehouseId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ builder.Property(c => c.Status)
+ .HasConversion().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(c => c.CreatedAt).IsRequired();
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasIndex(c => c.Status);
+ builder.HasIndex(c => c.CustomerType);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index ca895cd..1f4d2ce 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -22,6 +22,7 @@ public class ErpDbContext : DbContext
}
// --- Master Data (docs/10 Part C.1) ---
+ public DbSet Customers => Set();
public DbSet Categories => Set();
public DbSet SubCategories => Set();
public DbSet Brands => Set();
diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs
index 1a40914..5497874 100644
--- a/Backend/ERPCore/Program.cs
+++ b/Backend/ERPCore/Program.cs
@@ -7,6 +7,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
+using ERPCore.Services.Interfaces;
using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces;
@@ -62,6 +63,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Master-data services (docs/11 ยง2)
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs
new file mode 100644
index 0000000..0f7c64e
--- /dev/null
+++ b/Backend/ERPCore/Services/CustomerService.cs
@@ -0,0 +1,168 @@
+using ERPCore.Common.Http;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Infra.UoW;
+using ERPCore.Repositories.Interfaces;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Services;
+
+public sealed class CustomerService : ICustomerService
+{
+ private readonly IRepository _customers;
+ private readonly IRepository _warehouses;
+ private readonly IUnitOfWork _uow;
+
+ public CustomerService(IRepository customers, IRepository warehouses, IUnitOfWork uow)
+ {
+ _customers = customers;
+ _warehouses = warehouses;
+ _uow = uow;
+ }
+
+ public async Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default)
+ {
+ var q = _customers.Query().AsNoTracking();
+ if (!string.IsNullOrWhiteSpace(query.Q))
+ {
+ var term = query.Q.Trim();
+ q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%")
+ || EF.Functions.ILike(c.CustomerCode, $"%{term}%")
+ || (c.DisplayName != null && EF.Functions.ILike(c.DisplayName, $"%{term}%")));
+ }
+ if (status is not null) q = q.Where(c => c.Status == status);
+ if (customerType is not null) q = q.Where(c => c.CustomerType == customerType);
+
+ var total = await q.CountAsync(ct);
+ var rows = await q.OrderBy(c => c.Name)
+ .Skip(query.Skip).Take(query.PageSize)
+ .Select(c => new CustomerDto(
+ c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
+ c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
+ c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt))
+ .ToListAsync(ct);
+
+ return PagedResponse.Create(rows, query.Page, query.PageSize, total);
+ }
+
+ public async Task?> GetAsync(int customerId, CancellationToken ct = default)
+ {
+ var customer = await _customers.Query().AsNoTracking()
+ .FirstOrDefaultAsync(c => c.CustomerId == customerId, ct);
+ return customer is null ? null : new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default)
+ {
+ var code = request.CustomerCode.Trim();
+ var name = request.Name.Trim();
+
+ if (await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower(), ct))
+ throw new ConflictException($"A customer code '{code}' already exists.");
+
+ if (!string.IsNullOrWhiteSpace(request.Email))
+ {
+ var email = request.Email.Trim();
+ if (await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == email.ToLower(), ct))
+ throw new ConflictException($"A customer with email '{email}' already exists.");
+ }
+
+ if (request.DefaultWarehouseId is not null)
+ {
+ var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
+ if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
+ }
+
+ var customer = new Customer
+ {
+ CustomerCode = code,
+ CustomerType = request.CustomerType,
+ Name = name,
+ DisplayName = Normalize(request.DisplayName),
+ Phone = Normalize(request.Phone),
+ Email = Normalize(request.Email),
+ AddressLine1 = Normalize(request.AddressLine1),
+ AddressLine2 = Normalize(request.AddressLine2),
+ City = Normalize(request.City),
+ Country = Normalize(request.Country),
+ TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
+ CreditLimit = request.CreditLimit,
+ CreditDays = request.CreditDays,
+ DefaultWarehouseId = request.DefaultWarehouseId,
+ Status = EntityStatus.Active,
+ CreatedAt = DateTime.UtcNow
+ };
+
+ await _customers.AddAsync(customer, ct);
+ await _uow.SaveChangesAsync(ct);
+
+ return new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default)
+ {
+ var customer = await _customers.GetByIdAsync(customerId, ct)
+ ?? throw new NotFoundException($"Customer {customerId} was not found.");
+
+ if (customer.RowVersion != expectedRowVersion)
+ throw new DomainException(ErrorCodes.ConcurrencyConflict, "The customer was modified by another request.", 412);
+
+ var code = request.CustomerCode.Trim();
+ var name = request.Name.Trim();
+
+ if (!string.Equals(customer.CustomerCode, code, StringComparison.Ordinal)
+ && await _customers.Query().AnyAsync(c => c.CustomerCode.ToLower() == code.ToLower() && c.CustomerId != customerId, ct))
+ throw new ConflictException($"A customer code '{code}' already exists.");
+
+ if (!string.Equals(customer.Email, request.Email?.Trim(), StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(request.Email)
+ && await _customers.Query().AnyAsync(c => c.Email != null && c.Email.ToLower() == request.Email!.Trim().ToLower() && c.CustomerId != customerId, ct))
+ throw new ConflictException($"A customer with email '{request.Email.Trim()}' already exists.");
+
+ if (request.DefaultWarehouseId is not null)
+ {
+ var exists = await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.DefaultWarehouseId, ct);
+ if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
+ }
+
+ customer.CustomerCode = code;
+ customer.CustomerType = request.CustomerType;
+ customer.Name = name;
+ customer.DisplayName = Normalize(request.DisplayName);
+ customer.Phone = Normalize(request.Phone);
+ customer.Email = Normalize(request.Email);
+ customer.AddressLine1 = Normalize(request.AddressLine1);
+ customer.AddressLine2 = Normalize(request.AddressLine2);
+ customer.City = Normalize(request.City);
+ customer.Country = Normalize(request.Country);
+ customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
+ customer.CreditLimit = request.CreditLimit;
+ customer.CreditDays = request.CreditDays;
+ customer.DefaultWarehouseId = request.DefaultWarehouseId;
+ customer.UpdatedAt = DateTime.UtcNow;
+
+ await _uow.SaveChangesAsync(ct);
+ return new ETagged(Map(customer), customer.RowVersion);
+ }
+
+ public async Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default)
+ {
+ var customer = await _customers.GetByIdAsync(customerId, ct)
+ ?? throw new NotFoundException($"Customer {customerId} was not found.");
+
+ customer.Status = status;
+ customer.UpdatedAt = DateTime.UtcNow;
+ await _uow.SaveChangesAsync(ct);
+ }
+
+ private static CustomerDto Map(Customer c) => new(
+ c.CustomerId, c.CustomerCode, c.CustomerType, c.Name, c.DisplayName, c.Phone, c.Email,
+ c.AddressLine1, c.AddressLine2, c.City, c.Country, c.TaxRegistrationNo,
+ c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.CreatedAt, c.UpdatedAt);
+
+ private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+}
diff --git a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs
new file mode 100644
index 0000000..47e837c
--- /dev/null
+++ b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs
@@ -0,0 +1,15 @@
+using ERPCore.Common.Http;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Customers;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Services.Interfaces;
+
+public interface ICustomerService
+{
+ Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, CancellationToken ct = default);
+ Task?> GetAsync(int customerId, CancellationToken ct = default);
+ Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
+ Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
+ Task SetStatusAsync(int customerId, EntityStatus status, CancellationToken ct = default);
+}
diff --git a/docs/SALES_MODULE_PLAN.md b/docs/SALES_MODULE_PLAN.md
new file mode 100644
index 0000000..efb4bea
--- /dev/null
+++ b/docs/SALES_MODULE_PLAN.md
@@ -0,0 +1,383 @@
+# Sales Module Plan
+
+## Summary
+This sales module is split into two phases:
+- **Phase 1**: basic, standard sales features that fit the current ERP architecture
+- **Phase 2**: enterprise extensions that can be added after the core flow is stable
+
+The design stays aligned with the existing backend patterns:
+- controller thinness
+- service-based business rules
+- repository + unit of work
+- ETag concurrency
+- audit logging
+- stock FIFO and ledger posting
+
+Returns, credit notes, and sales returns are **out of scope for Phase 1**.
+
+---
+
+## Phase 1 - Basic Standard Sales Module
+
+### Goal
+Implement the minimum sales flow needed for both B2B and B2C:
+- maintain customers
+- create sales invoices
+- create sales slips
+- support fixed sale price fallback and GRN-based cost fallback
+- support discounts by percentage and value
+- support free issue lines
+- post stock movement and ledger entries
+- generate basic sales reports
+
+### In Scope
+- Customer master
+- Sales invoice
+- Sales invoice lines
+- Sales slip
+- Sales slip lines
+- Pricing resolver
+- Discount calculation
+- Free issue handling
+- Stock posting
+- Basic sales reports
+
+### Not in Scope for Phase 1
+- customer groups
+- price lists
+- promotions
+- reservations
+- sales payments allocation
+- approval workflow
+- returns and credit notes
+- advanced customer segmentation
+
+### Phase 1 Entity Design
+
+#### `Customer`
+Basic customer master used for both B2B and B2C.
+
+Fields:
+- `CustomerId`
+- `CustomerCode`
+- `CustomerType` (`B2B`, `B2C`, `WalkIn`)
+- `Name`
+- `DisplayName`
+- `Phone`
+- `Email`
+- `AddressLine1`
+- `AddressLine2`
+- `City`
+- `Country`
+- `TaxRegistrationNo`
+- `CreditLimit`
+- `CreditDays`
+- `DefaultWarehouseId`
+- `Status`
+- `CreatedAt`
+- `UpdatedAt`
+- `RowVersion`
+
+#### `SalesInvoice`
+Primary posted sales document.
+
+Fields:
+- `SalesInvoiceId`
+- `InvoiceNo`
+- `InvoiceDate`
+- `CustomerId`
+- `CustomerSnapshotName`
+- `CustomerSnapshotTaxNo`
+- `WarehouseId`
+- `InvoiceType` (`B2B`, `B2C`, `Cash`, `Credit`)
+- `Status` (`Draft`, `Posted`, `Cancelled`)
+- `Subtotal`
+- `DiscountTotal`
+- `TaxTotal`
+- `GrandTotal`
+- `RoundOff`
+- `NetPayable`
+- `PaidAmount`
+- `BalanceAmount`
+- `CreatedBy`
+- `CreatedAt`
+- `UpdatedAt`
+- `RowVersion`
+
+#### `SalesInvoiceLine`
+Invoice line with pricing, discount, and free issue support.
+
+Fields:
+- `SalesInvoiceLineId`
+- `SalesInvoiceId`
+- `ItemId`
+- `Description`
+- `Qty`
+- `FreeQty`
+- `UomId`
+- `WarehouseId`
+- `UnitPrice`
+- `BaseCost`
+- `PriceSource`
+- `DiscountPct`
+- `DiscountAmount`
+- `NetUnitPrice`
+- `LineTotal`
+- `TaxPct`
+- `TaxAmount`
+- `IsFreeIssue`
+- `ParentLineId`
+- `RowVersion`
+
+#### `SalesSlip`
+Fast retail or counter-sale document.
+
+Fields:
+- `SalesSlipId`
+- `SlipNo`
+- `SlipDate`
+- `CustomerId`
+- `CustomerSnapshotName`
+- `WarehouseId`
+- `CashierUserId`
+- `Status`
+- `Subtotal`
+- `DiscountTotal`
+- `TaxTotal`
+- `GrandTotal`
+- `PaidAmount`
+- `BalanceAmount`
+- `CreatedAt`
+- `UpdatedAt`
+- `RowVersion`
+
+#### `SalesSlipLine`
+Slip line with the same sales calculation rules as invoices.
+
+Fields:
+- `SalesSlipLineId`
+- `SalesSlipId`
+- `ItemId`
+- `Description`
+- `Qty`
+- `FreeQty`
+- `UomId`
+- `WarehouseId`
+- `UnitPrice`
+- `BaseCost`
+- `PriceSource`
+- `DiscountPct`
+- `DiscountAmount`
+- `NetUnitPrice`
+- `LineTotal`
+- `TaxPct`
+- `TaxAmount`
+- `IsFreeIssue`
+- `ParentLineId`
+- `RowVersion`
+
+### Phase 1 Pricing Rule
+Use the following order:
+1. fixed `Item.SalePrice`
+2. GRN-derived stock cost fallback
+3. FIFO valuation fallback
+
+Important:
+- use a weighted average when deriving from multiple GRNs
+- keep the resolved source in `PriceSource`
+- allow manual override only if permitted by business rule
+
+### Phase 1 Discount Rule
+Support:
+- percentage discount
+- fixed value discount
+
+Discount must be computed server-side and stored in line and document totals.
+
+### Phase 1 Free Issue Rule
+Support free issue lines in the same invoice/slip document.
+
+Rules:
+- free quantity must be separate from paid quantity
+- free issue still reduces stock
+- free issue must be visible in reports
+- free issue should not be merged into discount
+
+### Phase 1 Stock Posting Rule
+When an invoice or slip is posted:
+- reduce stock from the selected warehouse
+- consume FIFO layers
+- write `StockLedger` rows
+- maintain source document traceability
+- update totals in the same transaction
+
+### Phase 1 API Route List
+- `GET /api/v1/customers`
+- `GET /api/v1/customers/{id}`
+- `POST /api/v1/customers`
+- `PUT /api/v1/customers/{id}`
+- `PATCH /api/v1/customers/{id}/status`
+- `GET /api/v1/sales-invoices`
+- `GET /api/v1/sales-invoices/{id}`
+- `POST /api/v1/sales-invoices`
+- `PUT /api/v1/sales-invoices/{id}`
+- `POST /api/v1/sales-invoices/{id}/post`
+- `POST /api/v1/sales-invoices/{id}/cancel`
+- `GET /api/v1/sales-invoices/{id}/print-preview`
+- `GET /api/v1/sales-slips`
+- `GET /api/v1/sales-slips/{id}`
+- `POST /api/v1/sales-slips`
+- `POST /api/v1/sales-slips/{id}/post`
+- `POST /api/v1/sales-slips/{id}/cancel`
+- `GET /api/v1/sales-reports/daily-summary`
+- `GET /api/v1/sales-reports/item-wise`
+- `GET /api/v1/sales-reports/customer-wise`
+- `GET /api/v1/sales-reports/warehouse-wise`
+- `GET /api/v1/sales-reports/discount-summary`
+- `GET /api/v1/sales-reports/free-issue-summary`
+- `GET /api/v1/sales-reports/margin-summary`
+
+### Phase 1 Folder / Module Plan
+- `Domain/Entities`
+ - add `Customer`, `SalesInvoice`, `SalesInvoiceLine`, `SalesSlip`, `SalesSlipLine`
+- `Domain/Enums`
+ - add sales status enums and invoice/slip type enums
+- `Dtos/Sales`
+ - add request and response DTOs for customer, invoice, slip, and reports
+- `Services/Interfaces`
+ - add `ICustomerService`, `ISalesInvoiceService`, `ISalesSlipService`, `ISalesPricingService`, `ISalesReportService`
+- `Services`
+ - implement the sales services with transaction-safe logic
+- `Controllers`
+ - add `CustomersController`, `SalesInvoicesController`, `SalesSlipsController`, `SalesReportsController`
+- `Infra/Persistence/Configurations`
+ - add EF Core mappings for all sales entities
+- `Infra/Persistence/ErpDbContext.cs`
+ - register sales `DbSet`s
+- `Infra/Persistence/Migrations`
+ - add the sales schema migration after the model is defined
+
+### Phase 1 Implementation Order
+1. Customer master
+2. Sales invoice entity and DTOs
+3. Sales slip entity and DTOs
+4. Pricing resolver
+5. Discount computation
+6. Free issue handling
+7. Stock posting and ledger integration
+8. Basic sales reports
+9. Controllers and swagger wiring
+
+---
+
+## Phase 2 - Enterprise Extensions
+
+### Goal
+Add richer commercial features after Phase 1 is stable and tested.
+
+### In Scope
+- customer groups
+- price lists
+- promotions
+- free issue schemes
+- reservations
+- payment allocation
+- approval flow
+- advanced reporting dimensions
+
+### Phase 2 Entity Additions
+
+#### `CustomerGroup`
+Used only if group-level pricing or segmentation is needed.
+
+#### `PriceList`
+Customer, group, warehouse, or global price policies.
+
+#### `PriceListItem`
+Per-item pricing rows inside a price list.
+
+#### `Promotion`
+Promotional header.
+
+#### `PromotionRule`
+Buy-X-get-Y, discount, or reward rules.
+
+#### `FreeIssueScheme`
+Separate free issue header.
+
+#### `FreeIssueSchemeLine`
+Rule lines for free issue behavior.
+
+#### `SalesReservation`
+Stock reservation header for B2B order fulfillment.
+
+#### `SalesReservationLine`
+Reserved item quantities.
+
+#### `SalesPayment`
+Payment header for cash or credit settlement.
+
+#### `SalesPaymentAllocation`
+Allocation of a payment across invoices.
+
+### Phase 2 API Routes
+- `GET /api/v1/customer-groups`
+- `POST /api/v1/customer-groups`
+- `PUT /api/v1/customer-groups/{id}`
+- `PATCH /api/v1/customer-groups/{id}/status`
+- `GET /api/v1/price-lists`
+- `POST /api/v1/price-lists`
+- `PUT /api/v1/price-lists/{id}`
+- `PATCH /api/v1/price-lists/{id}/status`
+- `GET /api/v1/price-lists/{id}/items`
+- `PUT /api/v1/price-lists/{id}/items`
+- `GET /api/v1/pricing/resolve`
+- `GET /api/v1/promotions`
+- `POST /api/v1/promotions`
+- `PUT /api/v1/promotions/{id}`
+- `PATCH /api/v1/promotions/{id}/status`
+- `GET /api/v1/free-issue-schemes`
+- `POST /api/v1/free-issue-schemes`
+- `PUT /api/v1/free-issue-schemes/{id}`
+- `PATCH /api/v1/free-issue-schemes/{id}/status`
+- `POST /api/v1/sales-orders`
+- `POST /api/v1/sales-orders/{id}/reserve`
+- `POST /api/v1/sales-orders/{id}/confirm`
+- `POST /api/v1/sales-payments`
+- `POST /api/v1/sales-payments/{id}/allocate`
+- extended reporting routes for channel, cashier, tax, and credit views
+
+### Phase 2 Folder / Module Plan
+- extend the same sales folders rather than creating a separate module
+- add new entities and DTOs under the same sales namespace
+- add new service interfaces and service implementations next to Phase 1 sales services
+- add new controllers only for the advanced routes
+- add migrations incrementally so Phase 1 tables remain stable
+
+### Phase 2 Implementation Order
+1. customer groups
+2. price lists
+3. promotions and free issue schemes
+4. sales orders and reservations
+5. payments and allocations
+6. advanced reports
+7. permissions and approval workflow
+
+---
+
+## Test Plan
+- Verify customer CRUD with ETag concurrency and status changes.
+- Verify invoice and slip create/update/post flows.
+- Verify fixed sale price fallback works.
+- Verify GRN-derived fallback uses weighted average.
+- Verify discounts calculate correctly by percentage and fixed value.
+- Verify free issue lines post stock and appear in reports.
+- Verify stock ledger entries are created once per posted document.
+- Verify Phase 1 routes remain stable before Phase 2 is added.
+
+## Assumptions
+- Phase 1 is intentionally minimal and should not include customer groups or price lists.
+- `SalesInvoice` is the primary posted sales document.
+- `SalesSlip` is a simplified retail document.
+- Returns are deferred to a later step.
+- Existing ERP patterns must be preserved: repository, unit of work, audit, ETag, and FIFO stock posting.