Compare commits

...

84 Commits

Author SHA1 Message Date
HarithaRandunu ca81e9bc42 feat: add General Ledger integration and financial reporting features
- Implemented General Ledger client component for managing GL integration.
- Created a dedicated page for General Ledger configuration.
- Developed Finance Report client component for generating various financial reports.
- Added report blocks for structured display of financial data.
- Introduced API endpoints for fetching GL accounts and financial reports.
- Updated navigation to include links for GL integration and financial reports.
- Enhanced permissions to manage GL and view financial reports.
- Updated appsettings to include General Ledger API configuration.
2026-10-20 18:54:30 +05:30
kalana_Thilakarathna cc33ecdd54 Merge pull request 'Implement store credit tracking and enforcement' (#77) from dev into production
Reviewed-on: #77
2026-10-20 11:51:10 +05:30
Kalana B. Thilakarathna 5707bd4c31 Implement store credit tracking and enforcement
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m30s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m1s
Introduced StoreCreditMovement entity and migration to track store credit usage and settlements. Updated payment flow to enforce credit limits and record credit movements. Modified outstanding balance calculation to use credit movements. Enhanced frontend to display real-time credit info and prevent over-credit payments. Updated API types, seed data, and DI registrations accordingly.
2026-10-20 11:45:11 +05:30
kalana_Thilakarathna f77db46a02 Merge pull request 'Dev' (#76) from dev into production
Reviewed-on: #76
2026-10-19 23:38:07 +05:30
Kalana B. Thilakarathna 28c465f59b feat: refactor TopLoader to use a hook for active state management and improve CSS for fixed positioning
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m21s
2026-10-19 23:37:45 +05:30
Kalana B. Thilakarathna 21b38b5276 feat: enhance DataTable with custom empty state support and update GrnClient to utilize it 2026-10-19 23:23:18 +05:30
kalana_Thilakarathna 267144f7a1 Merge pull request 'feat: add EmptyState component for improved no-data display in supplier payments' (#75) from dev into production
Reviewed-on: #75
2026-10-19 22:35:48 +05:30
Kalana B. Thilakarathna 992cd292fa feat: add EmptyState component for improved no-data display in supplier payments
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m51s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m39s
2026-10-19 22:35:01 +05:30
kalana_Thilakarathna efb5657b6b Merge pull request 'feat: update row actions to include payment functionality and add corresponding icon' (#74) from dev into production
Reviewed-on: #74
2026-10-19 22:13:07 +05:30
Kalana B. Thilakarathna 7b6c9ac92a feat: update row actions to include payment functionality and add corresponding icon
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m33s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m20s
2026-10-19 22:12:40 +05:30
kalana_Thilakarathna 622b75e187 Merge pull request 'feat: integrate payment functionality with new payment sheet and enhance supplier payments UI' (#73) from dev into production
Reviewed-on: #73
2026-10-19 22:02:45 +05:30
Kalana B. Thilakarathna eb0b042f0c feat: integrate payment functionality with new payment sheet and enhance supplier payments UI
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Successful in 1m51s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 10m54s
2026-10-19 22:02:15 +05:30
kalana_Thilakarathna 293df41d4e Merge pull request 'Dev' (#72) from dev into production
Reviewed-on: #72
2026-10-19 21:41:42 +05:30
Kalana B. Thilakarathna a980e636f5 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m57s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m40s
2026-10-19 21:36:08 +05:30
Kalana B. Thilakarathna b992d07689 feat: add supplier payment and allocation models, API, and frontend components
- Created `SupplierPayment` and `SupplierPaymentGRN` models for managing supplier payments and their allocations.
- Implemented API endpoints for listing and creating supplier payments.
- Developed frontend components for displaying and recording supplier payments, including a dialog for payment creation.
- Integrated GRN allocations into the payment creation process, ensuring total allocations match the payment amount.
- Added pagination and loading states for the payments list.
2026-10-19 21:36:01 +05:30
DeepnaPooja 5e81fd77b1 Merge pull request 'Dev' (#71) from dev into production
Reviewed-on: #71
2026-10-19 16:47:06 +05:30
DeepnaPooja a4d58f3377 Merge pull request 'Fix changes' (#70) from fix-changes into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m33s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m20s
Reviewed-on: #70
2026-10-19 16:45:55 +05:30
Kalana B. Thilakarathna 94db2a311c Refactor code structure for improved readability and maintainability 2026-10-19 16:42:12 +05:30
DeepnaPooja 6b608ad2ef Update permission feature with edit and create role functionality 2026-10-19 16:39:47 +05:30
kalana_Thilakarathna a1fd5f1a08 Merge pull request 'feat: add cheque books and received cheques management' (#69) from dev into production
Reviewed-on: #69
2026-10-19 16:12:32 +05:30
Kalana B. Thilakarathna 87c807c16c feat: add cheque books and received cheques management
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m8s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m49s
- Implemented ChequeBooksPage for viewing and filtering cheque books.
- Added NewReceivedChequePage for recording received cheques.
- Created ReceivedChequesPage for listing and managing received cheques.
- Introduced API endpoints for bank accounts, cheque books, and received cheques.
- Defined types for payments including bank accounts, cheque books, and received cheques.
2026-10-19 16:09:05 +05:30
DeepnaPooja 3c75f9786e Add multi-series charts and enhance dashboard functionality 2026-10-19 15:51:39 +05:30
DeepnaPooja 2d6723a040 Update dashboard 2026-10-19 15:48:45 +05:30
kalana_Thilakarathna 2fe2ba007c Merge pull request 'fix: update login function to handle profile data correctly' (#68) from dev into production
Reviewed-on: #68
2026-10-19 10:25:57 +05:30
Kalana B. Thilakarathna f3f2b71bd4 fix: update login function to handle profile data correctly
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m43s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m30s
2026-10-19 10:25:29 +05:30
kalana_Thilakarathna bc72426495 Merge pull request 'auth fix' (#67) from dev into production
Reviewed-on: #67
2026-10-19 10:20:29 +05:30
Kalana B. Thilakarathna da29492798 auth fix
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m10s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m51s
2026-10-19 10:19:40 +05:30
kalana_Thilakarathna ce21aa6588 Merge pull request 'feat: add toast notifications for successful and failed login attempts' (#66) from dev into production
Reviewed-on: #66
2026-10-19 02:50:47 +05:30
Kalana B. Thilakarathna f44cbf711b feat: add toast notifications for successful and failed login attempts
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m53s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m41s
2026-10-19 02:50:06 +05:30
kalana_Thilakarathna aaac42b481 Merge pull request 'Dev' (#65) from dev into production
Reviewed-on: #65
2026-10-19 02:45:35 +05:30
Kalana B. Thilakarathna 7a21cde217 refactor: simplify user API calls by removing pageSize parameter in multiple components
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m52s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m5s
2026-10-19 02:44:19 +05:30
Kalana B. Thilakarathna 531b3a7fef Redesign login UI, add theme toggle, refactor user update
- Redesigned login page with two-panel layout and branding
- Added HexaDistro logo and theme toggle to sidebar/header
- Refactored user update: roles via update endpoint, removed assignRole API/controller
- Extended UpdateUserRequest for username, role, password changes
- UserService.UpdateAsync syncs changes with AuthHex, with rollback
- Stock balances client dedupes by itemId-locationId
- Middleware matcher excludes static assets from auth checks
- Improved localStorage clearing for user context
- Added lucide-react for icons
- Minor UI/code cleanups for consistency and UX
2026-10-19 02:20:14 +05:30
kalana_Thilakarathna a81e32bbe2 Merge pull request 'fe fix for loggin redirection' (#64) from dev into production
Reviewed-on: #64
2026-10-19 02:16:31 +05:30
Kalana B. Thilakarathna 4ef63420b7 fe fix for loggin redirection
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m36s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m19s
2026-10-18 23:38:28 +05:30
Kalana B. Thilakarathna 9fdf5aa8e3 Merge branch 'dev' into production 2026-10-18 23:23:12 +05:30
Kalana B. Thilakarathna d9c339949e conflict fixed 2026-10-18 23:22:04 +05:30
Kalana B. Thilakarathna 0052725b88 feat: add user and role view sheets with detailed information display
- Created UserViewSheet component to display user details including username, full name, role, status, and timestamps.
- Created RoleViewSheet component to display role details including name, level, status, creation date, and associated permissions.
- Introduced permissions configuration in a new permissions.ts file, defining categories and permission codes for better management.
- Implemented middleware for authentication, ensuring access control for protected routes.
2026-10-18 23:11:01 +05:30
DeepnaPooja 5d18aba1f9 Merge pull request 'Dev' (#63) from dev into production
Reviewed-on: #63
2026-10-16 10:53:09 +00:00
DeepnaPooja 5828879ddc Merge pull request 'chore: add exceljs, jspdf, and jspdf-autotable dependencies to package.json' (#62) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m0s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m11s
Reviewed-on: #62
2026-10-16 10:52:30 +00:00
DeepnaPooja 728f2a8b17 Merge pull request 'Dev' (#61) from dev into production
Reviewed-on: #61
2026-10-16 10:26:15 +00:00
DeepnaPooja 1b66d5d767 Merge pull request 'Refactor/main flow' (#60) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m3s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 15m51s
Reviewed-on: #60
2026-10-16 10:25:08 +00:00
DeepnaPooja 5c37f02f19 Merge pull request 'Dev' (#59) from dev into production
Reviewed-on: #59
2026-10-09 04:38:19 +00:00
DeepnaPooja 6b3f28d3ae Merge pull request 'Refactor/main flow' (#58) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m37s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m0s
Reviewed-on: #58
2026-10-09 04:37:42 +00:00
Kalana B. Thilakarathna 558afc6f9d auth layer implemented 2026-10-09 09:58:45 +05:30
Kalana B. Thilakarathna 5b2ac8cc14 Merge branch 'dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into dev 2026-10-09 09:37:38 +05:30
Kalana B. Thilakarathna 5cb32bee95 Revert "auth layer implemented"
This reverts commit 9adfff48a7.
2026-10-09 09:37:28 +05:30
Kalana B. Thilakarathna 9adfff48a7 auth layer implemented 2026-10-09 09:37:05 +05:30
DeepnaPooja f5967497be Merge pull request 'Dev' (#57) from dev into production
Reviewed-on: #57
2026-10-08 07:36:24 +00:00
DeepnaPooja 74f33d8979 Merge pull request 'Refactor/main flow' (#56) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Successful in 1m42s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 11m55s
Reviewed-on: #56
2026-10-08 07:35:34 +00:00
DeepnaPooja 4110e2fcfc Merge pull request 'Dev' (#55) from dev into production
Reviewed-on: #55
2026-10-07 05:17:54 +00:00
DeepnaPooja 23fb7361a4 Merge pull request 'Refactor/main flow' (#54) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 14m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m25s
Reviewed-on: #54
2026-10-07 05:16:58 +00:00
DeepnaPooja c52eaabec4 Merge pull request 'Dev' (#53) from dev into production
Reviewed-on: #53
2026-09-29 12:14:53 +00:00
DeepnaPooja 63c1c4ad1a Merge pull request 'fix' (#52) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m12s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m26s
Reviewed-on: #52
2026-09-29 12:14:19 +00:00
DeepnaPooja df0eb3cd5a Merge pull request 'Refactor/main flow' (#50) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m16s
Reviewed-on: #50
2026-09-29 12:04:26 +00:00
DeepnaPooja cc72e22664 Merge pull request 'Refactor/main flow' (#49) from refactor/main_flow into dev
Reviewed-on: #49
2026-09-29 09:45:51 +00:00
DeepnaPooja 37f4a88014 Merge pull request 'Dev' (#48) from dev into production
Reviewed-on: #48
2026-09-28 07:39:55 +00:00
DeepnaPooja a4f5d12234 Merge pull request 'Refactor/main flow' (#47) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m10s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m24s
Reviewed-on: #47
2026-09-28 07:39:07 +00:00
DeepnaPooja 2d365cd906 Merge pull request 'Dev' (#46) from dev into production
Reviewed-on: #46
2026-09-25 08:38:12 +00:00
DeepnaPooja d6fd685b3c Merge pull request 'Add loading sheet edit flow' (#45) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m57s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m8s
Reviewed-on: #45
2026-09-25 08:37:39 +00:00
DeepnaPooja c68c6e1ab9 Merge pull request 'Dev' (#44) from dev into production
Reviewed-on: #44
2026-09-24 12:00:43 +00:00
DeepnaPooja aaf6e5f12d Merge pull request 'Refactor/main flow' (#43) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 11m24s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m36s
Reviewed-on: #43
2026-09-24 12:00:17 +00:00
DeepnaPooja ddc3ecd3e6 Merge pull request 'Dev' (#42) from dev into production
Reviewed-on: #42
2026-09-23 08:38:21 +00:00
DeepnaPooja 556dedb8a3 Merge pull request 'Refactor/main flow' (#41) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m49s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m58s
Reviewed-on: #41
2026-09-23 08:36:57 +00:00
DeepnaPooja d7b6f29200 Merge pull request 'Dev' (#40) from dev into production
Reviewed-on: #40
2026-09-23 07:23:59 +00:00
DeepnaPooja 6f72f7a0e9 Merge pull request 'Refactor/main flow' (#39) from refactor/main_flow into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m8s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m21s
Reviewed-on: #39
2026-09-23 07:23:19 +00:00
DeepnaPooja 53dcdf929a Merge pull request 'Refactor/main flow' (#37) from refactor/main_flow into dev
Reviewed-on: #37
2026-09-22 11:43:52 +00:00
DeepnaPooja d2277f626c Merge pull request 'Refactor/main flow' (#38) from refactor/main_flow into production
Reviewed-on: #38
2026-09-22 11:43:07 +00:00
kalana_Thilakarathna 3149902574 Merge pull request 'Dev' (#36) from dev into production
Reviewed-on: #36
2026-09-21 04:05:56 +00:00
kalana_Thilakarathna 37fb72376a Merge pull request 'Dev' (#35) from dev into production
Reviewed-on: #35
2026-09-21 03:47:22 +00:00
kalana_Thilakarathna 52a9a3fde7 Merge pull request 'Add build argument for NEXT_PUBLIC_API_URL in Dockerfile and CI/CD workflow' (#32) from dev into production
Reviewed-on: #32
2026-09-20 11:21:49 +00:00
kalana_Thilakarathna 1b310c86b3 Merge pull request 'Add Dockerfile and .dockerignore; configure Next.js output settings' (#31) from dev into production
Reviewed-on: #31
2026-09-20 11:07:29 +00:00
kalana_Thilakarathna e398ee9d6b Merge pull request 'docker log' (#30) from dev into production
Reviewed-on: #30
2026-09-20 11:03:16 +00:00
kalana_Thilakarathna a3c0211654 Merge pull request 'pipline update' (#29) from dev into production
Reviewed-on: #29
2026-09-20 10:53:45 +00:00
kalana_Thilakarathna e4e12aa05a Merge pull request 'build fix' (#28) from dev into production
Reviewed-on: #28
2026-09-20 10:42:24 +00:00
kalana_Thilakarathna 3103fb66ef Merge pull request 'Dev' (#27) from dev into production
Reviewed-on: #27
2026-09-20 10:35:40 +00:00
Kalana B. Thilakarathna 93355d34f1 Merge branch 'dev' into production 2026-09-16 11:30:37 +05:30
Kalana B. Thilakarathna 9151f47d15 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-16 10:58:02 +05:30
Kalana B. Thilakarathna 661b522b7d Merge branch 'dev' into production 2026-09-16 10:57:55 +05:30
DeepnaPooja e0bb17d0ff Merge pull request 'Dev' (#25) from dev into production
Reviewed-on: #25
2026-09-16 05:13:39 +00:00
Kalana B. Thilakarathna 0f29413375 Merge branch 'dev' into production 2026-09-16 10:41:32 +05:30
DeepnaPooja ef17f7da0a Merge pull request 'Dev' (#23) from dev into production
Reviewed-on: #23
2026-09-14 06:29:20 +00:00
DeepnaPooja d9031f8e73 Merge pull request 'Dev' (#20) from dev into production
Reviewed-on: #20
2026-09-01 10:28:44 +00:00
Kalana B. Thilakarathna 75578ea7f6 Merge branch 'production' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP_Distribution into production 2026-09-01 10:54:20 +05:30
DeepnaPooja 4f02fa303d Merge pull request 'Dev' (#18) from dev into production
Reviewed-on: #18
2026-08-31 07:41:03 +00:00
236 changed files with 87141 additions and 1511 deletions
+154
View File
@@ -65,6 +65,14 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<RegionalTaxRule> RegionalTaxRules { get; set; }
public DbSet<RouteVisitLog> RouteVisitLogs { get; set; }
public DbSet<SysConfig> SysConfigs { get; set; }
public DbSet<BankAccount> BankAccounts { get; set; }
public DbSet<GlPostingLog> GlPostingLogs { get; set; }
public DbSet<ChequeBook> ChequeBooks { get; set; }
public DbSet<ChequePage> ChequePages { get; set; }
public DbSet<ReceivedCheque> ReceivedCheques { get; set; }
public DbSet<SupplierPayment> SupplierPayments { get; set; }
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -575,6 +583,16 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
.HasIndex(r => r.FK_RegionId)
.IsUnique();
// --- GL posting log ---
modelBuilder.Entity<GlPostingLog>()
.HasIndex(l => new { l.Event, l.SourceId })
.IsUnique();
modelBuilder.Entity<GlPostingLog>()
.Property(l => l.Event)
.HasMaxLength(60);
// --- SysConfig ---
modelBuilder.Entity<SysConfig>()
@@ -594,11 +612,147 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
new SysConfig { Key = Distribution.Features.SysConfig.SysConfigService.KeyDefaultWarrantyPeriodDays, Value = "0", UpdatedAt = new DateTime(2026, 10, 1), UpdatedBy = null }
);
// --- Payment service: Bank Accounts & Cheques ---
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.BankName).HasMaxLength(200);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CashAccountTypeName).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.AccountNumber).HasMaxLength(100);
modelBuilder.Entity<BankAccount>()
.Property(a => a.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<BankAccount>()
.HasIndex(a => new { a.AccountType, a.AccountName })
.IsUnique();
modelBuilder.Entity<BankAccount>().HasData(new BankAccount
{
Id = new Guid("11111111-1111-1111-1111-000000000001"),
CreatedAt = new DateTime(2026, 10, 19),
IsActive = true,
AccountType = BankAccountType.Cash,
AccountName = "Petty Cash",
CashAccountTypeName = "Petty Cash",
CurrencyCode = "LKR",
IsDefault = true
});
modelBuilder.Entity<ChequeBook>()
.Property(b => b.ChequeBookNo).HasMaxLength(100);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.StartChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.Property(b => b.EndChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequeBook>()
.HasIndex(b => b.ChequeBookNo).IsUnique();
modelBuilder.Entity<ChequeBook>()
.HasOne(b => b.BankAccount)
.WithMany()
.HasForeignKey(b => b.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ChequePage>()
.Property(p => p.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ChequePage>()
.Property(p => p.CurrencyCode).HasMaxLength(10);
modelBuilder.Entity<ChequePage>()
.HasIndex(p => p.ChequeNo).IsUnique();
modelBuilder.Entity<ChequePage>()
.HasOne(p => p.ChequeBook)
.WithMany(b => b.Pages)
.HasForeignKey(p => p.FK_ChequeBookId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ChequeNo).HasMaxLength(50);
modelBuilder.Entity<ReceivedCheque>()
.Property(c => c.ReceivedFromName).HasMaxLength(200);
modelBuilder.Entity<ReceivedCheque>()
.HasIndex(c => c.ChequeNo);
modelBuilder.Entity<ReceivedCheque>()
.HasOne(c => c.DepositBankAccount)
.WithMany()
.HasForeignKey(c => c.FK_DepositBankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
// --- SupplierPayment ---
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.Supplier)
.WithMany()
.HasForeignKey(p => p.FK_SupplierId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.BankAccount)
.WithMany()
.HasForeignKey(p => p.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.HasOne(p => p.ChequePage)
.WithMany()
.HasForeignKey(p => p.FK_ChequePageId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<SupplierPayment>()
.Property(p => p.Amount).HasPrecision(18, 2);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.SupplierPayment)
.WithMany(p => p.Allocations)
.HasForeignKey(a => a.FK_SupplierPaymentId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasOne(a => a.GoodsReceipt)
.WithMany()
.HasForeignKey(a => a.FK_GoodsReceiptId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<SupplierPaymentGRN>()
.HasIndex(a => a.FK_GoodsReceiptId);
// --- StoreCreditMovement ---
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Store)
.WithMany()
.HasForeignKey(m => m.FK_StoreId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Invoice)
.WithMany()
.HasForeignKey(m => m.FK_InvoiceId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.HasOne(m => m.Payment)
.WithMany()
.HasForeignKey(m => m.FK_PaymentId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<StoreCreditMovement>()
.Property(m => m.Amount).HasPrecision(18, 2);
modelBuilder.Entity<StoreCreditMovement>()
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder);
Seed.CommerceSeed.Apply(modelBuilder);
Seed.OperationsExtrasSeed.Apply(modelBuilder);
Seed.AdminSeed.Apply(modelBuilder);
}
}
+25
View File
@@ -0,0 +1,25 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class AdminSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 13);
// Same GUID as the AuthHex user — the sub claim from the JWT maps to this Id
internal static readonly Guid SuperAdminUserId = new("00000001-0000-0000-0000-000000000001");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(new User
{
Id = SuperAdminUserId,
Username = "superadmin",
FullName = "Super Admin",
FK_RoleId = RoleAndPermissionSeed.SuperAdminRoleId,
IsActive = true,
CreatedAt = SeedDate
});
}
}
+6 -10
View File
@@ -7,10 +7,6 @@ public static class MasterDataSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 20);
// Precomputed via PasswordHasher<User>().HashPassword(new User(), "Passw0rd!123") — see
// Implementation Notes in the plan; not derivable from HasData at migration time.
internal const string SeedPasswordHash = "AQAAAAIAAYagAAAAEKGnq/U4j6uM9g5mFkxbUE8WU/AhnSNiwrKCiPAqGnzNcaMgZAF5Zal/rJUUYPZPpg==";
// Role ids match Data/Seed/RoleAndPermissionSeed.cs (private there, so re-declared here by value).
internal static readonly Guid SalesRepRoleId = new("10000000-0000-0000-0000-000000000001");
internal static readonly Guid SupervisorRoleId = new("10000000-0000-0000-0000-000000000002");
@@ -115,12 +111,12 @@ public static class MasterDataSeed
);
modelBuilder.Entity<User>().HasData(
new User { Id = Supervisor1UserId, Username = "sunil.fernando", PasswordHash = SeedPasswordHash, FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera", PasswordHash = SeedPasswordHash, FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva", PasswordHash = SeedPasswordHash, FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya", PasswordHash = SeedPasswordHash, FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara", PasswordHash = SeedPasswordHash, FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa", PasswordHash = SeedPasswordHash, FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
new User { Id = Supervisor1UserId, Username = "sunil.fernando",FullName = "Sunil Fernando", FK_RoleId = SupervisorRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep1UserId, Username = "nimal.perera",FullName = "Nimal Perera", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = SalesRep2UserId, Username = "kamal.silva",FullName = "Kamal Silva", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver1UserId, Username = "ruwan.jayasuriya",FullName = "Ruwan Jayasuriya", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = Driver2UserId, Username = "priyantha.kumara",FullName = "Priyantha Kumara", FK_RoleId = SalesRepRoleId, FK_ReportsToUserId = Supervisor1UserId, CreatedAt = SeedDate, IsActive = true },
new User { Id = RegionalManager1UserId, Username = "priya.rajapaksa",FullName = "Priya Rajapaksa", FK_RoleId = RegionalManagerRoleId, FK_ReportsToUserId = null, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<DriverProfile>().HasData(
@@ -43,7 +43,7 @@ public static class OperationsExtrasSeed
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>().HasData(
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", PasswordHash = MasterDataSeed.SeedPasswordHash, FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
new User { Id = RegionalAgency1UserId, Username = "channa.desilva", FullName = "Channa De Silva", FK_RoleId = RegionalAgencyRoleId, FK_ReportsToUserId = null, CreatedAt = new DateTime(2026, 8, 20), IsActive = true }
);
modelBuilder.Entity<Uom>().HasData(
+187 -12
View File
@@ -7,22 +7,197 @@ public static class RoleAndPermissionSeed
{
private static readonly DateTime SeedDate = new(2026, 8, 18);
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
private static readonly Guid SalesRepId = new("10000000-0000-0000-0000-000000000001");
private static readonly Guid SupervisorId = new("10000000-0000-0000-0000-000000000002");
private static readonly Guid AreaManagerId = new("10000000-0000-0000-0000-000000000003");
private static readonly Guid AreaSupervisorId = new("10000000-0000-0000-0000-000000000004");
private static readonly Guid RegionalManagerId = new("10000000-0000-0000-0000-000000000005");
private static readonly Guid RegionalAgencyId = new("10000000-0000-0000-0000-000000000006");
internal static readonly Guid SuperAdminRoleId = new("10000000-0000-0000-0000-000000000007");
// ── Legacy broad permissions (nav.ts uses these for sidebar visibility) ────
private static readonly Guid PermOrdersView = new("20000000-0000-0000-0000-000000000001");
private static readonly Guid PermRidesView = new("20000000-0000-0000-0000-000000000002");
private static readonly Guid PermLoadingView = new("20000000-0000-0000-0000-000000000003");
private static readonly Guid PermStoresView = new("20000000-0000-0000-0000-000000000004");
private static readonly Guid PermSalesVisitsCheckin = new("20000000-0000-0000-0000-000000000005");
private static readonly Guid PermItemsView = new("20000000-0000-0000-0000-000000000006");
private static readonly Guid PermCategoriesView = new("20000000-0000-0000-0000-000000000007");
private static readonly Guid PermWarehousesView = new("20000000-0000-0000-0000-000000000008");
private static readonly Guid PermPurchaseOrdersView = new("20000000-0000-0000-0000-000000000009");
private static readonly Guid PermPricelistsView = new("20000000-0000-0000-0000-000000000010");
private static readonly Guid PermSchemesView = new("20000000-0000-0000-0000-000000000011");
private static readonly Guid PermSuppliersView = new("20000000-0000-0000-0000-000000000012");
private static readonly Guid PermDriverProfilesView = new("20000000-0000-0000-0000-000000000013");
private static readonly Guid PermComplianceManage = new("20000000-0000-0000-0000-000000000014");
private static readonly Guid PermUsersView = new("20000000-0000-0000-0000-000000000015");
private static readonly Guid PermRolesView = new("20000000-0000-0000-0000-000000000016");
private static readonly Guid PermRegionsView = new("20000000-0000-0000-0000-000000000017");
private static readonly Guid PermRoutesView = new("20000000-0000-0000-0000-000000000018");
private static readonly Guid PermVehiclesView = new("20000000-0000-0000-0000-000000000019");
// ── Granular API permissions (used in [Authorize("...")] on controller actions) ─
private static readonly Guid PermClaimView = new("20000000-0000-0000-0000-000000000020");
private static readonly Guid PermClaimManage = new("20000000-0000-0000-0000-000000000021");
private static readonly Guid PermDeliveryTaskView = new("20000000-0000-0000-0000-000000000022");
private static readonly Guid PermDeliveryTaskManage = new("20000000-0000-0000-0000-000000000023");
private static readonly Guid PermDriverView = new("20000000-0000-0000-0000-000000000024");
private static readonly Guid PermDriverManage = new("20000000-0000-0000-0000-000000000025");
private static readonly Guid PermGrnView = new("20000000-0000-0000-0000-000000000026");
private static readonly Guid PermGrnManage = new("20000000-0000-0000-0000-000000000027");
private static readonly Guid PermLoadingSheetView = new("20000000-0000-0000-0000-000000000028");
private static readonly Guid PermLoadingSheetManage = new("20000000-0000-0000-0000-000000000029");
private static readonly Guid PermPriceListView = new("20000000-0000-0000-0000-000000000030");
private static readonly Guid PermPriceListManage = new("20000000-0000-0000-0000-000000000031");
private static readonly Guid PermPurchaseOrderView = new("20000000-0000-0000-0000-000000000032");
private static readonly Guid PermPurchaseOrderManage= new("20000000-0000-0000-0000-000000000033");
private static readonly Guid PermPurchaseReturnManage=new("20000000-0000-0000-0000-000000000034");
private static readonly Guid PermRegionView = new("20000000-0000-0000-0000-000000000035");
private static readonly Guid PermRegionManage = new("20000000-0000-0000-0000-000000000036");
private static readonly Guid PermReturnView = new("20000000-0000-0000-0000-000000000037");
private static readonly Guid PermReturnManage = new("20000000-0000-0000-0000-000000000038");
private static readonly Guid PermRouteView = new("20000000-0000-0000-0000-000000000039");
private static readonly Guid PermRouteManage = new("20000000-0000-0000-0000-000000000040");
private static readonly Guid PermSchemeView = new("20000000-0000-0000-0000-000000000041");
private static readonly Guid PermSchemeManage = new("20000000-0000-0000-0000-000000000042");
private static readonly Guid PermStockView = new("20000000-0000-0000-0000-000000000043");
private static readonly Guid PermStockManage = new("20000000-0000-0000-0000-000000000044");
private static readonly Guid PermStoreView = new("20000000-0000-0000-0000-000000000045");
private static readonly Guid PermStoreManage = new("20000000-0000-0000-0000-000000000046");
private static readonly Guid PermSupplierClaimView = new("20000000-0000-0000-0000-000000000047");
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
private static readonly Guid PermFinanceReportView = new("20000000-0000-0000-0000-000000000051");
private static readonly Guid PermGlManage = new("20000000-0000-0000-0000-000000000052");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Role>().HasData(
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId, Name = "RegionalManager", Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true }
new Role { Id = SalesRepId, Name = "SalesRep", Level = 10, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SupervisorId, Name = "Supervisor", Level = 20, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaManagerId, Name = "AreaManager", Level = 30, CreatedAt = SeedDate, IsActive = true },
new Role { Id = AreaSupervisorId, Name = "AreaSupervisor", Level = 40, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalManagerId,Name = "RegionalManager",Level = 50, CreatedAt = SeedDate, IsActive = true },
new Role { Id = RegionalAgencyId, Name = "RegionalAgency", Level = 60, CreatedAt = SeedDate, IsActive = true },
new Role { Id = SuperAdminRoleId, Name = "SuperAdmin", Level = 100, CreatedAt = SeedDate, IsActive = true }
);
modelBuilder.Entity<Permission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new Permission { Id = PermOrdersView, Code = "orders.view", Description = "View shop POs, invoices, payments and returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRidesView, Code = "rides.view", Description = "View and manage delivery rides and delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingView, Code = "loading.view", Description = "View and manage loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoresView, Code = "stores.view", Description = "View store accounts, credit limits and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSalesVisitsCheckin, Code = "sales_visits.checkin", Description = "Check in and out of scheduled store visits", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermItemsView, Code = "items.view", Description = "View and manage catalogue products", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermCategoriesView, Code = "categories.view", Description = "View and manage product categories and brands", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermWarehousesView, Code = "warehouses.view", Description = "View stock balances, movements, warehouses and reports", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrdersView, Code = "purchase_orders.view", Description = "View and manage purchase orders, GRNs and supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPricelistsView, Code = "pricelists.view", Description = "View and manage regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemesView, Code = "schemes.view", Description = "View and manage pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSuppliersView, Code = "suppliers.view", Description = "View and manage supplier accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverProfilesView, Code = "driver_profiles.view", Description = "View and manage driver profiles and licensing", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermComplianceManage, Code = "compliance.manage", Description = "Manage driver compliance rules", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermUsersView, Code = "users.view", Description = "View and manage staff user accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRolesView, Code = "roles.view", Description = "View and manage roles and their permission sets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionsView, Code = "regions.view", Description = "View and manage regions and region configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRoutesView, Code = "routes.view", Description = "View and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermVehiclesView, Code = "vehicles.view", Description = "View and manage fleet vehicles", CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new Permission { Id = PermClaimView, Code = "claim.view", Description = "View agency claims and settlement status", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermClaimManage, Code = "claim.manage", Description = "Create and settle agency claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskView, Code = "delivery-task.view", Description = "View delivery tasks within a ride", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDeliveryTaskManage, Code = "delivery-task.manage", Description = "Confirm and fail delivery tasks", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverView, Code = "driver.view", Description = "View driver profiles and license details", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermDriverManage, Code = "driver.manage", Description = "Create and update driver profiles", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnView, Code = "grn.view", Description = "View goods receipt notes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGrnManage, Code = "grn.manage", Description = "Create and confirm goods receipts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetView, Code = "loading-sheet.view", Description = "View loading sheets and their items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermLoadingSheetManage, Code = "loading-sheet.manage", Description = "Create, edit, and confirm loading sheets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListView, Code = "price-list.view", Description = "View regional price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPriceListManage, Code = "price-list.manage", Description = "Create and manage item price lists", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderView, Code = "purchase-order.view", Description = "View purchase orders and line items", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseOrderManage,Code = "purchase-order.manage", Description = "Create, confirm, and close purchase orders", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermPurchaseReturnManage,Code= "purchase-return.manage",Description = "Create and manage purchase returns to suppliers", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionView, Code = "region.view", Description = "View regions and their configuration", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRegionManage, Code = "region.manage", Description = "Create and update regions, targets, and budgets", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnView, Code = "return.view", Description = "View order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermReturnManage, Code = "return.manage", Description = "Create and manage order returns", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteView, Code = "route.view", Description = "View delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermRouteManage, Code = "route.manage", Description = "Create and manage delivery routes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeView, Code = "scheme.view", Description = "View pricing schemes and promotions", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSchemeManage, Code = "scheme.manage", Description = "Create and configure pricing schemes", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockView, Code = "stock.view", Description = "View stock balances and movements", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStockManage, Code = "stock.manage", Description = "Transfer stock and record damage or adjustments", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreView, Code = "store.view", Description = "View store accounts and balances", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermStoreManage, Code = "store.manage", Description = "Create and update store accounts", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermFinanceReportView, Code = "finance-reports.view", Description = "View financial reports (trial balance, P&L, balance sheet, cash flow...)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermGlManage, Code = "gl.manage", Description = "Set up the General Ledger link and retry failed postings", CreatedAt = SeedDate, IsActive = true }
);
// SuperAdmin gets every permission
modelBuilder.Entity<RolePermission>().HasData(
// ── Legacy broad permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRidesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoresView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSalesVisitsCheckin, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermItemsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermCategoriesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermWarehousesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrdersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPricelistsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSuppliersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverProfilesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermComplianceManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermUsersView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRolesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionsView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRoutesView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermVehiclesView, CreatedAt = SeedDate, IsActive = true },
// ── Granular API permissions ──────────────────────────────────────────
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermClaimManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDeliveryTaskManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermDriverManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGrnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermLoadingSheetManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPriceListManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseOrderManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermPurchaseReturnManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRegionManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermReturnManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermRouteManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSchemeManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStockManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermStoreManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermFinanceReportView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermGlManage, CreatedAt = SeedDate, IsActive = true }
);
}
}
+73 -10
View File
@@ -1,3 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using Distribution.Features.User;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -6,32 +8,93 @@ namespace Distribution.Features.Auth;
[ApiController]
[Route("api/auth")]
public class AuthController(AuthService service, ICurrentUserService currentUser) : ControllerBase
public class AuthController(AuthService service, ICurrentUserService currentUser, IUserRepository userRepo) : ControllerBase
{
private static IActionResult ToActionResult(AuthHexResponse result) =>
result.Success ? new OkObjectResult(result) : new ObjectResult(result) { StatusCode = result.StatusCode is >= 400 and < 600 ? result.StatusCode : 401 };
[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct) =>
ToActionResult(await service.LoginAsync(request, ct));
[AllowAnonymous]
public async Task<IActionResult> Login(LoginRequest request, CancellationToken ct)
{
var result = await service.LoginAsync(request, ct);
if (!result.Success || result.Data is not { } data)
return ToActionResult(result);
string? token = null;
TimeSpan maxAge = TimeSpan.FromHours(1);
if (data.TryGetProperty("accessToken", out var tokenEl))
token = tokenEl.GetString();
if (data.TryGetProperty("expiresIn", out var expEl))
maxAge = TimeSpan.FromSeconds(expEl.GetInt32());
if (!string.IsNullOrEmpty(token))
{
Response.Cookies.Append("access-token", token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
MaxAge = maxAge,
Secure = Request.IsHttps,
});
}
MeResponse? profile = null;
if (!string.IsNullOrEmpty(token))
{
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
var userIdStr = jwt.Claims.FirstOrDefault(c => c.Type == "UserId")?.Value;
if (Guid.TryParse(userIdStr, out var userId))
{
var user = await userRepo.GetByIdAsync(userId);
if (user is not null)
profile = new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0);
}
}
return Ok(new { result.StatusCode, result.Success, result.Message, result.Data, profile });
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterRequest request, CancellationToken ct) =>
ToActionResult(await service.RegisterAsync(request, ct));
[HttpGet("me")]
[Authorize]
public async Task<IActionResult> Me(CancellationToken ct)
public async Task<IActionResult> Me()
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
return ToActionResult(await service.GetUserDetailsAsync(userId, ct));
if (currentUser.UserId is null) return Unauthorized();
var user = await userRepo.GetByIdAsync(currentUser.UserId.Value);
if (user is null) return Unauthorized();
return Ok(new MeResponse(
user.Id,
user.Username,
user.FK_RoleId,
user.Role?.Name ?? string.Empty,
user.Role?.Level ?? 0));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
[AllowAnonymous]
public IActionResult Logout()
{
var token = Request.Headers.Authorization.ToString().Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase);
return ToActionResult(await service.LogoutAsync(token, ct));
Response.Cookies.Delete("access-token", new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
Secure = Request.IsHttps,
});
return Ok(new { success = true });
}
}
+13 -2
View File
@@ -1,5 +1,16 @@
namespace Distribution.Features.Auth;
public record LoginRequest(string Username, string Password);
public record LoginRequest(
string Identifier,
string Password,
Guid? UserTypeId = null,
string DeviceName = "Unknown Device");
public record RegisterRequest(string Username, string Password, string? FullName);
public record RegisterRequest(string Username, string email, string Password, string? FullName);
public record MeResponse(
Guid UserId,
string Username,
Guid RoleId,
string RoleCode,
int RoleLevel);
@@ -1,12 +1,11 @@
using System.Net.Http.Json;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Proxies own-user auth calls (login/register/getUserDetails/logout) to the external AuthHex
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates
/// (see JwtTokenService's replacement — token generation is no longer done locally); this app
/// never sees or stores a plaintext/hashed password for the AuthHex-authenticated flow.
/// identity service. AuthHex owns credentials and issues the RS256-signed JWT this app validates;
/// this app never sees or stores a plaintext/hashed password.
/// </summary>
public class AuthHexClient(HttpClient http) : IAuthHexClient
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>Raw response shape returned by AuthHex's `/api/user` endpoint for every functionName.</summary>
public record AuthHexResponse(
+8 -4
View File
@@ -1,17 +1,21 @@
using Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
/// <summary>
/// Own-user auth is fully delegated to AuthHex — this service only proxies the four functions
/// AuthHex exposes (loginUser/registerUser/getUserDetails/LogoutUser). No local password check or
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates
/// (see Program.cs's JwtBearer registration + OnTokenValidated role mapping).
/// token issuance happens here; AuthHex owns credentials and signs the JWT this app validates.
/// </summary>
public class AuthService(IAuthHexClient authHex)
{
public Task<AuthHexResponse> LoginAsync(LoginRequest request, CancellationToken ct = default) =>
authHex.CallAsync("loginUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
authHex.CallAsync("loginUser", new
{
identifier = request.Identifier,
password = request.Password,
userTypeId = request.UserTypeId,
deviceName = request.DeviceName
}, reference: Guid.NewGuid().ToString(), ct: ct);
public Task<AuthHexResponse> RegisterAsync(RegisterRequest request, CancellationToken ct = default) =>
authHex.CallAsync("registerUser", request, reference: Guid.NewGuid().ToString(), ct: ct);
@@ -1,20 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Typed HttpClient that proxies requests to the external Auth Service.
/// Registered via AddHttpClient&lt;AuthServiceClient&gt; in Program.cs.
/// BaseAddress is configured from AuthService:BaseUrl in appsettings.json.
///
/// TODO: Implement methods once Auth Service contract is agreed.
/// </summary>
public class AuthServiceClient(HttpClient http) : IAuthServiceClient
{
// TODO: implement proxy methods, e.g.:
//
// public async Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct)
// {
// var response = await http.PostAsJsonAsync("/TODO_AUTH_SERVICE_LOGIN_PATH", request, ct);
// if (!response.IsSuccessStatusCode) return null;
// return await response.Content.ReadFromJsonAsync<AuthServiceResponse>(ct);
// }
}
@@ -1,11 +0,0 @@
namespace Distribution.Features.Auth;
public class AuthServiceOptions
{
public const string SectionName = "AuthService";
/// <summary>Base URL of the external Auth Service, e.g. "https://auth.internal".</summary>
public string BaseUrl { get; set; } = string.Empty;
// TODO: add other config (API keys, timeout, etc.) once Auth Service is defined.
}
@@ -1,4 +1,4 @@
namespace Distribution.Infra.Authorization;
namespace Distribution.Features.Auth;
public interface IAuthHexClient
{
@@ -1,16 +0,0 @@
namespace Distribution.Features.Auth;
/// <summary>
/// Connector to the external Auth Service. This backend proxies all auth
/// traffic from the Next.js frontend through to the Auth Service — the
/// frontend never calls the Auth Service directly.
///
/// TODO: Fill in the method signatures once the Auth Service contract is agreed.
/// </summary>
public interface IAuthServiceClient
{
// TODO: define methods based on Auth Service contract, e.g.:
// Task<AuthServiceResponse?> LoginAsync(LoginRequest request, CancellationToken ct);
// Task<AuthServiceResponse?> RefreshAsync(string refreshToken, CancellationToken ct);
// Task LogoutAsync(string token, CancellationToken ct);
}
@@ -0,0 +1,66 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.BankAccount;
[ApiController]
[Route("api")]
public class BankAccountController(BankAccountService service) : ControllerBase
{
[HttpGet("bank-accounts")]
public async Task<IActionResult> List([FromQuery] BankAccountType? accountType)
{
var result = await service.ListAsync(accountType);
return Ok(result);
}
[HttpPost("bank-accounts")]
public async Task<IActionResult> CreateBank(CreateBankAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateBankAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("cash-accounts")]
public async Task<IActionResult> CreateCash(CreateCashAccountRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateCashAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cash-accounts/{id:guid}")]
public async Task<IActionResult> UpdateCash(Guid id, UpdateCashAccountRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateCashAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPatch("cash-accounts/{id:guid}/status")]
public async Task<IActionResult> SetCashStatus(Guid id, SetCashAccountStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.SetCashStatusAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpDelete("cash-accounts/{id:guid}")]
public async Task<IActionResult> DeleteCash(Guid id, CancellationToken ct)
{
try
{
var ok = await service.DeleteCashAsync(id, ct);
return ok ? NoContent() : NotFound();
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("cash-account-types")]
public async Task<IActionResult> ListCashAccountTypes() => Ok(await service.ListCashAccountTypesAsync());
}
@@ -0,0 +1,33 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public record BankAccountResponse(
BankAccountType AccountType,
Guid AccountId,
string AccountName,
string? BankName,
string? CashAccountTypeName,
string? AccountNumber,
string CurrencyCode,
DateTime CreatedAt,
bool? IsActive,
bool? IsDefault);
public record CreateBankAccountRequest(
string AccountName,
string? BankName,
string? AccountNumber,
string? CurrencyCode);
public record CreateCashAccountRequest(
string AccountName,
string CashAccountTypeName,
string? AccountNumber,
string? CurrencyCode);
public record UpdateCashAccountRequest(string AccountName);
public record SetCashAccountStatusRequest(bool IsActive);
public record CashAccountTypeDto(string Name);
@@ -0,0 +1,40 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.BankAccount;
public class BankAccountRepository(AppDBContext context) : IBankAccountRepository
{
public async Task<Models.BankAccount?> GetByIdAsync(Guid id) =>
await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == id);
public async Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType)
{
var query = context.BankAccounts.AsQueryable();
if (accountType is not null)
query = query.Where(a => a.AccountType == accountType);
return await query.OrderByDescending(a => a.CreatedAt).ToListAsync();
}
public async Task<List<string>> ListCashAccountTypeNamesAsync() =>
await context.BankAccounts
.Where(a => a.AccountType == BankAccountType.Cash && a.CashAccountTypeName != null)
.Select(a => a.CashAccountTypeName!)
.Distinct()
.OrderBy(n => n)
.ToListAsync();
public async Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null) =>
await context.BankAccounts.AnyAsync(a =>
a.AccountType == type &&
a.AccountName == accountName &&
(excludeId == null || a.Id != excludeId));
public async Task AddAsync(Models.BankAccount account) =>
await context.BankAccounts.AddAsync(account);
public void Update(Models.BankAccount account) => context.BankAccounts.Update(account);
public void Remove(Models.BankAccount account) => context.BankAccounts.Remove(account);
}
@@ -0,0 +1,116 @@
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public class BankAccountService(IBankAccountRepository repo, IUnitOfWork uow)
{
private const string DefaultCurrency = "LKR";
private static BankAccountResponse ToResponse(Models.BankAccount a) =>
new(
a.AccountType,
a.Id,
a.AccountName,
a.AccountType == BankAccountType.Bank ? a.BankName : null,
a.AccountType == BankAccountType.Cash ? a.CashAccountTypeName : null,
a.AccountNumber,
a.CurrencyCode,
a.CreatedAt,
a.AccountType == BankAccountType.Cash ? a.IsActive : null,
a.AccountType == BankAccountType.Cash ? a.IsDefault : null);
public async Task<List<BankAccountResponse>> ListAsync(BankAccountType? accountType) =>
(await repo.ListAsync(accountType)).Select(ToResponse).ToList();
public async Task<List<CashAccountTypeDto>> ListCashAccountTypesAsync() =>
(await repo.ListCashAccountTypeNamesAsync()).Select(n => new CashAccountTypeDto(n)).ToList();
public async Task<BankAccountResponse> CreateBankAsync(CreateBankAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Bank, request.AccountName))
throw new InvalidOperationException("A bank account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Bank,
AccountName = request.AccountName,
BankName = request.BankName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse> CreateCashAsync(CreateCashAccountRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (string.IsNullOrWhiteSpace(request.CashAccountTypeName))
throw new InvalidOperationException("CashAccountTypeName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName))
throw new InvalidOperationException("A cash account with this name already exists");
var account = new Models.BankAccount
{
AccountType = BankAccountType.Cash,
AccountName = request.AccountName,
CashAccountTypeName = request.CashAccountTypeName,
AccountNumber = request.AccountNumber,
CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode!,
IsActive = true,
IsDefault = false
};
await repo.AddAsync(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> UpdateCashAsync(Guid id, UpdateCashAccountRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (string.IsNullOrWhiteSpace(request.AccountName))
throw new InvalidOperationException("AccountName is required");
if (await repo.ExistsByNameAsync(BankAccountType.Cash, request.AccountName, excludeId: id))
throw new InvalidOperationException("A cash account with this name already exists");
account.AccountName = request.AccountName;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<BankAccountResponse?> SetCashStatusAsync(Guid id, SetCashAccountStatusRequest request, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return null;
if (account.IsDefault && !request.IsActive)
throw new InvalidOperationException("The default cash account cannot be deactivated");
account.IsActive = request.IsActive;
repo.Update(account);
await uow.CommitAsync(ct);
return ToResponse(account);
}
public async Task<bool> DeleteCashAsync(Guid id, CancellationToken ct = default)
{
var account = await repo.GetByIdAsync(id);
if (account is null || account.AccountType != BankAccountType.Cash) return false;
if (account.IsDefault)
throw new InvalidOperationException("The default cash account cannot be deleted");
repo.Remove(account);
await uow.CommitAsync(ct);
return true;
}
}
@@ -0,0 +1,14 @@
using Distribution.Models;
namespace Distribution.Features.BankAccount;
public interface IBankAccountRepository
{
Task<Models.BankAccount?> GetByIdAsync(Guid id);
Task<List<Models.BankAccount>> ListAsync(BankAccountType? accountType);
Task<List<string>> ListCashAccountTypeNamesAsync();
Task<bool> ExistsByNameAsync(BankAccountType type, string accountName, Guid? excludeId = null);
Task AddAsync(Models.BankAccount account);
void Update(Models.BankAccount account);
void Remove(Models.BankAccount account);
}
@@ -0,0 +1,55 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ChequeBook;
[ApiController]
[Route("api")]
public class ChequeBookController(ChequeBookService service) : ControllerBase
{
[HttpGet("cheque-books")]
public async Task<IActionResult> List(
[FromQuery] Guid? bankAccountId,
[FromQuery] int? branchId,
[FromQuery] ChequeBookStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
=> Ok(await service.ListAsync(bankAccountId, branchId, status, page, pageSize));
[HttpGet("cheque-books/{chequeBookNo}")]
public async Task<IActionResult> Get(string chequeBookNo, [FromQuery] string? expand)
{
var expandPages = string.Equals(expand, "pages", StringComparison.OrdinalIgnoreCase);
var result = await service.GetAsync(chequeBookNo, expandPages);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cheque-books")]
public async Task<IActionResult> Create(CreateChequeBookRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/issue")]
public async Task<IActionResult> Issue(string chequeNo, IssueChequePageRequest request, CancellationToken ct)
{
try
{
var result = await service.IssueAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("cheque-pages/{chequeNo}/status")]
public async Task<IActionResult> UpdateStatus(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdatePageStatusAsync(chequeNo, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,75 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public record ChequePageResponse(
Guid Id,
string ChequeNo,
string ChequeBookNo,
ChequePageIssueStatus IssueStatus,
PayeeType? PayeeType,
int? PayeeId,
string? PayeeName,
DateTime? IssueDate,
decimal? Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy,
DateTime? PrintedAt,
DateTime? ClearedDate,
bool? ClearedByBank,
string? CancelReason);
public record ChequeBookResponse(
string ChequeBookNo,
int BranchId,
Guid BankAccountId,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy,
ChequeBookStatus Status,
List<ChequePageResponse> Pages);
public record PagedResult<T>(IReadOnlyList<T> Items, int TotalCount, int? Page, int? PageSize);
public record CreateChequeBookRequest(
int BranchId,
Guid BankAccountId,
string ChequeBookNo,
string StartChequeNo,
string EndChequeNo,
int TotalLeaves,
DateTime ReceivedDate,
string? Description,
string? CreatedBy);
public record IssueChequePageRequest(
PayeeType PayeeType,
int? PayeeId,
string PayeeName,
DateTime IssueDate,
decimal Amount,
string? CurrencyCode,
int? VoucherId,
string? ReferenceNo,
string? Purpose,
bool? IsCrossCheque,
bool? IsAccountPayee,
bool? IsPostDated,
string? Notes,
string? PrintedBy);
public record UpdateChequePageStatusRequest(
ChequePageStatusAction Action,
DateTime? ClearedDate,
string? CancelReason,
string? PerformedBy);
@@ -0,0 +1,46 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ChequeBook;
public class ChequeBookRepository(AppDBContext context) : IChequeBookRepository
{
public async Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages)
{
var query = context.ChequeBooks.AsQueryable();
if (includePages) query = query.Include(b => b.Pages);
return await query.FirstOrDefaultAsync(b => b.ChequeBookNo == chequeBookNo);
}
public async Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
var query = context.ChequeBooks.AsQueryable();
if (bankAccountId is not null) query = query.Where(b => b.FK_BankAccountId == bankAccountId);
if (branchId is not null) query = query.Where(b => b.BranchId == branchId);
if (status is not null) query = query.Where(b => b.Status == status);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(b => b.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<bool> BookNoExistsAsync(string chequeBookNo) =>
await context.ChequeBooks.AnyAsync(b => b.ChequeBookNo == chequeBookNo);
public async Task AddAsync(Models.ChequeBook book) => await context.ChequeBooks.AddAsync(book);
public void Update(Models.ChequeBook book) => context.ChequeBooks.Update(book);
public async Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo) =>
await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.ChequeNo == chequeNo);
public void UpdatePage(Models.ChequePage page) => context.ChequePages.Update(page);
}
@@ -0,0 +1,179 @@
using Distribution.Features.BankAccount;
using Distribution.Features.GeneralLedger;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public class ChequeBookService(
IChequeBookRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow,
GlPostingService gl)
{
private const string DefaultCurrency = "LKR";
private static ChequePageResponse ToPageResponse(Models.ChequePage p, string chequeBookNo) =>
new(p.Id, p.ChequeNo, chequeBookNo, p.IssueStatus, p.PayeeType, p.PayeeId, p.PayeeName,
p.IssueDate, p.Amount, p.CurrencyCode, p.VoucherId, p.ReferenceNo, p.Purpose,
p.IsCrossCheque, p.IsAccountPayee, p.IsPostDated, p.Notes,
p.PrintedBy, p.PrintedAt, p.ClearedDate, p.ClearedByBank, p.CancelReason);
private static ChequeBookResponse ToBookResponse(Models.ChequeBook b, bool includePages) =>
new(b.ChequeBookNo, b.BranchId, b.FK_BankAccountId, b.StartChequeNo, b.EndChequeNo,
b.TotalLeaves, b.ReceivedDate, b.Description, b.CreatedBy, b.Status,
includePages ? b.Pages.OrderBy(p => p.ChequeNo).Select(p => ToPageResponse(p, b.ChequeBookNo)).ToList() : new());
public async Task<PagedResult<ChequeBookResponse>> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
var (items, total) = await repo.ListAsync(bankAccountId, branchId, status, page, pageSize);
return new PagedResult<ChequeBookResponse>(
items.Select(b => ToBookResponse(b, includePages: false)).ToList(),
total, page, pageSize);
}
public async Task<ChequeBookResponse?> GetAsync(string chequeBookNo, bool expandPages)
{
var book = await repo.GetByNoAsync(chequeBookNo, includePages: expandPages);
return book is null ? null : ToBookResponse(book, includePages: expandPages);
}
public async Task<ChequeBookResponse> CreateAsync(CreateChequeBookRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.ChequeBookNo))
throw new InvalidOperationException("ChequeBookNo is required");
if (request.TotalLeaves <= 0)
throw new InvalidOperationException("TotalLeaves must be greater than zero");
if (!long.TryParse(request.StartChequeNo, out var start) ||
!long.TryParse(request.EndChequeNo, out var end))
throw new InvalidOperationException("StartChequeNo and EndChequeNo must be numeric");
if (end < start)
throw new InvalidOperationException("EndChequeNo must be greater than or equal to StartChequeNo");
if (end - start + 1 != request.TotalLeaves)
throw new InvalidOperationException("TotalLeaves does not match the Start/End cheque range");
if (await repo.BookNoExistsAsync(request.ChequeBookNo))
throw new InvalidOperationException("A cheque book with this number already exists");
var bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId);
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
throw new InvalidOperationException("BankAccountId does not reference a valid bank account");
var book = new Models.ChequeBook
{
ChequeBookNo = request.ChequeBookNo,
BranchId = request.BranchId,
FK_BankAccountId = request.BankAccountId,
StartChequeNo = request.StartChequeNo,
EndChequeNo = request.EndChequeNo,
TotalLeaves = request.TotalLeaves,
ReceivedDate = request.ReceivedDate,
Description = request.Description,
CreatedBy = request.CreatedBy,
Status = ChequeBookStatus.Active
};
var width = request.StartChequeNo.Length;
for (var n = start; n <= end; n++)
{
book.Pages.Add(new Models.ChequePage
{
ChequeNo = n.ToString().PadLeft(width, '0'),
IssueStatus = ChequePageIssueStatus.Unused
});
}
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(book);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToBookResponse(book, includePages: true);
}
public async Task<ChequePageResponse?> IssueAsync(string chequeNo, IssueChequePageRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
throw new InvalidOperationException("The cheque book is not active");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (string.IsNullOrWhiteSpace(request.PayeeName))
throw new InvalidOperationException("PayeeName is required");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = request.PayeeType;
page.PayeeId = request.PayeeId;
page.PayeeName = request.PayeeName;
page.IssueDate = request.IssueDate;
page.Amount = request.Amount;
page.CurrencyCode = string.IsNullOrWhiteSpace(request.CurrencyCode) ? DefaultCurrency : request.CurrencyCode;
page.VoucherId = request.VoucherId;
page.ReferenceNo = request.ReferenceNo;
page.Purpose = request.Purpose;
page.IsCrossCheque = request.IsCrossCheque;
page.IsAccountPayee = request.IsAccountPayee;
page.IsPostDated = request.IsPostDated;
page.Notes = request.Notes;
page.PrintedBy = request.PrintedBy;
page.PrintedAt = DateTime.Now;
repo.UpdatePage(page);
await uow.CommitAsync(ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
public async Task<ChequePageResponse?> UpdatePageStatusAsync(string chequeNo, UpdateChequePageStatusRequest request, CancellationToken ct = default)
{
var page = await repo.GetPageByNoAsync(chequeNo);
if (page is null) return null;
switch (request.Action)
{
case ChequePageStatusAction.Clear:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cleared");
if (request.ClearedDate is null)
throw new InvalidOperationException("ClearedDate is required when clearing a cheque");
page.IssueStatus = ChequePageIssueStatus.Cleared;
page.ClearedDate = request.ClearedDate;
page.ClearedByBank = true;
break;
case ChequePageStatusAction.Bounce:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be bounced");
page.IssueStatus = ChequePageIssueStatus.Bounced;
break;
case ChequePageStatusAction.Cancel:
if (page.IssueStatus != ChequePageIssueStatus.Issued)
throw new InvalidOperationException("Only issued cheques can be cancelled");
if (string.IsNullOrWhiteSpace(request.CancelReason))
throw new InvalidOperationException("CancelReason is required when cancelling a cheque");
page.IssueStatus = ChequePageIssueStatus.Cancelled;
page.CancelReason = request.CancelReason;
break;
case ChequePageStatusAction.Void:
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException("Only unused cheques can be voided");
page.IssueStatus = ChequePageIssueStatus.Void;
break;
}
repo.UpdatePage(page);
await uow.CommitAsync(ct);
await gl.PostIssuedChequeAsync(page, request.Action, ct);
return ToPageResponse(page, page.ChequeBook!.ChequeBookNo);
}
}
@@ -0,0 +1,16 @@
using Distribution.Models;
namespace Distribution.Features.ChequeBook;
public interface IChequeBookRepository
{
Task<Models.ChequeBook?> GetByNoAsync(string chequeBookNo, bool includePages);
Task<(List<Models.ChequeBook> Items, int TotalCount)> ListAsync(
Guid? bankAccountId, int? branchId, ChequeBookStatus? status, int page, int pageSize);
Task<bool> BookNoExistsAsync(string chequeBookNo);
Task AddAsync(Models.ChequeBook book);
void Update(Models.ChequeBook book);
Task<Models.ChequePage?> GetPageByNoAsync(string chequeNo);
void UpdatePage(Models.ChequePage page);
}
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Claims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Claims;
public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/claims")]
[Authorize("claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/claims/{id:guid}")]
[Authorize("claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims")]
[Authorize("claim.manage")]
public async Task<IActionResult> Create(CreateClaimRequest request, CancellationToken ct)
{
try
@@ -29,6 +33,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPut("api/claims/{id:guid}")]
[Authorize("claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateClaimRequest request, CancellationToken ct)
{
try
@@ -40,6 +45,7 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs
}
[HttpPost("api/claims/{id:guid}/settle")]
[Authorize("claim.manage")]
public async Task<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.SettledByUserId;
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DashboardReport;
[ApiController]
[Route("api/reports/dashboard")]
public class DashboardReportController(DashboardReportService service) : ControllerBase
{
[HttpGet("sales")]
public async Task<IActionResult> Sales(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? regionId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, regionId, categoryId, null);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetSalesSummaryAsync(f)) : BadRequest(new { message = error });
}
[HttpGet("inventory")]
public async Task<IActionResult> Inventory(
[FromQuery] DateTime? from, [FromQuery] DateTime? to,
[FromQuery] Guid? warehouseId, [FromQuery] Guid? categoryId)
{
var f = DashboardReportService.BuildFilter(from, to, null, categoryId, warehouseId);
var error = DashboardReportService.Validate(f);
return error is null ? Ok(await service.GetInventorySummaryAsync(f)) : BadRequest(new { message = error });
}
}
@@ -0,0 +1,32 @@
namespace Distribution.Features.DashboardReport;
public record DashboardFilter(DateTime From, DateTime To, Guid? RegionId, Guid? CategoryId, Guid? WarehouseId);
public record SalesTotals(decimal GrossSales, decimal ReturnValue, int Orders, decimal CostedSales, decimal CostOfSales);
public record SalesKpis(
decimal GrossSales, decimal ReturnValue, decimal NetSales, int Orders, decimal AverageOrderValue,
decimal GrossProfit, decimal? GrossProfitMarginPercent);
public record SalesKpiComparison(SalesKpis Current, SalesKpis Previous, DateTime PreviousFrom, DateTime PreviousTo);
public record TrendPoint(string Period, decimal Sales, decimal Returns);
public record NamedAmount(Guid? Id, string Name, decimal Amount);
public record SalesSummaryResponse(
DateTime From, DateTime To, string Granularity,
SalesKpiComparison Kpis, List<TrendPoint> Trend,
List<NamedAmount> ByCategory, List<NamedAmount> ByRegion, List<NamedAmount> ByRep);
public record PeriodAmount(DateTime Date, decimal Amount);
public record MovementPoint(string Period, decimal Received, decimal Issued, decimal Net);
public record WarehouseStockRow(Guid WarehouseId, string Name, decimal Quantity, decimal Value);
public record CategoryValueRow(string Category, decimal Quantity, decimal Value);
public record InventorySummaryResponse(
DateTime From, DateTime To, string Granularity,
List<CategoryValueRow> ValueByCategory, List<WarehouseStockRow> ByWarehouse, List<MovementPoint> MovementTrend);
@@ -0,0 +1,171 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.DashboardReport;
public class DashboardReportRepository(AppDBContext db) : IDashboardReportRepository
{
private sealed class SoldLine
{
public DateTime Date { get; set; }
public Guid PoId { get; set; }
public decimal Value { get; set; }
public decimal? Cost { get; set; }
public Guid CategoryId { get; set; }
public string CategoryName { get; set; } = "";
public Guid RegionId { get; set; }
public string RegionName { get; set; } = "";
public Guid RepId { get; set; }
}
// Same definition of "sold" as ProductReportRepository: fulfilled lines on non-cancelled POs
// whose invoice is Finalized, dated by Invoice.FinalizedAt. 'To' is inclusive (whole day).
private IQueryable<SoldLine> SoldLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q =
from li in db.ShopPOItems.AsNoTracking()
join inv in db.Invoices.AsNoTracking() on li.FK_ShopPOId equals inv.FK_ShopPOId
where inv.Status == InvoiceStatus.Finalized
&& li.ShopPO.Status != ShopPOStatus.Cancelled
&& inv.FinalizedAt >= start && inv.FinalizedAt < end
select new SoldLine
{
Date = inv.FinalizedAt!.Value.Date,
PoId = li.FK_ShopPOId,
Value = li.QuantityFulfilled * li.UnitPrice,
Cost = li.Item.CostPrice == null ? null : li.QuantityFulfilled * li.Item.CostPrice,
CategoryId = li.Item.FK_CategoryId,
CategoryName = li.Item.Category.Name,
RegionId = li.ShopPO.Store.Route.FK_RegionId,
RegionName = li.ShopPO.Store.Route.Region.Name,
RepId = li.ShopPO.RaisedById,
};
if (f.RegionId.HasValue) q = q.Where(l => l.RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(l => l.CategoryId == f.CategoryId.Value);
return q;
}
private IQueryable<SaleReturnItem> ReturnLines(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.SaleReturnItems.AsNoTracking()
.Where(r => r.SaleReturn.CreatedAt >= start && r.SaleReturn.CreatedAt < end);
if (f.RegionId.HasValue) q = q.Where(r => r.SaleReturn.ShopPO.Store.Route.FK_RegionId == f.RegionId.Value);
if (f.CategoryId.HasValue) q = q.Where(r => r.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
public async Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f)
{
var lines = SoldLines(f);
var gross = await lines.SumAsync(l => (decimal?)l.Value) ?? 0m;
var orders = await lines.Select(l => l.PoId).Distinct().CountAsync();
var costed = lines.Where(l => l.Cost != null);
var costedSales = await costed.SumAsync(l => (decimal?)l.Value) ?? 0m;
var cost = await costed.SumAsync(l => l.Cost) ?? 0m;
var returns = await ReturnLines(f).SumAsync(r => (decimal?)(r.Quantity * r.UnitPrice)) ?? 0m;
return new SalesTotals(gross, returns, orders, costedSales, cost);
}
public async Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => l.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Value) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f) =>
(await ReturnLines(f).GroupBy(r => r.SaleReturn.CreatedAt.Date)
.Select(g => new { Date = g.Key, Amount = g.Sum(x => x.Quantity * x.UnitPrice) })
.ToListAsync())
.Select(x => new PeriodAmount(x.Date, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.CategoryId, l.CategoryName })
.Select(g => new { g.Key.CategoryId, g.Key.CategoryName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.CategoryId, x.CategoryName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f) =>
(await SoldLines(f).GroupBy(l => new { l.RegionId, l.RegionName })
.Select(g => new { g.Key.RegionId, g.Key.RegionName, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).ToListAsync())
.Select(x => new GroupAggregate(x.RegionId, x.RegionName, x.Amount)).ToList();
public async Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f)
{
var rows = await SoldLines(f).GroupBy(l => l.RepId)
.Select(g => new { RepId = g.Key, Amount = g.Sum(x => x.Value) })
.OrderByDescending(x => x.Amount).Take(20).ToListAsync();
var ids = rows.Select(r => r.RepId).ToList();
var names = await db.Users.AsNoTracking().Where(u => ids.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName);
return rows.Select(r => new GroupAggregate(r.RepId, names.GetValueOrDefault(r.RepId, "Unknown"), r.Amount)).ToList();
}
private IQueryable<StockBalance> Balances(DashboardFilter f)
{
var q = db.StockBalances.AsNoTracking().AsQueryable();
if (f.WarehouseId.HasValue)
q = q.Where(b => b.LocationType == LocationType.Warehouse && b.LocationId == f.WarehouseId.Value);
if (f.CategoryId.HasValue) q = q.Where(b => b.Item.FK_CategoryId == f.CategoryId.Value);
return q;
}
// Value = quantity x cost price; items without a cost price contribute quantity only.
public async Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f) =>
(await Balances(f).GroupBy(b => b.Item.Category.Name)
.Select(g => new
{
Category = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.OrderByDescending(x => x.Value).ToListAsync())
.Select(x => new CategoryValueRow(x.Category, x.Quantity, x.Value)).ToList();
public async Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f)
{
var rows = await Balances(f).Where(b => b.LocationType == LocationType.Warehouse)
.GroupBy(b => b.LocationId)
.Select(g => new
{
Id = g.Key,
Quantity = g.Sum(x => x.Quantity),
Value = g.Sum(x => x.Quantity * (x.Item.CostPrice ?? 0m)),
})
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var names = await db.Warehouses.AsNoTracking().Where(w => ids.Contains(w.Id))
.ToDictionaryAsync(w => w.Id, w => w.Name);
return rows.Select(r => new WarehouseStockRow(r.Id, names.GetValueOrDefault(r.Id, "Unknown"), r.Quantity, r.Value))
.OrderByDescending(r => r.Value).ToList();
}
// Received = purchase receipts + customer returns; Issued = sales + purchase returns + damages.
// Transfers, loads and adjustments are internal/signed and excluded from this trend.
public async Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f)
{
var start = f.From.Date;
var end = f.To.Date.AddDays(1);
var q = db.StockMovements.AsNoTracking()
.Where(m => m.CreatedAt >= start && m.CreatedAt < end
&& (m.Type == MovementType.PurchaseReceipt || m.Type == MovementType.CustomerReturn
|| m.Type == MovementType.Sale || m.Type == MovementType.PurchaseReturn || m.Type == MovementType.Damage));
if (f.CategoryId.HasValue) q = q.Where(m => m.Item.FK_CategoryId == f.CategoryId.Value);
if (f.WarehouseId.HasValue)
q = q.Where(m => m.ToLocationId == f.WarehouseId.Value || m.FromLocationId == f.WarehouseId.Value);
var rows = await q.GroupBy(m => new { m.CreatedAt.Date, m.Type })
.Select(g => new { g.Key.Date, g.Key.Type, Qty = g.Sum(x => x.Quantity) })
.ToListAsync();
return rows.GroupBy(r => r.Date)
.Select(g => (g.Key,
g.Where(r => r.Type is MovementType.PurchaseReceipt or MovementType.CustomerReturn).Sum(r => r.Qty),
g.Where(r => r.Type is MovementType.Sale or MovementType.PurchaseReturn or MovementType.Damage).Sum(r => r.Qty)))
.ToList();
}
}
@@ -0,0 +1,83 @@
namespace Distribution.Features.DashboardReport;
public class DashboardReportService(IDashboardReportRepository repo)
{
public const int MaxRangeDays = 366;
private const int DailyGranularityMaxDays = 62;
public static DashboardFilter BuildFilter(DateTime? from, DateTime? to, Guid? regionId, Guid? categoryId, Guid? warehouseId)
{
var end = (to ?? DateTime.Now).Date;
var start = (from ?? end.AddDays(-29)).Date;
return new DashboardFilter(start, end, regionId, categoryId, warehouseId);
}
public static string? Validate(DashboardFilter f)
{
if (f.To < f.From) return "'to' must not be before 'from'.";
if ((f.To - f.From).TotalDays > MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days.";
return null;
}
private static bool Daily(DashboardFilter f) => (f.To - f.From).TotalDays <= DailyGranularityMaxDays;
private static string Bucket(DateTime d, bool daily) => daily ? d.ToString("yyyy-MM-dd") : d.ToString("yyyy-MM");
private static SalesKpis ToKpis(SalesTotals t)
{
var net = t.GrossSales - t.ReturnValue;
var gp = t.CostedSales - t.CostOfSales;
return new SalesKpis(
t.GrossSales, t.ReturnValue, net, t.Orders,
t.Orders > 0 ? Math.Round(t.GrossSales / t.Orders, 2) : 0m,
gp, t.CostedSales > 0 ? Math.Round(gp / t.CostedSales * 100, 2) : null);
}
public async Task<SalesSummaryResponse> GetSalesSummaryAsync(DashboardFilter f)
{
var days = (f.To - f.From).Days + 1;
var prev = f with { From = f.From.AddDays(-days), To = f.From.AddDays(-1) };
var daily = Daily(f);
var current = await repo.GetSalesTotalsAsync(f);
var previous = await repo.GetSalesTotalsAsync(prev);
var sales = await repo.GetDailySalesAsync(f);
var returns = await repo.GetDailyReturnsAsync(f);
var trend = sales.Select(s => (s.Date, Sales: s.Amount, Returns: 0m))
.Concat(returns.Select(r => (r.Date, Sales: 0m, Returns: r.Amount)))
.GroupBy(x => Bucket(x.Date, daily))
.OrderBy(g => g.Key)
.Select(g => new TrendPoint(g.Key, g.Sum(x => x.Sales), g.Sum(x => x.Returns)))
.ToList();
static NamedAmount Map(GroupAggregate g) => new(g.Key, g.Name, g.Amount);
return new SalesSummaryResponse(
f.From, f.To, daily ? "day" : "month",
new SalesKpiComparison(ToKpis(current), ToKpis(previous), prev.From, prev.To),
trend,
(await repo.GetSalesByCategoryAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRegionAsync(f)).Select(Map).ToList(),
(await repo.GetSalesByRepAsync(f)).Select(Map).ToList());
}
public async Task<InventorySummaryResponse> GetInventorySummaryAsync(DashboardFilter f)
{
var daily = Daily(f);
var movements = (await repo.GetDailyMovementsAsync(f))
.GroupBy(m => Bucket(m.Date, daily))
.OrderBy(g => g.Key)
.Select(g =>
{
var rec = g.Sum(x => x.Received);
var iss = g.Sum(x => x.Issued);
return new MovementPoint(g.Key, rec, iss, rec - iss);
}).ToList();
return new InventorySummaryResponse(
f.From, f.To, daily ? "day" : "month",
await repo.GetStockValueByCategoryAsync(f),
await repo.GetStockByWarehouseAsync(f),
movements);
}
}
@@ -0,0 +1,16 @@
namespace Distribution.Features.DashboardReport;
public record GroupAggregate(Guid? Key, string Name, decimal Amount);
public interface IDashboardReportRepository
{
Task<SalesTotals> GetSalesTotalsAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailySalesAsync(DashboardFilter f);
Task<List<PeriodAmount>> GetDailyReturnsAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByCategoryAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRegionAsync(DashboardFilter f);
Task<List<GroupAggregate>> GetSalesByRepAsync(DashboardFilter f);
Task<List<CategoryValueRow>> GetStockValueByCategoryAsync(DashboardFilter f);
Task<List<WarehouseStockRow>> GetStockByWarehouseAsync(DashboardFilter f);
Task<List<(DateTime Date, decimal Received, decimal Issued)>> GetDailyMovementsAsync(DashboardFilter f);
}
@@ -1,5 +1,6 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide;
@@ -9,6 +10,7 @@ namespace Distribution.Features.DeliveryRide;
public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpPost("rides/{rideId:guid}/tasks/generate")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Generate(Guid rideId, CancellationToken ct)
{
try
@@ -22,6 +24,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
[HttpGet("rides/{rideId:guid}/tasks")]
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetByRide(Guid rideId)
{
var result = await service.GetByRideIdAsync(rideId);
@@ -29,6 +32,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpGet("delivery-tasks")]
//[Authorize("delivery-task.view")]
public async Task<IActionResult> GetAll(
[FromQuery] DeliveryTaskStatus? status,
[FromQuery] int page = 1,
@@ -39,6 +43,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpGet("delivery-tasks/{id:guid}")]
[Authorize("delivery-task.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -46,6 +51,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPut("delivery-tasks/{id:guid}/quantities")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> SetQuantities(Guid id, SetQuantitiesRequest request, CancellationToken ct)
{
try
@@ -58,6 +64,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/confirm")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
try
@@ -70,6 +77,7 @@ public class DeliveryTaskController(DeliveryTaskService service, ICurrentUserSer
}
[HttpPost("delivery-tasks/{id:guid}/fail")]
[Authorize("delivery-task.manage")]
public async Task<IActionResult> Fail(Guid id, FailTaskRequest request, CancellationToken ct)
{
try
@@ -1,5 +1,6 @@
using Distribution.Features.DriverComplianceConfig;
using Distribution.Features.DriverComplianceConfig;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DriverProfile;
@@ -12,6 +13,7 @@ public class DriverProfileController(
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("driver.view")]
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(page, pageSize);
@@ -19,6 +21,7 @@ public class DriverProfileController(
}
[HttpGet("{id:guid}")]
[Authorize("driver.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -26,6 +29,7 @@ public class DriverProfileController(
}
[HttpPost]
[Authorize("driver.manage")]
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
{
try
@@ -44,6 +48,7 @@ public class DriverProfileController(
}
[HttpPut("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -51,6 +56,7 @@ public class DriverProfileController(
}
[HttpDelete("{id:guid}")]
[Authorize("driver.manage")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var success = await service.DeactivateAsync(id, ct);
@@ -58,6 +64,7 @@ public class DriverProfileController(
}
[HttpGet("compliance")]
[Authorize("driver.view")]
public async Task<IActionResult> GetCompliance()
{
var result = await complianceService.GetAsync();
@@ -65,6 +72,7 @@ public class DriverProfileController(
}
[HttpPut("compliance")]
[Authorize("driver.manage")]
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -0,0 +1,74 @@
using System.Text;
using System.Text.Json;
using Distribution.Features.GeneralLedger;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.FinanceReport;
/// <summary>
/// Proxies the General Ledger service's reports (TrialBalance, GeneralLedger, BalanceSheet, ProfitAndLoss, CashFlow,
/// BudgetVsActual, TaxSummary). Query parameters are forwarded as-is; Json is returned as the GL data payload and
/// Pdf/Csv are returned as downloadable files.
/// </summary>
[ApiController]
[Route("api/reports/finance")]
public class FinanceReportController(IGeneralLedgerClient gl) : ControllerBase
{
/// <summary>Active GL accounts (code + name) for the report filters' account dropdown.</summary>
[HttpGet("accounts")]
[Authorize("finance-reports.view")]
public async Task<IActionResult> Accounts(CancellationToken ct)
{
GeneralLedgerResult result;
try { result = await gl.SendAsync(HttpMethod.Get, "v1/accounts?isActive=true", null, ct); }
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
if (!result.Success || GlJson.Data(result) is not { } data)
return StatusCode(502, new { error = GlJson.Message(result) ?? "Could not load GL accounts." });
var items = GlJson.Get(data, "items");
var accounts = items is { ValueKind: JsonValueKind.Array }
? items.Value.EnumerateArray()
.Select(a => new { accountCode = GlJson.GetString(a, "accountCode") ?? "", accountName = GlJson.GetString(a, "accountName") ?? "" })
.OrderBy(a => a.accountCode, StringComparer.Ordinal)
.ToList()
: [];
return Ok(accounts);
}
[HttpGet("{reportType}")]
[Authorize("finance-reports.view")]
public async Task<IActionResult> Get(string reportType, [FromQuery] string outputFormat = "Json", CancellationToken ct = default)
{
var query = new StringBuilder($"v1/reports?reportType={Uri.EscapeDataString(reportType)}&outputFormat={Uri.EscapeDataString(outputFormat)}");
foreach (var (key, values) in Request.Query)
{
if (key is "reportType" or "outputFormat") continue;
foreach (var v in values)
query.Append('&').Append(Uri.EscapeDataString(key)).Append('=').Append(Uri.EscapeDataString(v ?? ""));
}
GeneralLedgerResult result;
try { result = await gl.SendAsync(HttpMethod.Get, query.ToString(), null, ct); }
catch (HttpRequestException) { return StatusCode(502, new { error = "The General Ledger service is unreachable." }); }
if (!result.Success)
return StatusCode(result.StatusCode is >= 400 and < 500 ? result.StatusCode : 502,
new { error = GlJson.Message(result) ?? "The General Ledger service rejected the request." });
var data = GlJson.Data(result);
if (data is null) return StatusCode(502, new { error = "The General Ledger service returned no data." });
if (!outputFormat.Equals("Json", StringComparison.OrdinalIgnoreCase))
{
var base64 = GlJson.GetString(data.Value, "contentBase64");
if (base64 is null) return StatusCode(502, new { error = "The General Ledger service returned no file." });
return File(Convert.FromBase64String(base64),
GlJson.GetString(data.Value, "contentType") ?? "application/octet-stream",
GlJson.GetString(data.Value, "fileName"));
}
return Content(data.Value.GetRawText(), "application/json");
}
}
@@ -0,0 +1,48 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace Distribution.Features.GeneralLedger;
public record GeneralLedgerResult(int StatusCode, bool Success, JsonElement? Body);
public interface IGeneralLedgerClient
{
Task<bool> IsHealthyAsync(CancellationToken ct = default);
Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default);
}
/// <summary>
/// Service-to-service client for the General Ledger microservice. The GL service is not user-facing:
/// every request carries the shared X-Api-Key (GeneralLedger:ApiKey, must equal the GL service's ApiKey:Value).
/// <paramref name="path"/> is relative to GeneralLedger:BaseUrl and carries the API version (e.g. "v1/accounts", "v1/journal-entries"), so moving a call to v2 is a one-path change.
/// </summary>
public class GeneralLedgerClient(HttpClient http) : IGeneralLedgerClient
{
public async Task<bool> IsHealthyAsync(CancellationToken ct = default)
{
try
{
using var response = await http.GetAsync("/health", ct);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
{
return false;
}
}
public async Task<GeneralLedgerResult> SendAsync(HttpMethod method, string path, object? payload = null, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(method, path.TrimStart('/'));
if (payload is not null) request.Content = JsonContent.Create(payload);
using var response = await http.SendAsync(request, ct);
JsonElement? body = null;
if (response.Content.Headers.ContentLength != 0)
{
try { body = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct); }
catch (JsonException) { }
}
return new GeneralLedgerResult((int)response.StatusCode, response.IsSuccessStatusCode, body);
}
}
@@ -0,0 +1,100 @@
using Distribution.Data;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.GeneralLedger;
public record GlSetupRequest(int? Year);
public record YearEndCloseRequest(int Year);
public record OpeningBalanceRequest(DateOnly Date, string? Narration, List<OpeningBalanceLine> Lines);
[ApiController]
[Route("api/gl")]
public class GlController(
GlSetupService setup,
GlPostingService posting,
IGeneralLedgerClient gl,
Distribution.Features.SysConfig.ISysConfigRepository configRepo,
AppDBContext context,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("status")]
[Authorize("gl.manage")]
public async Task<IActionResult> Status()
{
var mapping = new Dictionary<string, string?>();
foreach (var key in GlKeys.AllAccountKeys)
mapping[key] = (await configRepo.GetByKeyAsync(key))?.Value;
return Ok(new
{
glReachable = await gl.IsHealthyAsync(),
setupDone = (await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value == "true",
openingBalancePosted = await context.GlPostingLogs.AnyAsync(l => l.Event == "OpeningBalance" && l.Status == GlPostingStatus.Posted),
closedYears = await posting.GetClosedYearsAsync(),
mapping,
posted = await context.GlPostingLogs.CountAsync(l => l.Status == GlPostingStatus.Posted),
failed = await context.GlPostingLogs.CountAsync(l => l.Status != GlPostingStatus.Posted)
});
}
[HttpPost("setup")]
[Authorize("gl.manage")]
public async Task<IActionResult> Setup(GlSetupRequest request, CancellationToken ct)
{
try { return Ok(await setup.RunAsync(request.Year, currentUser.UserId, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("opening-balances")]
[Authorize("gl.manage")]
public async Task<IActionResult> OpeningBalances(OpeningBalanceRequest request, CancellationToken ct)
{
try
{
var row = await posting.PostOpeningBalanceAsync(request.Date, request.Narration, request.Lines, ct);
return row.Status == GlPostingStatus.Posted
? Ok(new { status = row.Status, glJournalNo = row.GlJournalNo })
: StatusCode(502, new { error = row.Error ?? "The General Ledger service did not accept the entry. It was saved and can be retried." });
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("year-end/preview")]
[Authorize("gl.manage")]
public async Task<IActionResult> YearEndPreview([FromQuery] int year, CancellationToken ct)
{
try { return Ok(await posting.GetYearEndPreviewAsync(year, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("year-end/close")]
[Authorize("gl.manage")]
public async Task<IActionResult> YearEndClose(YearEndCloseRequest request, CancellationToken ct)
{
try { return Ok(await posting.CloseYearAsync(request.Year, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("postings")]
[Authorize("gl.manage")]
public async Task<IActionResult> Postings([FromQuery] GlPostingStatus? status, [FromQuery] int take = 100)
{
var query = context.GlPostingLogs.AsQueryable();
if (status is not null) query = query.Where(l => l.Status == status);
var rows = await query.OrderByDescending(l => l.CreatedAt).Take(Math.Clamp(take, 1, 500))
.Select(l => new { l.Id, l.Event, l.SourceId, l.Reference, l.Status, l.GlJournalNo, l.Error, l.Attempts, l.LastAttemptAt, l.CreatedAt })
.ToListAsync();
return Ok(rows);
}
[HttpPost("postings/retry")]
[Authorize("gl.manage")]
public async Task<IActionResult> Retry(CancellationToken ct) =>
Ok(new { posted = await posting.RetryFailedAsync(ct) });
}
@@ -0,0 +1,33 @@
using System.Text.Json;
namespace Distribution.Features.GeneralLedger;
/// <summary>Small readers for the GL service's ApiResponse envelope ({statusCode, success, message, data}).</summary>
public static class GlJson
{
public static JsonElement? Data(GeneralLedgerResult result) =>
result.Body is { ValueKind: JsonValueKind.Object } body && Get(body, "data") is { } data ? data : null;
public static string? Message(GeneralLedgerResult result) =>
result.Body is { ValueKind: JsonValueKind.Object } body ? Get(body, "message")?.GetString()
?? Get(body, "Message")?.GetString() : null;
/// <summary>Case-insensitive property lookup (GL success bodies are camelCase, its error bodies PascalCase).</summary>
public static JsonElement? Get(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object) return null;
foreach (var p in element.EnumerateObject())
if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) return p.Value;
return null;
}
public static string? GetString(JsonElement element, string name)
{
var v = Get(element, name);
return v is null || v.Value.ValueKind == JsonValueKind.Null ? null
: v.Value.ValueKind == JsonValueKind.String ? v.Value.GetString() : v.Value.ToString();
}
public static long? GetLong(JsonElement element, string name) =>
long.TryParse(GetString(element, name), out var n) ? n : null;
}
@@ -0,0 +1,34 @@
namespace Distribution.Features.GeneralLedger;
/// <summary>SysConfig keys holding the GL account codes Distribution posts to (created by GlSetupService).</summary>
public static class GlKeys
{
public const string SetupDone = "Gl.Setup.Done";
public const string CashEquivalents = "Gl.Account.CashEquivalents"; // parent of Cash in Hand + Bank Accounts (GL cash root)
public const string CashInHand = "Gl.Account.CashInHand"; // parent of every Distribution Cash account
public const string BankAccounts = "Gl.Account.BankAccounts"; // parent of every Distribution Bank account
public const string ChequesInHand = "Gl.Account.ChequesInHand";
public const string Receivable = "Gl.Account.Receivable";
public const string Inventory = "Gl.Account.Inventory";
public const string InputVat = "Gl.Account.InputVat";
public const string InputNbt = "Gl.Account.InputNbt";
public const string Payable = "Gl.Account.Payable";
public const string ChequesIssued = "Gl.Account.ChequesIssued";
public const string Equity = "Gl.Account.Equity"; // header; parent of the equity accounts below
public const string ShareCapital = "Gl.Account.ShareCapital";
public const string RetainedEarnings = "Gl.Account.RetainedEarnings";
public const string Drawings = "Gl.Account.Drawings";
public const string GeneralReserve = "Gl.Account.GeneralReserve";
public const string RevaluationReserve = "Gl.Account.RevaluationReserve";
public const string Sales = "Gl.Account.Sales";
public static readonly string[] AllAccountKeys =
[
CashEquivalents, CashInHand, BankAccounts, ChequesInHand, Receivable, Inventory,
InputVat, InputNbt, Payable, ChequesIssued, Equity, ShareCapital, RetainedEarnings, Drawings,
GeneralReserve, RevaluationReserve, Sales
];
public static bool IsGlKey(string key) => key == SetupDone || AllAccountKeys.Contains(key);
}
@@ -0,0 +1,449 @@
using System.Globalization;
using System.Text.Json;
using Distribution.Data;
using Distribution.Features.Grn;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.GeneralLedger;
public record GlLine(string AccountCode, decimal Debit, decimal Credit);
/// <summary>What closing a financial year would post. Closable is false with a Reason when it cannot be closed.</summary>
public record YearEndPreview(int Year, string StartDate, string EndDate, decimal TotalIncome, decimal TotalExpenses,
decimal NetProfit, bool Closable, string? Reason, bool AlreadyClosed);
/// <summary>
/// One opening balance: Account is a key from OpeningAccounts (e.g. "Inventory", "ShareCapital") or "BankAccount"
/// with BankAccountId. Amount is the account's normal balance (>= 0); the debit/credit side comes from the account.
/// </summary>
public record OpeningBalanceLine(string Account, Guid? BankAccountId, decimal Amount);
/// <summary>
/// Mirrors Distribution business events into the General Ledger as journal entries. Every public Post* method is
/// called AFTER the business transaction has committed and never throws: a GL problem is recorded in GlPostingLogs
/// (status Failed, payload kept) and can be retried later, so it can never block a sale, payment or cheque action.
/// </summary>
public class GlPostingService(
IGeneralLedgerClient gl,
ISysConfigRepository configRepo,
GlSetupService setup,
AppDBContext context,
IUnitOfWork uow,
ILogger<GlPostingService> logger)
{
private record Payload(string EntryDate, string JournalType, string SourceModule, string Reference, string Narration,
string PreparedBy, List<PayloadLine> Lines);
private record PayloadLine(string AccountCode, decimal DebitAmount, decimal CreditAmount);
// ── Events ───────────────────────────────────────────────────────────────
public Task PostInvoiceFinalizedAsync(Models.Invoice invoice, CancellationToken ct = default) =>
PostAsync("InvoiceFinalized", invoice.Id, invoice.FinalizedAt ?? DateTime.Now, "INVOICE", $"Invoice {invoice.Id} finalized",
async () => invoice.TotalAmount <= 0 ? null :
[
new GlLine(await Acct(GlKeys.Receivable), invoice.TotalAmount, 0),
new GlLine(await Acct(GlKeys.Sales), 0, invoice.TotalAmount)
], ct);
public Task PostCustomerPaymentAsync(Models.Payment payment, CancellationToken ct = default) =>
PostAsync("CustomerPayment", payment.Id, payment.CollectedAt, "PAYMENT", $"Customer payment for invoice {payment.FK_InvoiceId}",
async () =>
{
if (payment.Amount <= 0 || payment.Method == PaymentMethod.Credit) return null;
var debit = payment.Method == PaymentMethod.Cash
? await BankCode(payment.FK_BankAccountId)
: await Acct(GlKeys.ChequesInHand);
return [new GlLine(debit, payment.Amount, 0), new GlLine(await Acct(GlKeys.Receivable), 0, payment.Amount)];
}, ct);
/// <summary>Deposit / Return / Cancel of a received cheque that came from a customer payment.</summary>
public Task PostReceivedChequeAsync(Models.ReceivedCheque cheque, ReceivedChequeStatusAction action, CancellationToken ct = default)
{
if (action is not (ReceivedChequeStatusAction.Deposit or ReceivedChequeStatusAction.Return or ReceivedChequeStatusAction.Cancel))
return Task.CompletedTask;
var date = action == ReceivedChequeStatusAction.Deposit ? cheque.DepositDate ?? DateTime.Now : DateTime.Now;
return PostAsync($"ReceivedCheque{action}", cheque.Id, date, "CHEQUE", $"Received cheque {cheque.ChequeNo} {action}",
async () =>
{
// Only cheques created by a customer payment were posted to Cheques-in-hand, so only they can move.
if (!await context.Payments.AnyAsync(p => p.FK_ReceivedChequeId == cheque.Id, ct)) return null;
var inHand = await Acct(GlKeys.ChequesInHand);
return action switch
{
ReceivedChequeStatusAction.Deposit =>
[new GlLine(await BankCode(cheque.FK_DepositBankAccountId), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)],
ReceivedChequeStatusAction.Return =>
[new GlLine(inHand, cheque.Amount, 0), new GlLine(await BankCode(cheque.FK_DepositBankAccountId), 0, cheque.Amount)],
_ => [new GlLine(await Acct(GlKeys.Receivable), cheque.Amount, 0), new GlLine(inHand, 0, cheque.Amount)]
};
}, ct);
}
public Task PostGrnAsync(Models.GoodsReceipt grn, CancellationToken ct = default) =>
PostAsync("Grn", grn.Id, grn.ReceivedAt, "GRN", $"Goods received {grn.Id}",
async () =>
{
decimal net = 0, nbt = 0, vat = 0;
foreach (var i in grn.Items.Where(i => i.IsActive))
{
var a = GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent);
net += a.Gross - a.Discount;
nbt += a.Nbt;
vat += a.Vat;
}
var total = net + nbt + vat;
if (total <= 0) return null;
var lines = new List<GlLine> { new(await Acct(GlKeys.Inventory), net, 0) };
if (nbt > 0) lines.Add(new GlLine(await Acct(GlKeys.InputNbt), nbt, 0));
if (vat > 0) lines.Add(new GlLine(await Acct(GlKeys.InputVat), vat, 0));
lines.Add(new GlLine(await Acct(GlKeys.Payable), 0, total));
return lines;
}, ct);
public Task PostSupplierPaymentAsync(Models.SupplierPayment payment, CancellationToken ct = default) =>
PostAsync("SupplierPayment", payment.Id, payment.PaidAt, "SUPPLIER_PAYMENT", $"Supplier payment {payment.Id}",
async () =>
{
if (payment.Amount <= 0) return null;
var credit = payment.Method == PaymentMethod.Cash
? await BankCode(payment.FK_BankAccountId)
: await Acct(GlKeys.ChequesIssued);
return [new GlLine(await Acct(GlKeys.Payable), payment.Amount, 0), new GlLine(credit, 0, payment.Amount)];
}, ct);
/// <summary>Clear / Bounce / Cancel of a cheque issued through a supplier payment.</summary>
public Task PostIssuedChequeAsync(Models.ChequePage page, ChequePageStatusAction action, CancellationToken ct = default)
{
if (action == ChequePageStatusAction.Void) return Task.CompletedTask;
var date = action == ChequePageStatusAction.Clear ? page.ClearedDate ?? DateTime.Now : DateTime.Now;
return PostAsync($"IssuedCheque{action}", page.Id, date, "CHEQUE", $"Issued cheque {page.ChequeNo} {action}",
async () =>
{
var amount = page.Amount ?? 0;
if (amount <= 0 || !await context.SupplierPayments.AnyAsync(p => p.FK_ChequePageId == page.Id, ct)) return null;
var issued = await Acct(GlKeys.ChequesIssued);
if (action == ChequePageStatusAction.Clear)
{
var bookBankId = await context.ChequeBooks.Where(b => b.Id == page.FK_ChequeBookId)
.Select(b => (Guid?)b.FK_BankAccountId).FirstOrDefaultAsync(ct);
return [new GlLine(issued, amount, 0), new GlLine(await BankCode(bookBankId), 0, amount)];
}
// Bounce / Cancel: the cheque no longer settles the payable, so put it back.
return [new GlLine(issued, amount, 0), new GlLine(await Acct(GlKeys.Payable), 0, amount)];
}, ct);
}
/// <summary>
/// Posts the opening balances (asset side debited, liability/equity side credited). Zero amounts are skipped, so the
/// form can list every account with a default of 0. Reports the outcome so the caller can show a failure.
/// </summary>
public async Task<GlPostingLog> PostOpeningBalanceAsync(DateOnly date, string? narration, List<OpeningBalanceLine> input, CancellationToken ct = default)
{
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
throw new InvalidOperationException("Run the General Ledger setup first.");
var lines = new List<GlLine>();
foreach (var l in input ?? [])
{
if (l.Amount < 0) throw new InvalidOperationException($"'{l.Account}': the amount cannot be negative.");
if (l.Amount == 0) continue;
if (l.Account == "BankAccount")
{
lines.Add(new GlLine(await BankCode(l.BankAccountId), l.Amount, 0));
continue;
}
if (!OpeningAccounts.TryGetValue(l.Account, out var def))
throw new InvalidOperationException($"Unknown account '{l.Account}'.");
var code = await Acct(def.Key);
lines.Add(def.DebitNormal ? new GlLine(code, l.Amount, 0) : new GlLine(code, 0, l.Amount));
}
if (lines.Count == 0)
throw new InvalidOperationException("Enter at least one opening balance greater than zero.");
var debit = lines.Sum(x => x.Debit);
var credit = lines.Sum(x => x.Credit);
if (debit != credit)
throw new InvalidOperationException(
$"The opening balances do not balance: assets (debits) total {debit:N2} but liabilities + equity (credits) total {credit:N2}. " +
"Adjust Share Capital / Retained Earnings so they match.");
var sourceId = Guid.NewGuid();
await PostAsync("OpeningBalance", sourceId, date.ToDateTime(TimeOnly.MinValue), "OPENING_BALANCE",
string.IsNullOrWhiteSpace(narration) ? "Opening balances" : narration.Trim(), () => Task.FromResult<List<GlLine>?>(lines), ct);
return await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == "OpeningBalance" && l.SourceId == sourceId, ct)
?? throw new InvalidOperationException("The opening balances could not be prepared. Check the server log.");
}
/// <summary>Accounts that can carry an opening balance (besides individual cash/bank accounts) and their normal side.</summary>
public static readonly Dictionary<string, (string Key, bool DebitNormal)> OpeningAccounts = new()
{
["ChequesInHand"] = (GlKeys.ChequesInHand, true),
["Receivable"] = (GlKeys.Receivable, true),
["Inventory"] = (GlKeys.Inventory, true),
["InputVat"] = (GlKeys.InputVat, true),
["InputNbt"] = (GlKeys.InputNbt, true),
["Drawings"] = (GlKeys.Drawings, true),
["Payable"] = (GlKeys.Payable, false),
["ChequesIssued"] = (GlKeys.ChequesIssued, false),
["ShareCapital"] = (GlKeys.ShareCapital, false),
["RetainedEarnings"] = (GlKeys.RetainedEarnings, false),
["GeneralReserve"] = (GlKeys.GeneralReserve, false),
["RevaluationReserve"] = (GlKeys.RevaluationReserve, false),
};
// ── Year-end close ───────────────────────────────────────────────────────
private const string YearEndEvent = "YearEndClose";
private static Guid YearEndSourceId(int year) =>
new(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes($"{YearEndEvent}:{year}")));
/// <summary>Years that have a posted closing journal.</summary>
public async Task<List<int>> GetClosedYearsAsync(CancellationToken ct = default) =>
(await context.GlPostingLogs
.Where(l => l.Event == YearEndEvent && l.Status == GlPostingStatus.Posted)
.Select(l => l.Reference)
.ToListAsync(ct))
.Select(r => int.TryParse(r.Split(':').Last(), out var y) ? y : 0)
.Where(y => y > 0).OrderBy(y => y).ToList();
private record CloseBuild(List<GlLine> Lines, decimal Income, decimal Expenses);
/// <summary>Reads the year's own P&amp;L from GL (which excludes earlier closing journals) and builds the closing lines.</summary>
private async Task<CloseBuild> BuildYearEndCloseAsync(int year, CancellationToken ct)
{
var result = await gl.SendAsync(HttpMethod.Get,
$"v1/reports?reportType=ProfitAndLoss&outputFormat=Json&periodStart={year}-01-01&periodEnd={year}-12-31", null, ct);
if (!result.Success || GlJson.Data(result) is not { } pl)
throw new InvalidOperationException($"Could not read the {year} profit and loss from the General Ledger: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
// Net CREDIT balance per account: income sections carry their amount as is, expense sections are reported
// as positive debits (so flip), unclassified rows are already credit - debit.
var netCredit = new Dictionary<string, decimal>();
decimal income = 0, expenses = 0;
void Collect(string section, int sign)
{
if (GlJson.Get(pl, section) is not { } s || GlJson.Get(s, "lines") is not { ValueKind: JsonValueKind.Array } sectionLines) return;
foreach (var l in sectionLines.EnumerateArray())
{
var code = GlJson.GetString(l, "accountCode");
if (code is null || !decimal.TryParse(GlJson.GetString(l, "amount"), NumberStyles.Any, CultureInfo.InvariantCulture, out var amount)) continue;
var credit = sign * amount;
netCredit[code] = netCredit.GetValueOrDefault(code) + credit;
if (credit > 0) income += credit; else expenses += -credit;
}
}
Collect("sales", 1); Collect("otherIncome", 1);
Collect("costOfSales", -1); Collect("distributionExpenses", -1); Collect("administrationExpenses", -1);
Collect("otherExpenses", -1); Collect("financialExpenses", -1);
Collect("unclassified", 1);
// Closing: an account with a net credit balance is debited, one with a net debit balance is credited.
var lines = netCredit.Where(kv => kv.Value != 0)
.Select(kv => kv.Value > 0 ? new GlLine(kv.Key, kv.Value, 0) : new GlLine(kv.Key, 0, -kv.Value)).ToList();
var debit = lines.Sum(x => x.Debit);
var credit2 = lines.Sum(x => x.Credit);
if (debit != credit2)
{
var retained = await Acct(GlKeys.RetainedEarnings);
lines.Add(debit > credit2 ? new GlLine(retained, 0, debit - credit2) : new GlLine(retained, credit2 - debit, 0));
}
return new CloseBuild(lines, income, expenses);
}
public async Task<YearEndPreview> GetYearEndPreviewAsync(int year, CancellationToken ct = default)
{
var start = $"{year}-01-01";
var end = $"{year}-12-31";
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true")
return new(year, start, end, 0, 0, 0, false, "Run the General Ledger setup first.", false);
var sourceId = YearEndSourceId(year);
var existing = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct);
if (existing?.Status == GlPostingStatus.Posted)
return new(year, start, end, 0, 0, 0, false, $"{year} has already been closed.", true);
var build = await BuildYearEndCloseAsync(year, ct);
var net = build.Income - build.Expenses;
string? reason = null;
if (new DateOnly(year, 12, 31) > DateOnly.FromDateTime(DateTime.Now))
reason = $"{year} has not ended yet. A year can only be closed after 31 December.";
else if (await context.GlPostingLogs.AnyAsync(l => l.Status != GlPostingStatus.Posted && !(l.Event == YearEndEvent && l.SourceId == sourceId), ct))
reason = "There are failed or pending General Ledger postings. Retry them first so the year's figures are complete.";
else if (build.Lines.Count == 0)
reason = $"There is no income or expense activity in {year} to close.";
return new(year, start, end, build.Income, build.Expenses, net, reason is null, reason, false);
}
/// <summary>Posts the closing journal for the year (income/expense into Retained Earnings), then locks the year's periods.</summary>
public async Task<object> CloseYearAsync(int year, CancellationToken ct = default)
{
var preview = await GetYearEndPreviewAsync(year, ct);
if (!preview.Closable) throw new InvalidOperationException(preview.Reason ?? "This year cannot be closed.");
var build = await BuildYearEndCloseAsync(year, ct);
var sourceId = YearEndSourceId(year);
await PostAsync(YearEndEvent, sourceId, new DateTime(year, 12, 31), "YEAR_END_CLOSE", $"Year-end closing entry for {year}",
() => Task.FromResult<List<GlLine>?>(build.Lines), ct, journalType: "CLOSING", reference: $"{YearEndEvent}:{year}");
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == YearEndEvent && l.SourceId == sourceId, ct)
?? throw new InvalidOperationException("The closing entry could not be prepared. Check the server log.");
if (row.Status != GlPostingStatus.Posted)
throw new InvalidOperationException((row.Error ?? "The General Ledger did not accept the closing entry.") + " Fix the cause and press Close again.");
var locked = await LockYearPeriodsAsync(year, ct);
return new { year, glJournalNo = row.GlJournalNo, netProfit = preview.NetProfit, periodsLocked = locked };
}
private async Task<int> LockYearPeriodsAsync(int year, CancellationToken ct)
{
var ids = new HashSet<long>();
for (var month = 1; month <= 12; month++)
{
var p = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={year}-{month:00}-15", null, ct);
if (p.Success && GlJson.Data(p) is { } pd && GlJson.GetLong(pd, "periodId") is { } id) ids.Add(id);
}
var locked = 0;
foreach (var id in ids)
{
var r = await gl.SendAsync(HttpMethod.Put, $"v1/periods/{id}/lock", new { action = "Close", reason = $"Year-end close {year}" }, ct);
if (r.Success) locked++;
else logger.LogWarning("Could not lock GL period {PeriodId} after closing {Year}: {Message}", id, year, GlJson.Message(r));
}
return locked;
}
// ── Retry ────────────────────────────────────────────────────────────────
/// <summary>Re-sends every Failed/Pending posting. Returns how many are now Posted.</summary>
public async Task<int> RetryFailedAsync(CancellationToken ct = default)
{
var rows = await context.GlPostingLogs
.Where(l => l.Status != GlPostingStatus.Posted)
.OrderBy(l => l.CreatedAt)
.ToListAsync(ct);
var posted = 0;
foreach (var row in rows)
{
await SendAsync(row, ct);
if (row.Status == GlPostingStatus.Posted) posted++;
}
return posted;
}
// ── Core ─────────────────────────────────────────────────────────────────
private async Task PostAsync(string evt, Guid sourceId, DateTime date, string sourceModule, string narration,
Func<Task<List<GlLine>?>> buildLines, CancellationToken ct, string journalType = "GENERAL", string? reference = null)
{
try
{
if ((await configRepo.GetByKeyAsync(GlKeys.SetupDone))?.Value != "true") return; // GL not set up yet
var row = await context.GlPostingLogs.FirstOrDefaultAsync(l => l.Event == evt && l.SourceId == sourceId, ct);
if (row is not null && row.Status == GlPostingStatus.Posted) return;
if (row is null)
{
var lines = await buildLines();
if (lines is null || lines.Count == 0) return;
reference ??= $"{evt}:{sourceId}";
var payload = new Payload(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), journalType, sourceModule, reference,
narration, "Distribution", lines.Select(l => new PayloadLine(l.AccountCode, l.Debit, l.Credit)).ToList());
row = new GlPostingLog
{
Event = evt,
SourceId = sourceId,
Reference = reference,
PayloadJson = JsonSerializer.Serialize(payload, JsonOptions)
};
await uow.BeginAsync(ct);
try { await context.GlPostingLogs.AddAsync(row, ct); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
}
await SendAsync(row, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "GL posting {Event}:{SourceId} failed before it could be sent", evt, sourceId);
}
}
private async Task SendAsync(GlPostingLog row, CancellationToken ct)
{
string? journalNo = null;
string? error = null;
try
{
var payload = JsonSerializer.Deserialize<JsonElement>(row.PayloadJson);
var entryDate = payload.GetProperty("entryDate").GetString()!;
var period = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={entryDate}", null, ct);
var periodId = period.Success && GlJson.Data(period) is { } pd ? GlJson.GetLong(pd, "periodId") : null;
if (periodId is null)
{
error = $"No accounting period in GL covers {entryDate}. Run GL setup for that year.";
}
else
{
var body = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(row.PayloadJson, JsonOptions)!;
body["periodId"] = JsonSerializer.SerializeToElement(periodId.Value);
var result = await gl.SendAsync(HttpMethod.Post, "v1/journal-entries", body, ct);
if (result.Success)
journalNo = GlJson.Data(result) is { } d ? GlJson.GetString(d, "journalNo") ?? "" : "";
else
error = GlJson.Message(result) ?? $"GL returned HTTP {result.StatusCode}";
}
}
catch (Exception ex)
{
error = ex.Message;
logger.LogWarning(ex, "GL posting {Reference} failed", row.Reference);
}
row.Attempts++;
row.LastAttemptAt = DateTime.Now;
row.Status = error is null ? GlPostingStatus.Posted : GlPostingStatus.Failed;
row.GlJournalNo = journalNo;
row.Error = error is null ? null : error.Length > 1000 ? error[..1000] : error;
await uow.BeginAsync(ct);
try { context.GlPostingLogs.Update(row); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
}
// ── Account lookups ──────────────────────────────────────────────────────
private async Task<string> Acct(string key) =>
(await configRepo.GetByKeyAsync(key))?.Value is { Length: > 0 } code
? code
: throw new InvalidOperationException($"GL account mapping '{key}' is missing. Run GL setup.");
private async Task<string> BankCode(Guid? bankAccountId)
{
if (bankAccountId is null) throw new InvalidOperationException("No cash/bank account on this transaction.");
var account = await context.BankAccounts.FirstOrDefaultAsync(a => a.Id == bankAccountId.Value)
?? throw new InvalidOperationException("Cash/bank account not found.");
return await setup.EnsureBankAccountAsync(account)
?? throw new InvalidOperationException("Could not map the cash/bank account to a GL account. Run GL setup.");
}
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
}
@@ -0,0 +1,226 @@
using System.Text.Json;
using Distribution.Data;
using Distribution.Features.SysConfig;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.GeneralLedger;
public record GlSetupResult(bool Done, List<string> Actions);
/// <summary>
/// One-time (re-runnable) bootstrap of the General Ledger service for Distribution: creates the chart of accounts
/// Distribution posts to, a fiscal year with its periods, the GL cash-root config, and a GL account for every
/// Distribution cash/bank account. The resulting GL account codes are stored in SysConfig (see GlKeys).
/// </summary>
public class GlSetupService(
IGeneralLedgerClient gl,
ISysConfigRepository configRepo,
AppDBContext context,
IUnitOfWork uow)
{
private const short Asset = 1, Liability = 2, Equity = 3, Income = 4;
// key, GL account name, account type, parent key, postable, tag name, tag value.
// Operating = working-capital account for the Cash Flow report (cashFlowCategory OPERATING).
private record AccountDef(string Key, string Name, short TypeId, string? ParentKey, bool Postable, string? TagField, string? TagValue, bool Operating = false);
private static readonly AccountDef[] Chart =
[
new(GlKeys.CashEquivalents, "Cash and Cash Equivalents", Asset, null, false, "balanceSheetClassification", "CURRENT_ASSET"),
new(GlKeys.CashInHand, "Cash in Hand", Asset, GlKeys.CashEquivalents, false, null, null),
new(GlKeys.BankAccounts, "Bank Accounts", Asset, GlKeys.CashEquivalents, false, null, null),
new(GlKeys.ChequesInHand, "Cheques in Hand", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
new(GlKeys.Receivable, "Trade Receivables", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
new(GlKeys.Inventory, "Inventory", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
new(GlKeys.InputVat, "Input VAT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
new(GlKeys.InputNbt, "Input NBT", Asset, null, true, "balanceSheetClassification", "CURRENT_ASSET", true),
new(GlKeys.Payable, "Trade Payables", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
new(GlKeys.ChequesIssued, "Cheques Issued - Uncleared", Liability, null, true, "balanceSheetClassification", "CURRENT_LIABILITY", true),
new(GlKeys.Equity, "Owner's Equity", Equity, null, false, null, null),
new(GlKeys.ShareCapital, "Share Capital / Owner's Capital", Equity, GlKeys.Equity, true, null, null),
new(GlKeys.RetainedEarnings, "Retained Earnings", Equity, GlKeys.Equity, true, null, null),
new(GlKeys.Drawings, "Drawings and Dividends", Equity, GlKeys.Equity, true, null, null),
new(GlKeys.GeneralReserve, "General Reserve", Equity, GlKeys.Equity, true, null, null),
new(GlKeys.RevaluationReserve, "Revaluation Reserve", Equity, GlKeys.Equity, true, null, null),
new(GlKeys.Sales, "Sales Revenue", Income, null, true, "incomeStatementSection", "SALES"),
];
public async Task<GlSetupResult> RunAsync(int? year, Guid? userId, CancellationToken ct = default)
{
if (!await gl.IsHealthyAsync(ct))
throw new InvalidOperationException("The General Ledger service is not reachable. Check GeneralLedger:BaseUrl and that the service is running.");
var actions = new List<string>();
var codes = new Dictionary<string, string>();
foreach (var key in GlKeys.AllAccountKeys)
{
var existing = (await configRepo.GetByKeyAsync(key))?.Value;
if (!string.IsNullOrWhiteSpace(existing)) codes[key] = existing;
}
// 1. Chart of accounts (skips accounts whose code is already stored and still exists in GL).
foreach (var def in Chart)
{
if (codes.TryGetValue(def.Key, out var code) && await AccountIdAsync(code, ct) is not null)
continue;
long? parentId = null;
if (def.ParentKey is not null)
parentId = await AccountIdAsync(codes[def.ParentKey], ct);
var created = await CreateAccountAsync(def.Name, def.TypeId, parentId, def.Postable, ct);
codes[def.Key] = created.Code;
actions.Add($"Created GL account {created.Code} - {def.Name}");
}
// Tags are applied on every run (idempotent), so accounts created by an earlier run are corrected too.
foreach (var def in Chart)
{
var tags = new Dictionary<string, object?>();
if (def.TagField is not null) tags[def.TagField] = def.TagValue;
if (def.Operating) tags["cashFlowCategory"] = "OPERATING";
if (!def.Postable) tags["isPostable"] = false; // header accounts (re-applied so older runs are corrected)
if (tags.Count > 0) await UpdateAccountAsync(codes[def.Key], tags, ct);
}
// 2. Fiscal year + periods for the requested year (default: current year).
var fy = year ?? DateTime.Now.Year;
var probe = await gl.SendAsync(HttpMethod.Get, $"v1/fiscal-years/periods/by-date?date={fy}-06-30", null, ct);
if (!probe.Success)
{
var fyResult = await gl.SendAsync(HttpMethod.Post, "v1/fiscal-years", new
{
yearName = $"FY{fy}",
startDate = $"{fy}-01-01",
endDate = $"{fy}-12-31",
autoGeneratePeriods = true,
periodCount = 12
}, ct);
if (!fyResult.Success)
throw new InvalidOperationException($"Could not create fiscal year {fy} in GL: {GlJson.Message(fyResult) ?? fyResult.StatusCode.ToString()}");
actions.Add($"Created fiscal year FY{fy} with 12 periods");
}
// 3. GL cash root (GL locks this key after one edit, so only set it when empty).
var cashRootId = await AccountIdAsync(codes[GlKeys.CashEquivalents], ct);
var cfgResult = await gl.SendAsync(HttpMethod.Get, "v1/system-config", null, ct);
var currentRoot = ReadGlConfigValue(cfgResult, "CASH_ACCOUNT_ROOT_ID");
if (string.IsNullOrWhiteSpace(currentRoot) && cashRootId is not null)
{
var set = await gl.SendAsync(HttpMethod.Put, "v1/system-config/CASH_ACCOUNT_ROOT_ID",
new { configValue = cashRootId.Value.ToString() }, ct);
if (!set.Success)
throw new InvalidOperationException($"Could not set CASH_ACCOUNT_ROOT_ID in GL: {GlJson.Message(set) ?? set.StatusCode.ToString()}");
actions.Add("Set GL CASH_ACCOUNT_ROOT_ID");
}
// 4. Persist the mapping, then mirror every existing Distribution cash/bank account.
await SaveConfigAsync(codes, userId, ct);
foreach (var account in await context.BankAccounts.Where(a => a.GlAccountCode == null).ToListAsync(ct))
{
var code = await EnsureBankAccountAsync(account, ct);
if (code is not null) actions.Add($"Mirrored {account.AccountName} as GL account {code}");
}
await SaveConfigAsync(new Dictionary<string, string> { [GlKeys.SetupDone] = "true" }, userId, ct);
return new GlSetupResult(true, actions);
}
/// <summary>
/// Returns the GL account code for a Distribution cash/bank account, creating the GL child account on first use.
/// Saves the code on the BankAccount. Returns null if GL setup has not been run.
/// </summary>
public async Task<string?> EnsureBankAccountAsync(Models.BankAccount account, CancellationToken ct = default)
{
if (!string.IsNullOrWhiteSpace(account.GlAccountCode)) return account.GlAccountCode;
var parentKey = account.AccountType == BankAccountType.Bank ? GlKeys.BankAccounts : GlKeys.CashInHand;
var parentCode = (await configRepo.GetByKeyAsync(parentKey))?.Value;
if (string.IsNullOrWhiteSpace(parentCode)) return null;
var parentId = await AccountIdAsync(parentCode, ct);
if (parentId is null) return null;
var name = account.AccountType == BankAccountType.Bank && !string.IsNullOrWhiteSpace(account.BankName)
? $"{account.BankName} - {account.AccountName}"
: account.AccountName;
var created = await CreateAccountAsync(name, Asset, parentId, true, ct);
account.GlAccountCode = created.Code;
await uow.BeginAsync(ct);
try
{
context.BankAccounts.Update(account);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return created.Code;
}
private async Task SaveConfigAsync(Dictionary<string, string> values, Guid? userId, CancellationToken ct)
{
await uow.BeginAsync(ct);
try
{
foreach (var (key, value) in values)
await configRepo.UpsertAsync(new Models.SysConfig { Key = key, Value = value, UpdatedAt = DateTime.Now, UpdatedBy = userId });
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
}
private async Task<long?> AccountIdAsync(string code, CancellationToken ct)
{
var result = await gl.SendAsync(HttpMethod.Get, $"v1/accounts/{Uri.EscapeDataString(code)}", null, ct);
if (!result.Success) return null;
var data = GlJson.Data(result);
if (data is null) return null;
// GET /accounts/{code} wraps the account: { account: {...}, ... }
var account = GlJson.Get(data.Value, "account") ?? data.Value;
return GlJson.GetLong(account, "accountId");
}
private async Task<(long Id, string Code)> CreateAccountAsync(string name, short typeId, long? parentId, bool postable, CancellationToken ct)
{
var result = await gl.SendAsync(HttpMethod.Post, "v1/accounts", new
{
accountName = name,
parentAccountId = parentId,
accountTypeId = typeId,
isControlAccount = false,
isPostable = postable
}, ct);
if (!result.Success)
throw new InvalidOperationException($"Could not create GL account '{name}': {GlJson.Message(result) ?? result.StatusCode.ToString()}");
var data = GlJson.Data(result) ?? throw new InvalidOperationException($"GL returned no data when creating account '{name}'.");
var id = GlJson.GetLong(data, "accountId");
var code = GlJson.GetString(data, "accountCode");
if (id is null || code is null)
throw new InvalidOperationException($"GL returned an unexpected body when creating account '{name}'.");
return (id.Value, code);
}
private async Task UpdateAccountAsync(string code, Dictionary<string, object?> fields, CancellationToken ct)
{
var result = await gl.SendAsync(HttpMethod.Put, $"v1/accounts/{Uri.EscapeDataString(code)}", fields, ct);
if (!result.Success)
throw new InvalidOperationException($"Could not update GL account {code}: {GlJson.Message(result) ?? result.StatusCode.ToString()}");
}
private static string? ReadGlConfigValue(GeneralLedgerResult result, string key)
{
if (!result.Success || GlJson.Data(result) is not { } data) return null;
var items = data.ValueKind == JsonValueKind.Array ? data : GlJson.Get(data, "items");
if (items is not { ValueKind: JsonValueKind.Array }) return null;
foreach (var item in items.Value.EnumerateArray())
if (GlJson.GetString(item, "configKey") == key) return GlJson.GetString(item, "configValue");
return null;
}
}
+8 -1
View File
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Grn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.Grn;
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? purchaseOrderId,
[FromQuery] int page = 1,
@@ -17,6 +19,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpGet("api/goods-receipts/{id:guid}")]
[Authorize("grn.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -24,6 +27,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpPost("api/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -42,6 +46,7 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
/// <summary>Goods received without a purchase order.</summary>
[HttpPost("api/goods-receipts/direct")]
[Authorize("grn.manage")]
public async Task<IActionResult> CreateDirect(CreateDirectGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
@@ -55,10 +60,12 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
}
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/goods-receipts")]
[Authorize("grn.manage")]
public async Task<IActionResult> Create(Guid id, CreateGrnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ReceivedByUserId;
+4 -1
View File
@@ -1,3 +1,4 @@
using Distribution.Features.GeneralLedger;
using Distribution.Features.Po;
using Distribution.Features.Stock;
using Distribution.Features.SysConfig;
@@ -13,7 +14,8 @@ public class GrnService(
IUnitOfWork uow,
Distribution.Features.Item.IItemRepository itemRepo,
Distribution.Features.Supplier.ISupplierRepository supplierRepo,
Distribution.Features.SysConfig.SysConfigService sysConfig)
Distribution.Features.SysConfig.SysConfigService sysConfig,
GlPostingService gl)
{
private static GrnItemResponse MapItem(Models.GoodsReceiptItem i)
{
@@ -482,6 +484,7 @@ public class GrnService(
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(goodsReceipt.Id);
await gl.PostGrnAsync(saved!, ct);
return Map(saved!);
}
}
@@ -10,6 +10,7 @@ public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
public record InvoiceResponse(
Guid Id,
Guid ShopPOId,
Guid StoreId,
string StoreName,
InvoiceStatus Status,
PaymentStatus PaymentStatus,
@@ -19,6 +20,7 @@ public record InvoiceResponse(
DateTime? FinalizedAt,
Guid? FinalizedBy,
decimal TotalAmount,
decimal PaidAmount,
bool IsActive,
DateTime CreatedAt);
@@ -21,6 +21,7 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
@@ -1,4 +1,5 @@
using Distribution.Features.Stock;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,15 +9,19 @@ namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
GlPostingService gl)
{
private static InvoiceResponse ToResponse(Models.Invoice i) =>
new(i.Id, i.FK_ShopPOId,
i.ShopPO?.FK_StoreId ?? Guid.Empty,
i.ShopPO?.Store?.StoreName ?? string.Empty,
i.Status, i.PaymentStatus, i.CreatedBy,
i.PrintedAt, i.PrintedBy,
i.FinalizedAt, i.FinalizedBy,
i.TotalAmount, i.IsActive, i.CreatedAt);
i.TotalAmount,
i.Payments?.Where(p => p.IsActive).Sum(p => p.Amount) ?? 0m,
i.IsActive, i.CreatedAt);
public async Task<InvoiceResponse?> GetByIdAsync(Guid id)
{
@@ -93,6 +98,8 @@ public class InvoiceService(
try { repo.Update(invoice); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
await gl.PostInvoiceFinalizedAsync(invoice, ct);
return ToResponse(invoice);
}
}
@@ -1,14 +1,19 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.LoadingEvent;
[ApiController]
[Route("api/loading-sheets")]
public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase
public class LoadingSheetController(
LoadingSheetService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
//[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? rideId,
[FromQuery] LoadingSheetStatus? status,
@@ -17,11 +22,26 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
if (routeId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, routeId.Value))
return StatusCode(403);
}
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
var result = await service.GetAllAsync(rideId, status, routeId, regionId, page, pageSize);
return Ok(result);
}
[HttpGet("{id:guid}")]
[Authorize("loading-sheet.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -29,6 +49,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Create(CreateLoadingSheetRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -45,6 +66,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPut("{id:guid}/items")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> SetItems(Guid id, SetItemsRequest request, CancellationToken ct)
{
try
@@ -57,6 +79,7 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
}
[HttpPost("{id:guid}/confirm")]
[Authorize("loading-sheet.manage")]
public async Task<IActionResult> Confirm(Guid id, ConfirmLoadingSheetRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
+9 -1
View File
@@ -1,4 +1,4 @@
using Distribution.Models;
using Distribution.Models;
namespace Distribution.Features.Payment;
@@ -6,8 +6,14 @@ public record CreatePaymentRequest(
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode = EntryMode.RealTime,
// Cash: required. Cheque: optional (used as the deposit account hint if provided).
Guid? BankAccountId = null,
// Cheque: required.
string? ChequeNumber = null,
DateTime? ChequeDate = null,
// Cheque: optional metadata forwarded to the auto-created ReceivedCheque.
string? DrawerBankName = null,
string? AccountHolderName = null,
Guid? CollectedByUserId = null);
public record PaymentResponse(
@@ -16,6 +22,8 @@ public record PaymentResponse(
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode,
Guid? BankAccountId,
Guid? ReceivedChequeId,
string? ChequeNumber,
DateTime? ChequeDate,
DateTime CollectedAt,
@@ -1,4 +1,8 @@
using Distribution.Features.Invoice;
using Distribution.Features.BankAccount;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Invoice;
using Distribution.Features.ReceivedCheque;
using Distribution.Features.StoreCredit;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -8,11 +12,17 @@ namespace Distribution.Features.Payment;
public class PaymentService(
IPaymentRepository repo,
IInvoiceRepository invoiceRepo,
IBankAccountRepository bankAccountRepo,
IReceivedChequeRepository receivedChequeRepo,
IStoreCreditMovementRepository creditRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
ICurrentUserService currentUser,
GlPostingService gl)
{
private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode, p.ChequeNumber, p.ChequeDate,
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode,
p.FK_BankAccountId, p.FK_ReceivedChequeId,
p.ChequeNumber, p.ChequeDate,
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
@@ -28,13 +38,46 @@ public class PaymentService(
{
var invoice = await invoiceRepo.GetByIdAsync(invoiceId);
if (invoice is null || !invoice.IsActive) return null;
if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
var existingPaid = (await repo.GetByInvoiceIdAsync(invoiceId)).Sum(p => p.Amount);
var remaining = invoice.TotalAmount - existingPaid;
if (request.Amount > remaining)
throw new InvalidOperationException(
$"Amount exceeds the outstanding balance of {remaining:0.##}");
if (request.Method == PaymentMethod.Cheque &&
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
if (request.Method == PaymentMethod.Cash && request.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
if (request.Method == PaymentMethod.Credit)
{
var store = invoice.ShopPO?.Store
?? throw new InvalidOperationException("Invoice is missing the owning store; cannot record a credit payment");
var currentOutstanding = await creditRepo.GetOutstandingForStoreAsync(store.Id);
if (currentOutstanding + request.Amount > store.CreditLimit)
{
var available = store.CreditLimit - currentOutstanding;
throw new InvalidOperationException(
$"Credit limit exceeded. Available credit: {available:0.##}");
}
}
Models.BankAccount? bankAccount = null;
if (request.BankAccountId is not null)
{
bankAccount = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
if (bankAccount is null)
throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (bankAccount.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
}
var recorderId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var collectedBy = request.CollectedByUserId ?? recorderId;
@@ -50,6 +93,7 @@ public class PaymentService(
EntryMode = request.EntryMode,
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
FK_BankAccountId = request.BankAccountId,
CollectedAt = DateTime.Now,
CollectedBy = collectedBy,
RecordedBy = recordedBy
@@ -58,11 +102,48 @@ public class PaymentService(
await uow.BeginAsync(ct);
try
{
// Auto-create a ReceivedCheque for the physical cheque. Status starts at Received;
// bookkeeping of the deposit/clear transitions happens through the Received Cheques screen.
if (request.Method == PaymentMethod.Cheque)
{
var cheque = new Models.ReceivedCheque
{
CompanyId = null,
BranchId = null,
ReceivedFromType = ReceivedFromType.Customer,
ReceivedFromId = null,
ReceivedFromName = $"Invoice {invoice.Id}",
DrawerBankName = request.DrawerBankName,
AccountHolderName = request.AccountHolderName,
ChequeNo = request.ChequeNumber!,
ChequeDate = request.ChequeDate!.Value,
Amount = request.Amount,
ReceivedDate = DateTime.Now,
ReferenceType = "Invoice",
Status = ReceivedChequeStatus.Received
};
await receivedChequeRepo.AddAsync(cheque);
payment.FK_ReceivedChequeId = cheque.Id;
}
await repo.AddAsync(payment);
var allPayments = await repo.GetByInvoiceIdAsync(invoiceId);
var total = allPayments.Sum(p => p.Amount) + request.Amount;
if (request.Method == PaymentMethod.Credit)
{
var movement = new StoreCreditMovement
{
FK_StoreId = invoice.ShopPO!.Store!.Id,
Direction = StoreCreditMovementDirection.Usage,
Amount = request.Amount,
FK_InvoiceId = invoice.Id,
FK_PaymentId = payment.Id,
OccurredAt = DateTime.Now,
RecordedBy = recorderId
};
await creditRepo.AddAsync(movement);
}
var total = existingPaid + request.Amount;
invoice.PaymentStatus = total == 0
? PaymentStatus.Pending
: total < invoice.TotalAmount
@@ -70,10 +151,14 @@ public class PaymentService(
: PaymentStatus.Paid;
invoiceRepo.Update(invoice);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
// Mirrors to the General Ledger after commit: Dr Cash/Bank (or Cheques-in-hand) / Cr Receivable. Never throws.
await gl.PostCustomerPaymentAsync(payment, ct);
return ToResponse(payment);
}
}
@@ -1,16 +1,31 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PriceList;
[ApiController]
[Route("api/price-lists")]
public class PriceListController(PriceListService service) : ControllerBase
public class PriceListController(
PriceListService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[Authorize("price-list.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId)
{
if (regionId.HasValue)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, regionId.Value))
return StatusCode(403);
}
return Ok(await service.GetAllAsync(regionId));
}
[HttpGet("resolve")]
[Authorize("price-list.view")]
public async Task<IActionResult> Resolve([FromQuery] Guid itemId, [FromQuery] Guid storeId, [FromQuery] DateTime saleDate)
{
var result = await service.ResolveByStoreAsync(itemId, storeId, saleDate);
@@ -18,6 +33,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpGet("{id:guid}")]
[Authorize("price-list.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -25,6 +41,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPost]
[Authorize("price-list.manage")]
public async Task<IActionResult> Create(CreatePriceListRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -32,6 +49,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePriceListRequest request, CancellationToken ct)
{
try
@@ -46,6 +64,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +72,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpPut("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> UpsertItem(Guid id, string sku, UpsertPriceListItemRequest request, CancellationToken ct)
{
try
@@ -67,6 +87,7 @@ public class PriceListController(PriceListService service) : ControllerBase
}
[HttpDelete("{id:guid}/items/{sku}")]
[Authorize("price-list.manage")]
public async Task<IActionResult> RemoveItem(Guid id, string sku, CancellationToken ct)
{
try
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Po;
@@ -9,10 +10,12 @@ namespace Distribution.Features.Po;
public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? supplierId, [FromQuery] PurchaseOrderStatus? status) =>
Ok(await service.GetAllAsync(supplierId, status));
[HttpGet("{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -20,6 +23,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Create(CreatePoRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -35,6 +39,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPut("{id:guid}")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Update(Guid id, UpdatePoRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/close")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Close(Guid id, CancellationToken ct)
{
try
@@ -57,6 +63,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> Cancel(Guid id, CancelPoRequest request, CancellationToken ct)
{
try
@@ -68,6 +75,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/items/{itemId:guid}/cancel")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> CancelLine(Guid id, Guid itemId, CancelPoLineRequest request, CancellationToken ct)
{
try
@@ -79,6 +87,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/match-invoice")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> MatchInvoice(Guid id, [FromQuery] Guid? invoiceId, [FromQuery] Guid? grnId)
{
var result = await service.MatchInvoiceToPoAsync(id, invoiceId, grnId);
@@ -86,6 +95,7 @@ public class PoController(PoService service, ICurrentUserService currentUser) :
}
[HttpPost("{id:guid}/resolve-variance")]
[Authorize("purchase-order.manage")]
public async Task<IActionResult> ResolveVariance(Guid id, ResolveInvoiceVarianceRequest request, CancellationToken ct)
{
try
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.PurchaseReturn;
@@ -7,6 +8,7 @@ namespace Distribution.Features.PurchaseReturn;
public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/purchase-returns/{id:guid}")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -14,10 +16,12 @@ public class PurchaseReturnController(PurchaseReturnService service, ICurrentUse
}
[HttpGet("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-order.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/returns")]
[Authorize("purchase-return.manage")]
public async Task<IActionResult> Create(Guid id, CreatePurchaseReturnRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -0,0 +1,13 @@
using Distribution.Models;
namespace Distribution.Features.ReceivedCheque;
public interface IReceivedChequeRepository
{
Task<Models.ReceivedCheque?> GetByIdAsync(Guid id);
Task<(List<Models.ReceivedCheque> Items, int TotalCount)> ListAsync(
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
int page, int pageSize);
Task AddAsync(Models.ReceivedCheque cheque);
void Update(Models.ReceivedCheque cheque);
}
@@ -0,0 +1,37 @@
using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ReceivedCheque;
[ApiController]
[Route("api/received-cheques")]
public class ReceivedChequeController(ReceivedChequeService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(
[FromQuery] int? companyId,
[FromQuery] int? branchId,
[FromQuery] ReceivedChequeStatus? status,
[FromQuery] ReceivedFromType? receivedFromType,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50)
=> Ok(await service.ListAsync(companyId, branchId, status, receivedFromType, page, pageSize));
[HttpPost]
public async Task<IActionResult> Create(CreateReceivedChequeRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}/status")]
public async Task<IActionResult> UpdateStatus(Guid id, UpdateReceivedChequeStatusRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateStatusAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,51 @@
using Distribution.Features.ChequeBook;
using Distribution.Models;
namespace Distribution.Features.ReceivedCheque;
public record ReceivedChequeResponse(
Guid ReceivedChequeId,
int? CompanyId,
int? BranchId,
ReceivedFromType ReceivedFromType,
int? ReceivedFromId,
string ReceivedFromName,
string? DrawerBankName,
string? DrawerBankBranch,
string? AccountHolderName,
string ChequeNo,
DateTime ChequeDate,
decimal Amount,
DateTime ReceivedDate,
string? ReferenceType,
int? ReferenceId,
string? Notes,
string? CreatedBy,
ReceivedChequeStatus Status,
Guid? DepositBankAccountId,
DateTime? DepositDate);
public record CreateReceivedChequeRequest(
int CompanyId,
int? BranchId,
ReceivedFromType ReceivedFromType,
int? ReceivedFromId,
string ReceivedFromName,
string? DrawerBankName,
string? DrawerBankBranch,
string? AccountHolderName,
string ChequeNo,
DateTime ChequeDate,
decimal Amount,
DateTime ReceivedDate,
string? ReferenceType,
int? ReferenceId,
string? Notes,
string? CreatedBy);
public record UpdateReceivedChequeStatusRequest(
ReceivedChequeStatusAction Action,
Guid? DepositBankAccountId,
DateTime? DepositDate,
string? Notes,
string? PerformedBy);
@@ -0,0 +1,34 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ReceivedCheque;
public class ReceivedChequeRepository(AppDBContext context) : IReceivedChequeRepository
{
public async Task<Models.ReceivedCheque?> GetByIdAsync(Guid id) =>
await context.ReceivedCheques.FirstOrDefaultAsync(c => c.Id == id);
public async Task<(List<Models.ReceivedCheque> Items, int TotalCount)> ListAsync(
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
int page, int pageSize)
{
var query = context.ReceivedCheques.AsQueryable();
if (companyId is not null) query = query.Where(c => c.CompanyId == companyId);
if (branchId is not null) query = query.Where(c => c.BranchId == branchId);
if (status is not null) query = query.Where(c => c.Status == status);
if (receivedFromType is not null) query = query.Where(c => c.ReceivedFromType == receivedFromType);
var total = await query.CountAsync();
var items = await query
.OrderByDescending(c => c.ReceivedDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, total);
}
public async Task AddAsync(Models.ReceivedCheque cheque) => await context.ReceivedCheques.AddAsync(cheque);
public void Update(Models.ReceivedCheque cheque) => context.ReceivedCheques.Update(cheque);
}
@@ -0,0 +1,118 @@
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Features.GeneralLedger;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.ReceivedCheque;
public class ReceivedChequeService(
IReceivedChequeRepository repo,
IBankAccountRepository bankAccountRepo,
IUnitOfWork uow,
GlPostingService gl)
{
private static ReceivedChequeResponse ToResponse(Models.ReceivedCheque c) =>
new(c.Id, c.CompanyId, c.BranchId, c.ReceivedFromType, c.ReceivedFromId, c.ReceivedFromName,
c.DrawerBankName, c.DrawerBankBranch, c.AccountHolderName,
c.ChequeNo, c.ChequeDate, c.Amount, c.ReceivedDate,
c.ReferenceType, c.ReferenceId, c.Notes, c.CreatedBy,
c.Status, c.FK_DepositBankAccountId, c.DepositDate);
public async Task<PagedResult<ReceivedChequeResponse>> ListAsync(
int? companyId, int? branchId, ReceivedChequeStatus? status, ReceivedFromType? receivedFromType,
int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
var (items, total) = await repo.ListAsync(companyId, branchId, status, receivedFromType, page, pageSize);
return new PagedResult<ReceivedChequeResponse>(
items.Select(ToResponse).ToList(), total, page, pageSize);
}
public async Task<ReceivedChequeResponse> CreateAsync(CreateReceivedChequeRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.ReceivedFromName))
throw new InvalidOperationException("ReceivedFromName is required");
if (string.IsNullOrWhiteSpace(request.ChequeNo))
throw new InvalidOperationException("ChequeNo is required");
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
var cheque = new Models.ReceivedCheque
{
CompanyId = request.CompanyId,
BranchId = request.BranchId,
ReceivedFromType = request.ReceivedFromType,
ReceivedFromId = request.ReceivedFromId,
ReceivedFromName = request.ReceivedFromName,
DrawerBankName = request.DrawerBankName,
DrawerBankBranch = request.DrawerBankBranch,
AccountHolderName = request.AccountHolderName,
ChequeNo = request.ChequeNo,
ChequeDate = request.ChequeDate,
Amount = request.Amount,
ReceivedDate = request.ReceivedDate,
ReferenceType = request.ReferenceType,
ReferenceId = request.ReferenceId,
Notes = request.Notes,
CreatedBy = request.CreatedBy,
Status = ReceivedChequeStatus.Received
};
await repo.AddAsync(cheque);
await uow.CommitAsync(ct);
return ToResponse(cheque);
}
public async Task<ReceivedChequeResponse?> UpdateStatusAsync(Guid id, UpdateReceivedChequeStatusRequest request, CancellationToken ct = default)
{
var cheque = await repo.GetByIdAsync(id);
if (cheque is null) return null;
switch (request.Action)
{
case ReceivedChequeStatusAction.Deposit:
if (cheque.Status != ReceivedChequeStatus.Received)
throw new InvalidOperationException("Only received cheques can be deposited");
if (request.DepositBankAccountId is null)
throw new InvalidOperationException("DepositBankAccountId is required when depositing");
if (request.DepositDate is null)
throw new InvalidOperationException("DepositDate is required when depositing");
var bankAccount = await bankAccountRepo.GetByIdAsync(request.DepositBankAccountId.Value);
if (bankAccount is null || bankAccount.AccountType != BankAccountType.Bank)
throw new InvalidOperationException("DepositBankAccountId does not reference a valid bank account");
cheque.Status = ReceivedChequeStatus.Deposited;
cheque.FK_DepositBankAccountId = request.DepositBankAccountId;
cheque.DepositDate = request.DepositDate;
break;
case ReceivedChequeStatusAction.Clear:
if (cheque.Status != ReceivedChequeStatus.Deposited)
throw new InvalidOperationException("Only deposited cheques can be cleared");
cheque.Status = ReceivedChequeStatus.Cleared;
break;
case ReceivedChequeStatusAction.Return:
if (cheque.Status != ReceivedChequeStatus.Deposited)
throw new InvalidOperationException("Only deposited cheques can be returned");
cheque.Status = ReceivedChequeStatus.Returned;
break;
case ReceivedChequeStatusAction.Cancel:
if (cheque.Status != ReceivedChequeStatus.Received)
throw new InvalidOperationException("Only received cheques can be cancelled");
cheque.Status = ReceivedChequeStatus.Cancelled;
break;
}
if (!string.IsNullOrWhiteSpace(request.Notes))
cheque.Notes = request.Notes;
repo.Update(cheque);
await uow.CommitAsync(ct);
await gl.PostReceivedChequeAsync(cheque, request.Action, ct);
return ToResponse(cheque);
}
}
@@ -1,28 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Region;
[ApiController]
[Route("api/regions")]
public class RegionController(RegionService service) : ControllerBase
public class RegionController(
RegionService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("region.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Suggested code for the Add Region form; the user may override it.
[HttpGet("next-code")]
[Authorize("region.view")]
public async Task<IActionResult> GetNextCode() =>
Ok(await service.GetNextCodeAsync());
[HttpGet("{id:guid}")]
[Authorize("region.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRegionScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("region.manage")]
public async Task<IActionResult> Create(CreateRegionRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,6 +41,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRegionRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -37,6 +49,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -44,6 +57,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/manager")]
[Authorize("region.view")]
public async Task<IActionResult> GetManager(Guid id)
{
var result = await service.GetManagerAsync(id);
@@ -51,6 +65,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpPut("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> SetManager(Guid id, SetRegionManagerRequest request, CancellationToken ct)
{
try
@@ -65,14 +80,15 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpDelete("{id:guid}/manager")]
[Authorize("region.manage")]
public async Task<IActionResult> RemoveManager(Guid id, CancellationToken ct)
{
var removed = await service.RemoveManagerAsync(id, ct);
return removed ? NoContent() : NotFound();
}
//targets/budgets/agencies/tax-rule.
[HttpPut("{id:guid}/manage")]
[Authorize("region.manage")]
public async Task<IActionResult> Manage(Guid id, ManageRegionRequest request, CancellationToken ct)
{
var result = await service.ManageAsync(id, request, ct);
@@ -80,6 +96,7 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/hierarchy")]
[Authorize("region.view")]
public async Task<IActionResult> GetHierarchy(Guid id)
{
var result = await service.GetHierarchyAsync(id);
@@ -87,18 +104,20 @@ public class RegionController(RegionService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("region.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
return result is null ? NotFound() : Ok(result);
}
// Flat CRUD-list view over targets/budgets/tax-rules/agency-maps across all regions.
[HttpGet("manage-entries")]
[Authorize("region.view")]
public async Task<IActionResult> GetManageEntries() =>
Ok(await service.GetManageEntriesAsync());
[HttpDelete("manage-entries/{entryType}/{id:guid}")]
[Authorize("region.manage")]
public async Task<IActionResult> DeleteManageEntry(RegionManageEntryType entryType, Guid id, CancellationToken ct)
{
var deleted = await service.DeleteManageEntryAsync(entryType, id, ct);
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Returns;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Returns;
public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/returns/{id:guid}")]
[Authorize("return.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpPatch("api/returns/{id:guid}/reasons")]
[Authorize("return.manage")]
public async Task<IActionResult> UpdateReasons(Guid id, UpdateSaleReturnReasonsRequest request, CancellationToken ct)
{
try
@@ -29,10 +33,12 @@ public class ReturnController(ReturnService service, ICurrentUserService current
}
[HttpGet("api/shop-pos/{id:guid}/returns")]
[Authorize("return.view")]
public async Task<IActionResult> GetByShopPO(Guid id) =>
Ok(await service.GetByShopPOIdAsync(id));
[HttpPost("api/shop-pos/{id:guid}/returns")]
[Authorize("return.manage")]
public async Task<IActionResult> Create(Guid id, CreateSaleReturnRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.CreatedByUserId;
@@ -5,4 +5,8 @@ public interface IRoleRepository
Task<Models.Role?> GetByIdAsync(Guid id);
Task<List<Models.Role>> GetAllAsync();
Task<List<Models.RolePermission>> GetPermissionsAsync(Guid roleId);
Task AddAsync(Models.Role role);
void Update(Models.Role role);
void Remove(Models.Role role);
Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds);
}
@@ -23,4 +23,32 @@ public class RoleController(RoleService service) : ControllerBase
var result = await service.GetPermissionsAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateRoleRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, UpdateRoleRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/permissions")]
public async Task<IActionResult> AssignPermissions(Guid id, AssignRolePermissionsRequest request, CancellationToken ct)
{
var result = await service.AssignPermissionsAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
{
var deactivated = await service.DeactivateAsync(id, ct);
return deactivated ? NoContent() : NotFound();
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
namespace Distribution.Features.Role;
public record RoleResponse(Guid Id, string Name, int Level, bool IsActive, DateTime CreatedAt);
public record RolePermissionResponse(Guid PermissionId, string Code, string Description);
public record CreateRoleRequest(string Name, int Level);
public record UpdateRoleRequest(string Name, int Level);
public record AssignRolePermissionsRequest(List<Guid> PermissionIds);
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Role;
@@ -16,4 +17,29 @@ public class RoleRepository(AppDBContext context) : IRoleRepository
.Include(rp => rp.Permission)
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.ToListAsync();
public async Task AddAsync(Models.Role role) =>
await context.Roles.AddAsync(role);
public void Update(Models.Role role) =>
context.Roles.Update(role);
public void Remove(Models.Role role)
{
role.IsActive = false;
context.Roles.Update(role);
}
public async Task ReplacePermissionsAsync(Guid roleId, List<Guid> permissionIds)
{
var existing = await context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId)
.ToListAsync();
context.RolePermissions.RemoveRange(existing);
context.RolePermissions.AddRange(permissionIds.Select(pid => new RolePermission
{
FK_RoleId = roleId,
FK_PermissionId = pid
}));
}
}
+81 -3
View File
@@ -1,18 +1,23 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.Role;
public class RoleService(IRoleRepository repo)
public class RoleService(IRoleRepository repo, IUnitOfWork uow)
{
private static RoleResponse ToResponse(Models.Role r) =>
new(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt);
public async Task<List<RoleResponse>> GetAllAsync() =>
(await repo.GetAllAsync())
.Where(r => r.IsActive)
.Select(r => new RoleResponse(r.Id, r.Name, r.Level, r.IsActive, r.CreatedAt))
.Select(ToResponse)
.ToList();
public async Task<RoleResponse?> GetByIdAsync(Guid id)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
return new RoleResponse(role.Id, role.Name, role.Level, role.IsActive, role.CreatedAt);
return ToResponse(role);
}
public async Task<List<RolePermissionResponse>?> GetPermissionsAsync(Guid roleId)
@@ -23,4 +28,77 @@ public class RoleService(IRoleRepository repo)
.Select(rp => new RolePermissionResponse(rp.FK_PermissionId, rp.Permission.Code, rp.Permission.Description))
.ToList();
}
public async Task<RoleResponse> CreateAsync(CreateRoleRequest request, CancellationToken ct = default)
{
var role = new Models.Role { Name = request.Name, Level = request.Level };
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<RoleResponse?> UpdateAsync(Guid id, UpdateRoleRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
role.Name = request.Name;
role.Level = request.Level;
await uow.BeginAsync(ct);
try
{
repo.Update(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(role);
}
public async Task<bool> DeactivateAsync(Guid id, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return false;
await uow.BeginAsync(ct);
try
{
repo.Remove(role);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return true;
}
public async Task<List<RolePermissionResponse>?> AssignPermissionsAsync(Guid id, AssignRolePermissionsRequest request, CancellationToken ct = default)
{
var role = await repo.GetByIdAsync(id);
if (role is null || !role.IsActive) return null;
await uow.BeginAsync(ct);
try
{
await repo.ReplacePermissionsAsync(id, request.PermissionIds);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return await GetPermissionsAsync(id);
}
}
+21 -2
View File
@@ -1,23 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Route;
[ApiController]
[Route("api/routes")]
public class RouteController(RouteService service) : ControllerBase
public class RouteController(
RouteService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("route.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("{id:guid}")]
[Authorize("route.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
if (userId.HasValue && !await scopeAccess.HasRouteScopeAsync(userId.Value, id))
return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("route.manage")]
public async Task<IActionResult> Create(CreateRouteRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -25,6 +36,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Update(Guid id, UpdateRouteRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -32,6 +44,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/region")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignToRegion(Guid id, AssignRouteToRegionRequest request, CancellationToken ct)
{
var result = await service.AssignToRegionAsync(id, request, ct);
@@ -39,6 +52,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("route.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -46,6 +60,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/stores")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignStores(Guid id, AssignStoresToRouteRequest request, CancellationToken ct)
{
var result = await service.AssignStoresToRouteAsync(id, request, ct);
@@ -53,6 +68,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPut("{id:guid}/owner")]
[Authorize("route.manage")]
public async Task<IActionResult> AssignOwner(Guid id, AssignRouteOwnerRequest request, CancellationToken ct)
{
var result = await service.AssignOwnerAsync(id, request, ct);
@@ -60,10 +76,12 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/optimize")]
[Authorize("route.manage")]
public IActionResult OptimizeSequence(Guid id) =>
StatusCode(StatusCodes.Status501NotImplemented);
[HttpPost("{id:guid}/visits/check-in")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckIn(Guid id, RecordCheckInRequest request, CancellationToken ct)
{
try
@@ -75,6 +93,7 @@ public class RouteController(RouteService service) : ControllerBase
}
[HttpPost("{id:guid}/visits/check-out")]
[Authorize("route.manage")]
public async Task<IActionResult> CheckOut(Guid id, RecordCheckOutRequest request, CancellationToken ct)
{
var result = await service.RecordCheckOutAsync(id, request, ct);
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Scheme;
@@ -7,10 +8,12 @@ namespace Distribution.Features.Scheme;
public class SchemeController(SchemeService service) : ControllerBase
{
[HttpGet]
[Authorize("scheme.view")]
public async Task<IActionResult> GetAll([FromQuery] Guid? regionId) =>
Ok(await service.GetAllAsync(regionId));
[HttpGet("{id:guid}")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,6 +21,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPost]
[Authorize("scheme.manage")]
public async Task<IActionResult> Create(CreateSchemeRequest request, CancellationToken ct)
{
try
@@ -32,6 +36,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSchemeRequest request, CancellationToken ct)
{
try
@@ -46,6 +51,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -53,6 +59,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> SetFreeGoodsRule(Guid id, SetFreeGoodsRuleRequest request, CancellationToken ct)
{
try
@@ -67,6 +74,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/free-goods-rule")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ClearFreeGoodsRule(Guid id, CancellationToken ct)
{
var result = await service.ClearFreeGoodsRuleAsync(id, ct);
@@ -74,6 +82,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpPut("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> UpsertBundleComponent(Guid id, Guid itemId, UpsertBundleComponentRequest request, CancellationToken ct)
{
try
@@ -88,6 +97,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpDelete("{id:guid}/bundle-components/{itemId:guid}")]
[Authorize("scheme.manage")]
public async Task<IActionResult> RemoveBundleComponent(Guid id, Guid itemId, CancellationToken ct)
{
var result = await service.RemoveBundleComponentAsync(id, itemId, ct);
@@ -95,6 +105,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("eligible")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetEligible([FromQuery] Guid storeId, [FromQuery] Guid orderId)
{
var result = await service.EvaluateEligibilityAsync(storeId, orderId);
@@ -102,6 +113,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/spend")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetSpend(Guid id)
{
var result = await service.GetSpendAsync(id);
@@ -109,6 +121,7 @@ public class SchemeController(SchemeService service) : ControllerBase
}
[HttpGet("{id:guid}/roi")]
[Authorize("scheme.view")]
public async Task<IActionResult> GetROI(Guid id)
{
var result = await service.GetROIAsync(id);
@@ -121,6 +134,7 @@ public class SchemeController(SchemeService service) : ControllerBase
public class SchemeOrderController(SchemeService service) : ControllerBase
{
[HttpPost("{id:guid}/apply-scheme")]
[Authorize("scheme.manage")]
public async Task<IActionResult> ApplyScheme(Guid id, ApplySchemeRequest request, CancellationToken ct)
{
try
+14 -3
View File
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Stock;
@@ -8,9 +9,10 @@ namespace Distribution.Features.Stock;
[Route("api/stock")]
public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase
{
// ── Query ──────────────────────────────────────────────────────────────
// ── Query ──────────────────────────────────────────────────────────────────
[HttpGet("balances")]
[Authorize("stock.view")]
public async Task<IActionResult> GetBalances(
[FromQuery] Guid? locationId,
[FromQuery] LocationType? locationType,
@@ -23,6 +25,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpGet("low-stock")]
[Authorize("stock.view")]
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
{
var result = await service.GetLowStockAsync(locationId);
@@ -30,6 +33,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpGet("balances/{itemId}/total")]
[Authorize("stock.view")]
public async Task<IActionResult> GetItemTotal(Guid itemId)
{
var result = await service.GetItemTotalAsync(itemId);
@@ -39,6 +43,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
/// <summary>Next free number for auto-generated serials (e.g. after ...-0005 the answer is 6).</summary>
[HttpGet("serials/next-sequence")]
[Authorize("stock.view")]
public async Task<IActionResult> GetNextSerialSequence([FromQuery] Guid itemId, [FromQuery] string prefix = "")
{
var next = await service.GetNextSerialSequenceAsync(itemId, prefix);
@@ -46,6 +51,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpGet("movements")]
//[Authorize("stock.view")]
public async Task<IActionResult> GetMovements(
[FromQuery] Guid? locationId,
[FromQuery] Guid? itemId,
@@ -77,9 +83,10 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result);
}
// ── Write ──────────────────────────────────────────────────────────────
// ── Write ──────────────────────────────────────────────────────────────────
[HttpPost("transfer")]
[Authorize("stock.manage")]
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -92,6 +99,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("damage")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordDamage(DamageRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -104,6 +112,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("customer-return")]
[Authorize("stock.manage")]
public async Task<IActionResult> RecordCustomerReturn(CustomerReturnRequest request, CancellationToken ct)
{
if (currentUser.UserId is null) return Unauthorized();
@@ -116,6 +125,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPost("adjustment")]
[Authorize("stock.manage")]
public async Task<IActionResult> Adjust(AdjustmentRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -131,6 +141,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
}
[HttpPatch("movements/{id}/reason")]
[Authorize("stock.manage")]
public async Task<IActionResult> UpdateMovementReason(Guid id, UpdateMovementReasonRequest request, CancellationToken ct)
{
try
+23 -2
View File
@@ -1,13 +1,19 @@
using Distribution.Models;
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Store;
[ApiController]
[Route("api/stores")]
public class StoreController(StoreService service) : ControllerBase
public class StoreController(
StoreService service,
IScopeAccessService scopeAccess,
ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("store.view")]
public async Task<IActionResult> GetAll(
[FromQuery] Guid? routeId,
[FromQuery] string? search,
@@ -16,13 +22,18 @@ public class StoreController(StoreService service) : ControllerBase
Ok(await service.GetAllAsync(routeId, search, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize("store.view")]
public async Task<IActionResult> GetById(Guid id)
{
var userId = currentUser.UserId;
//if (userId.HasValue && !await scopeAccess.HasStoreScopeAsync(userId.Value, id))
// return StatusCode(403);
var result = await service.GetByIdAsync(id);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize("store.manage")]
public async Task<IActionResult> Create(CreateStoreRequest request, CancellationToken ct)
{
var result = await service.CreateAsync(request, ct);
@@ -30,6 +41,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Update(Guid id, UpdateStoreRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
@@ -37,6 +49,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpDelete("{id:guid}")]
[Authorize("store.manage")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await service.DeleteAsync(id, ct);
@@ -44,6 +57,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/credit-limit")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct)
{
var result = await service.UpdateCreditLimitAsync(id, request, ct);
@@ -51,6 +65,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/outstanding-balance")]
[Authorize("store.manage")]
public async Task<IActionResult> UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct)
{
var result = await service.UpdateOutstandingBalanceAsync(id, request, ct);
@@ -58,6 +73,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/outstanding-balance")]
[Authorize("store.view")]
public async Task<IActionResult> GetOutstandingBalance(Guid id)
{
var result = await service.GetOutstandingBalanceAsync(id);
@@ -65,6 +81,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPut("{id:guid}/segment")]
[Authorize("store.manage")]
public async Task<IActionResult> ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct)
{
var result = await service.ClassifySegmentAsync(id, request, ct);
@@ -72,6 +89,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/visit-history")]
[Authorize("store.view")]
public async Task<IActionResult> GetVisitHistory(Guid id)
{
var result = await service.GetVisitHistoryAsync(id);
@@ -79,6 +97,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")]
[Authorize("store.view")]
public async Task<IActionResult> CheckSchemeEligibility(Guid id, Guid schemeId)
{
var result = await service.CheckSchemeEligibilityAsync(id, schemeId);
@@ -86,6 +105,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpGet("{id:guid}/dashboard")]
[Authorize("store.view")]
public async Task<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
{
var result = await service.GetDashboardAsync(id, period);
@@ -100,6 +120,7 @@ public class StoreController(StoreService service) : ControllerBase
}
[HttpPatch("{id:guid}/status")]
[Authorize("store.manage")]
public async Task<IActionResult> PatchStatus(Guid id, PatchStoreStatusRequest request, CancellationToken ct)
{
var result = await service.SetActiveStatusAsync(id, request.IsActive, ct);
+7 -5
View File
@@ -1,17 +1,18 @@
using Distribution.Features.Scheme;
using Distribution.Features.Stock;
using Distribution.Features.StoreCredit;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
namespace Distribution.Features.Store;
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo)
public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess, SchemeService schemeService, Route.IRouteRepository routeRepo, IStoreCreditMovementRepository creditRepo)
{
private const int GlobalScopeRoleLevel = 50;
private const int CreditApproverRoleLevel = 20;
private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel;
private async Task<bool> HasGlobalScopeAsync() => await currentUser.GetRoleLevelAsync() >= GlobalScopeRoleLevel;
private async Task<bool> HasStoreScopeAsync(Guid storeId)
{
@@ -122,7 +123,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -143,7 +144,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
public async Task<StoreResponse?> UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default)
{
if (currentUser.RoleLevel < CreditApproverRoleLevel) return null;
if (await currentUser.GetRoleLevelAsync() < CreditApproverRoleLevel) return null;
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
@@ -167,7 +168,8 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
var store = await repo.GetByIdAsync(id);
if (store is null || !store.IsActive) return null;
if (!await HasStoreScopeAsync(id)) return null;
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance);
var outstanding = await creditRepo.GetOutstandingForStoreAsync(id);
return new OutstandingBalanceResponse(store.Id, store.CreditLimit, outstanding, store.CreditLimit - outstanding);
}
public async Task<StoreResponse?> ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default)
{
@@ -0,0 +1,11 @@
using Distribution.Models;
namespace Distribution.Features.StoreCredit;
public interface IStoreCreditMovementRepository
{
Task<decimal> GetOutstandingForStoreAsync(Guid storeId);
Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize);
Task<int> GetCountByStoreAsync(Guid storeId);
Task AddAsync(StoreCreditMovement movement);
}
@@ -0,0 +1,36 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.StoreCredit;
public class StoreCreditMovementRepository(AppDBContext context) : IStoreCreditMovementRepository
{
public async Task<decimal> GetOutstandingForStoreAsync(Guid storeId)
{
var usage = await context.StoreCreditMovements
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Usage)
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
var settlements = await context.StoreCreditMovements
.Where(m => m.IsActive && m.FK_StoreId == storeId && m.Direction == StoreCreditMovementDirection.Settlement)
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
return usage - settlements;
}
public async Task<List<StoreCreditMovement>> GetByStoreAsync(Guid storeId, int page, int pageSize) =>
await context.StoreCreditMovements
.Where(m => m.IsActive && m.FK_StoreId == storeId)
.OrderByDescending(m => m.OccurredAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountByStoreAsync(Guid storeId) =>
await context.StoreCreditMovements
.CountAsync(m => m.IsActive && m.FK_StoreId == storeId);
public async Task AddAsync(StoreCreditMovement movement) =>
await context.StoreCreditMovements.AddAsync(movement);
}
@@ -1,4 +1,5 @@
using Distribution.Infra.Authorization;
using Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierClaims;
@@ -7,10 +8,12 @@ namespace Distribution.Features.SupplierClaims;
public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet("api/supplier-claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
[HttpGet("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetById(Guid id)
{
var result = await service.GetByIdAsync(id);
@@ -18,10 +21,12 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpGet("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.view")]
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
Ok(await service.GetByPurchaseOrderIdAsync(id));
[HttpPost("api/purchase-orders/{id:guid}/claims")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Create(Guid id, CreateSupplierClaimRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
@@ -37,6 +42,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPut("api/supplier-claims/{id:guid}")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Update(Guid id, UpdateSupplierClaimRequest request, CancellationToken ct)
{
try
@@ -48,6 +54,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/approve")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.ApprovedByUserId;
@@ -62,6 +69,7 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS
}
[HttpPost("api/supplier-claims/{id:guid}/reject")]
[Authorize("supplier-claim.manage")]
public async Task<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.RejectedByUserId;
@@ -0,0 +1,17 @@
using Distribution.Models;
namespace Distribution.Features.SupplierPayment;
public interface ISupplierPaymentRepository
{
Task<Models.SupplierPayment?> GetByIdAsync(Guid id);
Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize);
Task<int> CountBySupplierAsync(Guid supplierId);
Task AddAsync(Models.SupplierPayment payment);
/// <summary>Sum of currently-allocated amounts to a GRN, across all active SupplierPayments.</summary>
Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId);
/// <summary>Load a GRN with its items so the total can be computed.</summary>
Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId);
}
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SupplierPayment;
[ApiController]
[Route("api/suppliers/{supplierId:guid}/payments")]
public class SupplierPaymentController(SupplierPaymentService service) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> List(Guid supplierId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var result = await service.ListBySupplierAsync(supplierId, page, pageSize);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.CreateAsync(supplierId, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,43 @@
using Distribution.Models;
namespace Distribution.Features.SupplierPayment;
public record SupplierPaymentAllocationRequest(
Guid GoodsReceiptId,
decimal AllocatedAmount);
public record CreateSupplierPaymentRequest(
decimal Amount,
PaymentMethod Method,
// One or more GRNs this payment covers. Sum of AllocatedAmount must equal Amount.
List<SupplierPaymentAllocationRequest> Allocations,
// Cash: required.
Guid? BankAccountId = null,
// Cheque: required — an Unused page from an Active cheque book.
Guid? ChequePageId = null,
string? Notes = null,
Guid? PaidByUserId = null);
public record SupplierPaymentAllocationResponse(
Guid GoodsReceiptId,
decimal AllocatedAmount);
public record SupplierPaymentResponse(
Guid Id,
Guid SupplierId,
decimal Amount,
PaymentMethod Method,
Guid? BankAccountId,
Guid? ChequePageId,
string? ChequeNo,
DateTime PaidAt,
Guid? PaidByUserId,
string? Notes,
List<SupplierPaymentAllocationResponse> Allocations,
DateTime CreatedAt);
public record PagedSupplierPaymentsResponse(
List<SupplierPaymentResponse> Items,
int Page,
int PageSize,
int Total);
@@ -0,0 +1,43 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierPayment;
public class SupplierPaymentRepository(AppDBContext context) : ISupplierPaymentRepository
{
public async Task<Models.SupplierPayment?> GetByIdAsync(Guid id) =>
await context.SupplierPayments
.Include(p => p.ChequePage)
.Include(p => p.Allocations)
.FirstOrDefaultAsync(p => p.Id == id);
public async Task<List<Models.SupplierPayment>> ListBySupplierAsync(Guid supplierId, int page, int pageSize) =>
await context.SupplierPayments
.Include(p => p.ChequePage)
.Include(p => p.Allocations)
.Where(p => p.FK_SupplierId == supplierId && p.IsActive)
.OrderByDescending(p => p.PaidAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountBySupplierAsync(Guid supplierId) =>
await context.SupplierPayments
.CountAsync(p => p.FK_SupplierId == supplierId && p.IsActive);
public async Task AddAsync(Models.SupplierPayment payment) =>
await context.SupplierPayments.AddAsync(payment);
public async Task<decimal> GetAllocatedTotalForGrnAsync(Guid goodsReceiptId) =>
await context.SupplierPaymentGRNs
.Where(a => a.FK_GoodsReceiptId == goodsReceiptId
&& a.IsActive
&& a.SupplierPayment!.IsActive)
.SumAsync(a => (decimal?)a.AllocatedAmount) ?? 0m;
public async Task<Models.GoodsReceipt?> GetGrnWithItemsAsync(Guid goodsReceiptId) =>
await context.GoodsReceipts
.Include(g => g.Items)
.FirstOrDefaultAsync(g => g.Id == goodsReceiptId && g.IsActive);
}
@@ -0,0 +1,175 @@
using Distribution.Data;
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Features.GeneralLedger;
using Distribution.Features.Grn;
using Distribution.Features.Supplier;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.SupplierPayment;
public class SupplierPaymentService(
AppDBContext context,
ISupplierPaymentRepository repo,
ISupplierRepository supplierRepo,
IBankAccountRepository bankAccountRepo,
IChequeBookRepository chequeBookRepo,
IUnitOfWork uow,
ICurrentUserService currentUser,
GlPostingService gl)
{
private static SupplierPaymentResponse ToResponse(Models.SupplierPayment p) =>
new(p.Id, p.FK_SupplierId, p.Amount, p.Method,
p.FK_BankAccountId, p.FK_ChequePageId, p.ChequePage?.ChequeNo,
p.PaidAt, p.PaidByUserId, p.Notes,
p.Allocations
.Where(a => a.IsActive)
.Select(a => new SupplierPaymentAllocationResponse(a.FK_GoodsReceiptId, a.AllocatedAmount))
.ToList(),
p.CreatedAt);
/// <summary>GRN total = sum per line of (gross - discount + NBT + VAT). Rejected qty never costs.</summary>
private static decimal GrnTotal(Models.GoodsReceipt grn) =>
grn.Items.Where(i => i.IsActive).Sum(i =>
GrnPricing.Line(i.AcceptedQuantity, i.UnitCost, i.DiscountPercent, i.NbtPercent, i.VatPercent).Total);
public async Task<PagedSupplierPaymentsResponse?> ListBySupplierAsync(Guid supplierId, int page, int pageSize)
{
var supplier = await supplierRepo.GetByIdAsync(supplierId);
if (supplier is null) return null;
if (page < 1) page = 1;
if (pageSize < 1) pageSize = 20;
if (pageSize > 200) pageSize = 200;
var payments = await repo.ListBySupplierAsync(supplierId, page, pageSize);
var total = await repo.CountBySupplierAsync(supplierId);
return new PagedSupplierPaymentsResponse(payments.Select(ToResponse).ToList(), page, pageSize, total);
}
public async Task<SupplierPaymentResponse?> CreateAsync(Guid supplierId, CreateSupplierPaymentRequest request, CancellationToken ct = default)
{
var supplier = await supplierRepo.GetByIdAsync(supplierId);
if (supplier is null) return null;
if (request.Amount <= 0)
throw new InvalidOperationException("Amount must be greater than zero");
if (request.Method == PaymentMethod.Credit)
throw new InvalidOperationException("Credit is not a valid outgoing payment method");
if (request.Allocations is null || request.Allocations.Count == 0)
throw new InvalidOperationException("At least one GRN allocation is required");
var duplicateGrn = request.Allocations.GroupBy(a => a.GoodsReceiptId).FirstOrDefault(g => g.Count() > 1);
if (duplicateGrn is not null)
throw new InvalidOperationException($"GRN {duplicateGrn.Key} is allocated more than once");
if (request.Allocations.Any(a => a.AllocatedAmount <= 0))
throw new InvalidOperationException("Each allocation amount must be greater than zero");
var allocationSum = request.Allocations.Sum(a => a.AllocatedAmount);
if (allocationSum != request.Amount)
throw new InvalidOperationException($"Allocations sum ({allocationSum}) must equal Amount ({request.Amount})");
// Validate each GRN exists, belongs to this supplier, and the new allocation does not push cumulative paid over the GRN total.
foreach (var alloc in request.Allocations)
{
var grn = await repo.GetGrnWithItemsAsync(alloc.GoodsReceiptId)
?? throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} not found");
var grnSupplierId = grn.FK_SupplierId
?? grn.PurchaseOrder?.FK_SupplierId;
if (grnSupplierId is null)
{
// PO-based GRN without the PO loaded — re-query to confirm supplier match.
var po = await context.PurchaseOrders.FindAsync([grn.FK_PurchaseOrderId], ct);
grnSupplierId = po?.FK_SupplierId;
}
if (grnSupplierId != supplierId)
throw new InvalidOperationException($"GRN {alloc.GoodsReceiptId} does not belong to the specified supplier");
var grnTotal = GrnTotal(grn);
var alreadyAllocated = await repo.GetAllocatedTotalForGrnAsync(grn.Id);
if (alreadyAllocated + alloc.AllocatedAmount > grnTotal)
throw new InvalidOperationException(
$"Allocation to GRN {grn.Id} ({alloc.AllocatedAmount}) exceeds outstanding balance " +
$"(GRN total {grnTotal}, already paid {alreadyAllocated})");
}
var payerId = currentUser.UserId ?? request.PaidByUserId;
var payment = new Models.SupplierPayment
{
FK_SupplierId = supplierId,
Amount = request.Amount,
Method = request.Method,
PaidAt = DateTime.Now,
PaidByUserId = payerId,
Notes = request.Notes,
Allocations = request.Allocations.Select(a => new Models.SupplierPaymentGRN
{
FK_GoodsReceiptId = a.GoodsReceiptId,
AllocatedAmount = a.AllocatedAmount
}).ToList()
};
await uow.BeginAsync(ct);
try
{
if (request.Method == PaymentMethod.Cash)
{
if (request.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
var account = await bankAccountRepo.GetByIdAsync(request.BankAccountId.Value);
if (account is null)
throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (account.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
payment.FK_BankAccountId = account.Id;
}
else // Cheque
{
if (request.ChequePageId is null)
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
var page = await context.ChequePages
.Include(p => p.ChequeBook)
.FirstOrDefaultAsync(p => p.Id == request.ChequePageId.Value, ct);
if (page is null)
throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
if (page.IssueStatus != ChequePageIssueStatus.Unused)
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
throw new InvalidOperationException("The cheque book is not active");
page.IssueStatus = ChequePageIssueStatus.Issued;
page.PayeeType = PayeeType.Supplier;
page.PayeeName = supplier.Name;
page.IssueDate = DateTime.Now;
page.Amount = request.Amount;
page.CurrencyCode = "LKR";
page.PrintedAt = DateTime.Now;
chequeBookRepo.UpdatePage(page);
payment.FK_ChequePageId = page.Id;
payment.ChequePage = page;
}
await repo.AddAsync(payment);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
// Mirrors to the General Ledger after commit: Dr Payable / Cr Cash/Bank (or Cheques-issued). Never throws.
await gl.PostSupplierPaymentAsync(payment, ct);
return ToResponse(payment);
}
}
@@ -1,5 +1,6 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.SysConfig;
@@ -9,11 +10,12 @@ namespace Distribution.Features.SysConfig;
public class SysConfigController(SysConfigService service, ICurrentUserService currentUser) : ControllerBase
{
[HttpGet]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetAll() =>
Ok(await service.GetAllAsync());
// Typed shortcut — must be declared before {key} to take precedence
[HttpGet("invoice-creation-mode")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetInvoiceCreationMode()
{
var mode = await service.GetInvoiceCreationModeAsync();
@@ -21,6 +23,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("invoice-creation-mode")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> SetInvoiceCreationMode(SetInvoiceCreationModeRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
@@ -33,6 +36,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpGet("{key}")]
[Authorize("sys-config.view")]
public async Task<IActionResult> GetByKey(string key)
{
var result = await service.GetByKeyAsync(key);
@@ -40,6 +44,7 @@ public class SysConfigController(SysConfigService service, ICurrentUserService c
}
[HttpPut("{key}")]
[Authorize("sys-config.manage")]
public async Task<IActionResult> Set(string key, SetSysConfigRequest request, CancellationToken ct)
{
var userId = currentUser.UserId ?? request.UserId;
@@ -31,13 +31,6 @@ public class UserController(UserService service) : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/role")]
public async Task<IActionResult> AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct)
{
var result = await service.AssignRoleAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPut("{id:guid}/scope")]
public async Task<IActionResult> AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct)
{
+9 -2
View File
@@ -1,13 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Distribution.Features.User;
public record CreateUserRequest(
string Username,
[EmailAddress] string Username,
string Password,
string FullName,
Guid RoleId,
Guid? ReportsToUserId);
public record UpdateUserRequest(string FullName);
public record UpdateUserRequest(
string FullName,
string? Username = null,
Guid? RoleId = null,
string? CurrentPassword = null,
string? NewPassword = null);
public record AssignUserRoleRequest(Guid RoleId);
+64 -21
View File
@@ -1,12 +1,10 @@
using Distribution.Features.Auth;
using Distribution.Infra.UoW;
using Microsoft.AspNetCore.Identity;
namespace Distribution.Features.User;
public class UserService(IUserRepository repo, IUnitOfWork uow)
public class UserService(IUserRepository repo, IUnitOfWork uow, IAuthHexClient authHex)
{
private readonly PasswordHasher<Models.User> _hasher = new();
private static UserResponse ToResponse(Models.User u) =>
new(u.Id, u.Username, u.FullName, u.FK_RoleId, u.Role?.Name ?? string.Empty,
u.FK_ReportsToUserId, u.IsActive, u.CreatedAt, u.UpdatedAt);
@@ -33,12 +31,30 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
FK_RoleId = request.RoleId,
FK_ReportsToUserId = request.ReportsToUserId
};
user.PasswordHash = _hasher.HashPassword(user, request.Password);
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(user);
var authResult = await authHex.CallAsync("registerUser", new
{
userId = user.Id,
userName = request.Username,
email = request.Username,
fullname = request.FullName,
password = request.Password,
roleId = request.RoleId.ToString(),
userTypeId = "00000001-0000-0000-0000-200000000000",
sendCredentialsEmail = false
}, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex registration failed: {authResult.Message}");
}
await uow.CommitAsync(ct);
}
catch
@@ -53,12 +69,52 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
{
var user = await repo.GetByIdAsync(id);
if (user is null || !user.IsActive) return null;
user.FullName = request.FullName;
if (!string.IsNullOrWhiteSpace(request.Username))
user.Username = request.Username;
if (request.RoleId.HasValue)
user.FK_RoleId = request.RoleId.Value;
user.UpdatedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(user);
var hasUsernameChange = !string.IsNullOrWhiteSpace(request.Username);
var hasRoleChange = request.RoleId.HasValue;
if (hasUsernameChange || hasRoleChange)
{
var payload = new Dictionary<string, object> { ["userId"] = user.Id.ToString() };
if (hasUsernameChange) payload["userName"] = request.Username!;
if (hasRoleChange) payload["roleCode"] = request.RoleId!.Value.ToString();
var authResult = await authHex.CallAsync("UpdateUser", payload, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex update failed: {authResult.Message}");
}
}
if (!string.IsNullOrWhiteSpace(request.CurrentPassword) && !string.IsNullOrWhiteSpace(request.NewPassword))
{
var authResult = await authHex.CallAsync("ChangeUserPassword", new
{
userId = user.Id.ToString(),
currentPassword = request.CurrentPassword,
newPassword = request.NewPassword
}, reference: user.Id.ToString(), ct: ct);
if (!authResult.Success)
{
await uow.RollbackAsync(ct);
throw new InvalidOperationException($"AuthHex password change failed: {authResult.Message}");
}
}
await uow.CommitAsync(ct);
}
catch
@@ -71,22 +127,9 @@ public class UserService(IUserRepository repo, IUnitOfWork uow)
public async Task<UserResponse?> AssignRoleAsync(Guid id, AssignUserRoleRequest request, CancellationToken ct = default)
{
var user = await repo.GetByIdAsync(id);
if (user is null || !user.IsActive) return null;
user.FK_RoleId = request.RoleId;
user.UpdatedAt = DateTime.Now;
await uow.BeginAsync(ct);
try
{
repo.Update(user);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
throw;
}
return ToResponse(user);
return await UpdateAsync(id, new UpdateUserRequest(
FullName: (await repo.GetByIdAsync(id))?.FullName ?? string.Empty,
RoleId: request.RoleId), ct);
}
public async Task<bool> AssignScopeAsync(Guid id, AssignUserScopeRequest request, CancellationToken ct = default)
@@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Distribution.Infra.Authorization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticatedAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
if (context.HttpContext.User.Identity?.IsAuthenticated != true)
context.Result = new UnauthorizedResult();
}
}
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Infra.Authorization;
@@ -17,6 +17,14 @@ public class AuthorizationController(
return Ok(new PermissionCheckResponse(result));
}
[HttpGet("my-permissions")]
public async Task<IActionResult> GetMyPermissions()
{
if (currentUser.RoleId is not { } roleId) return Unauthorized();
var permissions = await permissionService.GetRolePermissionsAsync(roleId);
return Ok(new RolePermissionsResponse(permissions));
}
[HttpGet("scopes/stores/{storeId:guid}")]
public async Task<IActionResult> CheckStoreScope(Guid storeId)
{
@@ -49,3 +57,15 @@ public class AuthorizationController(
return Ok(new AccessibleStoresResponse(result));
}
}
[ApiController]
[Route("api/permissions")]
public class PermissionController(IPermissionService permissionService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll()
{
var permissions = await permissionService.GetAllAsync();
return Ok(permissions.Select(p => new { permissionId = p.Id, p.Code, p.Description }));
}
}
@@ -2,6 +2,8 @@ namespace Distribution.Infra.Authorization;
public record PermissionCheckResponse(bool HasPermission);
public record RolePermissionsResponse(List<string> Permissions);
public record StoreScopeCheckResponse(bool HasScope);
public record RouteScopeCheckResponse(bool HasScope);
@@ -1,46 +0,0 @@
using Microsoft.AspNetCore.Http;
namespace Distribution.Infra.Authorization;
public class CurrentUserService : ICurrentUserService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUserService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
private System.Security.Claims.ClaimsPrincipal? User => _httpContextAccessor.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId
{
get
{
var value = User?.FindFirst("sub")?.Value ?? User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public Guid? RoleId
{
get
{
var value = User?.FindFirst("role_id")?.Value;
return Guid.TryParse(value, out var id) ? id : null;
}
}
public string? Username => User?.FindFirst("username")?.Value;
public int RoleLevel
{
get
{
var value = User?.FindFirst("role_level")?.Value;
return int.TryParse(value, out var level) ? level : 0;
}
}
}
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Distribution.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace Distribution.Infra.Authorization;
public class CurrentUserService(IHttpContextAccessor http, AppDBContext db, IMemoryCache cache) : ICurrentUserService
{
private ClaimsPrincipal? User => http.HttpContext?.User;
public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
public Guid? UserId => Guid.TryParse(User?.FindFirst("UserId")?.Value, out var id) ? id : null;
public Guid? RoleId => Guid.TryParse(User?.FindFirst("RoleId")?.Value, out var id) ? id : null;
public async Task<string?> GetUsernameAsync()
{
if (UserId is not { } id) return null;
var key = $"user_rolename_{id}";
if (cache.TryGetValue(key, out string? cached)) return cached;
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
cache.Set(key, user?.Username, TimeSpan.FromMinutes(10));
return user?.Username;
}
public async Task<string?> GetRoleCodeAsync()
{
if (RoleId is not { } id) return null;
var key = $"role_name_{id}";
if (cache.TryGetValue(key, out string? cached)) return cached;
var role = await db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
cache.Set(key, role?.Name, TimeSpan.FromMinutes(10));
return role?.Name;
}
public async Task<int> GetRoleLevelAsync()
{
if (RoleId is not { } id) return 0;
var key = $"role_level_{id}";
if (cache.TryGetValue(key, out int cached)) return cached;
var role = await db.Roles.AsNoTracking().FirstOrDefaultAsync(r => r.Id == id);
var level = role?.Level ?? 0;
cache.Set(key, level, TimeSpan.FromMinutes(10));
return level;
}
}
@@ -4,7 +4,8 @@ public interface ICurrentUserService
{
Guid? UserId { get; }
Guid? RoleId { get; }
string? Username { get; }
int RoleLevel { get; }
bool IsAuthenticated { get; }
Task<string?> GetUsernameAsync();
Task<string?> GetRoleCodeAsync();
Task<int> GetRoleLevelAsync();
}
@@ -1,32 +0,0 @@
using System.Security.Cryptography;
using System.Xml.Linq;
namespace Distribution.Infra.Authorization;
/// <summary>
/// Parses an XML-format RSA public key (`&lt;RSAKeyValue&gt;...&lt;/RSAKeyValue&gt;`, as issued by
/// AuthHex) into an <see cref="RSA"/> instance. Written by hand instead of relying on
/// RSA.FromXmlString, which is Windows-only in .NET (throws PlatformNotSupportedException on
/// Linux/macOS) — this keeps token validation portable across hosts.
/// </summary>
public static class RsaXmlKeyReader
{
public static RSA ReadPublicKey(string xml)
{
var root = XElement.Parse(xml);
var modulus = root.Element("Modulus")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Modulus>.");
var exponent = root.Element("Exponent")?.Value
?? throw new InvalidOperationException("RSA XML key is missing <Exponent>.");
var parameters = new RSAParameters
{
Modulus = Convert.FromBase64String(modulus),
Exponent = Convert.FromBase64String(exponent)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return rsa;
}
}
@@ -1,10 +0,0 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>Stand-in for OSMCore's OMSCore.Common.Models.IOs.ApiResponse&lt;T&gt;, which has no equivalent in this project.</summary>
public sealed record ApiResponse<T>
{
public int StatusCode { get; init; }
public bool Success { get; init; }
public string? Message { get; init; }
public T? Data { get; init; }
}
@@ -1,13 +0,0 @@
namespace Distribution.Infra.Authorization.Middleware;
/// <summary>
/// Ported from OSMCore's System/Authentication/JwtOptions.cs. Binds to the same "Jwt" config
/// section already used by Program.cs's AddJwtBearer setup (Jwt:PublicKey/Audience/Issuer) — no
/// new config keys needed.
/// </summary>
public sealed record JwtOptions
{
public string PublicKey { get; init; } = string.Empty;
public string Audience { get; init; } = string.Empty;
public string Issuer { get; init; } = string.Empty;
}
@@ -1,6 +1,10 @@
using Distribution.Models;
namespace Distribution.Infra.Authorization;
public interface IPermissionRepository
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
Task<List<string>> GetRolePermissionsAsync(Guid roleId);
Task<List<Permission>> GetAllAsync();
}
@@ -1,6 +1,10 @@
using Distribution.Models;
namespace Distribution.Infra.Authorization;
public interface IPermissionService
{
Task<bool> HasPermissionAsync(Guid roleId, string permissionCode);
Task<List<string>> GetRolePermissionsAsync(Guid roleId);
Task<List<Permission>> GetAllAsync();
}
@@ -1,4 +1,5 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Infra.Authorization;
@@ -10,4 +11,15 @@ public class PermissionRepository(AppDBContext context) : IPermissionRepository
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.Join(context.Permissions.Where(p => p.IsActive), rp => rp.FK_PermissionId, p => p.Id, (rp, p) => p.Code)
.AnyAsync(code => code == permissionCode);
public Task<List<string>> GetRolePermissionsAsync(Guid roleId) =>
context.RolePermissions
.Where(rp => rp.FK_RoleId == roleId && rp.IsActive)
.Select(rp => rp.Permission)
.Where(p => p.IsActive)
.Select(p => p.Code)
.ToListAsync();
public Task<List<Permission>> GetAllAsync() =>
context.Permissions.Where(p => p.IsActive).ToListAsync();
}
@@ -1,7 +1,15 @@
using Distribution.Models;
namespace Distribution.Infra.Authorization;
public class PermissionService(IPermissionRepository repo) : IPermissionService
{
public Task<bool> HasPermissionAsync(Guid roleId, string permissionCode) =>
repo.HasPermissionAsync(roleId, permissionCode);
public Task<List<string>> GetRolePermissionsAsync(Guid roleId) =>
repo.GetRolePermissionsAsync(roleId);
public Task<List<Permission>> GetAllAsync() =>
repo.GetAllAsync();
}
@@ -1,23 +1,24 @@

//namespace Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
//public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
//{
// private readonly IPermissionService _permissionService;
namespace Distribution.Infra.Authorization;
// public PermissionAuthorizationHandler(IPermissionService permissionService)
// {
// _permissionService = permissionService;
// }
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
private readonly IPermissionService _permissionService;
// protected override async Task HandleRequirementAsync(
// AuthorizationHandlerContext context, PermissionRequirement requirement)
// {
// var roleIdClaim = context.User.FindFirst("role_id")?.Value;
// if (!Guid.TryParse(roleIdClaim, out var roleId))
// return;
public PermissionAuthorizationHandler(IPermissionService permissionService)
{
_permissionService = permissionService;
}
// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
// context.Succeed(requirement);
// }
//}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
var roleIdClaim = context.User.FindFirst("RoleId")?.Value;
if (!Guid.TryParse(roleIdClaim, out var roleId))
return;
if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode))
context.Succeed(requirement);
}
}
@@ -1,31 +1,32 @@
//using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
//namespace Distribution.Infra.Authorization;
namespace Distribution.Infra.Authorization;
//public class PermissionPolicyProvider : IAuthorizationPolicyProvider
//{
// private readonly DefaultAuthorizationPolicyProvider _fallback;
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
// {
// _fallback = new DefaultAuthorizationPolicyProvider(options);
// }
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
{
_fallback = new DefaultAuthorizationPolicyProvider(options);
}
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
// {
// if (policyName.Contains('.'))
// {
// var policy = new AuthorizationPolicyBuilder()
// .RequireAuthenticatedUser()
// .AddRequirements(new PermissionRequirement(policyName))
// .Build();
// return Task.FromResult<AuthorizationPolicy?>(policy);
// }
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.Contains('.'))
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(policyName))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
// return _fallback.GetPolicyAsync(policyName);
// }
//}
return _fallback.GetPolicyAsync(policyName);
}
}
@@ -1,12 +1,13 @@

//namespace Distribution.Infra.Authorization;
using Microsoft.AspNetCore.Authorization;
//public class PermissionRequirement : IAuthorizationRequirement
//{
// public string PermissionCode { get; }
namespace Distribution.Infra.Authorization;
// public PermissionRequirement(string permissionCode)
// {
// PermissionCode = permissionCode;
// }
//}
public class PermissionRequirement : IAuthorizationRequirement
{
public string PermissionCode { get; }
public PermissionRequirement(string permissionCode)
{
PermissionCode = permissionCode;
}
}
+10 -4
View File
@@ -1,5 +1,5 @@
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;
namespace Distribution.Middleware;
@@ -13,18 +13,24 @@ public class AuthMiddleware(RequestDelegate next, IConfiguration config)
.Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase)
.Trim();
if (string.IsNullOrEmpty(token))
token = context.Request.Cookies["access-token"] ?? string.Empty;
if (!string.IsNullOrEmpty(token))
{
var key = config["Jwt:Key"];
if (!string.IsNullOrEmpty(key))
var publicKeyXml = config["Jwt:PublicKey"];
if (!string.IsNullOrEmpty(publicKeyXml))
{
try
{
var rsa = RSA.Create();
rsa.FromXmlString(publicKeyXml);
var principal = new JwtSecurityTokenHandler().ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
IssuerSigningKey = new RsaSecurityKey(rsa),
ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
ValidateIssuer = true,
ValidIssuer = config["Jwt:Issuer"],
ValidateAudience = true,
@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Text.Encodings.Web;
namespace Distribution.Middleware;
/// <summary>
/// Authentication handler that exists solely to provide Challenge (401) and Forbid (403)
/// responses for UseAuthorization(). Token validation is handled upstream by AuthMiddleware,
/// so HandleAuthenticateAsync returns NoResult to leave HttpContext.User untouched.
/// </summary>
public class BearerResponseHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync() =>
Task.FromResult(AuthenticateResult.NoResult());
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
}
}
@@ -98,6 +98,12 @@ namespace Distribution.Migrations
table: "DeliveryTasks",
newName: "FK_ShopPOId");
// MariaDB drops the ascii collation on CHANGE COLUMN; restore it so the FK to ShopPOs.Id can be created.
migrationBuilder.Sql("ALTER TABLE `DeliveryTasks` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.Sql("ALTER TABLE `SchemeRedemptions` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NULL;");
migrationBuilder.Sql("ALTER TABLE `SaleReturns` MODIFY `FK_ShopPOId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.Sql("ALTER TABLE `Payments` MODIFY `FK_InvoiceId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL;");
migrationBuilder.RenameIndex(
name: "IX_DeliveryTasks_FK_OrderId",
table: "DeliveryTasks",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedPermissionsAndRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

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