Compare commits

..

44 Commits

Author SHA1 Message Date
Sasanka 5d4ae3871b Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-22 11:42:23 +05:30
Sasanka b6b5a142a0 fixers 10/22 2026-10-22 11:42:20 +05:30
Naveen f100a0260d feat(dashboard): add date filter and stat groups components
- Implemented DashboardDateFilter component for selecting date ranges including custom options.
- Created DashboardStatGroups component to display various statistical cards grouped by categories.
- Added dashboard-chart-data module to handle chart specifications and data processing for dashboard charts.
- Introduced Chart component with tooltip and legend functionalities for better data visualization.
2026-10-21 14:58:21 +05:30
Naveen 0a212ed1aa feat(dashboard): add date filter and stat groups components
- Implemented DashboardDateFilter component for selecting date ranges.
- Created DashboardStatGroups component to display various dashboard statistics.
- Added dashboard chart data processing logic to support new chart specifications.
- Introduced reusable Chart components for rendering different types of charts.
2026-10-21 14:57:54 +05:30
ashan_rusiru e6efc33ac0 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m43s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m56s
2026-10-21 11:09:57 +05:30
Sasanka a27bdaea64 sales fixers 2026-10-21 10:45:19 +05:30
Sasanka 47479fb4f2 bug fixers 10/20 2026-10-20 15:33:19 +05:30
Sasanka 0e423b806c Sms & Email 2026-10-20 10:45:03 +05:30
ashan_rusiru 5feb2ae28b Merge pull request 'Dev' (#117) from Dev into production
Reviewed-on: #117
2026-10-19 10:41:35 +05:30
ashan_rusiru a40e463e8b Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m5s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m34s
2026-10-19 10:41:29 +05:30
Dhananjaya99 15716a558c Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-19 10:41:02 +05:30
Dhananjaya99 e9334be01f fix: update AuthHex BaseUrl to correct development server address 2026-10-19 10:40:55 +05:30
ashan_rusiru 9a3ab2a4f6 Merge pull request 'Dev' (#116) from Dev into production
Reviewed-on: #116
2026-10-18 16:23:26 +05:30
ashan_rusiru 6e4d16dc88 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m45s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m14s
2026-10-18 16:23:18 +05:30
Dhananjaya99 a0793b35d3 Add migration to include AccruedExpensesCode in gl_account_config table
- Introduced a new column 'AccruedExpensesCode' of type varchar(20) to the 'gl_account_config' table.
- Added SQL command to update existing records with a default value of '2300' for the 'AccruedExpensesCode' where it is currently null.
2026-10-18 16:21:38 +05:30
Dhananjaya99 dcdb628647 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-18 14:49:34 +05:30
Dhananjaya99 ce35973b58 feat: Enhance Stock Ledger functionality with item details and filters
- Updated StockService to compute running balance on read and include item SKU, name, category, and brand in StockLedgerRowDto.
- Modified GetLedgerAsync method to accept categoryId and brandId parameters for filtering.
- Enhanced StockController to support new query parameters for ledger retrieval.
- Updated StockDtos to include additional item information in StockLedgerRowDto.
- Adjusted frontend Stock Ledger page to display item details and implement searchable item picker.
- Added tests for StockLedger to verify running balance calculations and filtering by category and brand.
- Updated appsettings for service URLs and modified login page navigation logic.
2026-10-18 14:49:26 +05:30
ashan_rusiru efab804b04 Merge pull request 'Dev' (#115) from Dev into production
Reviewed-on: #115
2026-10-18 12:50:58 +05:30
ashan_rusiru 8726531d94 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m41s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m8s
2026-10-18 12:50:52 +05:30
Dhananjaya99 7da8834ce9 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-18 12:46:57 +05:30
Dhananjaya99 e7b1f270a5 feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
2026-10-18 12:46:31 +05:30
ashan_rusiru 70e8193247 Merge pull request 'Dev' (#114) from Dev into production
Reviewed-on: #114
2026-10-17 18:25:55 +05:30
ashan_rusiru 8b61c0910f Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m44s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m19s
2026-10-17 18:25:49 +05:30
Dhananjaya99 c27db06880 feat: add PriceAdjustmentLine repository to PriceAdjustmentService and optimize pending item query 2026-10-17 18:24:57 +05:30
Sasanka e289b8430f ui fixers in 10/16 2026-10-16 14:34:28 +05:30
Dhananjaya99 379ad204bd Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-15 10:54:34 +05:30
Dhananjaya99 61330f3927 feat: implement user role status toggle and reset password functionality
- Added toggle functionality for user roles (activate/deactivate) in roles and users pages.
- Introduced a reset password page that validates and updates user passwords via a reset link.
- Enhanced user detail page with edit functionality for user information (display name, email, mobile number).
- Implemented sending reset link feature for users, with appropriate error handling and success notifications.
- Updated API endpoints for user management to support new functionalities.
- Improved UI components for better user experience during status updates and password changes.
2026-10-15 10:54:28 +05:30
ashan_rusiru 81695a1fda Merge pull request 'Dev' (#113) from Dev into production
Reviewed-on: #113
2026-10-14 19:14:48 +00:00
ashan_rusiru e33fe85bbb Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m30s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 17m0s
2026-10-14 19:14:42 +00:00
Dhananjaya99 5ec3edc0c6 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-14 17:22:27 +05:30
Dhananjaya99 eb274e62c4 Remove sample trend data and update chart rendering logic for better handling of empty trends 2026-10-14 17:22:19 +05:30
ashan_rusiru 0a1ef66f68 Merge pull request 'Dev' (#112) from Dev into production
Reviewed-on: #112
2026-10-14 08:50:31 +00:00
ashan_rusiru 0ff6f33e9a Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 12m49s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 16m23s
2026-10-14 08:50:21 +00:00
Dhananjaya99 fd019e8ee6 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-10-14 14:19:37 +05:30
Dhananjaya99 7fa871c449 Remove 'peer' field from several dependencies in package-lock.json 2026-10-14 14:19:25 +05:30
ashan_rusiru 6b87cd2d64 Merge pull request 'Dev' (#111) from Dev into production
Reviewed-on: #111
2026-10-14 07:55:17 +00:00
ashan_rusiru b78152b428 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 11m33s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 13m2s
2026-10-14 07:54:55 +00:00
Dhananjaya99 d926254419 Remove build.ps1 script and correct typo in page.tsx comment 2026-10-14 13:24:32 +05:30
Dhananjaya99 169a7c325c Update .dockerignore and .gitignore to include 'dist' directory; add build.ps1 script for frontend packaging 2026-10-14 13:21:27 +05:30
ImanThiyanga 24a8b1045c Merge pull request 'Dev' (#109) from Dev into production
Reviewed-on: #109
2026-10-14 05:46:44 +00:00
ImanThiyanga b3f3b45c27 Merge branch 'production' into Dev
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 11m36s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 15m7s
2026-10-14 05:46:37 +00:00
ImanThiyanga a1160e4f15 Merge pull request 'Fix/ui bug fixers rebase repair' (#108) from fix/ui-bug-fixers-rebase-repair into Dev
Reviewed-on: #108
2026-10-14 05:45:31 +00:00
ImanThiyanga 3be425284c Repair botched rebase resolutions from ui-bug-fixers
The rebase onto Dev resolved five conflicts; four were wrong. In the
service files Dev's constructor parameter lists won while the branch's
field declarations and constructor bodies stayed, so the bodies assigned
parameters that no longer existed.

- AdjustmentService: restore fifo, approvals, db and serialTracking
  parameters (Dev's glAccounts kept, configuration dropped).
- FifoCostingService: restore the LowStockTracker field, parameter and
  assignment.
- CountService: restore _serialTracking and _adjustments, and delete
  Dev's PostAdjustmentJournalAsync. Dev's call site did not survive the
  rebase, so the method was orphaned dead code referencing fields this
  class no longer has. The branch routes count variances through
  AdjustmentService.SubmitAsync, which posts via Dev's role-keyed
  glCodes, so nothing is lost by removing it.
- PurchaseInvoicePaymentService: this one compiled. paymentDay was
  computed but the GL period and EntryDate still used now, so a
  back-dated payment posted its journal to today's period. Use
  paymentDay, and resolve "today" through BusinessClock, matching
  SalesInvoicePaymentService.
- ErpDbContextModelSnapshot: the resolution dropped the whole Expense
  module from the definition and relationship sections while leaving its
  navigation entries behind, so the snapshot no longer loaded. Rebuilt as
  the branch's snapshot plus Dev's GlAccountConfig blocks; EF now reports
  no pending model changes.

Tests: BankDepositTestSupport's FakeGeneralLedger gains the
ListAccountsAsync member Dev added to IGeneralLedgerService, and
DualApprovalTests passes a StubGlAccountConfigService in place of the
removed IConfiguration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 09:49:09 +05:30
ImanThiyanga 1cddbaf84f Merge pull request 'Add GL account configuration and role mapping entities' (#107) from Dev into production
Reviewed-on: #107
2026-10-07 08:19:12 +00:00
413 changed files with 184315 additions and 6708 deletions
+512
View File
@@ -0,0 +1,512 @@
using System.Security.Claims;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// The ERP-owned role model: AuthHex proves who someone is; the role, its permissions and "is admin" come from the
/// ERP's own users/roles tables, are read on every request, and a change applies on the very next one.
/// </summary>
public class AccessControlTests
{
private const int AdminRole = 1, ApproverRole = 2, ViewerRole = 3, NoAccessRole = 4, InactiveRole = 5;
// ---------------------------------------------------------------- helpers
private sealed class FakeUser(int userId = 10, int? roleId = null, bool isAdmin = false) : ICurrentUser
{
public int AuditUserId { get; set; } = userId;
public int? RoleId { get; set; } = roleId;
public string? RoleCode { get; set; }
public bool IsAdmin { get; set; } = isAdmin;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
public int? BranchId => null;
}
private static async Task<ErpDbContext> SeedAsync(bool withDefault = true)
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = AdminRole, Code = "Admin", Name = "Administrator", IsAdmin = true, IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = ApproverRole, Code = "Approver", Name = "Approver", CreatedAt = DateTime.UtcNow },
new Role { RoleId = ViewerRole, Code = "Viewer", Name = "Viewer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = NoAccessRole, Code = "NoAccess", Name = "No access", IsSystemRole = true, CreatedAt = DateTime.UtcNow },
new Role { RoleId = InactiveRole, Code = "Retired", Name = "Retired", Status = EntityStatus.Inactive, CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "procurement", Label = "Procurement" });
db.SubNavItems.Add(new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "procurement.vendors", Label = "Vendors" });
db.NavItems.Add(new NavItem { NavItemId = 102, Code = "stock", Label = "Stock" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:procurement", NavItemId = 100 },
new Permission { PermissionId = 201, Code = "NAV:procurement.vendors", SubNavItemId = 101 },
new Permission { PermissionId = 202, Code = "NAV:stock", NavItemId = 102 });
db.RolePermissions.AddRange(
new RolePermission { RoleId = ApproverRole, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Approve },
new RolePermission { RoleId = ViewerRole, PermissionId = 201, Actions = RolePermissionAction.View },
new RolePermission { RoleId = InactiveRole, PermissionId = 202, Actions = RolePermissionAction.All });
if (withDefault) db.AccessConfig.Add(new AccessConfig { ConfigId = AccessConfig.SingletonId, DefaultRoleId = NoAccessRole });
await db.SaveChangesAsync();
return db;
}
private static ClaimsPrincipal Principal(Guid authUserId, string? tokenRoleCode = null)
{
var claims = new List<Claim> { new("UserId", authUserId.ToString()), new("NIC", "N-1") };
if (tokenRoleCode is not null) claims.Add(new Claim("RoleCode", tokenRoleCode));
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static ShadowUserClaimsTransformation Transformer(ErpDbContext db, bool legacyFallback = true) =>
new(db, new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:LegacyRoleClaimFallback"] = legacyFallback.ToString() })
.Build());
private static string? Claim(ClaimsPrincipal p, string type) => p.FindFirstValue(type);
private static User NewUser(Guid authId, int? roleId, EntityStatus status = EntityStatus.Active, int id = 0) => new()
{
UserId = id == 0 ? Random.Shared.Next(100, 100000) : id,
AuthUserId = authId, Username = $"u-{authId:N}", DisplayName = "Someone", RoleId = roleId, Status = status
};
private static NavPermissionService NavService(ErpDbContext db) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions()));
private static RoleService RoleSvc(ErpDbContext db, ICurrentUser user, NavPermissionService nav) =>
new(new Repository<Role>(db), new Repository<RolePermission>(db), new Repository<Permission>(db), new Repository<Employee>(db),
new Repository<User>(db), new Repository<AccessConfig>(db), user, nav, new FakeBranchConfig(), new PassThroughUnitOfWork(db));
// The AuthHex client, link and employee services are not touched by role/status changes (users here have no AuthUserId).
private static UserManagementService UserSvc(ErpDbContext db, ICurrentUser user) =>
new(new Repository<User>(db), new Repository<Role>(db), new Repository<Employee>(db), null!, null!, null!, user, new PassThroughUnitOfWork(db));
// ---------------------------------------------------------------- claims: the role comes from the ERP
[Fact]
public async Task Role_IsReadFromTheErpTable_NotFromTheTokensRoleClaim()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ApproverRole));
await db.SaveChangesAsync();
// The token still says "Admin" (an old AuthHex token). The ERP says Approver, and the ERP wins.
var principal = await Transformer(db).TransformAsync(Principal(auth, tokenRoleCode: "Admin"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("Approver", Claim(principal, AuthHexClaims.ErpRoleCode));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task Admin_IsTheRoleFlag_NotTheRoleName()
{
await using var db = await SeedAsync();
var boss = Guid.NewGuid();
var impostor = Guid.NewGuid();
db.Roles.Add(new Role { RoleId = 20, Code = "Boss", Name = "The boss", IsAdmin = true, CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 21, Code = "ADMIN2", Name = "admin", CreatedAt = DateTime.UtcNow }); // named like an admin, is not
db.Users.AddRange(NewUser(boss, 20), NewUser(impostor, 21));
await db.SaveChangesAsync();
Assert.Equal("true", Claim(await Transformer(db).TransformAsync(Principal(boss)), AuthHexClaims.ErpIsAdmin));
Assert.Equal("false", Claim(await Transformer(db).TransformAsync(Principal(impostor)), AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task RenamingTheAdminRole_DoesNotCostAnyoneTheirAdminRights()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole));
await db.SaveChangesAsync();
db.Roles.Single(r => r.RoleId == AdminRole).Code = "Owners";
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("true", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal("Owners", Claim(principal, AuthHexClaims.ErpRoleCode));
}
[Fact]
public async Task FirstSignIn_CreatesTheUser_AndGivesItTheDefaultRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var principal = await Transformer(db).TransformAsync(Principal(auth));
var user = Assert.Single(db.Users.Where(u => u.AuthUserId == auth));
Assert.Equal(NoAccessRole, user.RoleId); // saved, so the Users screen shows it
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.Equal(user.UserId.ToString(), Claim(principal, ClaimTypes.NameIdentifier));
}
[Fact]
public async Task WithNoDefaultRole_APersonWithoutARoleHasNoRoleClaims()
{
await using var db = await SeedAsync(withDefault: false);
var principal = await Transformer(db).TransformAsync(Principal(Guid.NewGuid()));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
Assert.NotNull(Claim(principal, ClaimTypes.NameIdentifier)); // still a known user
}
[Fact]
public async Task LegacyFallback_UsesTheTokensRole_ForARolelessUser_AndSavesIt()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: true).TransformAsync(Principal(auth, "Approver"));
Assert.Equal(ApproverRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ApproverRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task LegacyFallbackOff_IgnoresTheTokenAndUsesTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, roleId: null));
await db.SaveChangesAsync();
var principal = await Transformer(db, legacyFallback: false).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(NoAccessRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal("false", Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task AnAssignedRole_IsNeverOverwrittenByTheFallbackOrTheDefault()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, ViewerRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth, "Admin"));
Assert.Equal(ViewerRole.ToString(), Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Equal(ViewerRole, db.Users.Single(u => u.AuthUserId == auth).RoleId);
}
[Fact]
public async Task InactiveUser_IsBlocked_AndGetsNoRole()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, AdminRole, EntityStatus.Inactive));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Equal("1", Claim(principal, AuthHexClaims.ErpBlocked));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpIsAdmin));
}
[Fact]
public async Task InactiveRole_GrantsNothing()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
db.Users.Add(NewUser(auth, InactiveRole));
await db.SaveChangesAsync();
var principal = await Transformer(db).TransformAsync(Principal(auth));
Assert.Null(Claim(principal, AuthHexClaims.ErpRoleId));
Assert.Null(Claim(principal, AuthHexClaims.ErpBlocked)); // not blocked — just no permissions
}
[Fact]
public async Task Branch_ComesFromTheLinkedEmployee_AndTransformingTwiceAddsNothingNew()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 500);
db.Users.Add(user);
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E1", FullName = "Emp", UserId = 500, BranchId = Branch2 });
await db.SaveChangesAsync();
var transformer = Transformer(db);
var principal = await transformer.TransformAsync(Principal(auth));
var claimsAfterFirst = principal.Claims.Count();
principal = await transformer.TransformAsync(principal);
Assert.Equal(Branch2.ToString(), Claim(principal, AuthHexClaims.BranchId));
Assert.Equal(claimsAfterFirst, principal.Claims.Count());
}
// ---------------------------------------------------------------- permissions by role id
[Fact]
public async Task Nav_AccessAndActions_AreDecidedByTheRolesPermissions()
{
await using var db = await SeedAsync();
var nav = NavService(db);
Assert.True(await nav.HasAccessAsync(ApproverRole, "procurement"));
Assert.True(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Approve));
Assert.False(await nav.HasActionAsync(ApproverRole, "procurement", RolePermissionAction.Delete));
Assert.False(await nav.HasAccessAsync(ApproverRole, "stock"));
// A parent module is reachable through a child grant; an action is exact.
Assert.True(await nav.HasAccessAsync(ViewerRole, "procurement"));
Assert.False(await nav.HasActionAsync(ViewerRole, "procurement", RolePermissionAction.View));
Assert.True(await nav.HasActionAsync(ViewerRole, "procurement.vendors", RolePermissionAction.View));
Assert.False(await nav.HasAccessAsync(null, "procurement"));
Assert.False(await nav.HasAccessAsync(999, "procurement"));
}
[Fact]
public async Task Nav_InactiveRole_HasNoAccess()
{
await using var db = await SeedAsync();
Assert.False(await NavService(db).HasAccessAsync(InactiveRole, "stock"));
}
[Fact]
public async Task EditingARolesPermissions_AppliesImmediately_NotAfterACacheTimeout()
{
await using var db = await SeedAsync();
var nav = NavService(db);
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), nav);
Assert.False(await nav.HasAccessAsync(ViewerRole, "stock")); // cached as "no"
await roles.AssignPermissionsAsync(ViewerRole, new Dtos.Rbac.AssignRolePermissionsRequest { NavItemIds = [102] });
Assert.True(await nav.HasAccessAsync(ViewerRole, "stock")); // visible straight away
}
// ---------------------------------------------------------------- /auth/me
[Fact]
public async Task Me_ForAnAdministrator_ListsEveryPermission_EvenOnesNeverGranted()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.True(me.IsAdmin);
Assert.Equal(AdminRole, me.RoleId);
Assert.Contains("procurement.vendors", me.NavCodes);
Assert.Contains("stock", me.NavCodes);
Assert.Equal((int)RolePermissionAction.All, me.Permissions!["stock"]);
}
[Fact]
public async Task Me_ForAnOrdinaryRole_ListsOnlyItsGrants_WithTheirActions()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: ApproverRole), NavService(db)).GetMeAsync();
Assert.True(me.HasRole);
Assert.False(me.IsAdmin);
Assert.Equal(["procurement"], me.NavCodes);
Assert.Equal((int)(RolePermissionAction.View | RolePermissionAction.Approve), me.Permissions!["procurement"]);
}
[Fact]
public async Task Me_WithoutARole_SaysSo_InsteadOfReturningAnEmptyMenu()
{
await using var db = await SeedAsync();
var me = await RoleSvc(db, new FakeUser(roleId: null), NavService(db)).GetMeAsync();
Assert.False(me.HasRole);
Assert.Empty(me.NavCodes);
Assert.Empty(me.Permissions!);
}
// ---------------------------------------------------------------- role management guards
[Fact]
public async Task TheAdministratorSystemAndDefaultRoles_CannotBeDeactivatedOrDeleted()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(AdminRole, EntityStatus.Inactive));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(AdminRole));
await Assert.ThrowsAsync<DomainException>(() => roles.DeleteAsync(NoAccessRole)); // system role and the default
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(NoAccessRole, EntityStatus.Inactive));
}
[Fact]
public async Task ARoleInUse_CannotBeDeactivated_ButAnUnusedOneCanBeDeleted()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole));
await db.SaveChangesAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
await Assert.ThrowsAsync<DomainException>(() => roles.SetStatusAsync(ViewerRole, EntityStatus.Inactive));
await roles.DeleteAsync(ApproverRole);
Assert.False(db.Roles.Any(r => r.RoleId == ApproverRole));
}
[Fact]
public async Task CreatedRoles_NeverCarryTheAdminFlag()
{
await using var db = await SeedAsync();
var roles = RoleSvc(db, new FakeUser(roleId: AdminRole, isAdmin: true), NavService(db));
var created = (await roles.CreateAsync(new Dtos.Rbac.CreateRoleRequest { Code = "Clerk", Name = "Clerk" })).Value;
Assert.False(created.IsAdmin);
Assert.False(created.IsSystemRole);
Assert.Null(db.Roles.Single(r => r.RoleId == created.RoleId).AuthRoleId);
}
// ---------------------------------------------------------------- user management guards
[Fact]
public async Task ChangingAUsersRole_TakesEffectOnTheirNextRequest_WithNoRelogin()
{
await using var db = await SeedAsync();
var auth = Guid.NewGuid();
var user = NewUser(auth, ViewerRole, id: 700);
db.Users.Add(user);
await db.SaveChangesAsync();
Assert.Equal(ViewerRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
await UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(700, new UpdateUserRoleRequest { RoleId = ApproverRole });
// A fresh request for the same login now carries the new role.
Assert.Equal(ApproverRole.ToString(), Claim(await Transformer(db).TransformAsync(Principal(auth)), AuthHexClaims.ErpRoleId));
}
[Fact]
public async Task TheLastActiveAdministrator_CannotBeDemotedOrDeactivated()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), AdminRole, id: 801));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 1, roleId: AdminRole, isAdmin: true));
var demote = await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
var deactivate = await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
Assert.Equal(409, demote.StatusCode);
Assert.Equal(409, deactivate.StatusCode);
Assert.Equal(AdminRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task AnAdministrator_CanBeDemoted_WhenAnotherActiveAdminRemains()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
await UserSvc(db, new FakeUser(userId: 802, roleId: AdminRole, isAdmin: true))
.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole });
Assert.Equal(ViewerRole, db.Users.Single(u => u.UserId == 801).RoleId);
}
[Fact]
public async Task Nobody_CanRemoveTheirOwnAdminAccess_OrDeactivateThemselves()
{
await using var db = await SeedAsync();
db.Users.AddRange(NewUser(Guid.NewGuid(), AdminRole, id: 801), NewUser(Guid.NewGuid(), AdminRole, id: 802));
await db.SaveChangesAsync();
var svc = UserSvc(db, new FakeUser(userId: 801, roleId: AdminRole, isAdmin: true));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateRoleAsync(801, new UpdateUserRoleRequest { RoleId = ViewerRole }));
await Assert.ThrowsAsync<DomainException>(() => svc.SetStatusAsync(801, EntityStatus.Inactive));
}
[Fact]
public async Task AUser_CannotBeGivenAnInactiveRole()
{
await using var db = await SeedAsync();
db.Users.Add(NewUser(Guid.NewGuid(), ViewerRole, id: 900));
await db.SaveChangesAsync();
await Assert.ThrowsAsync<DomainException>(
() => UserSvc(db, new FakeUser(1, AdminRole, true)).UpdateRoleAsync(900, new UpdateUserRoleRequest { RoleId = InactiveRole }));
}
// ---------------------------------------------------------------- access config (default role)
private static AccessConfigService AccessSvc(ErpDbContext db) =>
new(new Repository<AccessConfig>(db), new Repository<Role>(db), new FakeUser(), new PassThroughUnitOfWork(db));
[Fact]
public async Task TheDefaultRole_CanNeverBeAnAdministratorOrAnInactiveRole()
{
await using var db = await SeedAsync();
var svc = AccessSvc(db);
var etag = db.AccessConfig.Single().RowVersion;
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = AdminRole }, etag));
await Assert.ThrowsAsync<DomainException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = InactiveRole }, etag));
await Assert.ThrowsAsync<NotFoundException>(() => svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = 999 }, etag));
Assert.Equal(NoAccessRole, db.AccessConfig.Single().DefaultRoleId);
var changed = (await svc.UpdateAsync(new UpdateAccessConfigRequest { DefaultRoleId = ViewerRole }, etag)).Value;
Assert.Equal(("Viewer", ViewerRole), (changed.DefaultRoleName, changed.DefaultRoleId));
}
// ---------------------------------------------------------------- seeding
[Fact]
public async Task Seeder_EnsuresAnAdminRoleAndALeastPrivilegeDefault_AndIsIdempotent()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
await DataSeeder.SeedAsync(db);
await DataSeeder.SeedAsync(db);
var admin = Assert.Single(db.Roles.Where(r => r.IsAdmin));
Assert.True(admin.IsSystemRole);
Assert.Equal(db.Permissions.Count(), db.RolePermissions.Count(rp => rp.RoleId == admin.RoleId));
var noAccess = Assert.Single(db.Roles.Where(r => r.Code == DataSeeder.DefaultNoAccessRoleCode));
Assert.Empty(db.RolePermissions.Where(rp => rp.RoleId == noAccess.RoleId));
Assert.Equal(noAccess.RoleId, Assert.Single(db.AccessConfig).DefaultRoleId);
}
[Fact]
public async Task Seeder_FlagsAnExistingRoleCalledAdmin_InsteadOfAddingASecond_AndKeepsAChosenDefault()
{
await using var db = CreateContext();
db.Database.EnsureCreated();
db.Roles.Add(new Role { RoleId = 50, Code = "ADMIN", Name = "Old admin", CreatedAt = DateTime.UtcNow });
db.Roles.Add(new Role { RoleId = 51, Code = "Reader", Name = "Reader", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
db.AccessConfig.Single().DefaultRoleId = 51; // an administrator's own choice
await db.SaveChangesAsync();
await DataSeeder.SeedAsync(db);
Assert.True(db.Roles.Single(r => r.RoleId == 50).IsAdmin);
Assert.Equal(1, db.Roles.Count(r => r.IsAdmin));
Assert.Equal(51, db.AccessConfig.Single().DefaultRoleId);
}
}
@@ -0,0 +1,156 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Customers;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Sales Configuration decides which optional customer fields exist (phone, email, address, city, country, credit limit,
/// credit days). The forms hide a switched-off field; the server refuses a new value for it and keeps what is stored.
/// </summary>
public class CustomerFieldSwitchTests
{
private static CustomerService Svc(ErpDbContext db) =>
new(new CustomerRepository(db), new FakeBranchConfig(), new PassThroughUnitOfWork(db));
private static async Task SwitchesAsync(ErpDbContext db, Action<SalesConfig> tweak)
{
var config = new SalesConfig { ConfigId = SalesConfig.SingletonId };
tweak(config);
db.SalesConfig.Add(config);
await db.SaveChangesAsync();
}
private static CreateCustomerRequest NewCustomer(Action<CreateCustomerRequest>? tweak = null)
{
var request = new CreateCustomerRequest { CustomerCode = "C-1", Name = "Nimal Perera", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private static UpdateCustomerRequest Same(Customer c, Action<UpdateCustomerRequest>? tweak = null)
{
var request = new UpdateCustomerRequest
{
CustomerCode = c.CustomerCode, CustomerType = c.CustomerType, Name = c.Name, DisplayName = c.DisplayName,
Phone = c.Phone, Email = c.Email, AddressLine1 = c.AddressLine1, AddressLine2 = c.AddressLine2,
City = c.City, Country = c.Country, TaxRegistrationNo = c.TaxRegistrationNo,
CreditLimit = c.CreditLimit, CreditDays = c.CreditDays, BranchId = c.BranchId
};
tweak?.Invoke(request);
return request;
}
[Fact]
public async Task EveryFieldIsOn_WhenNothingIsConfigured_SoExistingBehaviourIsUnchanged()
{
await using var db = CreateContext(); // no sales_config row at all
var created = (await Svc(db).CreateAsync(NewCustomer(r =>
{
r.Phone = "0771234567"; r.Email = "a@b.lk"; r.AddressLine1 = "No. 12"; r.City = "Colombo";
r.Country = "Sri Lanka"; r.CreditLimit = 5000m; r.CreditDays = 30;
}))).Value;
Assert.Equal(("0771234567", "a@b.lk", "No. 12", "Colombo", "Sri Lanka"), (created.Phone, created.Email, created.AddressLine1, created.City, created.Country));
Assert.Equal((5000m, 30), (created.CreditLimit, created.CreditDays));
}
[Theory]
[InlineData("phone")]
[InlineData("email")]
[InlineData("address")]
[InlineData("city")]
[InlineData("country")]
[InlineData("creditLimit")]
[InlineData("creditDays")]
public async Task Create_ANewValueForASwitchedOffField_IsRefused(string field)
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = field != "phone";
c.CustomerEmailEnabled = field != "email";
c.CustomerAddressEnabled = field != "address";
c.CustomerCityEnabled = field != "city";
c.CustomerCountryEnabled = field != "country";
c.CustomerCreditLimitEnabled = field != "creditLimit";
c.CustomerCreditDaysEnabled = field != "creditDays";
});
var ex = await Assert.ThrowsAsync<DomainException>(() => Svc(db).CreateAsync(NewCustomer(r =>
{
switch (field)
{
case "phone": r.Phone = "0771234567"; break;
case "email": r.Email = "a@b.lk"; break;
case "address": r.AddressLine2 = "Wellawatte"; break;
case "city": r.City = "Colombo"; break;
case "country": r.Country = "Sri Lanka"; break;
case "creditLimit": r.CreditLimit = 100m; break;
default: r.CreditDays = 7; break;
}
})));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Equal(422, ex.StatusCode);
Assert.Empty(db.Customers.Where(c => c.CustomerCode == "C-1"));
}
[Fact]
public async Task Create_WithTheSwitchedOffFieldsLeftBlank_Works()
{
await using var db = CreateContext();
await SwitchesAsync(db, c =>
{
c.CustomerPhoneEnabled = c.CustomerEmailEnabled = c.CustomerAddressEnabled = false;
c.CustomerCityEnabled = c.CustomerCountryEnabled = false;
c.CustomerCreditLimitEnabled = c.CustomerCreditDaysEnabled = false;
});
var created = (await Svc(db).CreateAsync(NewCustomer())).Value;
Assert.Equal("Nimal Perera", created.Name);
Assert.Null(created.Phone);
Assert.Equal((0m, 0), (created.CreditLimit, created.CreditDays));
}
[Fact]
public async Task Update_KeepsAStoredValueWhoseFieldWasSwitchedOff_EvenWhenTheFormSendsItBlank()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => { r.Phone = "0771234567"; r.CreditLimit = 5000m; r.CreditDays = 30; }))).Value;
await SwitchesAsync(db, c => { c.CustomerPhoneEnabled = false; c.CustomerCreditLimitEnabled = false; c.CustomerCreditDaysEnabled = false; });
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
// The edit form no longer has the fields, so it sends blanks / zeros.
var updated = (await svc.UpdateAsync(created.CustomerId,
Same(stored, r => { r.Name = "Nimal P."; r.Phone = null; r.CreditLimit = 0m; r.CreditDays = 0; }), stored.RowVersion)).Value;
Assert.Equal("Nimal P.", updated.Name);
Assert.Equal("0771234567", updated.Phone);
Assert.Equal((5000m, 30), (updated.CreditLimit, updated.CreditDays));
}
[Fact]
public async Task Update_ADifferentValueForASwitchedOffField_IsRefused()
{
await using var db = CreateContext();
var svc = Svc(db);
var created = (await svc.CreateAsync(NewCustomer(r => r.CreditLimit = 5000m))).Value;
await SwitchesAsync(db, c => c.CustomerCreditLimitEnabled = false);
var stored = db.Customers.Single(c => c.CustomerId == created.CustomerId);
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.UpdateAsync(created.CustomerId, Same(stored, r => r.CreditLimit = 9999m), stored.RowVersion));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
}
@@ -237,13 +237,8 @@ public class DayEndReportServiceTests
.Build();
var clock = new BusinessDayClock(config);
var branch = new CurrentBranchContext(new TestCurrentUser());
var cash = new CashInHandService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db), branch, clock, config);
return new DayEndReportService(
new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoice>(db),
new Repository<SalesInvoicePayment>(db), new Repository<StockLedger>(db),
new Repository<Expense>(db), new Repository<BankDeposit>(db), cash, branch, clock);
var cash = new CashInHandService(new CashInHandRepository(db), branch, clock, config);
return new DayEndReportService(new DayEndReportRepository(db), cash, branch, clock);
}
private static ErpDbContext CreateContext()
+111 -13
View File
@@ -2,6 +2,7 @@ using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Products;
using ERPCore.Dtos.Stock;
@@ -9,6 +10,7 @@ using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
@@ -27,7 +29,8 @@ public class DualApprovalTests
// Requester, two approvers, and a clerk who can approve but isn't a manager.
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
private const int ApproverRoleId = 2, ClerkRoleId = 3;
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
// ReasonId is an ordinary adjustment reason (VAR); WastageReasonId is a wastage one (DMG), which has its own approval switch.
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1, WastageReasonId = 2;
// --- Stock adjustments ------------------------------------------------------------------
@@ -140,7 +143,7 @@ public class DualApprovalTests
[Fact]
public async Task SecondApproval_CanBeLimitedToRoles()
{
var h = await Harness.CreateAsync(level2Roles: "Approver");
var h = await Harness.CreateAsync(level2RoleIds: ApproverRoleId.ToString());
var id = (await h.Stock.CreateAsync(Request(-1))).AdjustmentId;
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(id, null);
@@ -225,11 +228,95 @@ public class DualApprovalTests
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
// --- Wastage: its own approval switch ------------------------------------------------------
[Fact]
public async Task Wastage_WithTheSwitchOff_ReducesStockAtOnce_EvenWhenAdjustmentsNeedTwoApprovals()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.Posted, dto.Status);
Assert.Equal(0, dto.RequiredApprovals);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task Wastage_WithTheSwitchOn_IsARequest_AndMovesNothingUntilApproved()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 2);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(2, dto.RequiredApprovals);
Assert.Empty(h.Mutator.Calls);
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Empty(h.Mutator.Calls); // one of two: still nothing
h.User.Be(ApproverB);
var posted = await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task TheWastageSwitch_DoesNotLoosenOrdinaryAdjustments_AndTheirLevelsDoNotTouchWastage()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
// An ordinary adjustment (not a wastage reason) still needs two approvals.
var ordinary = await h.Stock.CreateAsync(Request(-3));
Assert.Equal(AdjustmentStatus.PendingApproval, ordinary.Status);
// A stock GAIN with a wastage reason is not wastage, so it follows the adjustment levels too.
var gain = await h.Stock.CreateAsync(Request(+3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, gain.Status);
}
[Fact]
public async Task WastageList_ReturnsOnlyAdjustmentsWithAWastageReason()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
await h.Stock.CreateAsync(Request(-2));
var all = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null);
var wastage = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true);
Assert.Equal(2, all.Items.Count);
var only = Assert.Single(wastage.Items);
Assert.Equal(WastageReasonId, only.ReasonCodeId);
}
[Fact]
public async Task WastageList_CanBeNarrowedToOneItem()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
var forItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: ItemId);
var otherItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: 999);
Assert.Single(forItem.Items);
Assert.Empty(otherItem.Items);
}
[Fact]
public void WastageApproval_IsOffByDefault_AndTheSettingIsReadBack()
{
Assert.Equal(0, new ApprovalConfig().WastageLevels);
Assert.True(ERPCore.Domain.WastageReasonCodes.IsWastage("dmg"));
Assert.False(ERPCore.Domain.WastageReasonCodes.IsWastage("VAR"));
}
// --- helpers ------------------------------------------------------------------------------
private static CreateAdjustmentRequest Request(decimal qty) => new()
private static CreateAdjustmentRequest Request(decimal qty, int reasonId = ReasonId) => new()
{
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = reasonId,
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
};
@@ -249,7 +336,7 @@ public class DualApprovalTests
public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; }
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2Roles = null)
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null, int wastageLevels = 0)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
@@ -278,11 +365,14 @@ public class DualApprovalTests
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ReasonCodes.AddRange(
new ReasonCode { ReasonCodeId = ReasonId, Code = "VAR", Description = "Variance", Context = ReasonContext.Adjustment },
new ReasonCode { ReasonCodeId = WastageReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ApprovalConfig.Add(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleCodes = level2Roles
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds,
WastageLevels = wastageLevels
});
await db.SaveChangesAsync();
@@ -291,7 +381,7 @@ public class DualApprovalTests
var notifications = new RecordingNotifications();
var approvals = new ApprovalService(
new Repository<ApprovalConfig>(db), new Repository<DocumentApproval>(db), new Repository<User>(db),
new Repository<RolePermission>(db), user, notifications, uow);
new Repository<RolePermission>(db), new Repository<Role>(db), user, notifications, uow);
var gl = new FakeGeneralLedger();
var mutator = new RecordingMutator();
var numbers = new FakeNumberSequence();
@@ -300,12 +390,13 @@ public class DualApprovalTests
new Repository<StockAdjustment>(db), new Repository<Warehouse>(db), new Repository<Item>(db),
new Repository<ReasonCode>(db), new Repository<StockLedger>(db), mutator, new FixedValuation(),
numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl,
new ConfigurationBuilder().Build(),
new StubGlAccountConfigService(
(GlAccountRole.Inventory, "1200"),
(GlAccountRole.InventoryGain, "4900"),
(GlAccountRole.InventoryLoss, "5900")),
new SerialTrackingService(new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
new Repository<ProductConfig>(db), user));
var prices = new PriceAdjustmentService(
new Repository<PriceAdjustment>(db), new Repository<Item>(db), new Repository<ItemPriceHistory>(db),
new Repository<User>(db), new Repository<ApprovalConfig>(db), approvals, notifications, numbers, user, uow);
var prices = new PriceAdjustmentService(new PriceAdjustmentRepository(db), approvals, notifications, numbers, user, uow);
return new Harness { Db = db, User = user, Mutator = mutator, Gl = gl, Notifications = notifications, Stock = stock, Prices = prices };
}
@@ -319,7 +410,13 @@ public class DualApprovalTests
[Requester] = "Requester", [ApproverA] = "Approver", [ApproverB] = "Approver", [Clerk] = "Clerk"
};
private static readonly Dictionary<int, int> RoleIds = new()
{
[Requester] = 1, [ApproverA] = ApproverRoleId, [ApproverB] = ApproverRoleId, [Clerk] = ClerkRoleId
};
public int AuditUserId { get; private set; } = userId;
public int? RoleId { get; private set; } = RoleIds[userId];
public string? RoleCode { get; private set; } = roleCode;
public string UserId => AuditUserId.ToString();
public bool IsAuthenticated => true;
@@ -329,6 +426,7 @@ public class DualApprovalTests
public void Be(int userId)
{
AuditUserId = userId;
RoleId = RoleIds[userId];
RoleCode = Roles[userId];
}
}
@@ -364,7 +462,7 @@ public class DualApprovalTests
=> throw new NotSupportedException();
public Task<StockLedger> PostLedgerAsync(int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, string sourceDocType, int sourceDocId,
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null) => throw new NotSupportedException();
DateTime createdAt, CancellationToken ct = default, decimal? valueOverride = null, bool postGlJournal = true) => throw new NotSupportedException();
public Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default) => Task.FromResult(10m);
}
@@ -91,7 +91,8 @@ public class LowStockAlertServiceTests
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
int? categoryId = null, int? brandId = null) => throw new NotSupportedException();
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
=> throw new NotSupportedException();
}
@@ -0,0 +1,428 @@
using System.Reflection;
using System.Security.Claims;
using ERPCore.Controllers;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Search;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.Extensions.Caching.Memory;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Permission coverage (Phase B): every API endpoint that changes data sits behind a permission, every permission the
/// controllers name actually exists in the seeded catalogue, HR data is gated, global search respects the role,
/// state-changing cookie requests need the CSRF token, and a branch-less person can't read every branch.
/// </summary>
public class PermissionCoverageTests
{
// The seeded (HasData) rows live in the design-time model; the runtime model drops them.
private static IEnumerable<IDictionary<string, object?>> Seed<T>(ErpDbContext db) =>
Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions.GetService<IDesignTimeModel>(db)
.Model.FindEntityType(typeof(T))!.GetSeedData();
// ---------------------------------------------------------------- controllers <-> permissions
private static IEnumerable<Type> ApiControllers() =>
typeof(ApiControllerBase).Assembly.GetTypes()
.Where(t => !t.IsAbstract && typeof(ApiControllerBase).IsAssignableFrom(t));
private static IEnumerable<string> NavPolicyNames(MemberInfo member) =>
member.GetCustomAttributes<AuthorizeAttribute>(inherit: true)
.Select(a => a.Policy)
.Where(p => p is not null && p.StartsWith(NavPolicies.Prefix, StringComparison.Ordinal))!;
private static bool IsReadOnly(MethodInfo m) =>
m.GetCustomAttributes<HttpGetAttribute>().Any() && !m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any(a => a is not HttpGetAttribute);
/// <summary>Endpoints that deliberately sit behind the login door only: personal, or checked inside the service.</summary>
private static readonly HashSet<string> OpenByDesign = new()
{
nameof(AuthController), // sign-in, sessions, password: the person's own
nameof(NavController), // "what may I see": the person's own permissions
nameof(NotificationsController), // the person's own inbox
nameof(SupportTicketsController), // anyone may raise a ticket; the service scopes by owner/admin
nameof(TrackingController), // read-only serial/batch pickers used inside sales and stock screens
nameof(SearchController), // gated per result type inside GlobalSearchService
nameof(MetaController),
};
[Fact]
public void EveryDataChangingEndpoint_IsBehindAPermission()
{
var uncovered = new List<string>();
foreach (var controller in ApiControllers().Where(c => !OpenByDesign.Contains(c.Name)))
{
var classCovered = NavPolicyNames(controller).Any();
foreach (var action in controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.GetCustomAttributes().OfType<Microsoft.AspNetCore.Mvc.Routing.HttpMethodAttribute>().Any()))
{
if (IsReadOnly(action)) continue;
if (action.GetCustomAttributes<AllowAnonymousAttribute>().Any()) continue;
if (!classCovered && !NavPolicyNames(action).Any()) uncovered.Add($"{controller.Name}.{action.Name}");
}
}
Assert.True(uncovered.Count == 0, "Data-changing endpoints with no Nav permission: " + string.Join(", ", uncovered));
}
[Fact]
public void EveryControllerAboutSensitiveData_IsGatedAsAWhole()
{
// HR (salaries, bank accounts, loans, payslips) and the audit trail used to be open to every signed-in person.
foreach (var name in new[]
{
"AttendanceBatchesController", "LeaveRequestsController", "PayrollRunsController", "PayslipsController",
"HrReportsController", "EmployeesController", "SalaryComponentsController", "TaxSlabsController",
"PayrollStatutorySettingsController", "AuditLogsController",
})
{
var type = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == name);
Assert.True(NavPolicyNames(type).Any(), $"{name} has no class-level Nav permission.");
}
}
[Fact]
public void PayData_NeedsThePayrollPermission_OnTopOfEmployees()
{
var employees = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "EmployeesController");
foreach (var action in new[] { "ListBankDetails", "GetSalaryStructureHistory", "ListLoans", "GetLoan", "CreateLoan", "CreateSalaryStructure", "ReplaceBankDetails" })
{
var method = employees.GetMethod(action)!;
Assert.Contains(NavPolicyNames(method), p => p.StartsWith(NavPolicies.Prefix + "hrm.payroll", StringComparison.Ordinal));
}
var reports = typeof(ApiControllerBase).Assembly.GetTypes().Single(t => t.Name == "HrReportsController");
foreach (var action in new[] { "PayrollRegister", "SalaryHistory" })
Assert.Contains(NavPolicyNames(reports.GetMethod(action)!), p => p == NavPolicies.Prefix + "hrm.payroll");
}
[Fact]
public void EveryNavCodeTheControllersName_ExistsInTheSeededCatalogue()
{
// The old Nav:vendors policy named a code that no longer existed, so only administrators got through.
using var db = CreateContext();
var seeded = Seed<Permission>(db)
.Select(d => ((string)d[nameof(Permission.Code)]!).Substring("NAV:".Length))
.ToHashSet(StringComparer.Ordinal);
var missing = new SortedSet<string>();
foreach (var controller in ApiControllers())
{
var names = NavPolicyNames(controller).Concat(
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).SelectMany(NavPolicyNames));
foreach (var name in names)
{
var rest = name[NavPolicies.Prefix.Length..];
var sep = rest.LastIndexOf(NavPolicies.ActionSeparator, StringComparison.Ordinal);
var code = sep >= 0 && Enum.TryParse<RolePermissionAction>(rest[(sep + 1)..], out _) ? rest[..sep] : rest;
// A module code is satisfied by the module itself or any screen inside it (HasAccessAsync does the same).
if (!seeded.Contains(code) && !seeded.Any(s => s.StartsWith(code + ".", StringComparison.Ordinal))) missing.Add($"{controller.Name}: {code}");
}
}
Assert.True(missing.Count == 0, "Controllers name permissions that are not seeded: " + string.Join("; ", missing));
}
[Fact]
public void SeededCatalogue_HasNoDuplicateCodes_AndEverySubItemHasAPermission()
{
using var db = CreateContext();
var permissions = Seed<Permission>(db).ToList();
var codes = permissions.Select(p => (string)p[nameof(Permission.Code)]!).ToList();
Assert.Equal(codes.Count, codes.Distinct().Count());
var subItems = Seed<SubNavItem>(db);
var granted = permissions.Where(p => p[nameof(Permission.SubNavItemId)] is not null).Select(p => (int)p[nameof(Permission.SubNavItemId)]!).ToHashSet();
foreach (var sub in subItems)
Assert.True(granted.Contains((int)sub[nameof(SubNavItem.SubNavItemId)]!), $"No permission row for sub item {sub[nameof(SubNavItem.Code)]}.");
}
[Theory]
[InlineData("hrm")]
[InlineData("hrm.employees")]
[InlineData("hrm.payroll")]
[InlineData("sales-returns")]
[InlineData("settings.audit-logs")]
[InlineData("dashboard.stock")]
[InlineData("dashboard.procurement")]
[InlineData("dashboard.approvals")]
[InlineData("dashboard.sales")]
[InlineData("dashboard.accounts")]
[InlineData("dashboard.hrm")]
[InlineData("dashboard.production")]
[InlineData("dashboard.service")]
public void NewPermissions_AreSeeded(string code)
{
using var db = CreateContext();
var codes = Seed<Permission>(db).Select(d => (string)d[nameof(Permission.Code)]!);
Assert.Contains("NAV:" + code, codes);
}
// ---------------------------------------------------------------- the policies actually decide
private sealed class FakeUser(int? roleId, bool isAdmin = false, int? branchId = null, bool branching = true) : ICurrentUser
{
public int AuditUserId => 10;
public int? RoleId => roleId;
public string? RoleCode => null;
public bool IsAdmin => isAdmin;
public string UserId => "10";
public bool IsAuthenticated => true;
public int? BranchId => branchId;
public bool BranchingEnabled => branching;
}
private static async Task<(ErpDbContext Db, NavPermissionService Nav)> RolesAsync()
{
var db = CreateContext();
db.Roles.AddRange(
new Role { RoleId = 1, Code = "Hr", Name = "HR officer", CreatedAt = DateTime.UtcNow },
new Role { RoleId = 2, Code = "Clerk", Name = "Clerk", CreatedAt = DateTime.UtcNow });
db.NavItems.Add(new NavItem { NavItemId = 100, Code = "hrm", Label = "HR" });
db.SubNavItems.AddRange(
new SubNavItem { SubNavItemId = 101, NavItemId = 100, Code = "hrm.employees", Label = "Employees" },
new SubNavItem { SubNavItemId = 102, NavItemId = 100, Code = "hrm.payroll", Label = "Payroll" });
db.Permissions.AddRange(
new Permission { PermissionId = 200, Code = "NAV:hrm.employees", SubNavItemId = 101 },
new Permission { PermissionId = 201, Code = "NAV:hrm.payroll", SubNavItemId = 102 });
db.RolePermissions.Add(new RolePermission { RoleId = 1, PermissionId = 200, Actions = RolePermissionAction.View | RolePermissionAction.Edit });
await db.SaveChangesAsync();
return (db, new NavPermissionService(new Repository<Role>(db), new Repository<RolePermission>(db), new MemoryCache(new MemoryCacheOptions())));
}
private static async Task<bool> PassesAsync(NavPermissionService nav, ICurrentUser user, string policyName)
{
var provider = new NavPolicyProvider(Microsoft.Extensions.Options.Options.Create(new AuthorizationOptions()));
var policy = (await provider.GetPolicyAsync(policyName))!;
var requirement = policy.Requirements.OfType<NavRequirement>().Single();
var context = new AuthorizationHandlerContext(new[] { requirement }, new ClaimsPrincipal(new ClaimsIdentity("test")), null);
await new NavAuthorizationHandler(user, nav).HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task HrOfficer_ReachesEmployees_ButNotPayData_AndAnAdministratorReachesAll()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
var hr = new FakeUser(roleId: 1);
Assert.True(await PassesAsync(nav, hr, "Nav:hrm")); // the module, via a screen inside it
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees"));
Assert.True(await PassesAsync(nav, hr, "Nav:hrm.employees:Edit"));
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.employees:Delete")); // not granted
Assert.False(await PassesAsync(nav, hr, "Nav:hrm.payroll")); // salaries, loans, bank accounts
Assert.False(await PassesAsync(nav, hr, "Nav:settings.audit-logs"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: 2), "Nav:hrm"));
Assert.False(await PassesAsync(nav, new FakeUser(roleId: null), "Nav:hrm"));
Assert.True(await PassesAsync(nav, new FakeUser(roleId: 2, isAdmin: true), "Nav:hrm.payroll:Approve"));
}
// ---------------------------------------------------------------- global search follows the role
[Fact]
public async Task Search_OnlyReturnsKindsTheRoleMayOpen()
{
var (db, nav) = await RolesAsync();
await using var _ = db;
db.Employees.Add(new Employee { EmployeeId = 1, EmployeeCode = "E-1", FullName = "Alice Perera", BranchId = Branch1 });
await db.SaveChangesAsync();
GlobalSearchService Search(ICurrentUser u) => new(
new Repository<Item>(db), new Repository<PurchaseOrder>(db), new Repository<SalesSlip>(db), new Repository<Employee>(db), u, nav);
var hr = await Search(new FakeUser(roleId: 1)).SearchAsync("Alice");
Assert.Contains(hr, r => r.Type == GlobalSearchResultType.Employee);
var clerk = await Search(new FakeUser(roleId: 2)).SearchAsync("Alice");
Assert.DoesNotContain(clerk, r => r.Type == GlobalSearchResultType.Employee);
var admin = await Search(new FakeUser(roleId: 2, isAdmin: true)).SearchAsync("Alice");
Assert.Contains(admin, r => r.Type == GlobalSearchResultType.Employee);
}
// ---------------------------------------------------------------- CSRF on every cookie-authenticated mutation
private static (CsrfProtectionFilter Filter, ActionExecutingContext Context) CsrfCall(
string method, string? cookie = "token", string? header = null, bool sessionCookie = true,
bool bearer = false, bool anonymousEndpoint = false, bool hasCsrfAttribute = false)
{
var http = new DefaultHttpContext();
http.Request.Method = method;
var cookies = new List<string>();
if (sessionCookie) cookies.Add("erp_at=jwt");
if (cookie is not null) cookies.Add($"XSRF-TOKEN={cookie}");
if (cookies.Count > 0) http.Request.Headers.Cookie = string.Join("; ", cookies);
if (header is not null) http.Request.Headers["X-XSRF-TOKEN"] = header;
if (bearer) http.Request.Headers.Authorization = "Bearer abc";
var metadata = new List<object>();
if (anonymousEndpoint) metadata.Add(new AllowAnonymousAttribute());
if (hasCsrfAttribute) metadata.Add(new ValidateCsrfAttribute());
http.SetEndpoint(new Endpoint(null, new EndpointMetadataCollection(metadata), "test"));
var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor());
var context = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object?>(), new object());
return (new CsrfProtectionFilter(), context);
}
private static async Task<bool> RunAsync(CsrfProtectionFilter filter, ActionExecutingContext context)
{
var ran = false;
await filter.OnActionExecutionAsync(context, () =>
{
ran = true;
return Task.FromResult(new ActionExecutedContext(context, context.Filters, context.Controller));
});
return ran;
}
[Theory]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("PATCH")]
[InlineData("DELETE")]
public async Task CookieRequest_WithoutTheCsrfHeader_IsRefused(string method)
{
var (filter, context) = CsrfCall(method, cookie: "token", header: null);
var ex = await Assert.ThrowsAsync<DomainException>(() => RunAsync(filter, context));
Assert.Equal(ErrorCodes.CsrfTokenMismatch, ex.Code);
Assert.Equal(403, ex.StatusCode);
}
[Fact]
public async Task CookieRequest_WithAWrongOrMissingCsrfCookie_IsRefused()
{
var (f1, c1) = CsrfCall("POST", cookie: "token", header: "other");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, header: "token");
await Assert.ThrowsAsync<DomainException>(() => RunAsync(f2, c2));
}
[Fact]
public async Task CookieRequest_WithMatchingCsrfHeader_Runs()
{
var (filter, context) = CsrfCall("DELETE", cookie: "token", header: "token");
Assert.True(await RunAsync(filter, context));
}
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
[InlineData("OPTIONS")]
public async Task SafeMethods_AreNotChecked(string method)
{
var (filter, context) = CsrfCall(method, cookie: null);
Assert.True(await RunAsync(filter, context));
}
[Fact]
public async Task BearerCallers_AnonymousEndpoints_AndRequestsWithoutASessionCookie_AreNotChecked()
{
var (f1, c1) = CsrfCall("POST", cookie: null, bearer: true);
Assert.True(await RunAsync(f1, c1));
var (f2, c2) = CsrfCall("POST", cookie: null, anonymousEndpoint: true);
Assert.True(await RunAsync(f2, c2));
var (f3, c3) = CsrfCall("POST", cookie: null, sessionCookie: false);
Assert.True(await RunAsync(f3, c3));
}
[Fact]
public async Task ActionsThatCarryTheirOwnCsrfAttribute_AreNotCheckedTwice()
{
var (filter, context) = CsrfCall("POST", cookie: null, hasCsrfAttribute: true);
Assert.True(await RunAsync(filter, context)); // the attribute itself enforces it
}
[Fact]
public void TheCsrfFilter_IsRegisteredGlobally()
{
var program = File.ReadAllText(Path.Combine(FindBackendRoot(), "ERPCore", "Program.cs"));
Assert.Contains("Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>()", program);
}
private static string FindBackendRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "ERPCore"))) dir = dir.Parent;
return dir?.FullName ?? throw new DirectoryNotFoundException("Backend root not found.");
}
// ---------------------------------------------------------------- a branch-less person can't read every branch
[Fact]
public void BranchContext_AdministratorsAreNeverScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, isAdmin: true, branchId: 5));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public void BranchContext_AnEmployeeIsScopedToTheirOwnBranch()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: 5));
Assert.True(ctx.IsActive);
Assert.Equal(5, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_InABranchingDeployment_SeesNoBranchAtAll()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: true));
Assert.True(ctx.IsActive);
Assert.Equal(CurrentBranchContext.NoBranchId, ctx.BranchId);
}
[Fact]
public void BranchContext_APersonWithNoBranch_WhenBranchingIsOff_IsNotScoped()
{
var ctx = new CurrentBranchContext(new FakeUser(roleId: 1, branchId: null, branching: false));
Assert.False(ctx.IsActive);
Assert.Null(ctx.BranchId);
}
[Fact]
public async Task QueryFilters_HideEveryWarehouse_FromABranchlessNonAdmin_ButNotFromAnAdminOrWhenBranchingIsOff()
{
var options = new DbContextOptionsBuilder<ErpDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N")).Options;
async Task<int> CountFor(ICurrentUser user, bool seed)
{
await using var db = new ErpDbContext(options, user, new CurrentBranchContext(user));
if (seed)
{
db.Warehouses.AddRange(
new Warehouse { WarehouseId = 1, Code = "W1", Name = "One", BranchId = 1 },
new Warehouse { WarehouseId = 2, Code = "W2", Name = "Two", BranchId = 2 });
await db.SaveChangesAsync();
}
return db.Warehouses.Count();
}
Assert.Equal(2, await CountFor(new FakeUser(1, isAdmin: true), seed: true));
Assert.Equal(1, await CountFor(new FakeUser(1, branchId: 2), seed: false));
Assert.Equal(0, await CountFor(new FakeUser(1, branchId: null, branching: true), seed: false));
Assert.Equal(2, await CountFor(new FakeUser(1, branchId: null, branching: false), seed: false));
}
}
@@ -0,0 +1,837 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using ERPCore.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>
/// Procurement configuration (vendor contact / address, PO tax) and the one-time opening balances.
/// Every rule here is also enforced by the UI, so these tests are about the server refusing what a stale
/// or hand-built request sends.
/// </summary>
public class ProcurementAndOpeningBalanceTests
{
// ---------------------------------------------------------------- helpers
private static async Task SeedProcurementConfigAsync(
ErpDbContext db, VendorContactMode mode = VendorContactMode.None, bool address = false, bool tax = false,
bool terms = true, bool taxReg = true)
{
db.ProcurementConfig.Add(new ProcurementConfig
{
ConfigId = ProcurementConfig.SingletonId,
VendorContactMode = mode,
VendorAddressEnabled = address,
PoTaxEnabled = tax,
VendorPaymentTermsEnabled = terms,
VendorTaxRegEnabled = taxReg
});
await db.SaveChangesAsync();
}
private static ProcurementConfigService ConfigService(ErpDbContext db) =>
new(new ProcurementConfigRepository(db), new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static VendorService VendorSvc(ErpDbContext db) =>
new(new Repository<Vendor>(db), new FakeBranchConfig(), ConfigService(db), new PassThroughUnitOfWork(db));
private static CreateVendorRequest NewVendor(string code, Action<CreateVendorRequest>? tweak = null)
{
var request = new CreateVendorRequest { Code = code, Name = $"Vendor {code}", Currency = "LKR", BranchId = Branch1 };
tweak?.Invoke(request);
return request;
}
private sealed class NullNotifications : INotificationService
{
public Task RaiseAsync(string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? relatedEntityType = null, int? relatedEntityId = null, CancellationToken ct = default) => Task.CompletedTask;
public Task<NotificationFeedDto> GetFeedAsync(int userId, int take = 20, CancellationToken ct = default) => throw new NotSupportedException();
public Task MarkAllReadAsync(int userId, CancellationToken ct = default) => Task.CompletedTask;
}
// ---------------------------------------------------------------- vendors
[Fact]
public async Task Vendor_Mobile_IsRejected_WhileContactModeIsNone()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.None);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V1", r => r.Mobile = "0771234567")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.Vendors);
}
[Fact]
public async Task Vendor_Mobile_IsSaved_WhenContactModeIsMobile_AndEmailIsStillRefused()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V2", r => r.Mobile = " +94 77 123 4567 "))).Value;
Assert.Equal("+94 77 123 4567", created.Mobile);
Assert.Null(created.Email);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V3", r => r.Email = "a@b.lk")));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
}
[Theory]
[InlineData("abc")]
[InlineData("12")]
public async Task Vendor_InvalidMobile_IsRejected(string mobile)
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Mobile);
var ex = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V4", r => r.Mobile = mobile)));
Assert.Equal(ErrorCodes.Validation, ex.Code);
}
[Fact]
public async Task Vendor_Address_NeedsTheSwitchOn()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, address: false);
var off = await Assert.ThrowsAsync<DomainException>(
() => VendorSvc(db).CreateAsync(NewVendor("V5", r => r.Address = "12 Galle Road")));
Assert.Equal(ErrorCodes.ConfigDisabled, off.Code);
db.ProcurementConfig.Single().VendorAddressEnabled = true;
await db.SaveChangesAsync();
var created = (await VendorSvc(db).CreateAsync(NewVendor("V6", r => r.Address = "12 Galle Road"))).Value;
Assert.Equal("12 Galle Road", created.Address);
}
[Fact]
public async Task Vendor_Update_KeepsStoredContact_WhenTheSwitchIsOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: true);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V7", r =>
{
r.Email = "accounts@supplier.lk";
r.Address = "Colombo";
}))).Value;
// Contact mode switched off later: an update must not erase what was recorded.
db.ProcurementConfig.Single().VendorContactMode = VendorContactMode.None;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V7", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal("accounts@supplier.lk", db.Vendors.Single().Email);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreOnByDefault_AndSaved()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db); // defaults: terms + tax registration on
var created = (await VendorSvc(db).CreateAsync(NewVendor("V8", r =>
{
r.Terms = " NET30 ";
r.TaxReg = "134567890-7000";
}))).Value;
Assert.Equal("NET30", created.Terms);
Assert.Equal("134567890-7000", created.TaxReg);
}
[Fact]
public async Task Vendor_TermsAndTaxReg_AreRejectedWhenTheirSwitchesAreOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, terms: false, taxReg: false);
var terms = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V9", r => r.Terms = "NET30")));
var taxReg = await Assert.ThrowsAsync<DomainException>(() => VendorSvc(db).CreateAsync(NewVendor("V10", r => r.TaxReg = "123")));
Assert.Equal(ErrorCodes.ConfigDisabled, terms.Code);
Assert.Equal(ErrorCodes.ConfigDisabled, taxReg.Code);
Assert.Empty(db.Vendors);
// Blank is fine: the form simply doesn't send them.
var ok = (await VendorSvc(db).CreateAsync(NewVendor("V11"))).Value;
Assert.Null(ok.Terms);
}
[Fact]
public async Task Vendor_Update_KeepsStoredTermsAndTaxReg_WhenSwitchedOff()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
var created = (await VendorSvc(db).CreateAsync(NewVendor("V12", r =>
{
r.Terms = "NET45";
r.TaxReg = "TRN-1";
}))).Value;
var cfg = db.ProcurementConfig.Single();
cfg.VendorPaymentTermsEnabled = false;
cfg.VendorTaxRegEnabled = false;
await db.SaveChangesAsync();
var tracked = db.Vendors.Single(v => v.VendorId == created.VendorId);
var updated = (await VendorSvc(db).UpdateAsync(
created.VendorId,
new UpdateVendorRequest { Code = "V12", Name = "Renamed", Currency = "LKR", BranchId = Branch1 },
tracked.RowVersion)).Value;
Assert.Equal("Renamed", updated.Name);
Assert.Equal(("NET45", "TRN-1"), (db.Vendors.Single().Terms, db.Vendors.Single().TaxReg));
}
// ---------------------------------------------------------------- purchase-order tax
private static PurchaseOrderService PoSvc(ErpDbContext db) =>
new(new PurchaseOrderRepository(db),
new FakeNumberSequence(), new TestCurrentUser(), new NullNotifications(), new FakeBranchConfig(),
ConfigService(db), new PassThroughUnitOfWork(db));
private static async Task<int> SeedVendorAndItemAsync(ErpDbContext db)
{
db.Vendors.Add(new Vendor { VendorId = 1, Code = "V-PO", Name = "PO Vendor", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola 500ml", CategoryId = 1, BaseUomId = 1 });
await db.SaveChangesAsync();
return 1;
}
private static CreatePurchaseOrderRequest PoRequest(decimal taxRate) => new()
{
VendorId = 1,
BranchId = Branch1,
SaveAsDraft = true,
Lines = [new CreatePoLineInput { ItemId = 1, WarehouseId = Warehouse1, Qty = 10, UnitPrice = 100m, Tax = taxRate }]
};
[Fact]
public async Task Po_TaxOn_StoresTheLineTaxAmount_AndTotalsIncludeIt()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
var po = (await PoSvc(db).CreateAsync(PoRequest(0.18m))).Value;
var line = Assert.Single(po.Lines);
Assert.Equal(180m, line.TaxAmount);
Assert.Equal((1000m, 180m, 1180m), (po.Totals.SubTotal, po.Totals.Tax, po.Totals.GrandTotal));
Assert.Equal(180m, db.PoLines.Single().TaxAmount);
}
[Fact]
public async Task Po_TaxAmount_RoundsHalfUp_ToTwoDecimals()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: true);
await SeedVendorAndItemAsync(db);
// 3 x 33.5 = 100.5; at 5% the tax is 5.025 -> 5.03 (half away from zero, not banker's rounding).
var request = PoRequest(0.05m);
request.Lines[0].Qty = 3;
request.Lines[0].UnitPrice = 33.5m;
var po = (await PoSvc(db).CreateAsync(request)).Value;
Assert.Equal(5.03m, Assert.Single(po.Lines).TaxAmount);
}
[Fact]
public async Task Po_TaxOff_RejectsANonZeroRate_ButAcceptsZero()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, tax: false);
await SeedVendorAndItemAsync(db);
var ex = await Assert.ThrowsAsync<DomainException>(() => PoSvc(db).CreateAsync(PoRequest(0.18m)));
Assert.Equal(ErrorCodes.ConfigDisabled, ex.Code);
Assert.Empty(db.PurchaseOrders);
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.Equal(0m, po.Totals.Tax);
}
[Fact]
public async Task Po_CarriesVendorDetails_OnlyForTheEnabledFields()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db, VendorContactMode.Email, address: false);
await SeedVendorAndItemAsync(db);
var vendor = db.Vendors.Single();
vendor.Mobile = "0771234567";
vendor.Email = "po@vendor.lk";
vendor.Address = "Colombo";
await db.SaveChangesAsync();
var po = (await PoSvc(db).CreateAsync(PoRequest(0m))).Value;
Assert.NotNull(po.Vendor);
Assert.Equal("PO Vendor", po.Vendor!.Name);
Assert.Equal("po@vendor.lk", po.Vendor.Email);
Assert.Null(po.Vendor.Mobile); // contact mode is Email
Assert.Null(po.Vendor.Address); // address switched off
}
// ---------------------------------------------------------------- purchase-order list: date filter + sort
/// <summary>Three POs from two vendors, created on known business days; returns their ids in creation order.</summary>
private static async Task<int[]> SeedListAsync(ErpDbContext db)
{
db.Vendors.AddRange(
new Vendor { VendorId = 1, Code = "V-B", Name = "Beta Traders", BranchId = Branch1, Status = EntityStatus.Active },
new Vendor { VendorId = 2, Code = "V-A", Name = "Alpha Foods", BranchId = Branch1, Status = EntityStatus.Active });
db.Items.Add(new Item { ItemId = 1, Sku = "SKU-1", Name = "Cola", CategoryId = 1, BaseUomId = 1 });
PurchaseOrder Po(int id, int vendor, DateTime createdUtc, decimal qty) => new()
{
PoId = id, DocNo = $"PO-{id}", VendorId = vendor, BranchId = Branch1, Status = PurchaseOrderStatus.Draft,
CreatedAt = createdUtc,
Lines = [new PoLine { ItemId = 1, WarehouseId = Warehouse1, Qty = qty, UnitPrice = 10m }]
};
db.PurchaseOrders.AddRange(
Po(1, 1, new DateTime(2026, 4, 1, 4, 0, 0, DateTimeKind.Utc), 5), // 1 Apr (Colombo 09:30), total 50
Po(2, 2, new DateTime(2026, 4, 1, 20, 0, 0, DateTimeKind.Utc), 30), // 2 Apr in Colombo (01:30), total 300
Po(3, 1, new DateTime(2026, 4, 10, 6, 0, 0, DateTimeKind.Utc), 10)); // 10 Apr, total 100
await db.SaveChangesAsync();
return [1, 2, 3];
}
private static async Task<List<int>> ListIds(PurchaseOrderService svc, string? sort = null, DateOnly? from = null, DateOnly? to = null)
{
var page = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Sort = sort, PageSize = 50 }, null, null, from, to);
return page.Items.Select(i => i.PoId).ToList();
}
[Fact]
public async Task PoList_DateRange_UsesTheBusinessDayOfTheCreatedTime_AndIsInclusive()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
// PO 2 was created 20:00 UTC on 1 Apr = 2 Apr in Colombo, so a "1 Apr only" filter must not include it.
Assert.Equal([1], await ListIds(svc, from: new DateOnly(2026, 4, 1), to: new DateOnly(2026, 4, 1)));
Assert.Equal([2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 2)));
Assert.Equal([3, 2], await ListIds(svc, from: new DateOnly(2026, 4, 2), to: new DateOnly(2026, 4, 10)));
Assert.Equal([3, 2, 1], await ListIds(svc, to: new DateOnly(2026, 4, 30)));
Assert.Equal([3], await ListIds(svc, from: new DateOnly(2026, 4, 3)));
}
[Fact]
public async Task PoList_Sorts_ByCreatedVendorAndTotal_BothWays()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
Assert.Equal([3, 2, 1], await ListIds(svc)); // default: newest first
Assert.Equal([1, 2, 3], await ListIds(svc, "created")); // oldest first
Assert.Equal([2, 3, 1], await ListIds(svc, "vendor")); // Alpha, then Beta (ties: newest first)
Assert.Equal([3, 1, 2], await ListIds(svc, "-vendor"));
Assert.Equal([2, 3, 1], await ListIds(svc, "-total")); // 300, 100, 50
Assert.Equal([1, 3, 2], await ListIds(svc, "total"));
Assert.Equal([3, 2, 1], await ListIds(svc, "nonsense")); // unknown key falls back to the default
}
[Fact]
public async Task PoList_Search_MatchesVendorNameAndCode()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedListAsync(db);
var svc = PoSvc(db);
var byName = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "alpha", PageSize = 50 }, null, null);
var byCode = await svc.ListAsync(new ERPCore.Dtos.Common.PageQuery { Q = "V-B", PageSize = 50 }, null, null);
Assert.Equal([2], byName.Items.Select(i => i.PoId));
Assert.Equal([3, 1], byCode.Items.Select(i => i.PoId));
}
// ---------------------------------------------------------------- opening balances
/// <summary>A GL that knows two LKR cash/bank accounts, optionally the equity account, and can create it.</summary>
private sealed class OpeningGl : IGeneralLedgerService
{
public List<GlJournalEntryRequest> Posted { get; } = new();
public int AccountCreates { get; private set; }
public bool EquityExists { get; init; } = true;
public string BankCurrency { get; init; } = "LKR";
public Task<GeneralLedgerResponse> ForwardAsync(HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
AccountCreates++;
return Task.FromResult(new GeneralLedgerResponse
{
StatusCode = 201,
Body = """{"statusCode":201,"success":true,"message":"Account created successfully","data":{"accountCode":"3000"}}"""
});
}
public Task<GlJournalEntryResult> PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
{
Posted.Add(request);
return Task.FromResult(new GlJournalEntryResult($"JV-2026-{Posted.Count:000000}", true));
}
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlBankAccount>>(
[
new("Cash", 1, "Main Cash Account", null, "Petty Cash", "CASH-000001", 17, "1400-01-01", "LKR"),
new("Bank", 2, "BOC Current", "Bank of Ceylon", null, "0001234567", 18, "1000-01", BankCurrency)
]);
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<GlAccount>>(EquityExists
? [new("3000", "Opening Balance Equity", 3, true, true)]
: [new("1000", "Bank", 1, false, true)]);
}
private static OpeningBalanceService OpeningSvc(ErpDbContext db, OpeningGl gl) =>
new(new Repository<OpeningBalanceRun>(db), gl, new TestCurrentUser(), new PassThroughUnitOfWork(db));
private static PostCashBankOpeningRequest Opening(DateOnly date, params (string Code, decimal Amount)[] lines) => new()
{
CutoverDate = date,
Lines = lines.Select(l => new OpeningBalanceLineInput { GlAccountCode = l.Code, Amount = l.Amount }).ToList()
};
private static readonly DateOnly OpeningDate = new(2026, 4, 1);
[Fact]
public async Task Opening_PostsOneBalancedJournal_DebitingEachAccount_AndCreditingEquity()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 500m), ("1000-01", 1500m)));
var entry = Assert.Single(gl.Posted);
Assert.Equal("OPENING_BALANCE", entry.JournalType);
Assert.Equal("OPENING", entry.SourceModule);
Assert.Equal(OpeningDate, entry.EntryDate);
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Contains(entry.Lines, l => l is { AccountCode: "1400-01-01", DebitAmount: 500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 1500m, CreditAmount: 0m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 2000m });
Assert.Equal(2000m, run.TotalAmount);
Assert.Equal("JV-2026-000001", run.GlJournalNo);
Assert.Equal(2, run.Lines.Count);
Assert.Equal(0, gl.AccountCreates); // the equity account already existed
}
[Fact]
public async Task Opening_CreatesTheEquityAccount_WhenTheChartHasNone()
{
await using var db = CreateContext();
var gl = new OpeningGl { EquityExists = false };
await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
Assert.Equal(1, gl.AccountCreates);
Assert.Contains(Assert.Single(gl.Posted).Lines, l => l.AccountCode == "3000" && l.CreditAmount == 100m);
}
[Fact]
public async Task Opening_SecondPosting_IsRefused_AndPostsNothingMore()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 100m)));
var ex = await Assert.ThrowsAsync<DomainException>(
() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 999m))));
Assert.Equal(ErrorCodes.OpeningBalanceAlreadyPosted, ex.Code);
Assert.Equal(409, ex.StatusCode);
Assert.Single(gl.Posted);
Assert.Equal(100m, Assert.Single(db.OpeningBalanceRuns.Include(r => r.Lines)).Lines.Single().Amount);
}
[Fact]
public async Task Opening_Overdraft_IsCreditedToTheAccount_AndEquityTakesTheNet()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var run = await OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 300m), ("1000-01", -100m)));
var entry = Assert.Single(gl.Posted);
Assert.Contains(entry.Lines, l => l is { AccountCode: "1000-01", DebitAmount: 0m, CreditAmount: 100m });
Assert.Contains(entry.Lines, l => l is { AccountCode: "3000", DebitAmount: 0m, CreditAmount: 200m });
Assert.Equal(entry.Lines.Sum(l => l.DebitAmount), entry.Lines.Sum(l => l.CreditAmount));
Assert.Equal(200m, run.TotalAmount);
}
[Fact]
public async Task Opening_ShowsPostedAmounts_OnTheStateEndpoint()
{
await using var db = CreateContext();
var svc = OpeningSvc(db, new OpeningGl());
var before = await svc.GetCashBankAsync();
Assert.Null(before.Posted);
Assert.All(before.Accounts, a => Assert.Null(a.Amount));
await svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 250m)));
var after = await svc.GetCashBankAsync();
Assert.NotNull(after.Posted);
Assert.Equal(250m, after.Accounts.Single(a => a.GlAccountCode == "1400-01-01").Amount);
Assert.Null(after.Accounts.Single(a => a.GlAccountCode == "1000-01").Amount);
}
[Fact]
public async Task Opening_RejectsBadInput_WithoutPostingAnything()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var svc = OpeningSvc(db, gl);
// not a cash/bank account
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("9999", 10m))));
// more than 2 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 10.005m))));
// nothing but zeros
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 0m))));
// the same account twice
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(OpeningDate, ("1400-01-01", 5m), ("1400-01-01", 6m))));
// in the future
await Assert.ThrowsAsync<DomainException>(() => svc.PostCashBankAsync(Opening(DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30), ("1400-01-01", 5m))));
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
}
[Fact]
public async Task Opening_RefusesANonBaseCurrencyAccount()
{
await using var db = CreateContext();
var gl = new OpeningGl { BankCurrency = "USD" };
var ex = await Assert.ThrowsAsync<DomainException>(
() => OpeningSvc(db, gl).PostCashBankAsync(Opening(OpeningDate, ("1000-01", 100m))));
Assert.Equal(ErrorCodes.Validation, ex.Code);
Assert.Empty(gl.Posted);
}
// ---------------------------------------------------------------- opening stock
private static OpeningStockService StockSvc(ErpDbContext db, OpeningGl gl)
{
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
return new OpeningStockService(
new OpeningStockRepository(db), fifo, gl, glAccounts, new TestCurrentUser(), new PassThroughUnitOfWork(db));
}
private static Item StockedItem(int id, Action<Item>? tweak = null)
{
var item = new Item
{
ItemId = id, Sku = $"SKU-{id}", Name = $"Item {id}", CategoryId = 1, BaseUomId = 1,
StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None, Status = EntityStatus.Active
};
tweak?.Invoke(item);
return item;
}
private static PostOpeningStockRequest StockRequest(params (int Item, decimal Qty, decimal Cost)[] lines) => new()
{
CutoverDate = OpeningDate,
Lines = lines.Select(l => new OpeningStockLineInput { ItemId = l.Item, WarehouseId = Warehouse1, Qty = l.Qty, UnitCost = l.Cost }).ToList()
};
[Fact]
public async Task OpeningStock_CreatesLayerAndLedgerRow_AndPostsExactlyOneJournal()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 24m, 12.5m), (2, 10m, 3m)));
Assert.Equal(330m, run.TotalValue);
Assert.Equal(2, run.LineCount);
var layer = db.StockLayers.Single(l => l.ItemId == 1);
Assert.Equal((24m, 24m, 12.5m), (layer.QtyReceived, layer.QtyRemaining, layer.UnitCost));
var row = db.StockLedger.Single(l => l.ItemId == 1);
Assert.Equal(("OPN", Direction.In, 24m, 300m, 24m), (row.SourceDocType, row.Direction, row.QtyBase, row.Value, row.RunningBalance));
Assert.Equal(run.OpeningBalanceRunId, row.SourceDocId);
// One journal in total — the regular stock path would have added a STOCK journal (Dr Inventory / Cr Clearing) per row.
var entry = Assert.Single(gl.Posted);
Assert.Equal(("OPENING", "OPENING_BALANCE"), (entry.SourceModule, entry.JournalType));
Assert.Collection(entry.Lines,
l => Assert.Equal(("1100", 330m, 0m), (l.AccountCode, l.DebitAmount, l.CreditAmount)),
l => Assert.Equal(("3000", 0m, 330m), (l.AccountCode, l.DebitAmount, l.CreditAmount)));
Assert.Equal(2, db.JournalEntryStubs.Count(j => j.SourceDocType == "OPN"));
}
[Fact]
public async Task OpeningStock_RefusesAnItemThatAlreadyHasMovements_InThatWarehouse()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
db.StockLedger.Add(new StockLedger { ItemId = 1, WarehouseId = Warehouse1, Direction = Direction.In, QtyBase = 5, UnitCost = 1, Value = 5, RunningBalance = 5, SourceDocType = "GRN", SourceDocId = 1 });
await db.SaveChangesAsync();
var gl = new OpeningGl();
var ex = await Assert.ThrowsAsync<DomainException>(() => StockSvc(db, gl).PostAsync(StockRequest((1, 3m, 2m), (2, 3m, 2m))));
Assert.Equal(409, ex.StatusCode);
Assert.Contains("SKU-1", ex.Message);
Assert.Empty(gl.Posted);
Assert.Empty(db.OpeningBalanceRuns);
Assert.Empty(db.StockLayers); // nothing at all was written, not even for the clean item
}
[Fact]
public async Task OpeningStock_CanBeLoadedInSeveralRuns_ForDifferentItems()
{
await using var db = CreateContext();
db.Items.AddRange(StockedItem(1), StockedItem(2));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await svc.PostAsync(StockRequest((1, 5m, 2m)));
await svc.PostAsync(StockRequest((2, 7m, 4m)));
Assert.Equal(2, gl.Posted.Count);
Assert.Equal(2, (await svc.ListAsync()).Count);
// ...but the same item cannot be opened a second time.
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m))));
}
[Fact]
public async Task OpeningStock_RefusesUnsuitableItemsAndBadNumbers()
{
await using var db = CreateContext();
db.Items.AddRange(
StockedItem(1, i => i.TrackingMode = TrackingMode.Serial),
StockedItem(2, i => i.TrackingMode = TrackingMode.Batch),
StockedItem(3, i => i.StockNature = StockNature.Service),
StockedItem(4, i => i.Status = EntityStatus.Inactive),
StockedItem(5));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var svc = StockSvc(db, gl);
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((1, 1m, 1m)))); // serial
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((2, 1m, 1m)))); // batch
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((3, 1m, 1m)))); // service
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((4, 1m, 1m)))); // inactive
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((99, 1m, 1m)))); // unknown
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 0m, 1m)))); // zero qty
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, -1m)))); // negative cost
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1.00001m, 1m)))); // > 4 decimals
await Assert.ThrowsAsync<DomainException>(() => svc.PostAsync(StockRequest((5, 1m, 1m), (5, 2m, 1m)))); // duplicate
Assert.Empty(gl.Posted);
Assert.Empty(db.StockLayers);
}
[Fact]
public async Task OpeningStock_ZeroCostRun_PostsNoJournal()
{
await using var db = CreateContext();
db.Items.Add(StockedItem(1));
await db.SaveChangesAsync();
var gl = new OpeningGl();
var run = await StockSvc(db, gl).PostAsync(StockRequest((1, 10m, 0m)));
Assert.Equal(0m, run.TotalValue);
Assert.Null(run.GlJournalNo);
Assert.Empty(gl.Posted);
Assert.Equal(10m, db.StockLayers.Single().QtyRemaining);
}
// ---------------------------------------------------------------- purchase-order rejection
[Fact]
public async Task Po_Reject_ShowsRejected_NotDraftOrPending_AndCanBeRevisedAndResubmitted()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
// Somebody else raised it: the approver can't be its creator.
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
var rejected = await svc.RejectAsync(po.PoId, "Wrong price");
Assert.Equal(PurchaseOrderStatus.Rejected, rejected.Status);
// A rejected order is reopened like a draft: it can be sent for approval again...
var resubmitted = await svc.SubmitAsync(po.PoId);
Assert.Equal(PurchaseOrderStatus.PendingApproval, resubmitted.Status);
}
[Fact]
public async Task Po_Rejected_CanBeDeleted_ButAnApprovedOneCannot()
{
await using var db = CreateContext();
await SeedProcurementConfigAsync(db);
await SeedVendorAndItemAsync(db);
var svc = PoSvc(db);
var po = (await svc.CreateAsync(PoRequest(0m))).Value;
await svc.SubmitAsync(po.PoId);
db.PurchaseOrders.Single().CreatedBy = 999;
await db.SaveChangesAsync();
await svc.RejectAsync(po.PoId, null);
await svc.DeleteAsync(po.PoId);
Assert.Empty(db.PurchaseOrders);
}
// ---------------------------------------------------------------- General Ledger payload
private sealed class CapturingGlClient(int status, string body) : IGeneralLedgerClient
{
public string? SentBody { get; private set; }
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? requestBody, CancellationToken ct)
{
if (requestBody is not null)
{
using var reader = new StreamReader(requestBody);
SentBody = await reader.ReadToEndAsync(ct);
}
return new GeneralLedgerResponse { StatusCode = status, ContentType = "application/json", Body = body };
}
}
private static GlJournalEntryRequest GrnJournal() => new()
{
PeriodId = 1,
EntryDate = new DateOnly(2026, 10, 18),
SourceModule = "GRN",
Reference = "GRN-1",
Lines = [new GlJournalEntryLineRequest("1100", 10m, 0m, "x"), new GlJournalEntryLineRequest("2100", 0m, 10m)]
};
[Fact]
public async Task GlPost_NeverSendsNulls_BecauseGlTreatsANullJournalTypeAsMissing()
{
// GL's request DTO has a non-nullable JournalType; an explicit "journalType": null is a 400 from its model
// validation. Only opening balances set one, so every other posting (GRN, sales, ...) must leave it out.
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var result = await new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None);
Assert.Equal("J-1", result.JournalNo);
Assert.DoesNotContain("journalType", client.SentBody!);
Assert.DoesNotContain("null", client.SentBody!);
}
[Fact]
public async Task GlPost_KeepsAJournalTypeWhenOneIsSet()
{
var client = new CapturingGlClient(201, "{\"statusCode\":201,\"success\":true,\"message\":\"ok\",\"data\":{\"journalNo\":\"J-1\",\"isPosted\":true}}");
var request = GrnJournal();
request.JournalType = "OPENING_BALANCE";
await new GeneralLedgerService(client).PostJournalEntryAsync(request, CancellationToken.None);
Assert.Contains("\"journalType\":\"OPENING_BALANCE\"", client.SentBody!);
}
[Fact]
public async Task GlPost_AValidationFailureFromGl_IsReportedWithItsReason_NotAsAGenericRejection()
{
var problem = "{\"title\":\"One or more validation errors occurred.\",\"status\":400,\"errors\":{\"JournalType\":[\"The JournalType field is required.\"]}}";
var client = new CapturingGlClient(400, problem);
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal(ErrorCodes.GlRequestFailed, ex.Code);
Assert.Contains("JournalType", ex.Message);
Assert.Contains("required", ex.Message);
}
[Fact]
public async Task GlPost_GlsOwnMessage_StillWins()
{
var client = new CapturingGlClient(400, "{\"StatusCode\":400,\"Success\":false,\"Message\":\"Period is locked\",\"Data\":null}");
var ex = await Assert.ThrowsAsync<DomainException>(
() => new GeneralLedgerService(client).PostJournalEntryAsync(GrnJournal(), CancellationToken.None));
Assert.Equal("Period is locked", ex.Message);
}
// ---------------------------------------------------------------- stock movement vs. document journal
[Fact]
public async Task StockLedgerPost_AddsTheGenericStockJournal_OnlyWhenTheDocumentHasNoJournalOfItsOwn()
{
await using var db = CreateContext();
var gl = new OpeningGl();
var glAccounts = new StubGlAccountConfigService((GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
var fifo = new FifoCostingService(
new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<JournalEntryStub>(db), db, gl, glAccounts);
var now = DateTime.UtcNow;
// A GRN / sales invoice / adjustment posts its own balanced journal: the ledger row must not add a second one,
// or the movement is counted twice in the General Ledger.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 100m, 200m, 100m, "GRN", 1, now,
CancellationToken.None, postGlJournal: false);
await db.SaveChangesAsync();
Assert.Empty(gl.Posted);
Assert.Single(db.StockLedger);
// Production and transfers have no document journal, so they keep the generic one.
await fifo.PostLedgerAsync(1, Warehouse1, null, null, null, 1, Direction.In, 5m, 10m, 105m, "TRF", 1, now,
CancellationToken.None);
var journal = Assert.Single(gl.Posted);
Assert.Equal("STOCK", journal.SourceModule);
}
}
+97
View File
@@ -0,0 +1,97 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories;
using ERPCore.Services.Stock;
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
namespace ERPCore.Tests;
/// <summary>The Stock Ledger screen: item name/SKU/category/brand on every row, category and brand filters, and a running
/// balance that is the real balance after each movement (not whatever a posting stored).</summary>
public class StockLedgerTests
{
private static StockService Svc(ErpDbContext db) =>
new(null!, new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<StockTransferLine>(db),
new Repository<Warehouse>(db), new Repository<Item>(db));
private static StockLedger Row(int id, int item, int warehouse, Direction dir, decimal qty, decimal unitCost, decimal storedBalance = 0m) => new()
{
LedgerId = id, ItemId = item, WarehouseId = warehouse, Direction = dir, QtyBase = qty, UnitCost = unitCost,
Value = qty * unitCost, RunningBalance = storedBalance, SourceDocType = "T", SourceDocId = id, UserId = 1,
CreatedAt = new DateTime(2026, 10, 18, 4, 0, 0, DateTimeKind.Utc).AddMinutes(id)
};
private static async Task<ErpDbContext> SeedAsync()
{
var db = CreateContext();
db.Categories.AddRange(new Category { CategoryId = 1, Name = "Beverages" }, new Category { CategoryId = 2, Name = "Snacks" });
db.Brands.AddRange(new Brand { BrandId = 1, Name = "Milo" }, new Brand { BrandId = 2, Name = "Munchee" });
db.Items.AddRange(
new Item { ItemId = 1, Sku = "MILO-240", Name = "Milo 240ml", CategoryId = 1, BrandId = 1, BaseUomId = 1 },
new Item { ItemId = 2, Sku = "BISC-1", Name = "Biscuit", CategoryId = 2, BrandId = 2, BaseUomId = 1 });
db.StockLedger.AddRange(
Row(1, 1, Warehouse1, Direction.In, 100m, 200m, storedBalance: 100m),
Row(2, 1, Warehouse1, Direction.Out, 30m, 200m, storedBalance: 0m), // a sales issue: stored balance was never filled in
Row(3, 1, Warehouse1, Direction.In, 50m, 210m, storedBalance: 120m),
Row(4, 1, Warehouse2, Direction.In, 10m, 200m, storedBalance: 10m),
Row(5, 2, Warehouse1, Direction.In, 5m, 40m, storedBalance: 5m));
await db.SaveChangesAsync();
return db;
}
private static Task<PagedResponse<ERPCore.Dtos.Stock.StockLedgerRowDto>> Ledger(
ErpDbContext db, int page = 1, int pageSize = 50, int? itemId = null, int? warehouseId = null, int? categoryId = null, int? brandId = null) =>
Svc(db).GetLedgerAsync(itemId, warehouseId, null, null, null, null, null,
new PageQuery { Page = page, PageSize = pageSize }, CancellationToken.None, categoryId, brandId);
[Fact]
public async Task RunningBalance_IsTheRealBalanceAfterEachMovement_PerItemAndWarehouse()
{
await using var db = await SeedAsync();
var rows = (await Ledger(db)).Items.ToDictionary(r => r.LedgerId);
Assert.Equal(100m, rows[1].RunningBalance);
Assert.Equal(70m, rows[2].RunningBalance); // the stored 0 is ignored
Assert.Equal(120m, rows[3].RunningBalance);
Assert.Equal(10m, rows[4].RunningBalance); // another warehouse starts from its own zero
Assert.Equal(5m, rows[5].RunningBalance); // another item too
}
[Fact]
public async Task RunningBalance_IsStillRight_OnALaterPage_AndWithFiltersApplied()
{
await using var db = await SeedAsync();
// Newest first, 2 per page: page 2 holds ledger 3 and 2, whose balances depend on rows before and after it.
var page2 = (await Ledger(db, page: 2, pageSize: 2)).Items;
Assert.Equal(new long[] { 3, 2 }, page2.Select(r => (long)r.LedgerId));
Assert.Equal(new[] { 120m, 70m }, page2.Select(r => r.RunningBalance));
// Filtering to one warehouse and item changes which rows show, not what the balance is.
var only = (await Ledger(db, itemId: 1, warehouseId: Warehouse1)).Items.OrderBy(r => r.LedgerId).ToList();
Assert.Equal(new[] { 100m, 70m, 120m }, only.Select(r => r.RunningBalance));
}
[Fact]
public async Task EveryRow_CarriesTheItemsSkuNameCategoryAndBrand()
{
await using var db = await SeedAsync();
var row = (await Ledger(db, itemId: 1)).Items.First();
Assert.Equal(("MILO-240", "Milo 240ml", "Beverages", "Milo"), (row.ItemSku, row.ItemName, row.CategoryName, row.BrandName));
}
[Fact]
public async Task CategoryAndBrandFilters_NarrowTheLedgerToThoseItems()
{
await using var db = await SeedAsync();
Assert.All((await Ledger(db, categoryId: 2)).Items, r => Assert.Equal(2, r.ItemId));
Assert.Equal(4, (await Ledger(db, brandId: 1)).Items.Count);
Assert.Empty((await Ledger(db, categoryId: 2, brandId: 1)).Items);
}
}
@@ -45,6 +45,10 @@ internal sealed class FakeGeneralLedger : IGeneralLedgerService
public Task<GlPeriod> GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1));
public Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts);
/// <summary>Not implemented on purpose — nothing here picks a GL account by code; see
/// <see cref="StubGeneralLedgerService"/> for the fake that does.</summary>
public Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException();
}
/// <summary>The in-memory provider has no transactions: run the work, then save — what the real one commits.</summary>
@@ -141,9 +145,7 @@ internal static class BankDepositTestSupport
}
public static CashInHandService CashService(ErpDbContext db, DateOnly? trackingStart)
=> new(new Repository<SalesSlip>(db), new Repository<BundleSale>(db), new Repository<SalesInvoicePayment>(db),
new Repository<ExpensePayment>(db), new Repository<BankDeposit>(db),
new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
=> new(new CashInHandRepository(db), new CurrentBranchContext(new TestCurrentUser()), Clock, Config(trackingStart));
public static BankDepositService DepositService(ErpDbContext db, DateOnly? trackingStart, FakeGeneralLedger gl)
{
@@ -14,6 +14,7 @@ internal sealed class TestCurrentUser : ICurrentUser
// Unauthenticated, so no role claim and no branch scoping: the three members the
// AuthHex integration added to ICurrentUser.
public int? RoleId => null;
public string? RoleCode => null;
public bool IsAdmin => false;
public int? BranchId => null;
@@ -0,0 +1,40 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Access configuration — the default role for people who have none. Both reading and changing it need the
/// <c>settings</c> nav permission (it decides what a brand-new sign-in can do).
/// </summary>
[Route("api/v1/access-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class AccessConfigController : ApiControllerBase
{
private readonly IAccessConfigService _config;
public AccessConfigController(IAccessConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<AccessConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(AccessConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<AccessConfigDto>> Update([FromBody] UpdateAccessConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -1,6 +1,8 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -10,6 +12,7 @@ namespace ERPCore.Controllers;
/// the audit trail is required (AR-01 compensating control) and read access is the
/// only way to consume it.
/// </summary>
[Authorize(Policy = NavPolicies.Prefix + "settings.audit-logs")]
[Route("api/v1/audit-logs")]
public sealed class AuditLogsController : ApiControllerBase
{
@@ -47,8 +47,7 @@ public sealed class AuthController : ControllerBase
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
return Ok(await _roles.GetMeAsync(ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
@@ -12,12 +12,25 @@ namespace ERPCore.Controllers;
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
private readonly IDashboardChartsService _charts;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
public DashboardController(IDashboardService dashboard, IDashboardChartsService charts)
{
_dashboard = dashboard;
_charts = charts;
}
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
/// <summary>Aggregate counts for stock, GRN, procurement, sales, accounts, service, production and HR.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats([FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, ct));
public async Task<ActionResult<DashboardStatsDto>> GetStats(
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(branchId, from, to, ct));
/// <summary>Datasets for the dashboard graphs; the time-based ones follow the optional from / to period.</summary>
[HttpGet("charts")]
[ProducesResponseType(typeof(DashboardChartsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardChartsDto>> GetCharts(
[FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _charts.GetChartsAsync(branchId, from, to, ct));
}
@@ -32,9 +32,15 @@ public sealed class GeneralLedgerController : ApiControllerBase
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
[HttpPatch("{**path}")]
public Task<IActionResult> Patch(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Patch, path, ct);
[HttpDelete("{**path}")]
public Task<IActionResult> Delete(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Delete, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var body = method == HttpMethod.Get || method == HttpMethod.Delete ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
@@ -25,8 +25,9 @@ public sealed class GrnsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, ct));
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, branchId, from, to, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance")]
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
@@ -44,6 +46,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
@@ -62,6 +65,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -70,6 +74,7 @@ public sealed class AttendanceBatchesController : ApiControllerBase
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -79,18 +84,21 @@ public sealed class AttendanceBatchesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.attendance" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -31,6 +33,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +44,7 @@ public sealed class BranchesController : ApiControllerBase
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +57,7 @@ public sealed class BranchesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +67,7 @@ public sealed class BranchesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "settings.branches")]
[HttpDelete("{branchId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -54,6 +59,7 @@ public sealed class DepartmentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -63,6 +69,7 @@ public sealed class DepartmentsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{departmentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class DesignationsController : ApiControllerBase
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class DesignationsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class DesignationsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{designationId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,6 +3,7 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
@@ -11,6 +12,9 @@ namespace ERPCore.Controllers.Hrm;
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
// Any HR role may list employees (the leave and payroll screens pick from it); everything else about a person
// - the record itself, documents, pay data - is behind the Employees (or Payroll) permission, endpoint by endpoint.
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
@@ -44,11 +48,13 @@ public sealed class EmployeesController : ApiControllerBase
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -60,6 +66,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -71,6 +78,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -83,6 +91,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -93,6 +102,7 @@ public sealed class EmployeesController : ApiControllerBase
}
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[HttpDelete("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDeleteResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -103,6 +113,7 @@ public sealed class EmployeesController : ApiControllerBase
return Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -113,6 +124,7 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -122,11 +134,13 @@ public sealed class EmployeesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -140,6 +154,7 @@ public sealed class EmployeesController : ApiControllerBase
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -147,11 +162,13 @@ public sealed class EmployeesController : ApiControllerBase
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -163,11 +180,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -177,6 +196,7 @@ public sealed class EmployeesController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -186,11 +206,13 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
@@ -206,6 +228,7 @@ public sealed class EmployeesController : ApiControllerBase
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees")]
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -215,6 +238,7 @@ public sealed class EmployeesController : ApiControllerBase
return File(content, contentType, fileName);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.employees" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class EmploymentTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{employmentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class HrDocumentTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{hrDocumentTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.reports")]
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
@@ -30,11 +33,13 @@ public sealed class HrReportsController : ApiControllerBase
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave")]
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
@@ -35,6 +37,7 @@ public sealed class LeaveRequestsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -45,24 +48,28 @@ public sealed class LeaveRequestsController : ApiControllerBase
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.leave" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class LeaveTypesController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{leaveTypeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -3,11 +3,13 @@ using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
@@ -50,6 +52,7 @@ public sealed class PayrollRunsController : ApiControllerBase
return result is null ? NotFound() : Ok(result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -59,24 +62,28 @@ public sealed class PayrollRunsController : ApiControllerBase
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.ApproveAction)]
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -1,11 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
@@ -23,6 +25,7 @@ public sealed class PayrollStatutorySettingsController : ApiControllerBase
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm.payroll")]
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class SalaryComponentsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{salaryComponentId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,10 +1,13 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
@@ -17,6 +20,7 @@ public sealed class TaxSlabsController : ApiControllerBase
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
@@ -1,12 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Authorize(Policy = NavPolicies.Prefix + "hrm")]
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
@@ -31,6 +34,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -41,6 +45,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -53,6 +58,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -62,6 +68,7 @@ public sealed class WorkShiftsController : ApiControllerBase
return NoContent();
}
[Authorize(Policy = NavPolicies.Prefix + "hrm.settings")]
[HttpDelete("{workShiftId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -0,0 +1,71 @@
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// One-time opening balances. Gated on the <c>accounts.opening-balances</c> nav code (Admin always passes;
/// other roles need it granted under Settings → Roles).
/// </summary>
[Route("api/v1/opening-balances")]
[Authorize(Policy = NavPolicies.Prefix + "accounts.opening-balances")]
public sealed class OpeningBalancesController : ApiControllerBase
{
private readonly IOpeningBalanceService _openingBalances;
private readonly IOpeningStockService _openingStock;
public OpeningBalancesController(IOpeningBalanceService openingBalances, IOpeningStockService openingStock)
{
_openingBalances = openingBalances;
_openingStock = openingStock;
}
/// <summary>The cash and bank accounts to enter opening amounts for — or the posted record, once done.</summary>
[HttpGet("cash-bank")]
[ProducesResponseType(typeof(CashBankOpeningStateDto), StatusCodes.Status200OK)]
public async Task<ActionResult<CashBankOpeningStateDto>> GetCashBank(CancellationToken ct) =>
Ok(await _openingBalances.GetCashBankAsync(ct));
/// <summary>Posts the cash and bank opening balances as one journal. Allowed once; later calls get 409.</summary>
[HttpPost("cash-bank")]
[ProducesResponseType(typeof(OpeningBalanceRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningBalanceRunDto>> PostCashBank(
[FromBody] PostCashBankOpeningRequest request, CancellationToken ct)
{
var run = await _openingBalances.PostCashBankAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
/// <summary>Opening-stock runs posted so far, newest first.</summary>
[HttpGet("stock")]
[ProducesResponseType(typeof(IReadOnlyList<OpeningStockRunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<OpeningStockRunSummaryDto>>> ListStock(CancellationToken ct) =>
Ok(await _openingStock.ListAsync(ct));
/// <summary>One opening-stock run with its lines.</summary>
[HttpGet("stock/{runId:int}")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OpeningStockRunDto>> GetStock(int runId, CancellationToken ct)
{
var run = await _openingStock.GetAsync(runId, ct);
return run is null ? NotFound() : Ok(run);
}
/// <summary>
/// Posts opening stock: a FIFO layer per line and one journal (Dr Inventory / Cr Opening Balance Equity).
/// An item that already has stock movements in that warehouse is refused with 409.
/// </summary>
[HttpPost("stock")]
[ProducesResponseType(typeof(OpeningStockRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<OpeningStockRunDto>> PostStock([FromBody] PostOpeningStockRequest request, CancellationToken ct)
{
var run = await _openingStock.PostAsync(request, ct);
return StatusCode(StatusCodes.Status201Created, run);
}
}
@@ -0,0 +1,77 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.OwnerReport;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5) — recipients, schedule,
/// preview, "send test now" and the send log. Gated on the Settings nav policy like the other
/// configuration screens (BranchConfigController, GlAccountConfigController).
/// </summary>
[Route("api/v1/settings/owner-report")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class OwnerReportController : ApiControllerBase
{
// One test send per minute (whole deployment) so the button can't be used to spam the owner.
private static readonly SemaphoreSlim TestGate = new(1, 1);
private static DateTime _lastTestUtc = DateTime.MinValue;
private readonly IOwnerReportService _service;
public OwnerReportController(IOwnerReportService service) => _service = service;
[HttpGet]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportConfigDto>> Get(CancellationToken ct)
{
var result = await _service.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(OwnerReportConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<OwnerReportConfigDto>> Update([FromBody] UpdateOwnerReportConfigRequest request, CancellationToken ct)
{
var result = await _service.UpdateAsync(request, RequireIfMatch(), ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("preview")]
[ProducesResponseType(typeof(OwnerReportPreviewDto), StatusCodes.Status200OK)]
public async Task<ActionResult<OwnerReportPreviewDto>> Preview([FromQuery] DateOnly? date, CancellationToken ct)
=> Ok(await _service.PreviewAsync(date, ct));
[HttpPost("test")]
[ProducesResponseType(typeof(IReadOnlyList<OwnerReportSendResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<ActionResult<IReadOnlyList<OwnerReportSendResultDto>>> SendTest([FromBody] OwnerReportTestRequest? request, CancellationToken ct)
{
await TestGate.WaitAsync(ct);
try
{
if (DateTime.UtcNow - _lastTestUtc < TimeSpan.FromMinutes(1))
return StatusCode(StatusCodes.Status429TooManyRequests,
new { title = "A test was sent less than a minute ago — wait a moment before sending another." });
_lastTestUtc = DateTime.UtcNow;
}
finally
{
TestGate.Release();
}
return Ok(await _service.SendTestAsync(request?.Date, ct));
}
[HttpGet("dispatches")]
[ProducesResponseType(typeof(PagedResponse<OwnerReportDispatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<OwnerReportDispatchDto>>> Dispatches([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _service.ListDispatchesAsync(query, ct));
}
@@ -0,0 +1,46 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Procurement configuration endpoints — optional vendor fields and PO tax.
/// <para>
/// <b>Authorization:</b> reading is open to any signed-in ERP user, because the vendor and purchase-order
/// forms must read the flags and those screens sit under different nav codes. The flags are not
/// sensitive. Changing them needs the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// </para>
/// </summary>
[Route("api/v1/procurement-config")]
public sealed class ProcurementConfigController : ApiControllerBase
{
private readonly IProcurementConfigService _config;
public ProcurementConfigController(IProcurementConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProcurementConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[ProducesResponseType(typeof(ProcurementConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProcurementConfigDto>> Update(
[FromBody] UpdateProcurementConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -20,8 +20,9 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, from, to, ct));
[HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,7 +1,9 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -20,6 +22,7 @@ public sealed class ReasonCodesController : ApiControllerBase
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _codes.ListAsync(context, query, ct));
[Authorize(Policy = NavPolicies.Prefix + "settings")]
[HttpPost]
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -92,8 +92,26 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
// Totals for the list's stat cards: same filters as List, but over every match rather than one page.
[HttpGet("totals")]
[ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceListTotalsDto>> Totals(
[FromQuery] string? q,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
[FromQuery] int? branchId,
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
[FromQuery] bool? overdue,
CancellationToken ct)
=> Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, overdue, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -1,15 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
[Route("api/v1/sales-returns")]
[Authorize(Policy = NavPolicies.Prefix + "sales")]
[Authorize(Policy = NavPolicies.Prefix + "sales-returns")]
public sealed class SalesReturnsController : ApiControllerBase
{
private readonly ISalesReturnService _returns;
@@ -29,8 +29,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
[FromQuery] AdjustmentStatus? status, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
[FromQuery] AdjustmentStatus? status, CancellationToken ct, [FromQuery] bool wastage = false, [FromQuery] int? itemId = null)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct, wastage, itemId));
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -58,8 +58,9 @@ public sealed class StockController : ApiControllerBase
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
[FromQuery] PageQuery query, CancellationToken ct,
[FromQuery] int? categoryId = null, [FromQuery] int? brandId = null)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct, categoryId, brandId));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
+33 -3
View File
@@ -41,8 +41,8 @@ public sealed class UsersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, [FromQuery] bool? isAdmin, CancellationToken ct)
=> Ok(await _users.ListAsync(query, status, roleCode, isAdmin, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
@@ -74,13 +74,27 @@ public sealed class UsersController : ApiControllerBase
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
/// <summary>Edits DisplayName/Email/MobileNumber on both backends — see <see cref="IUserManagementService.UpdateAsync"/>.</summary>
[HttpPut("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> Update(int userId, [FromBody] UpdateManagedUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateAsync(userId, request, ct));
/// <summary>Revokes/restores standing in both ERPCore and AuthHex — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
[HttpPatch("{userId:int}/status")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
/// <summary>Emails the user a 15-minute password-reset link — see <see cref="IUserManagementService.SendResetLinkAsync"/>.</summary>
[HttpPost("{userId:int}/send-reset-link")]
[ProducesResponseType(typeof(SendResetLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SendResetLinkResponse>> SendResetLink(int userId, CancellationToken ct)
=> Ok(await _users.SendResetLinkAsync(userId, ct));
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
[HttpGet("{userId:int}/login-devices")]
@@ -103,4 +117,20 @@ public sealed class UsersController : ApiControllerBase
return Ok(await _users.PurgeAuthHexIdentityAsync(request, ct));
}
/// <summary>
/// One-time migration helper (administrators only): copies each role-less user's role from AuthHex into the ERP.
/// Safe to repeat — users that already have a role are left alone. See
/// <see cref="IUserManagementService.BackfillRolesFromAuthHexAsync"/>.
/// </summary>
[HttpPost("backfill-roles")]
[ProducesResponseType(typeof(BackfillRolesResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<BackfillRolesResultDto>> BackfillRoles(CancellationToken ct)
{
if (!_currentUser.IsAdmin)
throw new DomainException("FORBIDDEN", "Only an administrator can run the role backfill.", 403);
return Ok(await _users.BackfillRolesFromAuthHexAsync(ct));
}
}
@@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using ERPCore.Infra.Auth;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
[Route("api/v1/vendors")]
[Authorize(Policy = NavPolicies.Prefix + "vendors")]
[Authorize(Policy = NavPolicies.Prefix + "procurement")]
public sealed class VendorsController : ApiControllerBase
{
private readonly IVendorService _vendors;
@@ -35,6 +35,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.CreateAction)]
[HttpPost]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -45,6 +46,7 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -57,6 +59,7 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.EditAction)]
[HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -67,7 +70,7 @@ public sealed class VendorsController : ApiControllerBase
}
[HttpDelete("{vendorId:int}")]
[Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[Authorize(Policy = NavPolicies.Prefix + "procurement.vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
@@ -0,0 +1,26 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Access configuration — a singleton row (same shape as <see cref="GrnConfig"/>).
/// <see cref="DefaultRoleId"/> is the role a person gets on their first sign-in, and when they have none:
/// without it they would have no permissions at all. It must be a least-privilege role and can never be the
/// administrator role (enforced when it is saved).
/// </summary>
public class AccessConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public int? DefaultRoleId { get; set; }
public Role? DefaultRole { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -8,6 +8,12 @@ public class BundleSaleTemplate
public string TemplateCode { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>
/// What the whole bundle sells for. On save it is split across the included lines' unit prices
/// (see BundleSaleService.AllocateBundlePrice), so selling the template still yields per-component prices.
/// 0 on templates created before bundle prices existed — their line prices stand as entered.
/// </summary>
public decimal BundlePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
@@ -35,12 +35,17 @@ public class ApprovalConfig
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
/// <summary>Approvals wastage (damage / theft / expiry write-offs) needs: 0 = none, stock reduces on save (default);
/// 2 = two different approvers first. Separate from <see cref="StockAdjustmentLevels"/>, which governs every other adjustment.</summary>
public int WastageLevels { get; set; }
public int PriceAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Comma-separated role codes allowed to give the second approval; empty = anyone with the Approve action.</summary>
public string? Level2RoleCodes { get; set; }
/// <summary>Comma-separated role IDS allowed to give the second approval; empty = anyone with the Approve action.
/// (Ids, not codes: a role can be renamed without breaking this rule.)</summary>
public string? Level2RoleIds { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -60,6 +60,7 @@ public class GlAccountConfig
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
public string? CashAccountCode { get; set; }
public string? AccruedExpensesCode { get; set; }
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,74 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One posted opening balance (cash and bank, or stock) — the record that the opening entry was made, when,
/// by whom, and which General Ledger journal carries it. The General Ledger has no "opening posted" notion, so
/// this table is what refuses a second posting: cash/bank sets <see cref="OnceKey"/> (unique index), so it can
/// be posted once. Stock leaves it null and can be loaded in several runs; each item in a warehouse can be
/// opened only once (an item that already has stock movements there is refused).
/// </summary>
public class OpeningBalanceRun
{
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceKind Kind { get; set; }
/// <summary>Set (to the kind's name) only for kinds that may be posted once; a unique index on it is the
/// once-only guard. Null for stock, which may be loaded in batches (MySQL allows many NULLs under a unique index).</summary>
public string? OnceKey { get; set; }
/// <summary>The opening date — the GL journal's entry date. Reports starting after it show these amounts as opening.</summary>
public DateOnly CutoverDate { get; set; }
/// <summary>The GL journal number (JV-…). Null only for the instant between claiming the run and the GL
/// confirming the posting, inside one transaction; never null on a committed row.</summary>
public string? GlJournalNo { get; set; }
/// <summary>Sum of the debit amounts (what the Opening Balance Equity account is credited with).</summary>
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = "LKR";
public int PostedBy { get; set; }
public DateTime PostedAt { get; set; }
public ICollection<OpeningBalanceLine> Lines { get; set; } = new List<OpeningBalanceLine>();
public ICollection<OpeningStockLine> StockLines { get; set; } = new List<OpeningStockLine>();
}
/// <summary>One cash or bank account's opening amount within an <see cref="OpeningBalanceRun"/>.
/// Positive = a debit balance (money held); negative = a credit balance (an overdraft).</summary>
public class OpeningBalanceLine
{
public int OpeningBalanceLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public string AccountCode { get; set; } = string.Empty;
public string AccountName { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
/// <summary>One item's opening stock in one warehouse within a stock <see cref="OpeningBalanceRun"/>.</summary>
public class OpeningStockLine
{
public int OpeningStockLineId { get; set; }
public int OpeningBalanceRunId { get; set; }
public OpeningBalanceRun? Run { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Quantity in the item's base UOM.</summary>
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
/// <summary>Qty x UnitCost, rounded to 4 dp — what the inventory account is debited with.</summary>
public decimal Value { get; set; }
}
@@ -0,0 +1,79 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Day-end owner report settings — a singleton row, same shape as <see cref="SalesConfig"/>
/// (docs/DAY_END_OWNER_REPORT_PLAN.md §3.1). When <see cref="Enabled"/>, OwnerReportJob sends the
/// day's figures to every active <see cref="OwnerReportRecipient"/> at <see cref="SendAtLocalTime"/>.
/// </summary>
public class OwnerReportConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Master switch — nothing is sent automatically while off ("Send test now" still works).</summary>
public bool Enabled { get; set; }
public bool SmsEnabled { get; set; } = true;
public bool EmailEnabled { get; set; } = true;
/// <summary>Local time of day (DayEndReport:TimeZone) after which the day's report goes out.</summary>
public TimeOnly SendAtLocalTime { get; set; } = new(21, 0);
/// <summary>Adds one summary row per active branch when branching is switched on.</summary>
public bool IncludeBranchBreakdown { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
public uint RowVersion { get; set; }
}
/// <summary>Someone who receives the day-end report — by SMS, email or both.</summary>
public class OwnerReportRecipient
{
public int RecipientId { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Normalised to +94XXXXXXXXX (Sri Lankan rule, see PhoneRules).</summary>
public string? Mobile { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// One send of one day's report on one channel to one recipient — the send log, and the guard
/// against sending twice: (BusinessDate, Channel, RecipientId, IsTest) is unique for automatic
/// sends, so a restart or a second server instance can't send the same day again.
/// </summary>
public class OwnerReportDispatch
{
public long DispatchId { get; set; }
public DateOnly BusinessDate { get; set; }
public OwnerReportChannel Channel { get; set; }
public int? RecipientId { get; set; }
public OwnerReportRecipient? Recipient { get; set; }
/// <summary>Number / email address at send time — kept even if the recipient is edited later.</summary>
public string Destination { get; set; } = string.Empty;
public string RecipientName { get; set; } = string.Empty;
public OwnerReportDispatchStatus Status { get; set; }
public int Attempts { get; set; }
public string? Error { get; set; }
/// <summary>Test sends ("Send test now") — never block or count as the day's automatic send.</summary>
public bool IsTest { get; set; }
/// <summary>For tests: a unique token so repeated tests don't collide on the unique index.</summary>
public string DedupeKey { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public DateTime? NextAttemptAt { get; set; }
}
@@ -21,5 +21,10 @@ public class PoLine
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
/// <summary>Tax amount for this line = round(Qty x UnitPrice x <see cref="Tax"/>, 2). Stored so the
/// figure on the PO, its PDF and the totals never drifts from what was approved. Always 0 while
/// Procurement configuration's PO tax switch is off.</summary>
public decimal TaxAmount { get; set; }
public decimal QtyReceived { get; set; }
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Procurement configuration — a singleton row, same shape as <see cref="GrnConfig"/>.
/// Controls the optional vendor fields and whether purchase orders carry tax.
/// </summary>
public class ProcurementConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
/// <summary>Which contact detail vendors carry: none (default), a mobile number or an email.
/// A value sent for a field that is not selected is rejected server-side (CONFIG_DISABLED).</summary>
public VendorContactMode VendorContactMode { get; set; } = VendorContactMode.None;
/// <summary>When on, vendors carry an optional company address.</summary>
public bool VendorAddressEnabled { get; set; }
/// <summary>When on (default — the field existed before it became optional), vendors carry optional payment terms.
/// When off the field is hidden and a value for it is rejected (CONFIG_DISABLED); stored values are kept.</summary>
public bool VendorPaymentTermsEnabled { get; set; } = true;
/// <summary>When on (default), vendors carry an optional tax registration number. Same rules as payment terms.
/// Purchase invoices still snapshot a vendor's stored number either way.</summary>
public bool VendorTaxRegEnabled { get; set; } = true;
/// <summary>When on, purchase-order lines carry a tax rate (prefilled from the item) and tax amount;
/// when off, tax is hidden everywhere on POs and a non-zero tax is rejected server-side.</summary>
public bool PoTaxEnabled { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -51,6 +51,31 @@ public class ProductConfig
/// </summary>
public bool CustomWarrantyPeriodsEnabled { get; set; }
/// <summary>
/// Shows the stock-nature choice (Stocked / Non-stocked / Service) on item screens. Off:
/// new items are Stocked. Enforced on item writes - an item keeps the nature it already
/// has, only a change <em>to</em> a non-Stocked nature is refused.
/// </summary>
public bool StockNatureEnabled { get; set; } = true;
/// <summary>
/// Shows the tracking-mode choice on item screens. Off: new items are untracked (None).
/// Enforced on item writes like <see cref="StockNatureEnabled"/> - existing modes are kept.
/// </summary>
public bool TrackingModeEnabled { get; set; } = true;
/// <summary>
/// Shows the low-stock alert (reorder point / qty) section on item create and the item page.
/// Advisory: it gates the item screens; reorder rules already saved keep working.
/// </summary>
public bool LowStockAlertsEnabled { get; set; } = true;
/// <summary>
/// Lets the low-stock alert be pinned to one warehouse. Off: the alert watches every
/// warehouse. Depends on <see cref="LowStockAlertsEnabled"/>; advisory like it.
/// </summary>
public bool AlertWarehouseEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
+13 -8
View File
@@ -3,25 +3,30 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// An ERP role. The ERP owns roles and what they may do: <see cref="User.RoleId"/> points here and
/// <see cref="RolePermission"/> holds the permissions. AuthHex only authenticates people and knows nothing about roles.
/// </summary>
public class Role
{
public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
/// <summary>Legacy link to the AuthHex role this one used to mirror. Unused now; kept (nullable) until the AuthHex
/// role table is retired, then dropped.</summary>
public Guid? AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystemRole { get; set; }
/// <summary>True for the administrator role: its holders pass every permission check, bypass branch scoping and can
/// manage users and roles. This flag — never the role's code or name — is what "admin" means, so the role can be
/// renamed freely. Not settable through the API; only the seeded Admin role carries it.</summary>
public bool IsAdmin { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -51,6 +51,31 @@ public class SalesConfig
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
// ---- Optional customer fields. Each is on by default (the field existed before it became optional). When off the
// field is hidden on the customer forms and a new value for it is rejected server-side (CONFIG_DISABLED); values
// already stored are kept.
/// <summary>Customers carry an optional phone number.</summary>
public bool CustomerPhoneEnabled { get; set; } = true;
/// <summary>Customers carry an optional email address.</summary>
public bool CustomerEmailEnabled { get; set; } = true;
/// <summary>Customers carry an optional address (lines 1 and 2).</summary>
public bool CustomerAddressEnabled { get; set; } = true;
/// <summary>Customers carry an optional city.</summary>
public bool CustomerCityEnabled { get; set; } = true;
/// <summary>Customers carry an optional country.</summary>
public bool CustomerCountryEnabled { get; set; } = true;
/// <summary>Customers have a credit limit.</summary>
public bool CustomerCreditLimitEnabled { get; set; } = true;
/// <summary>Customers have credit days (how long an invoice may stay unpaid — the customer's "age").</summary>
public bool CustomerCreditDaysEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
@@ -15,6 +15,13 @@ public class Vendor
public string? Terms { get; set; }
public string? TaxReg { get; set; }
public string Currency { get; set; } = "LKR";
/// <summary>Mobile number — only recorded while Procurement configuration's vendor contact mode is Mobile.</summary>
public string? Mobile { get; set; }
/// <summary>Email — only recorded while the vendor contact mode is Email.</summary>
public string? Email { get; set; }
/// <summary>Company address — only recorded while vendor address is enabled.</summary>
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int BranchId { get; set; }
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which opening balance a run carries. Each kind is posted ONCE for the life of the system, so a
/// unique index on the kind is the "already posted" guard (the General Ledger itself does not stop a
/// second opening entry). Stored as a string.
/// </summary>
public enum OpeningBalanceKind
{
CashBank,
Stock
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>How the day-end owner report is delivered (docs/DAY_END_OWNER_REPORT_PLAN.md).</summary>
public enum OwnerReportChannel
{
Sms,
Email,
}
/// <summary>State of one owner-report send.</summary>
public enum OwnerReportDispatchStatus
{
Pending,
Sent,
Failed,
}
@@ -3,8 +3,10 @@ namespace ERPCore.Domain.Enums;
/// <summary>
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Issuing a PO (create
/// non-draft, or submit a Draft) enters <see cref="PendingApproval"/>; an Admin who
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it back
/// to <see cref="Draft"/> (FR-PROC-04). Stored as a string.
/// isn't the creator must Approve it to reach <see cref="Approved"/>, or Reject it to
/// <see cref="Rejected"/> (FR-PROC-04) - a status of its own, so a rejected order is never mistaken for a fresh
/// Draft or one still awaiting approval. A Rejected order is editable like a Draft and goes back through
/// approval when resubmitted. Stored as a string.
/// </summary>
public enum PurchaseOrderStatus
{
@@ -14,5 +16,6 @@ public enum PurchaseOrderStatus
PartiallyReceived,
FullyReceived,
Closed,
Cancelled
Cancelled,
Rejected
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Which single contact detail a deployment records for vendors (Procurement configuration).
/// <see cref="None"/> hides the field everywhere. Stored as a string.
/// </summary>
public enum VendorContactMode
{
None,
Mobile,
Email
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain;
/// <summary>
/// Which Adjustment reason codes make a stock adjustment "wastage" (damage, theft, expiry). Wastage is stored as a stock
/// adjustment; this is what tells the two apart. Matches the frontend's <c>isWastageReasonCode</c> (lib/api/wastage.ts).
/// </summary>
public static class WastageReasonCodes
{
public static readonly IReadOnlyCollection<string> Codes = new[] { "DMG", "THEFT", "EXP" };
public static bool IsWastage(string? reasonCode) =>
reasonCode is not null && Codes.Contains(reasonCode.Trim(), StringComparer.OrdinalIgnoreCase);
}
@@ -10,7 +10,7 @@ public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int D
public sealed record ApprovalConfigDto(
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
IReadOnlyList<string> Level2RoleCodes, DateTime? UpdatedAt);
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt, int WastageLevels = 0);
public sealed class UpdateApprovalConfigRequest
{
@@ -18,7 +18,11 @@ public sealed class UpdateApprovalConfigRequest
[Range(0, double.MaxValue)] public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
[Range(0, 2)] public int PriceAdjustmentLevels { get; set; }
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
public List<string> Level2RoleCodes { get; set; } = new();
/// <summary>Roles allowed to give the second approval; empty = anyone with the Approve action.</summary>
public List<int> Level2RoleIds { get; set; } = new();
/// <summary>0 = wastage posts on save, 2 = two approvals first. Null keeps the stored value (an older client that doesn't know the setting).</summary>
[Range(0, 2)] public int? WastageLevels { get; set; }
}
public sealed class ApproveRequest
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+12 -1
View File
@@ -44,11 +44,22 @@ public sealed class DeletedAuthHexUser
public string? Email { get; set; }
}
/// <summary>Admin-only profile edit — any field left null is left unchanged upstream.</summary>
public sealed class AdminUpdateUserRequest
{
public string? FullName { get; set; }
public string? UserName { get; set; }
public string? Email { get; set; }
public string? MobileNumber { get; set; }
}
public sealed class RegisterRequest
{
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
public Guid? UserId { get; set; }
[Required] public Guid RoleId { get; set; }
/// <summary>Ignored for self-registration and never taken from a caller: the ERP assigns roles itself. Sent to AuthHex only
/// while the old AuthHex (which still demands a role id) is deployed — see <c>AuthHex:LegacyRoleId</c>.</summary>
public Guid? RoleId { get; set; }
[Required] public Guid UserTypeId { get; set; }
public string? Fullname { get; set; }
public string? UserName { get; set; }
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Access configuration resource: which role a person gets when they have none.</summary>
public sealed record AccessConfigDto(int? DefaultRoleId, string? DefaultRoleName, DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>Full replacement. <c>null</c> clears the default (people without a role then have no permissions at all).</summary>
public sealed class UpdateAccessConfigRequest
{
[Range(1, int.MaxValue)] public int? DefaultRoleId { get; set; }
}
@@ -47,4 +47,6 @@ public sealed class UpdateGlAccountConfigRequest
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
/// <summary>Optional so an older client that doesn't know this role can't blank it out: null keeps the stored code.</summary>
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccruedExpensesCode { get; set; }
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>Day-end owner report settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.5).</summary>
/// <param name="SendAtLocalTime">"HH:mm" in <paramref name="TimeZone"/>.</param>
public sealed record OwnerReportConfigDto(
bool Enabled, bool SmsEnabled, bool EmailEnabled, string SendAtLocalTime, bool IncludeBranchBreakdown,
IReadOnlyList<OwnerReportRecipientDto> Recipients, string TimeZone, DateTime? UpdatedAt, int? UpdatedBy);
public sealed record OwnerReportRecipientDto(int RecipientId, string Name, string? Mobile, string? Email, bool IsActive);
public sealed class UpdateOwnerReportConfigRequest
{
[Required] public bool? Enabled { get; set; }
[Required] public bool? SmsEnabled { get; set; }
[Required] public bool? EmailEnabled { get; set; }
[Required] public bool? IncludeBranchBreakdown { get; set; }
/// <summary>"HH:mm", 24-hour.</summary>
[Required, RegularExpression(@"^([01]\d|2[0-3]):[0-5]\d$", ErrorMessage = "Send time must be HH:mm (24-hour).")]
public string SendAtLocalTime { get; set; } = "21:00";
/// <summary>The full list — recipients not in it are removed. At most 5.</summary>
[Required, MaxLength(5, ErrorMessage = "At most 5 recipients.")]
public List<OwnerReportRecipientInput> Recipients { get; set; } = new();
}
public sealed class OwnerReportRecipientInput
{
/// <summary>Existing recipient to update; null adds a new one.</summary>
public int? RecipientId { get; set; }
[Required, StringLength(100, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
[StringLength(20)] public string? Mobile { get; set; }
[StringLength(200), EmailAddress] public string? Email { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>The report as it would be sent for <paramref name="Date"/>.</summary>
public sealed record OwnerReportPreviewDto(DateOnly Date, string SmsText, string EmailSubject, string EmailHtml);
public sealed class OwnerReportTestRequest
{
/// <summary>Day to report; null = today in the report's time zone.</summary>
public DateOnly? Date { get; set; }
}
public sealed record OwnerReportSendResultDto(string Channel, string RecipientName, string Destination, bool Ok, string? Error);
public sealed record OwnerReportDispatchDto(
long DispatchId, DateOnly BusinessDate, string Channel, string RecipientName, string Destination,
string Status, int Attempts, string? Error, bool IsTest, DateTime CreatedAt, DateTime? SentAt);
@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Config;
/// <summary>Procurement configuration resource.</summary>
public sealed record ProcurementConfigDto(
VendorContactMode VendorContactMode, bool VendorAddressEnabled, bool PoTaxEnabled,
DateTime? UpdatedAt, int? UpdatedBy,
bool VendorPaymentTermsEnabled = true, bool VendorTaxRegEnabled = true);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// Nullable so an omitted flag is a 400 rather than silently binding to a default
/// (same reasoning as <see cref="UpdateGrnConfigRequest"/>).
/// </summary>
public sealed class UpdateProcurementConfigRequest
{
[Required] public VendorContactMode? VendorContactMode { get; set; }
[Required] public bool? VendorAddressEnabled { get; set; }
[Required] public bool? PoTaxEnabled { get; set; }
[Required] public bool? VendorPaymentTermsEnabled { get; set; }
[Required] public bool? VendorTaxRegEnabled { get; set; }
}
@@ -14,6 +14,7 @@ public sealed record ProductConfigDto(
bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
bool CustomVariantsEnabled, bool ItemImagesEnabled,
bool CustomWarrantyPeriodsEnabled,
bool StockNatureEnabled, bool TrackingModeEnabled, bool LowStockAlertsEnabled, bool AlertWarehouseEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
@@ -37,4 +38,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? CustomVariantsEnabled { get; set; }
[Required] public bool? ItemImagesEnabled { get; set; }
[Required] public bool? CustomWarrantyPeriodsEnabled { get; set; }
[Required] public bool? StockNatureEnabled { get; set; }
[Required] public bool? TrackingModeEnabled { get; set; }
[Required] public bool? LowStockAlertsEnabled { get; set; }
[Required] public bool? AlertWarehouseEnabled { get; set; }
}
+11 -1
View File
@@ -6,7 +6,10 @@ namespace ERPCore.Dtos.Config;
/// the generic cash customer (null until the option has been switched on at least once).</summary>
public sealed record SalesConfigDto(
bool AllowCashCustomer, int? CashCustomerId, string? CashCustomerName, bool AllowFreeItems, bool AllowDiscounts, bool AllowOverStockSale,
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy);
bool AllowWarehouseSelection, int? DefaultWarehouseId, string? DefaultWarehouseName, DateTime? UpdatedAt, int? UpdatedBy,
bool CustomerPhoneEnabled = true, bool CustomerEmailEnabled = true, bool CustomerAddressEnabled = true,
bool CustomerCityEnabled = true, bool CustomerCountryEnabled = true,
bool CustomerCreditLimitEnabled = true, bool CustomerCreditDaysEnabled = true);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
@@ -22,4 +25,11 @@ public sealed class UpdateSalesConfigRequest
[Required] public bool? AllowWarehouseSelection { get; set; }
/// <summary>Required when <see cref="AllowWarehouseSelection"/> is false.</summary>
public int? DefaultWarehouseId { get; set; }
[Required] public bool? CustomerPhoneEnabled { get; set; }
[Required] public bool? CustomerEmailEnabled { get; set; }
[Required] public bool? CustomerAddressEnabled { get; set; }
[Required] public bool? CustomerCityEnabled { get; set; }
[Required] public bool? CustomerCountryEnabled { get; set; }
[Required] public bool? CustomerCreditLimitEnabled { get; set; }
[Required] public bool? CustomerCreditDaysEnabled { get; set; }
}
@@ -0,0 +1,46 @@
namespace ERPCore.Dtos.Dashboard;
/// <summary>
/// Every dataset behind the dashboard graphs, in one computed-on-read object (same idea as
/// <see cref="DashboardStatsDto"/>). Time-based sets follow the From-To period the dashboard filter sends;
/// "snapshot" sets (ageing, batches expiring, low stock, warranties) describe what is open now.
/// Purchases-vs-sales by month and stock value by warehouse are already in <see cref="DashboardStatsDto"/>.
/// </summary>
public sealed record DashboardChartsDto(
// Sales
IReadOnlyList<DailySalesPointDto> DailySales,
IReadOnlyList<ChartPointDto> PaymentSplit,
IReadOnlyList<ChartPointDto> TopItems,
IReadOnlyList<ChartPointDto> SalesByCategory,
IReadOnlyList<ChartPointDto> SalesByCustomer,
IReadOnlyList<DocumentStatusDto> DocumentStatus,
// Money
IReadOnlyList<ChartPointDto> CashInHandByDay,
IReadOnlyList<ChartPointDto> ReceivablesAgeing,
IReadOnlyList<ChartPointDto> PayablesAgeing,
IReadOnlyList<ChartPointDto> ExpenseBreakdown,
// Purchasing
IReadOnlyList<ChartPointDto> TopVendors,
// Stock
IReadOnlyList<ChartPairDto> StockInVsOut,
IReadOnlyList<ChartPointDto> BatchesExpiring,
// HR
IReadOnlyList<ChartPointDto> AttendanceMix,
IReadOnlyList<ChartPointDto> PayrollCostByMonth,
IReadOnlyList<ChartPointDto> LeaveStatus,
// Service and production
IReadOnlyList<ChartPairDto> TicketsOverTime,
IReadOnlyList<ChartPointDto> ProductionStatus,
IReadOnlyList<ChartPointDto> WarrantyExpiryByMonth);
/// <summary>One labelled value: a bar, a donut slice or a point on a line.</summary>
public sealed record ChartPointDto(string Label, decimal Value);
/// <summary>Two values under one label (stock in/out by warehouse, tickets opened/resolved by day).</summary>
public sealed record ChartPairDto(string Label, decimal First, decimal Second);
/// <summary>One day of the Daily Sales, Gross Profit and Expenses line chart.</summary>
public sealed record DailySalesPointDto(string Label, decimal Sales, decimal GrossProfit, decimal Expenses);
/// <summary>Draft / Posted / Cancelled counts for one kind of document.</summary>
public sealed record DocumentStatusDto(string Doc, int Draft, int Posted, int Cancelled);
@@ -10,6 +10,9 @@ namespace ERPCore.Dtos.Dashboard;
/// Recent movements isn't here — it's just GET /stock/ledger with a small pageSize, no
/// aggregation needed. A single computed-on-read object, not a stored entity — same as
/// reorder alerts (docs/11 §5.7).
/// Most counts are a snapshot of what is open right now. The few that describe activity
/// (sales returns, bundle sales, bank deposits, employees present) are counted inside the
/// optional From-To period the dashboard filter sends (default: today).
/// </summary>
public sealed record DashboardStatsDto(
int LowStockAlerts,
@@ -24,7 +27,37 @@ public sealed record DashboardStatsDto(
int OpenRfqs,
int PendingLeaveRequests,
int OverdueSalesInvoices,
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales,
// Stock & warehouse
int BatchesExpiringSoon,
int TransfersInTransit,
int DraftStockAdjustments,
int WastagePendingApproval,
// Approvals
int PendingApprovals,
int PriceAdjustmentsPending,
// Sales (SalesReturns and BundleSales are counted inside the From-To period)
int DraftSalesInvoices,
int DraftSalesSlips,
int SalesReturns,
int BundleSales,
// Purchasing
int PurchaseReturnsPending,
int UnpaidPurchaseInvoices,
// Money
decimal CashInHand,
decimal BankDeposits,
// Service & warranty
int OpenSupportTickets,
int WarrantiesExpiringSoon,
int WarrantyClaimsOpen,
// Production
int ProductionRunsInProgress,
// HR
int PayrollRunsPending,
int AttendanceBatchesPending,
int EmployeesPresent,
int TotalActiveEmployees);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
+4 -2
View File
@@ -27,7 +27,8 @@ public sealed record GrnDto(
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount, decimal TotalValue,
int? PurchaseInvoiceId, string? PurchaseInvoiceNo);
int? PurchaseInvoiceId, string? PurchaseInvoiceNo,
string? PoDocNo = null, Dtos.Procurement.PoVendorDto? Vendor = null);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
@@ -93,7 +94,8 @@ public sealed class CreateGrnRequest
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Owning branch for this GRN — warehouses are shared, so this is what actually
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).</summary>
/// scopes the document (resolved via IBranchConfigService; required when branching is enabled).
/// Ignored when <see cref="PoId"/> is set: a GRN against a purchase order always belongs to that order's branch.</summary>
[Required] public int BranchId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.OpeningBalances;
/// <summary>One cash or bank account as the opening-balance screen shows it. <c>Amount</c> is filled only once posted.</summary>
public sealed record OpeningCashBankAccountDto(
string AccountType, string AccountName, string? BankName, string? AccountNumber,
string GlAccountCode, string CurrencyCode, decimal? Amount);
public sealed record OpeningBalanceLineDto(string AccountCode, string AccountName, decimal Amount);
/// <summary>A posted opening balance (the record of the one-time entry).</summary>
public sealed record OpeningBalanceRunDto(
int OpeningBalanceRunId, string Kind, DateOnly CutoverDate, string? GlJournalNo,
decimal TotalAmount, string Currency, DateTime PostedAt, int PostedBy,
IReadOnlyList<OpeningBalanceLineDto> Lines);
/// <summary>The cash/bank screen's state: the accounts to enter amounts for, or the posted record once done.</summary>
public sealed record CashBankOpeningStateDto(
OpeningBalanceRunDto? Posted, IReadOnlyList<OpeningCashBankAccountDto> Accounts, string BaseCurrency);
public sealed class OpeningBalanceLineInput
{
/// <summary>The GL account code of a cash or bank account, as returned by the state endpoint.</summary>
[Required, StringLength(20)] public string GlAccountCode { get; set; } = string.Empty;
/// <summary>Opening balance, at most 2 decimals. Positive = money held; negative = an overdraft. Zero lines are ignored.</summary>
public decimal Amount { get; set; }
}
public sealed class PostCashBankOpeningRequest
{
/// <summary>The opening date (the GL entry date). Must fall in an open accounting period and not be in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1)] public List<OpeningBalanceLineInput> Lines { get; set; } = new();
}
// ---------------------------------------------------------------- opening stock
public sealed class OpeningStockLineInput
{
[Required] public int ItemId { get; set; }
[Required] public int WarehouseId { get; set; }
/// <summary>Quantity in the item's base UOM; more than 0, at most 4 decimals.</summary>
public decimal Qty { get; set; }
/// <summary>Cost of ONE base unit, 0 or more, at most 4 decimals — this becomes the FIFO layer's cost.</summary>
public decimal UnitCost { get; set; }
}
public sealed class PostOpeningStockRequest
{
/// <summary>The opening date (the stock layers' receipt date and the GL entry date). Not in the future.</summary>
[Required] public DateOnly? CutoverDate { get; set; }
[Required, MinLength(1), MaxLength(500)] public List<OpeningStockLineInput> Lines { get; set; } = new();
}
public sealed record OpeningStockLineDto(
int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseCode,
decimal Qty, decimal UnitCost, decimal Value);
public sealed record OpeningStockRunSummaryDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy);
public sealed record OpeningStockRunDto(
int OpeningBalanceRunId, DateOnly CutoverDate, string? GlJournalNo, decimal TotalValue, int LineCount,
DateTime PostedAt, int PostedBy, IReadOnlyList<OpeningStockLineDto> Lines);
@@ -5,9 +5,14 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
/// <summary><c>Tax</c> is the rate (0..1); <c>TaxAmount</c> the stored amount for the line.</summary>
public sealed record PoLineDto(
int PoLineId, int ItemId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived, decimal TaxAmount = 0);
/// <summary>Vendor details shown on a purchase order. <c>Mobile</c>/<c>Email</c>/<c>Address</c> are null
/// unless the matching Procurement configuration switch is on.</summary>
public sealed record PoVendorDto(int VendorId, string Code, string Name, string? Mobile, string? Email, string? Address);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
@@ -15,11 +20,12 @@ public sealed record PurchaseOrderDto(
int PoId, string DocNo, int VendorId, int BranchId, int? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, int? ApprovedBy, DateTime? ApprovedAt,
DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines, PoVendorDto? Vendor = null);
public sealed record PurchaseOrderSummaryDto(
int PoId, string DocNo, int VendorId, int BranchId, PurchaseOrderStatus Status,
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals);
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, PoTotalsDto Totals,
PoVendorDto? Vendor = null);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
+3 -1
View File
@@ -11,4 +11,6 @@ namespace ERPCore.Dtos.Rbac;
/// entirely rather than merely restricting them by role.</summary>
public sealed record MeResponseDto(
string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes,
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled);
bool IsAdmin, int? BranchId, string? BranchName, bool BranchingEnabled, bool WarehouseBranchingEnabled,
int? RoleId = null, bool HasRole = false,
IReadOnlyDictionary<string, int>? Permissions = null);
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Rbac;
public sealed record RoleDto(
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
DateTime CreatedAt, DateTime? UpdatedAt, bool IsAdmin = false);
public sealed class CreateRoleRequest
{
+8 -2
View File
@@ -24,11 +24,11 @@ public sealed record BundleSaleTemplateLineDto(
decimal UnitPrice, bool IncludeInBundle, int SortOrder);
public sealed record BundleSaleTemplateDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt, IReadOnlyList<BundleSaleTemplateLineDto> Lines);
public sealed record BundleSaleTemplateSummaryDto(
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description,
int BundleSaleTemplateId, string TemplateCode, string TemplateName, string? Description, decimal BundlePrice,
EntityStatus Status, int LineCount, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed record BundleSalePostingIssueDto(
@@ -53,6 +53,12 @@ public sealed class CreateBundleSaleTemplateRequest
{
[Required] public string TemplateName { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>
/// The whole bundle's selling price. When set, it replaces the lines' UnitPrice: it is split across the
/// included lines in proportion to their UnitPrice × Qty (sent as a weight, e.g. the item's normal price),
/// or by Qty when no weights are given. Omit to keep the per-line prices as sent.
/// </summary>
[Range(0, double.MaxValue)] public decimal? BundlePrice { get; set; }
[Required, MinLength(1)] public List<CreateBundleSaleTemplateLineRequest> Lines { get; set; } = new();
}
@@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto(
DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt,
SalesInvoiceTotalsDto Totals, IReadOnlyList<SalesInvoiceLineDto> Lines);
/// <summary>Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page.</summary>
public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount);
public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
+2 -1
View File
@@ -11,7 +11,8 @@ public sealed record StockOnHandDto(
public sealed record StockLedgerRowDto(
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
string? ItemSku = null, string? ItemName = null, string? CategoryName = null, string? BrandName = null);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
+22
View File
@@ -67,5 +67,27 @@ public sealed class UpdateUserStatusRequest
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>
/// Edits an existing user's identity fields in both backends: the local shadow row
/// (DisplayName/Email) and, when the user has a linked AuthHex identity, the AuthHex
/// account itself via the admin-only AdminUpdateUser function. A user created before
/// AuthHex linking (AuthUserId null) only updates locally.
/// </summary>
public sealed class UpdateManagedUserRequest
{
[Required, StringLength(200)] public string DisplayName { get; set; } = string.Empty;
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? MobileNumber { get; set; }
}
public sealed record SendResetLinkResponse(DateTime? ExpiresAt);
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
/// <summary>Outcome of the one-time copy of each user's role from AuthHex into the ERP's users table.</summary>
public sealed record BackfillRolesResultDto(
int Examined, int Assigned, int AlreadyHadRole, IReadOnlyList<BackfillRolesIssueDto> Issues);
/// <summary>A user the backfill could not give a role, and why — for an administrator to assign by hand.</summary>
public sealed record BackfillRolesIssueDto(int UserId, string Username, string Reason);
+14 -1
View File
@@ -6,7 +6,8 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt);
string Currency, EntityStatus Status, int BranchId, DateTime CreatedAt, DateTime? UpdatedAt,
string? Mobile = null, string? Email = null, string? Address = null);
public sealed class CreateVendorRequest
{
@@ -15,6 +16,12 @@ public sealed class CreateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
/// <summary>Optional — resolved via <see cref="Services.Interfaces.IBranchConfigService.ResolveBranchIdAsync"/>
/// when branching is enabled; ignored (defaulted) otherwise.</summary>
public int BranchId { get; set; }
@@ -27,6 +34,12 @@ public sealed class UpdateVendorRequest
[StringLength(50)] public string? Terms { get; set; }
[StringLength(50)] public string? TaxReg { get; set; }
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
/// <summary>Only accepted while the vendor contact mode is Mobile (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(20)] public string? Mobile { get; set; }
/// <summary>Only accepted while the vendor contact mode is Email (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(200)] public string? Email { get; set; }
/// <summary>Only accepted while vendor address is enabled (otherwise 422 CONFIG_DISABLED).</summary>
[StringLength(500)] public string? Address { get; set; }
public int BranchId { get; set; }
}
@@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
/// <summary>Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`:
/// 10 digits starting with 0, or 94 followed by 9 digits with or without a leading +; spaces and hyphens allowed between digits.</summary>
public static class PhoneRules
{
public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+?94(?:[\s-]*\d){9})[\s-]*$";
public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567), or 94 followed by 9 digits (94771234567 or +94771234567).";
}
public sealed class CreateWarehouseRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(300)] public string? Address { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; }
[Required] public int BranchId { get; set; }
}
@@ -39,7 +39,12 @@ public sealed class AuthHexClient : IAuthHexClient
// ---- UserManager --------------------------------------------------
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
{
// Until AuthHex stops requiring a role (its roles are being retired), give it a fixed placeholder so
// registration keeps working. The ERP never reads this value; the person's real role is users.RoleId.
request.RoleId ??= Guid.TryParse(_config["AuthHex:LegacyRoleId"], out var legacy) ? legacy : null;
return CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
}
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
@@ -93,16 +98,35 @@ public sealed class AuthHexClient : IAuthHexClient
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
public Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct)
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call the admin deleteUser function.", 500);
return CallAsync<DeleteAuthHexUserResult>(
=> CallAsync<DeleteAuthHexUserResult>(
"user", "deleteUser",
new { email, mobileNumber, nic, userName = username },
null, ct,
extraHeaders: new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey });
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminUpdateUser",
new { userId, request.FullName, request.UserName, request.Email, request.MobileNumber },
null, ct,
extraHeaders: AdminHeaders());
public Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct)
=> CallAsync<UserSummaryDto?>(
"user", "AdminSetUserStatus",
new { userId, isActive },
null, ct,
extraHeaders: AdminHeaders());
/// <summary>Shared guard for every admin-only AuthHex function — throws up front rather
/// than leaving a misconfigured deployment to fail opaquely on the network call.</summary>
private Dictionary<string, string> AdminHeaders()
{
var adminApiKey = _config["AuthHex:AdminApiKey"];
if (string.IsNullOrEmpty(adminApiKey))
throw new DomainException(ErrorCodes.AuthUpstreamError, "AuthHex:AdminApiKey is not configured — cannot call this admin function.", 500);
return new Dictionary<string, string> { ["X-Admin-Api-Key"] = adminApiKey };
}
// ---- RecoveryManager ------------------------------------------------
@@ -130,22 +154,66 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- RoleManager --------------------------------------------------
// ---- Notify -------------------------------------------------------------
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> NotifyAsync("sms", new { to, text }, ct);
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
/// <summary>
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
/// </summary>
private async Task<IReadOnlyList<AuthHexNotifyResult>> NotifyAsync(string channel, object body, CancellationToken ct)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
foreach (var (key, value) in AdminHeaders())
httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
HttpResponseMessage httpResponse;
try
{
httpResponse = await _http.SendAsync(httpRequest, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
}
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
=> CallVoidAsync("role", "deleteRole", new { roleId }, null, ct);
NotifyResponse? payload = null;
try
{
payload = await httpResponse.Content.ReadFromJsonAsync<NotifyResponse>(JsonOptions, ct);
}
catch (JsonException)
{
// Fall through — reported below with the HTTP status.
}
if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
{
var message = payload?.Message
?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
? "The authentication service does not support sending messages yet (no /api/notify)."
: $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
}
return payload.Results;
}
private sealed class NotifyResponse
{
public List<AuthHexNotifyResult>? Results { get; set; }
public string? Message { get; set; }
}
// ---- Transport --------------------------------------------------------
@@ -38,6 +38,15 @@ public interface IAuthHexClient
/// </summary>
Task<DeleteAuthHexUserResult> DeleteUserAsync(string? email, string? mobileNumber, string? nic, string? username, CancellationToken ct);
/// <summary>Admin-only: updates another user's identity fields (not self-service —
/// <see cref="UpdateUserAsync"/> only ever touches the caller's own account).
/// Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminUpdateUserAsync(Guid userId, AdminUpdateUserRequest request, CancellationToken ct);
/// <summary>Admin-only: activates/deactivates another user's AuthHex identity and, when
/// deactivating, invalidates their live sessions. Gated by AuthHex:AdminApiKey.</summary>
Task<UserSummaryDto?> AdminSetUserStatusAsync(Guid userId, bool isActive, CancellationToken ct);
// RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
@@ -49,11 +58,11 @@ public interface IAuthHexClient
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
// Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
// SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
@@ -22,4 +22,24 @@ public static class AuthHexClaims
/// branch-scoped query filtering (docs/BRANCH_ISOLATION_PLAN.md).
/// </summary>
public const string BranchId = "erp:BranchId";
/// <summary>
/// Local claim: "false" when the deployment has branching switched off (Settings → Branch Configuration), absent or
/// "true" otherwise. Lets <see cref="ICurrentBranchContext"/> tell "a branch-less user in a single-branch
/// deployment" (sees everything) from "a branch-less user in a multi-branch deployment" (sees no branch's data).
/// </summary>
public const string BranchingEnabled = "erp:BranchingEnabled";
// ---- Local claims: the ERP's own authorization identity ---------------------------------------------------
// Injected by ShadowUserClaimsTransformation from the ERP's users/roles tables on every request. They are NEVER
// read from the token: the role belongs to the ERP, AuthHex only says who the person is. The AuthHex RoleId /
// RoleCode claims above are used only by the temporary legacy fallback.
/// <summary>The effective role's id (the user's role, or the default role). Absent when the person has no usable role.</summary>
public const string ErpRoleId = "erp:RoleId";
public const string ErpRoleCode = "erp:RoleCode";
/// <summary>"true" when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>).</summary>
public const string ErpIsAdmin = "erp:IsAdmin";
/// <summary>Present when the ERP user is inactive; the <c>ErpAccess</c> policy then refuses the request (403).</summary>
public const string ErpBlocked = "erp:Blocked";
}
+18 -5
View File
@@ -4,9 +4,10 @@ using ERPCore.Domain.Entities;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Resolves the audit actor from the current HTTP request's principal
/// (token `sub` / NameIdentifier). Falls back to <see cref="SystemActor"/>
/// when the request is unauthenticated (e.g. health checks, seeding).
/// Resolves the audit actor and the ERP-owned role from the current HTTP request's principal.
/// Every value here was put on the principal by <see cref="ShadowUserClaimsTransformation"/> from the ERP's own
/// tables — none comes from AuthHex role claims. Falls back to <see cref="SystemActor"/> when the request is
/// unauthenticated (e.g. health checks, seeding).
/// </summary>
public sealed class CurrentUser : ICurrentUser
{
@@ -32,9 +33,18 @@ public sealed class CurrentUser : ICurrentUser
public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.RoleCode);
public int? RoleId
{
get
{
var raw = _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleId);
return int.TryParse(raw, out var id) ? id : null;
}
}
public bool IsAdmin => string.Equals(RoleCode?.Trim(), "Admin", StringComparison.OrdinalIgnoreCase);
public string? RoleCode => _accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpRoleCode);
public bool IsAdmin => string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.ErpIsAdmin), "true", StringComparison.Ordinal);
public int? BranchId
{
@@ -44,4 +54,7 @@ public sealed class CurrentUser : ICurrentUser
return int.TryParse(raw, out var id) ? id : null;
}
}
public bool BranchingEnabled =>
!string.Equals(_accessor.HttpContext?.User?.FindFirstValue(AuthHexClaims.BranchingEnabled), "false", StringComparison.Ordinal);
}
@@ -9,7 +9,9 @@ namespace ERPCore.Infra.Auth;
public interface ICurrentBranchContext
{
/// <summary>The branch to scope reads to. Null means "don't filter" — used for
/// admins, unauthenticated/system contexts, and any caller with no resolved branch.</summary>
/// admins, unauthenticated/system contexts, and (only when the deployment has branching switched off) people with
/// no branch. In a branching deployment, a signed-in non-admin with no linked employee gets
/// <see cref="CurrentBranchContext.NoBranchId"/>: a branch that matches no data, so they see none (deny by default).</summary>
int? BranchId { get; }
/// <summary>False disables filtering entirely (e.g. background jobs, migrations,
@@ -18,17 +20,29 @@ public interface ICurrentBranchContext
}
/// <summary>
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins and users with
/// no resolved branch are never filtered — the same bypass <see cref="Services.Interfaces.IBranchAccessService"/>
/// already applies for write-path checks.
/// Default implementation: derives from <see cref="ICurrentUser"/>. Admins are never filtered, nor is anyone in a
/// deployment with branching off. A non-admin without a branch (no linked employee) in a branching deployment is scoped to
/// <see cref="NoBranchId"/> — they used to fall through unfiltered and could read every branch's records, while the
/// write path (<see cref="Services.Interfaces.IBranchAccessService"/>) already refused them.
/// </summary>
public sealed class CurrentBranchContext : ICurrentBranchContext
{
/// <summary>The "branch" of a person who has none in a branching deployment. No row has branch 0, so filters on it match nothing.</summary>
public const int NoBranchId = 0;
private readonly ICurrentUser _currentUser;
public CurrentBranchContext(ICurrentUser currentUser) => _currentUser = currentUser;
public int? BranchId => _currentUser.IsAdmin ? null : _currentUser.BranchId;
public bool IsActive => _currentUser.IsAuthenticated && !_currentUser.IsAdmin && _currentUser.BranchId is not null;
public int? BranchId
{
get
{
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return null;
if (_currentUser.BranchId is { } own) return own;
return _currentUser.BranchingEnabled ? NoBranchId : null;
}
}
public bool IsActive => BranchId is not null;
}
+20 -7
View File
@@ -1,9 +1,9 @@
namespace ERPCore.Infra.Auth;
/// <summary>
/// Abstraction over the authenticated principal, used purely as the audit actor
/// (RBAC is deferred for Phase 1). The actor is derived from the token's `sub`
/// claim — never from the request body (see 00-CORE §4).
/// Abstraction over the authenticated principal: the audit actor plus the ERP-owned role. Everything here is
/// derived from the ERP's own users/roles tables via ShadowUserClaimsTransformation, keyed by the AuthHex
/// identity — never from the request body, and never from AuthHex role claims (see 00-CORE §4).
/// </summary>
public interface ICurrentUser
{
@@ -20,13 +20,20 @@ public interface ICurrentUser
/// <summary>True when the request carries an authenticated principal.</summary>
bool IsAuthenticated { get; }
/// <summary>The AuthHex <c>RoleCode</c> claim verbatim, or null if absent/unauthenticated.</summary>
/// <summary>
/// The id of the person's effective ERP role (their own, else the configured default), read from the ERP's users
/// table on each request. Null when they have no usable role (none assigned and no default, or the role is
/// inactive) — they then have no permissions.
/// </summary>
int? RoleId { get; }
/// <summary>The effective role's code, for display and logging. Never use it to make a permission decision.</summary>
string? RoleCode { get; }
/// <summary>
/// True when <see cref="RoleCode"/> is "Admin" (case-insensitive — AuthHex mints the claim
/// independently of ERPCore's stored casing). Mirrors <c>RoleService.GetMeAsync</c>'s check;
/// the only role that bypasses branch-scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// True when the effective role is the administrator role (<see cref="Entities.Role.IsAdmin"/>) — not when its
/// name happens to be "Admin". Admins pass every permission check and are the only users that bypass
/// branch scoping (see <see cref="Services.Interfaces.IBranchAccessService"/>).
/// </summary>
bool IsAdmin { get; }
@@ -37,4 +44,10 @@ public interface ICurrentUser
/// see <see cref="Services.Interfaces.IBranchAccessService"/> and <see cref="ICurrentBranchContext"/>).
/// </summary>
int? BranchId { get; }
/// <summary>
/// Whether the deployment uses branches at all (Settings → Branch Configuration). When off, every document lives on the
/// one default branch and nobody is branch-scoped. Defaults to true — the safe side — for contexts that don't say.
/// </summary>
bool BranchingEnabled => true;
}
@@ -37,7 +37,6 @@ public static class JwtAuthExtensions
var publicKeyXml = config["Auth:RsaPublicKeyXml"]
?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
var requiredUserType = config["Auth:RequiredUserTypeCode"];
var requiredRole = config["Auth:RequiredRoleCode"];
// AuthHex publishes no JWKS; the RSA public key is configured statically.
var rsa = RSA.Create();
@@ -85,12 +84,13 @@ public static class JwtAuthExtensions
options.AddPolicy(ErpAccessPolicy, policy =>
{
policy.RequireAuthenticatedUser();
// Door gate: only enforce a UserType/Role when configured (AuthHex is a
// shared IdP). Empty config = require a valid ERP token only.
// Door gate: only enforce a UserType when configured (AuthHex is a shared IdP). Empty config =
// require a valid ERP token only. (There is no role door gate any more: roles are the ERP's own,
// and are enforced per endpoint by the Nav policies.)
if (!string.IsNullOrWhiteSpace(requiredUserType))
policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
if (!string.IsNullOrWhiteSpace(requiredRole))
policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
// An inactive ERP user is refused outright, even while their AuthHex token is still valid.
policy.RequireAssertion(ctx => !ctx.User.HasClaim(c => c.Type == AuthHexClaims.ErpBlocked));
});
});
@@ -57,9 +57,9 @@ public sealed class NavRequirement : IAuthorizationRequirement
}
}
/// <summary>Admin always passes (mirrors every other Admin-bypasses-scoping rule in this
/// codebase, e.g. <c>BranchAccessService</c>); everyone else needs the role permission,
/// and — when the policy names one — the specific action within it too.</summary>
/// <summary>An administrator (<see cref="ICurrentUser.IsAdmin"/> — the role flag, not a role name) always passes,
/// mirroring every other admin-bypasses-scoping rule in this codebase (e.g. <c>BranchAccessService</c>); everyone
/// else needs the role's permission and — when the policy names one — the specific action within it too.</summary>
public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequirement>
{
private readonly ICurrentUser _currentUser;
@@ -81,9 +81,9 @@ public sealed class NavAuthorizationHandler : AuthorizationHandler<NavRequiremen
return;
}
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleCode, requirement.NavCode)) return;
if (!await _navPermissions.HasAccessAsync(_currentUser.RoleId, requirement.NavCode)) return;
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleCode, requirement.NavCode, requirement.Action.Value))
if (requirement.Action is null || await _navPermissions.HasActionAsync(_currentUser.RoleId, requirement.NavCode, requirement.Action.Value))
context.Succeed(requirement);
}
}
@@ -8,19 +8,43 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
/// <c>int</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
/// Turns an authenticated AuthHex principal into ERPCore's authorization identity.
/// <para>
/// AuthHex says WHO the person is: its token carries a custom <c>UserId</c> (GUID) and no <c>sub</c>/<c>nameid</c>.
/// The ERP decides WHAT they may do, from its own tables. In one query this finds the local shadow
/// <see cref="User"/> (keyed by <c>auth_user_id</c>, JIT-provisioned on first sign-in) together with its
/// <see cref="Role"/> and branch, then injects:
/// <list type="bullet">
/// <item><c>NameIdentifier</c> — the local int user id (audit actor);</item>
/// <item><see cref="AuthHexClaims.ErpRoleId"/> / <see cref="AuthHexClaims.ErpRoleCode"/> / <see cref="AuthHexClaims.ErpIsAdmin"/> — the effective role;</item>
/// <item><see cref="AuthHexClaims.BranchId"/> — the branch of the linked employee;</item>
/// <item><see cref="AuthHexClaims.ErpBlocked"/> — when the ERP user is inactive (the door policy then answers 403).</item>
/// </list>
/// Because the role is read from the database on every request, changing someone's role or a role's permissions
/// takes effect on their very next request — no re-login, no stale token.
/// </para>
/// <para>
/// <b>Effective role</b> = the user's own role if it is active; otherwise nothing. A user with no role gets one
/// assigned and saved: during the rollout, the role named by a still-present AuthHex <c>RoleCode</c> claim
/// (<c>Auth:LegacyRoleClaimFallback</c>, on by default, to be switched off once every user has a role); else the
/// configured default role (<see cref="AccessConfig.DefaultRoleId"/>). With neither, they have no permissions.
/// </para>
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
/// </summary>
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
private readonly ErpDbContext _db;
private readonly bool _legacyRoleFallback;
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
public ShadowUserClaimsTransformation(ErpDbContext db, IConfiguration configuration)
{
_db = db;
// Default ON: the safe choice while some users may still have no local role.
_legacyRoleFallback = !bool.TryParse(configuration["Auth:LegacyRoleClaimFallback"], out var enabled) || enabled;
}
private sealed record UserRow(
int UserId, EntityStatus Status, int? RoleId, string? RoleCode, EntityStatus? RoleStatus, bool RoleIsAdmin, int? BranchId, bool? BranchingEnabled);
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
@@ -33,29 +57,63 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
if (!Guid.TryParse(raw, out var authUserId))
return principal; // no mappable identity → CurrentUser falls back to system
var nic = principal.FindFirstValue(AuthHexClaims.Nic);
var localId = await ResolveOrProvisionAsync(authUserId, nic);
var row = await LoadAsync(authUserId)
?? await ProvisionAsync(authUserId, principal.FindFirstValue(AuthHexClaims.Nic));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, row.UserId.ToString()));
if (row.BranchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, row.BranchId.Value.ToString()));
if (row.BranchingEnabled == false)
identity.AddClaim(new Claim(AuthHexClaims.BranchingEnabled, "false"));
var branchId = await _db.Employees.AsNoTracking()
.Where(e => e.UserId == localId)
.Select(e => (int?)e.BranchId)
.FirstOrDefaultAsync();
if (branchId is not null)
identity.AddClaim(new Claim(AuthHexClaims.BranchId, branchId.Value.ToString()));
if (row.Status != EntityStatus.Active)
{
identity.AddClaim(new Claim(AuthHexClaims.ErpBlocked, "1"));
return principal;
}
// No role yet? Assign one (and save it) so the Users screen shows what they actually have.
if (row.RoleId is null)
row = await AssignMissingRoleAsync(row, principal.FindFirstValue(AuthHexClaims.RoleCode));
// An inactive role grants nothing: the person is treated as having no role until it is reactivated or changed.
if (row.RoleId is not null && row.RoleStatus == EntityStatus.Active)
{
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleId, row.RoleId.Value.ToString()));
identity.AddClaim(new Claim(AuthHexClaims.ErpRoleCode, row.RoleCode ?? string.Empty));
identity.AddClaim(new Claim(AuthHexClaims.ErpIsAdmin, row.RoleIsAdmin ? "true" : "false"));
}
return principal;
}
private async Task<int> ResolveOrProvisionAsync(Guid authUserId, string? nic)
{
var existing = await _db.Users.AsNoTracking()
/// <summary>The user with its role and branch, in ONE round trip.</summary>
private Task<UserRow?> LoadAsync(Guid authUserId) =>
_db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync();
if (existing != 0) return existing;
private Task<UserRow?> LoadAsync(int userId) =>
_db.Users.AsNoTracking()
.Where(u => u.UserId == userId)
.Select(u => new UserRow(
u.UserId, u.Status, u.RoleId,
u.Role != null ? u.Role.Code : null,
u.Role != null ? (EntityStatus?)u.Role.Status : null,
u.Role != null && u.Role.IsAdmin,
_db.Employees.Where(e => e.UserId == u.UserId).Select(e => (int?)e.BranchId).FirstOrDefault(),
_db.BranchConfig.Where(c => c.ConfigId == BranchConfig.SingletonId).Select(c => (bool?)c.BranchingEnabled).FirstOrDefault()))
.FirstOrDefaultAsync();
private async Task<UserRow> ProvisionAsync(Guid authUserId, string? nic)
{
var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
var user = new User
{
@@ -69,16 +127,58 @@ public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
{
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user.UserId;
}
catch (DbUpdateException)
{
// Lost a race (unique auth_user_id) — the row now exists; re-read it.
// Lost a race (unique auth_user_id) — the row now exists; re-read it below.
_db.Entry(user).State = EntityState.Detached;
return await _db.Users.AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => u.UserId)
.FirstAsync();
}
return await LoadAsync(authUserId) ?? throw new InvalidOperationException("Could not provision the ERP user.");
}
private async Task<UserRow> AssignMissingRoleAsync(UserRow row, string? legacyRoleCode)
{
var role = null as Role;
// 1) Rollout safety net: the role the (old) AuthHex token still names — what this person could do until now.
if (_legacyRoleFallback && !string.IsNullOrWhiteSpace(legacyRoleCode))
{
var code = legacyRoleCode.Trim().ToLower();
role = await _db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Code.ToLower() == code && r.Status == EntityStatus.Active);
}
// 2) The configured default role (least privilege by design; never the admin role).
if (role is null)
{
role = await _db.AccessConfig.AsNoTracking()
.Where(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRole != null && c.DefaultRole.Status == EntityStatus.Active)
.Select(c => c.DefaultRole)
.FirstOrDefaultAsync();
}
if (role is null) return row; // no role and no default → no permissions
try
{
// Only fill a role that is still empty: one an administrator assigned a moment ago is never overwritten.
var tracked = await _db.Users.FirstOrDefaultAsync(u => u.UserId == row.UserId);
if (tracked is not null && tracked.RoleId is null)
{
tracked.RoleId = role.RoleId;
await _db.SaveChangesAsync();
}
else if (tracked?.RoleId is int existing && existing != role.RoleId)
{
return await LoadAsync(row.UserId) ?? row; // somebody assigned a role meanwhile: use theirs
}
}
catch (DbUpdateException)
{
// Not fatal: the request still carries the role for this call; the next request retries the save.
_db.ChangeTracker.Clear();
}
return row with { RoleId = role.RoleId, RoleCode = role.Code, RoleStatus = role.Status, RoleIsAdmin = role.IsAdmin };
}
}
@@ -1,34 +1,68 @@
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
/// issues alongside the session cookies.
/// <para>
/// Every mutating cookie-authenticated endpoint is covered by <see cref="CsrfProtectionFilter"/> (registered globally),
/// so this attribute is only needed on an action that must be checked even when the session cookie is absent
/// (AuthController's token-in-body endpoints).
/// </para>
/// </summary>
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
{
Enforce(context.HttpContext.Request);
await next();
return;
}
/// <summary>Throws a 403 <c>CSRF_TOKEN_MISMATCH</c> unless the request is bearer-authenticated or carries a header matching the CSRF cookie.</summary>
public static void Enforce(HttpRequest request)
{
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString())) return;
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
}
}
/// <summary>
/// Registered globally: any state-changing request (POST/PUT/PATCH/DELETE) that is authenticated by the browser's
/// session cookie must pass the double-submit check. Not applied to safe methods, to anonymous endpoints (login,
/// registration — no session to ride on), to actions that already carry <see cref="ValidateCsrfAttribute"/>, or to
/// requests with no session cookie at all (nothing for a forged request to ride on; those fail authentication anyway).
/// </summary>
public sealed class CsrfProtectionFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var http = context.HttpContext;
var request = http.Request;
var unsafeMethod = HttpMethods.IsPost(request.Method) || HttpMethods.IsPut(request.Method)
|| HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method);
var endpoint = http.GetEndpoint();
if (unsafeMethod
&& request.Cookies.ContainsKey(JwtAuthExtensions.AccessTokenCookie)
&& endpoint?.Metadata.GetMetadata<IAllowAnonymous>() is null
&& endpoint?.Metadata.GetMetadata<ValidateCsrfAttribute>() is null)
{
ValidateCsrfAttribute.Enforce(request);
}
await next();
}
@@ -0,0 +1,55 @@
using ERPCore.Infra.Auth.AuthHex;
namespace ERPCore.Infra.Messaging;
/// <summary>Per-recipient outcome of a send.</summary>
public sealed record SendResult(string To, bool Ok, string? Error);
/// <summary>
/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
/// Implementations report per-recipient failures in the result and only throw when the whole
/// call failed (e.g. AuthHex unreachable).
/// </summary>
public interface IMessageSender
{
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
public sealed class AuthHexMessageSender : IMessageSender
{
private readonly IAuthHexClient _authHex;
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
public async Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
}
/// <summary>
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
/// development and tests, so nothing reaches real phones or inboxes.
/// </summary>
public sealed class LogOnlyMessageSender : IMessageSender
{
private readonly ILogger<LogOnlyMessageSender> _logger;
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>The singleton access-configuration row; the check constraint makes "singleton" a database guarantee.</summary>
public sealed class AccessConfigConfiguration : IEntityTypeConfiguration<AccessConfig>
{
public void Configure(EntityTypeBuilder<AccessConfig> builder)
{
builder.ToTable("access_config", t =>
t.HasCheckConstraint("ck_access_config_singleton", $"`ConfigId` = {AccessConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
// If the default role is deleted the setting clears itself rather than blocking the delete.
builder.HasOne(c => c.DefaultRole)
.WithMany()
.HasForeignKey(c => c.DefaultRoleId)
.OnDelete(DeleteBehavior.SetNull);
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -34,7 +34,7 @@ public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<Appro
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
builder.Property(c => c.Level2RoleIds).HasMaxLength(500);
// Dual approval on by default for both documents (plan §8 recommended defaults).
builder.HasData(new ApprovalConfig
@@ -16,6 +16,7 @@ public sealed class BundleSaleTemplateConfiguration : IEntityTypeConfiguration<B
builder.HasIndex(x => x.TemplateCode).IsUnique();
builder.Property(x => x.TemplateName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Description).HasMaxLength(1000);
builder.Property(x => x.BundlePrice).HasPrecision(18, 2).HasDefaultValue(0m);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
@@ -35,7 +35,8 @@ public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration<GlAc
nameof(GlAccountConfig.InventoryGainAccountCode),
nameof(GlAccountConfig.CogsAccountCode),
nameof(GlAccountConfig.InventoryLossAccountCode),
nameof(GlAccountConfig.CashAccountCode)
nameof(GlAccountConfig.CashAccountCode),
nameof(GlAccountConfig.AccruedExpensesCode)
})
{
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
@@ -41,7 +41,11 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 },
// Added with the permission-coverage pass (Phase B): these two sidebar sections had no row, so no role could be
// granted them and the sidebar had to bypass the check for everybody.
new NavItem { NavItemId = 15, Code = "hrm", Label = "Human Resources", Href = "/dashboard/hrm", SortOrder = 15 },
new NavItem { NavItemId = 16, Code = "sales-returns", Label = "Sales Returns", Href = "/dashboard/sales/sales-returns", SortOrder = 16 }
);
}
}
@@ -0,0 +1,67 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Posted opening balances. The unique index on Kind is the once-only guard.</summary>
public sealed class OpeningBalanceRunConfiguration : IEntityTypeConfiguration<OpeningBalanceRun>
{
public void Configure(EntityTypeBuilder<OpeningBalanceRun> builder)
{
builder.ToTable("opening_balance_runs");
builder.HasKey(x => x.OpeningBalanceRunId);
builder.Property(x => x.Kind).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasIndex(x => x.Kind);
// The once-only guard: only kinds that may be posted once set this, so a second attempt hits the unique index.
builder.Property(x => x.OnceKey).HasMaxLength(20);
builder.HasIndex(x => x.OnceKey).IsUnique();
builder.Property(x => x.CutoverDate).IsRequired();
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.TotalAmount).HasPrecision(18, 4);
builder.Property(x => x.Currency).IsRequired().HasMaxLength(3);
builder.Property(x => x.PostedAt).IsRequired();
}
}
public sealed class OpeningBalanceLineConfiguration : IEntityTypeConfiguration<OpeningBalanceLine>
{
public void Configure(EntityTypeBuilder<OpeningBalanceLine> builder)
{
builder.ToTable("opening_balance_lines");
builder.HasKey(x => x.OpeningBalanceLineId);
builder.Property(x => x.AccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.AccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.Amount).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.Lines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
}
}
public sealed class OpeningStockLineConfiguration : IEntityTypeConfiguration<OpeningStockLine>
{
public void Configure(EntityTypeBuilder<OpeningStockLine> builder)
{
builder.ToTable("opening_stock_lines");
builder.HasKey(x => x.OpeningStockLineId);
builder.Property(x => x.Qty).HasPrecision(18, 4);
builder.Property(x => x.UnitCost).HasPrecision(18, 4);
builder.Property(x => x.Value).HasPrecision(18, 4);
builder.HasOne(x => x.Run)
.WithMany(r => r.StockLines)
.HasForeignKey(x => x.OpeningBalanceRunId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Warehouse).WithMany().HasForeignKey(x => x.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ItemId, x.WarehouseId });
}
}
@@ -0,0 +1,75 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
{
public void Configure(EntityTypeBuilder<OwnerReportConfig> builder)
{
builder.ToTable("owner_report_config", t =>
t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.SendAtLocalTime).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration<OwnerReportRecipient>
{
public void Configure(EntityTypeBuilder<OwnerReportRecipient> builder)
{
builder.ToTable("owner_report_recipients");
builder.HasKey(r => r.RecipientId);
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.Property(r => r.Mobile).HasMaxLength(20);
builder.Property(r => r.Email).HasMaxLength(200);
builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
}
}
public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration<OwnerReportDispatch>
{
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
{
builder.ToTable("owner_report_dispatches");
builder.HasKey(d => d.DispatchId);
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Status).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
builder.Property(d => d.Error).HasMaxLength(1000);
builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
// The "send once" guard: one automatic send per day, channel and recipient. Test sends get
// a unique DedupeKey so any number of them can coexist.
builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
builder.HasIndex(d => d.CreatedAt);
// Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
builder.HasOne(d => d.Recipient)
.WithMany()
.HasForeignKey(d => d.RecipientId)
.OnDelete(DeleteBehavior.SetNull);
}
}

Some files were not shown because too many files have changed in this diff Show More