Compare commits

..

4 Commits

Author SHA1 Message Date
Kalana B. Thilakarathna c775146b64 10.
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 13m39s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 15m1s
Implementation
A partner, not just software
HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm]

1
Discovery
Map your network, products and processes.

2
Configuration
Set up depots, pricing, schemes, routes and users.

3
Migration & training
Move your data and train every team.

4
Go-live & support
Dedicated account manager and local support desk.
2026-10-22 17:47:49 +05:30
DeepnaPooja 6e4bebb89b Merge pull request 'Feat notification' (#86) from feat-notification into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m33s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m7s
Reviewed-on: #86
2026-10-22 17:40:06 +05:30
DeepnaPooja e13ec0f109 Merge pull request 'Fix dashboard stat card handling issue' (#84) from feat-notification into dev
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m59s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m29s
Reviewed-on: #84
2026-10-22 10:54:52 +05:30
DeepnaPooja 39ad424d21 Merge pull request 'Invoice flow' (#82) from feat-notification into dev
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 12m58s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 14m20s
Reviewed-on: #82
2026-10-21 13:13:21 +05:30
52 changed files with 12003 additions and 53 deletions
+86
View File
@@ -76,6 +76,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; } public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
public DbSet<Notification> Notifications { get; set; } public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationRecipient> NotificationRecipients { get; set; } public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
public DbSet<ExpenseCategory> ExpenseCategories { get; set; }
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
public DbSet<Expense> Expenses { get; set; }
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; } public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -813,7 +818,88 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
modelBuilder.Entity<StoreCreditMovement>() modelBuilder.Entity<StoreCreditMovement>()
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt }); .HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
// --- Expense ---
modelBuilder.Entity<ExpenseCategory>()
.HasOne(c => c.Parent)
.WithMany()
.HasForeignKey(c => c.FK_ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
modelBuilder.Entity<ExpenseCategory>()
.Property(c => c.Name).HasMaxLength(100);
modelBuilder.Entity<ExpenseCategory>()
.Property(c => c.Code).HasMaxLength(50);
modelBuilder.Entity<ExpenseCategory>()
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
modelBuilder.Entity<ExpenseVendor>()
.Property(v => v.Name).HasMaxLength(200);
modelBuilder.Entity<ExpenseVendor>()
.HasIndex(v => v.Name).IsUnique();
modelBuilder.Entity<Expense>()
.HasOne(e => e.Category).WithMany()
.HasForeignKey(e => e.FK_CategoryId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Expense>()
.HasOne(e => e.Warehouse).WithMany()
.HasForeignKey(e => e.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.Vendor).WithMany()
.HasForeignKey(e => e.FK_VendorId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.BankAccount).WithMany()
.HasForeignKey(e => e.FK_BankAccountId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.ChequePage).WithMany()
.HasForeignKey(e => e.FK_ChequePageId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<Expense>()
.HasOne(e => e.RecurringTemplate).WithMany()
.HasForeignKey(e => e.FK_RecurringTemplateId)
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
modelBuilder.Entity<Expense>()
.Property(e => e.Payee).HasMaxLength(200);
modelBuilder.Entity<Expense>()
.Property(e => e.BillRef).HasMaxLength(100);
modelBuilder.Entity<Expense>()
.HasIndex(e => new { e.Status, e.BillDate });
modelBuilder.Entity<ExpenseAttachment>()
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
.HasForeignKey(a => a.FK_ExpenseId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.OriginalFileName).HasMaxLength(260);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.StoredPath).HasMaxLength(500);
modelBuilder.Entity<ExpenseAttachment>()
.Property(a => a.ContentType).HasMaxLength(100);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Category).WithMany()
.HasForeignKey(t => t.FK_CategoryId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Warehouse).WithMany()
.HasForeignKey(t => t.FK_WarehouseId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasOne(t => t.Vendor).WithMany()
.HasForeignKey(t => t.FK_VendorId)
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
modelBuilder.Entity<RecurringExpenseTemplate>()
.Property(t => t.Name).HasMaxLength(200);
modelBuilder.Entity<RecurringExpenseTemplate>()
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
Seed.RoleAndPermissionSeed.Apply(modelBuilder); Seed.RoleAndPermissionSeed.Apply(modelBuilder);
Seed.ExpenseSeed.Apply(modelBuilder);
Seed.MasterDataSeed.Apply(modelBuilder); Seed.MasterDataSeed.Apply(modelBuilder);
Seed.OperationsDataSeed.Apply(modelBuilder); Seed.OperationsDataSeed.Apply(modelBuilder);
Seed.ProcurementSeed.Apply(modelBuilder); Seed.ProcurementSeed.Apply(modelBuilder);
+61
View File
@@ -0,0 +1,61 @@
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Data.Seed;
public static class ExpenseSeed
{
private static readonly DateTime SeedDate = new(2026, 10, 21);
// Top-level categories
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
public static void Apply(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExpenseCategory>().HasData(
// Top-level
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
// Titles under Salaries
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
// Titles under Utilities
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
// Titles under Rent
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
// Titles under Vehicle
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
// Titles under Office
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
// Titles under Bank Fees
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
// Titles under Misc
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
);
}
}
@@ -68,6 +68,13 @@ public static class RoleAndPermissionSeed
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048"); 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 PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050"); private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
private static readonly Guid PermExpenseView = new("20000000-0000-0000-0000-000000000051");
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
public static void Apply(ModelBuilder modelBuilder) public static void Apply(ModelBuilder modelBuilder)
{ {
@@ -134,7 +141,14 @@ public static class RoleAndPermissionSeed
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", 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 = 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 = 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 = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", CreatedAt = SeedDate, IsActive = true }
); );
// SuperAdmin gets every permission // SuperAdmin gets every permission
@@ -191,7 +205,14 @@ public static class RoleAndPermissionSeed
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, 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 = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, 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 = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, CreatedAt = SeedDate, IsActive = true }
); );
} }
} }
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Expense;
[ApiController]
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
{
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
[Authorize("expense.create")]
[RequestSizeLimit(10 * 1024 * 1024)]
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
{
try
{
var result = await service.UploadAsync(expenseId, file, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpGet("api/expense-attachments/{id:guid}/download")]
[Authorize("expense.view")]
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
{
var result = await service.DownloadAsync(id, ct);
if (result is null) return NotFound();
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
}
[HttpDelete("api/expense-attachments/{id:guid}")]
[Authorize("expense.create")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
}
@@ -0,0 +1,108 @@
using Distribution.Data;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseAttachmentService(
AppDBContext context,
IConfiguration config,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private const long MaxBytes = 10 * 1024 * 1024;
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
private static readonly string[] AllowedContentTypes =
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
private string AttachmentsRoot =>
config["Expense:AttachmentsRoot"]
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
{
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
if (expense is null) return null;
if (file is null || file.Length == 0)
throw new InvalidOperationException("File is empty");
if (file.Length > MaxBytes)
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
throw new InvalidOperationException($"File type '{ext}' is not allowed");
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
var now = DateTime.Now;
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
Directory.CreateDirectory(absoluteDir);
var storedName = $"{Guid.NewGuid()}{ext}";
var absolutePath = Path.Combine(absoluteDir, storedName);
await using (var stream = File.Create(absolutePath))
{
await file.CopyToAsync(stream, ct);
}
var attachment = new Models.ExpenseAttachment
{
FK_ExpenseId = expenseId,
OriginalFileName = file.FileName,
StoredPath = absolutePath,
ContentType = file.ContentType,
SizeBytes = file.Length,
UploadedByUserId = userId
};
await uow.BeginAsync(ct);
try
{
await context.ExpenseAttachments.AddAsync(attachment, ct);
await uow.CommitAsync(ct);
}
catch
{
await uow.RollbackAsync(ct);
try { File.Delete(absolutePath); } catch { }
throw;
}
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
}
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
{
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
if (a is null) return null;
if (!File.Exists(a.StoredPath)) return null;
var stream = File.OpenRead(a.StoredPath);
return (stream, a.ContentType, a.OriginalFileName);
}
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
{
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
if (a is null) return false;
a.IsActive = false;
await uow.BeginAsync(ct);
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
// Best-effort file removal — keep DB soft-delete even if the file is gone.
try { File.Delete(a.StoredPath); } catch { }
return true;
}
}
@@ -0,0 +1,109 @@
using Distribution.Infra.Authorization;
using Distribution.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.Expense;
[ApiController]
[Route("api/expenses")]
public class ExpenseController(
ExpenseService service,
IPermissionService permissions,
ICurrentUserService currentUser) : ControllerBase
{
private async Task<bool> HasAsync(string code)
{
if (currentUser.RoleId is not { } roleId) return false;
return await permissions.HasPermissionAsync(roleId, code);
}
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> List(
[FromQuery] ExpenseStatus? status,
[FromQuery] Guid? categoryId,
[FromQuery] Guid? warehouseId,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20) =>
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
[HttpGet("{id:guid}")]
[Authorize("expense.view")]
public async Task<IActionResult> Get(Guid id)
{
var result = await service.GetAsync(id);
return result is null ? NotFound() : Ok(result);
}
/// <summary>
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
/// (requires expense.post + expense.pay). Draft is the default.
/// </summary>
[HttpPost]
[Authorize("expense.create")]
public async Task<IActionResult> Create(
CreateExpenseRequest request,
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
CancellationToken ct = default)
{
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
return Forbid();
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
return Forbid();
try { return Ok(await service.CreateAsync(request, action, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.create")]
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateDraftAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/post")]
[Authorize("expense.post")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
try
{
var result = await service.PostAsync(id, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/pay")]
[Authorize("expense.pay")]
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
{
try
{
var result = await service.PayAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/void")]
[Authorize("expense.void")]
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
{
try
{
var result = await service.VoidAsync(id, request?.Reason, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,77 @@
using Distribution.Models;
namespace Distribution.Features.Expense;
public record ExpensePaymentRequest(
PaymentMethod Method,
Guid? BankAccountId,
Guid? ChequePageId);
public record CreateExpenseRequest(
Guid CategoryId,
Guid? WarehouseId,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? Payee,
string? BillRef,
string? Description,
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
ExpensePaymentRequest? Payment);
public record UpdateExpenseDraftRequest(
Guid CategoryId,
Guid? WarehouseId,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? Payee,
string? BillRef,
string? Description);
public record VoidExpenseRequest(string? Reason);
public record ExpenseAttachmentResponse(
Guid Id,
string OriginalFileName,
string ContentType,
long SizeBytes,
DateTime CreatedAt);
public record ExpenseResponse(
Guid Id,
Guid CategoryId,
string CategoryName,
Guid? ParentCategoryId,
string? ParentCategoryName,
Guid? WarehouseId,
string? WarehouseName,
decimal Amount,
DateTime BillDate,
DateTime? DueDate,
DateTime? PeriodCovered,
Guid? VendorId,
string? VendorName,
string? Payee,
string? BillRef,
string? Description,
ExpenseStatus Status,
PaymentMethod? Method,
Guid? BankAccountId,
Guid? ChequePageId,
string? ChequeNo,
DateTime? PaidAt,
Guid RecordedByUserId,
Guid? PostedByUserId,
Guid? PaidByUserId,
Guid? VoidedByUserId,
string? VoidReason,
Guid? RecurringTemplateId,
List<ExpenseAttachmentResponse> Attachments,
DateTime CreatedAt);
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
@@ -0,0 +1,50 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
{
private IQueryable<Models.Expense> Base() =>
context.Expenses
.Include(e => e.Category!).ThenInclude(c => c.Parent)
.Include(e => e.Warehouse)
.Include(e => e.Vendor)
.Include(e => e.ChequePage)
.Include(e => e.Attachments.Where(a => a.IsActive));
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
private IQueryable<Models.Expense> Filtered(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
Base()
.Where(e => e.IsActive)
.Where(e => !status.HasValue || e.Status == status)
.Where(e => !categoryId.HasValue
|| e.FK_CategoryId == categoryId
|| e.Category!.FK_ParentCategoryId == categoryId)
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
.Where(e => !from.HasValue || e.BillDate >= from)
.Where(e => !to.HasValue || e.BillDate <= to);
public async Task<List<Models.Expense>> ListAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
int page, int pageSize) =>
await Filtered(status, categoryId, warehouseId, from, to)
.OrderByDescending(e => e.BillDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
public async Task AddAsync(Models.Expense entity) =>
await context.Expenses.AddAsync(entity);
public void Update(Models.Expense entity) =>
context.Expenses.Update(entity);
}
@@ -0,0 +1,269 @@
using Distribution.Data;
using Distribution.Features.BankAccount;
using Distribution.Features.ChequeBook;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Expense;
public class ExpenseService(
AppDBContext context,
IExpenseRepository repo,
IBankAccountRepository bankAccountRepo,
IChequeBookRepository chequeBookRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
private static ExpenseResponse ToResponse(Models.Expense e) =>
new(e.Id,
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
e.FK_WarehouseId, e.Warehouse?.Name,
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
e.FK_VendorId, e.Vendor?.Name,
e.Payee, e.BillRef, e.Description,
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
e.PaidAt,
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
e.FK_RecurringTemplateId,
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
e.CreatedAt);
public async Task<PagedExpensesResponse> ListAsync(
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 20;
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
}
public async Task<ExpenseResponse?> GetAsync(Guid id)
{
var e = await repo.GetByIdAsync(id);
return e is null ? null : ToResponse(e);
}
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
{
ValidateBase(request);
var category = await context.ExpenseCategories.Include(c => c.Parent)
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
?? throw new InvalidOperationException("Category not found");
if (category.FK_ParentCategoryId is null)
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
var entity = new Models.Expense
{
FK_CategoryId = request.CategoryId,
FK_WarehouseId = request.WarehouseId,
Amount = request.Amount,
BillDate = request.BillDate.Date,
DueDate = request.DueDate?.Date,
PeriodCovered = request.PeriodCovered?.Date,
FK_VendorId = request.VendorId,
Payee = request.Payee,
BillRef = request.BillRef,
Description = request.Description,
RecordedByUserId = userId,
Status = ExpenseStatus.Draft
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(entity);
if (action is ExpenseAction.Post or ExpenseAction.Pay)
{
entity.Status = ExpenseStatus.Posted;
entity.PostedByUserId = userId;
entity.PostedAt = DateTime.Now;
}
if (action == ExpenseAction.Pay)
{
if (request.Payment is null)
throw new InvalidOperationException("Payment details are required when action = Pay");
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
}
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Draft)
throw new InvalidOperationException("Only Draft expenses can be edited");
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
entity.FK_CategoryId = request.CategoryId;
entity.FK_WarehouseId = request.WarehouseId;
entity.Amount = request.Amount;
entity.BillDate = request.BillDate.Date;
entity.DueDate = request.DueDate?.Date;
entity.PeriodCovered = request.PeriodCovered?.Date;
entity.FK_VendorId = request.VendorId;
entity.Payee = request.Payee;
entity.BillRef = request.BillRef;
entity.Description = request.Description;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Draft)
throw new InvalidOperationException("Only Draft expenses can be posted");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
entity.Status = ExpenseStatus.Posted;
entity.PostedByUserId = userId;
entity.PostedAt = DateTime.Now;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status != ExpenseStatus.Posted)
throw new InvalidOperationException("Only Posted expenses can be paid");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
await uow.BeginAsync(ct);
try
{
await ApplyPaymentAsync(entity, payment, userId, ct);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
if (entity.Status == ExpenseStatus.Void)
throw new InvalidOperationException("Already voided");
if (entity.Status == ExpenseStatus.Paid)
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
entity.Status = ExpenseStatus.Void;
entity.VoidedByUserId = userId;
entity.VoidedAt = DateTime.Now;
entity.VoidReason = reason;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return ToResponse((await repo.GetByIdAsync(id))!);
}
// ─── helpers ──────────────────────────────────────────────────────────────
private static void ValidateBase(CreateExpenseRequest request)
{
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
throw new InvalidOperationException("Due date cannot be earlier than bill date");
}
/// <summary>
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
/// Cheque → consume an Unused ChequePage from an Active cheque book.
/// </summary>
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
{
if (payment.Method == PaymentMethod.Credit)
throw new InvalidOperationException("Credit is not a valid method for expense payments");
if (payment.Method == PaymentMethod.Cash)
{
if (payment.BankAccountId is null)
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
if (account.IsActive is false)
throw new InvalidOperationException("The selected account is inactive");
entity.FK_BankAccountId = account.Id;
}
else // Cheque
{
if (payment.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 == payment.ChequePageId.Value, ct)
?? 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.Other;
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
page.IssueDate = DateTime.Now;
page.Amount = entity.Amount;
page.CurrencyCode = "LKR";
page.PrintedAt = DateTime.Now;
chequeBookRepo.UpdatePage(page);
entity.FK_ChequePageId = page.Id;
entity.ChequePage = page;
}
entity.Method = payment.Method;
entity.Status = ExpenseStatus.Paid;
entity.PaidByUserId = userId;
entity.PaidAt = DateTime.Now;
// TODO ledger: when accounting is wired in, post a journal entry here —
// Debit Expense (category)
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
}
}
@@ -0,0 +1,24 @@
using Distribution.Models;
namespace Distribution.Features.Expense;
public interface IExpenseRepository
{
Task<Models.Expense?> GetByIdAsync(Guid id);
Task<List<Models.Expense>> ListAsync(
ExpenseStatus? status,
Guid? categoryId,
Guid? warehouseId,
DateTime? from,
DateTime? to,
int page,
int pageSize);
Task<int> CountAsync(
ExpenseStatus? status,
Guid? categoryId,
Guid? warehouseId,
DateTime? from,
DateTime? to);
Task AddAsync(Models.Expense entity);
void Update(Models.Expense entity);
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ExpenseCategory;
[ApiController]
[Route("api/expense-categories")]
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
{
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
[HttpPost]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,19 @@
namespace Distribution.Features.ExpenseCategory;
public record ExpenseCategoryResponse(
Guid Id,
string Name,
string? Code,
Guid? ParentCategoryId,
bool IsActive,
List<ExpenseCategoryResponse> Children);
public record CreateExpenseCategoryRequest(
string Name,
string? Code,
Guid? ParentCategoryId);
public record UpdateExpenseCategoryRequest(
string Name,
string? Code,
bool IsActive);
@@ -0,0 +1,19 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ExpenseCategory;
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
{
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
public async Task AddAsync(Models.ExpenseCategory entity) =>
await context.ExpenseCategories.AddAsync(entity);
public void Update(Models.ExpenseCategory entity) =>
context.ExpenseCategories.Update(entity);
}
@@ -0,0 +1,64 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.ExpenseCategory;
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
{
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
{
var all = await repo.GetAllAsync();
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
return all
.Where(c => c.FK_ParentCategoryId is null)
.Select(c => Map(c, byParent))
.ToList();
}
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new InvalidOperationException("Name is required");
if (request.ParentCategoryId is { } parentId)
{
var parent = await repo.GetByIdAsync(parentId)
?? throw new InvalidOperationException("Parent category not found");
if (parent.FK_ParentCategoryId is not null)
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
}
var entity = new Models.ExpenseCategory
{
Name = request.Name.Trim(),
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
FK_ParentCategoryId = request.ParentCategoryId
};
await uow.BeginAsync(ct);
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
}
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
entity.Name = request.Name.Trim();
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
entity.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
}
}
@@ -0,0 +1,11 @@
using Distribution.Models;
namespace Distribution.Features.ExpenseCategory;
public interface IExpenseCategoryRepository
{
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
Task<List<Models.ExpenseCategory>> GetAllAsync();
Task AddAsync(Models.ExpenseCategory entity);
void Update(Models.ExpenseCategory entity);
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.ExpenseVendor;
[ApiController]
[Route("api/expense-vendors")]
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
{
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> List(
[FromQuery] string? search,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 50) =>
Ok(await service.ListAsync(search, page, pageSize));
[HttpPost]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.manage_categories")]
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,7 @@
namespace Distribution.Features.ExpenseVendor;
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
@@ -0,0 +1,31 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.ExpenseVendor;
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
{
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
await context.ExpenseVendors
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
.OrderBy(v => v.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountAsync(string? search) =>
await context.ExpenseVendors
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
public async Task AddAsync(Models.ExpenseVendor entity) =>
await context.ExpenseVendors.AddAsync(entity);
public void Update(Models.ExpenseVendor entity) =>
context.ExpenseVendors.Update(entity);
}
@@ -0,0 +1,59 @@
using Distribution.Infra.UoW;
namespace Distribution.Features.ExpenseVendor;
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
{
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 50;
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
var items = await repo.ListAsync(search, page, pageSize);
var total = await repo.CountAsync(search);
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
}
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
{
var name = (request.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
if (await repo.NameExistsAsync(name, null))
throw new InvalidOperationException("A vendor with this name already exists");
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
await uow.BeginAsync(ct);
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(entity);
}
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
var name = (request.Name ?? "").Trim();
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
if (await repo.NameExistsAsync(name, id))
throw new InvalidOperationException("A vendor with this name already exists");
entity.Name = name;
entity.ContactInfo = request.ContactInfo;
entity.IsActive = request.IsActive;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(entity);
}
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.ExpenseVendor;
public interface IExpenseVendorRepository
{
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
Task<int> CountAsync(string? search);
Task<bool> NameExistsAsync(string name, Guid? excludeId);
Task AddAsync(Models.ExpenseVendor entity);
void Update(Models.ExpenseVendor entity);
}
@@ -0,0 +1,11 @@
namespace Distribution.Features.RecurringExpense;
public interface IRecurringExpenseRepository
{
Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id);
Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize);
Task<int> CountAsync();
Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf);
Task AddAsync(Models.RecurringExpenseTemplate entity);
void Update(Models.RecurringExpenseTemplate entity);
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.RecurringExpense;
public record ToggleRequest(bool Paused);
[ApiController]
[Route("api/recurring-expenses")]
public class RecurringExpenseController(RecurringExpenseService service) : ControllerBase
{
[HttpGet]
[Authorize("expense.view")]
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
Ok(await service.ListAsync(page, pageSize));
[HttpPost]
[Authorize("expense.manage_recurring")]
public async Task<IActionResult> Create(CreateRecurringExpenseRequest request, CancellationToken ct)
{
try { return Ok(await service.CreateAsync(request, ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPut("{id:guid}")]
[Authorize("expense.manage_recurring")]
public async Task<IActionResult> Update(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct)
{
try
{
var result = await service.UpdateAsync(id, request, ct);
return result is null ? NotFound() : Ok(result);
}
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/toggle")]
[Authorize("expense.manage_recurring")]
public async Task<IActionResult> Toggle(Guid id, ToggleRequest body, CancellationToken ct)
{
var result = await service.ToggleAsync(id, body.Paused, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("run-due")]
[Authorize("expense.manage_recurring")]
public async Task<IActionResult> RunDue(CancellationToken ct)
{
try { return Ok(await service.RunDueAsync(ct)); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
}
@@ -0,0 +1,60 @@
using Distribution.Models;
namespace Distribution.Features.RecurringExpense;
public record CreateRecurringExpenseRequest(
string Name,
Guid CategoryId,
Guid? WarehouseId,
Guid? VendorId,
decimal Amount,
string? Payee,
string? Description,
RecurringFrequency Frequency,
int? DayOfMonth,
int? DayOfWeek,
DateTime StartDate,
DateTime? EndDate,
bool AutoPost);
public record UpdateRecurringExpenseRequest(
string Name,
Guid CategoryId,
Guid? WarehouseId,
Guid? VendorId,
decimal Amount,
string? Payee,
string? Description,
RecurringFrequency Frequency,
int? DayOfMonth,
int? DayOfWeek,
DateTime StartDate,
DateTime? EndDate,
bool AutoPost);
public record RecurringExpenseResponse(
Guid Id,
string Name,
Guid CategoryId,
string CategoryName,
Guid? WarehouseId,
string? WarehouseName,
Guid? VendorId,
string? VendorName,
decimal Amount,
string? Payee,
string? Description,
RecurringFrequency Frequency,
int? DayOfMonth,
int? DayOfWeek,
DateTime StartDate,
DateTime? EndDate,
DateTime NextRunDate,
bool AutoPost,
bool IsPaused,
bool IsActive,
DateTime CreatedAt);
public record PagedRecurringExpensesResponse(List<RecurringExpenseResponse> Items, int Page, int PageSize, int Total);
public record RunDueResponse(int Generated, List<Guid> ExpenseIds);
@@ -0,0 +1,39 @@
using Distribution.Data;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.RecurringExpense;
public class RecurringExpenseRepository(AppDBContext context) : IRecurringExpenseRepository
{
private IQueryable<Models.RecurringExpenseTemplate> Base() =>
context.RecurringExpenseTemplates
.Include(t => t.Category)
.Include(t => t.Warehouse)
.Include(t => t.Vendor);
public async Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id) =>
await Base().FirstOrDefaultAsync(t => t.Id == id);
public async Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize) =>
await Base()
.Where(t => t.IsActive)
.OrderBy(t => t.IsPaused).ThenBy(t => t.NextRunDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> CountAsync() =>
await context.RecurringExpenseTemplates.CountAsync(t => t.IsActive);
public async Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf) =>
await Base()
.Where(t => t.IsActive && !t.IsPaused && t.NextRunDate <= asOf
&& (t.EndDate == null || t.EndDate >= asOf))
.ToListAsync();
public async Task AddAsync(Models.RecurringExpenseTemplate entity) =>
await context.RecurringExpenseTemplates.AddAsync(entity);
public void Update(Models.RecurringExpenseTemplate entity) =>
context.RecurringExpenseTemplates.Update(entity);
}
@@ -0,0 +1,183 @@
using Distribution.Data;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.RecurringExpense;
public class RecurringExpenseService(
AppDBContext context,
IRecurringExpenseRepository repo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) =>
new(t.Id, t.Name,
t.FK_CategoryId, t.Category?.Name ?? string.Empty,
t.FK_WarehouseId, t.Warehouse?.Name,
t.FK_VendorId, t.Vendor?.Name,
t.Amount, t.Payee, t.Description,
t.Frequency, t.DayOfMonth, t.DayOfWeek,
t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt);
public async Task<PagedRecurringExpensesResponse> ListAsync(int page, int pageSize)
{
if (page < 1) page = 1;
if (pageSize < 1 || pageSize > 200) pageSize = 20;
var items = await repo.ListAsync(page, pageSize);
var total = await repo.CountAsync();
return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total);
}
public async Task<RecurringExpenseResponse> CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default)
{
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
var category = await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
?? throw new InvalidOperationException("Category not found");
if (category.FK_ParentCategoryId is null)
throw new InvalidOperationException("Pick a specific title under a category");
var entity = new Models.RecurringExpenseTemplate
{
Name = request.Name.Trim(),
FK_CategoryId = request.CategoryId,
FK_WarehouseId = request.WarehouseId,
FK_VendorId = request.VendorId,
Amount = request.Amount,
Payee = request.Payee,
Description = request.Description,
Frequency = request.Frequency,
DayOfMonth = request.DayOfMonth,
DayOfWeek = request.DayOfWeek,
StartDate = request.StartDate.Date,
EndDate = request.EndDate?.Date,
NextRunDate = request.StartDate.Date,
AutoPost = request.AutoPost
};
await uow.BeginAsync(ct);
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map((await repo.GetByIdAsync(entity.Id))!);
}
public async Task<RecurringExpenseResponse?> UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
entity.Name = request.Name.Trim();
entity.FK_CategoryId = request.CategoryId;
entity.FK_WarehouseId = request.WarehouseId;
entity.FK_VendorId = request.VendorId;
entity.Amount = request.Amount;
entity.Payee = request.Payee;
entity.Description = request.Description;
entity.Frequency = request.Frequency;
entity.DayOfMonth = request.DayOfMonth;
entity.DayOfWeek = request.DayOfWeek;
entity.StartDate = request.StartDate.Date;
entity.EndDate = request.EndDate?.Date;
entity.AutoPost = request.AutoPost;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map((await repo.GetByIdAsync(id))!);
}
public async Task<RecurringExpenseResponse?> ToggleAsync(Guid id, bool paused, CancellationToken ct = default)
{
var entity = await repo.GetByIdAsync(id);
if (entity is null) return null;
entity.IsPaused = paused;
await uow.BeginAsync(ct);
try { repo.Update(entity); await uow.CommitAsync(ct); }
catch { await uow.RollbackAsync(ct); throw; }
return Map(entity);
}
/// <summary>
/// Materializes every active, non-paused template whose NextRunDate ≤ today
/// into an Expense row, then advances NextRunDate by one period. Idempotent
/// per call — advances only after a successful insert.
/// </summary>
public async Task<RunDueResponse> RunDueAsync(CancellationToken ct = default)
{
var today = DateTime.Today;
var due = await repo.ListDueAsync(today);
if (due.Count == 0) return new RunDueResponse(0, []);
var userId = currentUser.UserId
?? throw new InvalidOperationException("Authenticated user is required");
var createdIds = new List<Guid>();
await uow.BeginAsync(ct);
try
{
foreach (var t in due)
{
var expense = new Models.Expense
{
FK_CategoryId = t.FK_CategoryId,
FK_WarehouseId = t.FK_WarehouseId,
FK_VendorId = t.FK_VendorId,
Amount = t.Amount,
BillDate = t.NextRunDate,
PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1),
Payee = t.Payee,
Description = t.Description,
Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft,
RecordedByUserId = userId,
PostedByUserId = t.AutoPost ? userId : null,
PostedAt = t.AutoPost ? DateTime.Now : null,
FK_RecurringTemplateId = t.Id
};
await context.Expenses.AddAsync(expense, ct);
createdIds.Add(expense.Id);
t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency);
repo.Update(t);
}
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
return new RunDueResponse(createdIds.Count, createdIds);
}
private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch
{
RecurringFrequency.Weekly => from.AddDays(7),
RecurringFrequency.Monthly => from.AddMonths(1),
RecurringFrequency.Quarterly => from.AddMonths(3),
RecurringFrequency.Yearly => from.AddYears(1),
_ => throw new InvalidOperationException($"Unknown frequency {freq}")
};
private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek)
{
if (freq == RecurringFrequency.Weekly)
{
if (dayOfWeek is null or < 0 or > 6)
throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly");
}
else
{
if (dayOfMonth is null or < 1 or > 31)
throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly");
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -68,4 +68,10 @@ namespace Distribution.Models
public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel } public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel }
public enum ReceivedFromType { Customer, Supplier, Other } public enum ReceivedFromType { Customer, Supplier, Other }
public enum ExpenseStatus { Draft, Posted, Paid, Void }
public enum ExpenseAction { Draft, Post, Pay }
public enum RecurringFrequency { Weekly, Monthly, Quarterly, Yearly }
} }
+63
View File
@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models
{
public class Expense : Template
{
/// <summary>Leaf category (Title). Parent is the user-facing Category group.</summary>
public Guid FK_CategoryId { get; set; }
[ForeignKey("FK_CategoryId")]
public ExpenseCategory? Category { get; set; }
/// <summary>Branch the expense is attributed to. Null = company-wide.</summary>
public Guid? FK_WarehouseId { get; set; }
[ForeignKey("FK_WarehouseId")]
public Warehouse? Warehouse { get; set; }
[Precision(18, 2)]
public decimal Amount { get; set; }
public DateTime BillDate { get; set; }
public DateTime? DueDate { get; set; }
/// <summary>First day of the month the expense covers (YYYY-MM-01).</summary>
public DateTime? PeriodCovered { get; set; }
public Guid? FK_VendorId { get; set; }
[ForeignKey("FK_VendorId")]
public ExpenseVendor? Vendor { get; set; }
public string? Payee { get; set; }
public string? BillRef { get; set; }
public string? Description { get; set; }
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
/// <summary>Payment settlement — null until Status transitions to Paid.</summary>
public PaymentMethod? Method { get; set; }
public Guid? FK_BankAccountId { get; set; }
[ForeignKey("FK_BankAccountId")]
public BankAccount? BankAccount { get; set; }
public Guid? FK_ChequePageId { get; set; }
[ForeignKey("FK_ChequePageId")]
public ChequePage? ChequePage { get; set; }
public DateTime? PaidAt { get; set; }
public Guid RecordedByUserId { get; set; }
public Guid? PostedByUserId { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PaidByUserId { get; set; }
public Guid? VoidedByUserId { get; set; }
public DateTime? VoidedAt { get; set; }
public string? VoidReason { get; set; }
/// <summary>Null for manually-entered expenses; set when generated by run-due.</summary>
public Guid? FK_RecurringTemplateId { get; set; }
[ForeignKey("FK_RecurringTemplateId")]
public RecurringExpenseTemplate? RecurringTemplate { get; set; }
public ICollection<ExpenseAttachment> Attachments { get; set; } = [];
}
}
+21
View File
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class ExpenseAttachment : Template
{
public Guid FK_ExpenseId { get; set; }
[ForeignKey("FK_ExpenseId")]
public Expense? Expense { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
/// <summary>Absolute path on disk. Served through a streaming endpoint, never statically.</summary>
public string StoredPath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public Guid UploadedByUserId { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
/// <summary>
/// Two-level tree: top-level categories (e.g. "Salaries", "Utilities") and
/// child "titles" (e.g. "Bonus", "Electricity"). Expenses link to a leaf (Title).
/// </summary>
public class ExpenseCategory : Template
{
public string Name { get; set; } = string.Empty;
public string? Code { get; set; }
public Guid? FK_ParentCategoryId { get; set; }
[ForeignKey("FK_ParentCategoryId")]
public ExpenseCategory? Parent { get; set; }
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace Distribution.Models
{
/// <summary>
/// Lightweight vendor directory used only by the Expenses module. Kept
/// separate from Supplier (which has performance scores, PO/GRN linkage).
/// </summary>
public class ExpenseVendor : Template
{
public string Name { get; set; } = string.Empty;
public string? ContactInfo { get; set; }
}
}
@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Models
{
/// <summary>
/// Blueprint for a recurring expense. Materialized into Expense rows by
/// POST /api/recurring-expenses/run-due based on NextRunDate.
/// </summary>
public class RecurringExpenseTemplate : Template
{
public string Name { get; set; } = string.Empty;
public Guid FK_CategoryId { get; set; }
[ForeignKey("FK_CategoryId")]
public ExpenseCategory? Category { get; set; }
public Guid? FK_WarehouseId { get; set; }
[ForeignKey("FK_WarehouseId")]
public Warehouse? Warehouse { get; set; }
public Guid? FK_VendorId { get; set; }
[ForeignKey("FK_VendorId")]
public ExpenseVendor? Vendor { get; set; }
[Precision(18, 2)]
public decimal Amount { get; set; }
public string? Payee { get; set; }
public string? Description { get; set; }
public RecurringFrequency Frequency { get; set; }
/// <summary>1..31 for Monthly/Quarterly/Yearly. Ignored for Weekly.</summary>
public int? DayOfMonth { get; set; }
/// <summary>0..6 (Sun=0) for Weekly. Ignored otherwise.</summary>
public int? DayOfWeek { get; set; }
public DateTime StartDate { get; set; }
public DateTime? EndDate { get; set; }
public DateTime NextRunDate { get; set; }
/// <summary>When true, generated expenses jump straight to Posted; otherwise Draft.</summary>
public bool AutoPost { get; set; }
public bool IsPaused { get; set; }
}
}
+13
View File
@@ -31,6 +31,10 @@ using Distribution.Features.Scheme;
using Distribution.Features.Store; using Distribution.Features.Store;
using Distribution.Features.Supplier; using Distribution.Features.Supplier;
using Distribution.Features.SupplierPayment; using Distribution.Features.SupplierPayment;
using Distribution.Features.Expense;
using Distribution.Features.ExpenseCategory;
using Distribution.Features.ExpenseVendor;
using Distribution.Features.RecurringExpense;
using Distribution.Features.Sync; using Distribution.Features.Sync;
using Distribution.Features.Uom; using Distribution.Features.Uom;
using Distribution.Features.User; using Distribution.Features.User;
@@ -103,6 +107,10 @@ builder.Services.AddScoped<IBankAccountRepository, BankAccountRepository>();
builder.Services.AddScoped<IChequeBookRepository, ChequeBookRepository>(); builder.Services.AddScoped<IChequeBookRepository, ChequeBookRepository>();
builder.Services.AddScoped<IReceivedChequeRepository, ReceivedChequeRepository>(); builder.Services.AddScoped<IReceivedChequeRepository, ReceivedChequeRepository>();
builder.Services.AddScoped<ISupplierPaymentRepository, SupplierPaymentRepository>(); builder.Services.AddScoped<ISupplierPaymentRepository, SupplierPaymentRepository>();
builder.Services.AddScoped<IExpenseCategoryRepository, ExpenseCategoryRepository>();
builder.Services.AddScoped<IExpenseVendorRepository, ExpenseVendorRepository>();
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
builder.Services.AddScoped<IRecurringExpenseRepository, RecurringExpenseRepository>();
builder.Services.AddScoped<Distribution.Features.StoreCredit.IStoreCreditMovementRepository, Distribution.Features.StoreCredit.StoreCreditMovementRepository>(); builder.Services.AddScoped<Distribution.Features.StoreCredit.IStoreCreditMovementRepository, Distribution.Features.StoreCredit.StoreCreditMovementRepository>();
builder.Services.AddScoped<RegionService>(); builder.Services.AddScoped<RegionService>();
@@ -143,6 +151,11 @@ builder.Services.AddScoped<BankAccountService>();
builder.Services.AddScoped<ChequeBookService>(); builder.Services.AddScoped<ChequeBookService>();
builder.Services.AddScoped<ReceivedChequeService>(); builder.Services.AddScoped<ReceivedChequeService>();
builder.Services.AddScoped<SupplierPaymentService>(); builder.Services.AddScoped<SupplierPaymentService>();
builder.Services.AddScoped<ExpenseCategoryService>();
builder.Services.AddScoped<ExpenseVendorService>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<ExpenseAttachmentService>();
builder.Services.AddScoped<RecurringExpenseService>();
builder.Services.AddScoped<SysConfigService>(); builder.Services.AddScoped<SysConfigService>();
builder.Services.AddScoped<INotificationRepository, NotificationRepository>(); builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
+5
View File
@@ -0,0 +1,5 @@
{
"Expense": {
"AttachmentsRoot": "/home/distribution/core/assets/expenses"
}
}
+5
View File
@@ -26,6 +26,11 @@
"StoreManagement": { "StoreManagement": {
"UnverifiedKycOrderThreshold": 0 "UnverifiedKycOrderThreshold": 0
}, },
"Expense": {
// Dev default. Production overrides to /home/distribution/core/assets/expenses
// via appsettings.Production.json or an ENV var.
"AttachmentsRoot": "D:\\erp-storage\\expenses"
},
"AuthService": { "AuthService": {
"BaseUrl": "" "BaseUrl": ""
} }
@@ -0,0 +1,227 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { EmptyState } from '@/components/shared/empty-state'
import { useTopLoader } from '@/components/shared/top-loader'
import { useAuth } from '@/contexts/auth-context'
import { expenseCategories as api, getApiError } from '@/lib/api/index'
import type { ExpenseCategoryResponse } from '@/types/expenses'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Folder01Icon } from '@hugeicons/core-free-icons'
export default function ExpenseCategoriesPage() {
const { hasPermission } = useAuth()
const canManage = hasPermission('expense.manage_categories')
const [tree, setTree] = useState<ExpenseCategoryResponse[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState<null | { parentId: string | null; parentName?: string }>(null)
const [editing, setEditing] = useState<ExpenseCategoryResponse | null>(null)
useTopLoader(loading)
const load = useCallback(async () => {
setLoading(true)
try { setTree(await api.tree()) }
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
return (
<div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Expense Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Two-level tree. Expenses are tagged with a Title (a child under a Category).
</p>
</div>
{canManage && (
<Button onClick={() => setDialogOpen({ parentId: null })}>
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Category
</Button>
)}
</div>
<div className="mt-6 rounded-2xl border">
{!loading && tree.length === 0 ? (
<EmptyState icon={Folder01Icon} title="No categories yet" description="Add a top-level category then create titles under it." />
) : (
<ul className="divide-y">
{tree.map(parent => (
<li key={parent.id} className="p-4">
<div className="flex items-center justify-between">
<div>
<span className="font-medium">{parent.name}</span>
{parent.code && <span className="ml-2 text-xs text-muted-foreground">{parent.code}</span>}
{!parent.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
</div>
{canManage && (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => setEditing(parent)}>Edit</Button>
<Button size="sm" variant="outline" onClick={() => setDialogOpen({ parentId: parent.id, parentName: parent.name })}>
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
</Button>
</div>
)}
</div>
{parent.children.length > 0 && (
<ul className="mt-2 ml-4 divide-y border-l pl-4">
{parent.children.map(child => (
<li key={child.id} className="py-2 flex items-center justify-between text-sm">
<div>
{child.name}
{!child.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
</div>
{canManage && (
<Button size="sm" variant="ghost" onClick={() => setEditing(child)}>Edit</Button>
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</div>
<CategoryDialog
open={!!dialogOpen}
parentId={dialogOpen?.parentId ?? null}
parentName={dialogOpen?.parentName}
onClose={() => setDialogOpen(null)}
onSaved={() => { setDialogOpen(null); load() }}
/>
<EditCategoryDialog
category={editing}
onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); load() }}
/>
</div>
)
}
function CategoryDialog({
open, parentId, parentName, onClose, onSaved,
}: {
open: boolean
parentId: string | null
parentName?: string
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => { if (open) { setName(''); setCode('') } }, [open])
async function submit() {
if (!name.trim()) return
setBusy(true)
try {
await api.create({ name: name.trim(), code: code || null, parentCategoryId: parentId })
toast.add({ title: 'Saved', type: 'success' })
onSaved()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={open} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{parentId ? `New title under ${parentName}` : 'New category'}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Field>
<FieldLabel>Name</FieldLabel>
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
</Field>
{!parentId && (
<Field>
<FieldLabel>Code (optional)</FieldLabel>
<Input value={code} onChange={e => setCode(e.target.value)} placeholder="e.g. SAL" />
</Field>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function EditCategoryDialog({
category, onClose, onSaved,
}: {
category: ExpenseCategoryResponse | null
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState('')
const [code, setCode] = useState('')
const [active, setActive] = useState(true)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (category) { setName(category.name); setCode(category.code ?? ''); setActive(category.isActive) }
}, [category])
if (!category) return null
async function submit() {
if (!category) return
if (!name.trim()) return
setBusy(true)
try {
await api.update(category.id, { name: name.trim(), code: code || null, isActive: active })
toast.add({ title: 'Updated', type: 'success' })
onSaved()
} catch (err) {
toast.add({ title: 'Failed to update', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={!!category} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Edit {category.name}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field>
<FieldLabel>Code</FieldLabel>
<Input value={code} onChange={e => setCode(e.target.value)} />
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
Active
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,345 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast'
import { EmptyState } from '@/components/shared/empty-state'
import { useTopLoader } from '@/components/shared/top-loader'
import { useAuth } from '@/contexts/auth-context'
import {
expenseCategories as categoriesApi,
recurringExpenses as api,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type {
CreateRecurringExpenseRequest,
ExpenseCategoryResponse,
RecurringExpenseResponse,
RecurringFrequency,
} from '@/types/expenses'
import type { WarehouseResponse } from '@/types/api'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Calendar03Icon } from '@hugeicons/core-free-icons'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtAmount = (n: number) =>
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
export default function RecurringExpensesPage() {
const { hasPermission } = useAuth()
const canManage = hasPermission('expense.manage_recurring')
const [items, setItems] = useState<RecurringExpenseResponse[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<RecurringExpenseResponse | 'new' | null>(null)
const [runBusy, setRunBusy] = useState(false)
useTopLoader(loading || runBusy)
const load = useCallback(async () => {
setLoading(true)
try { setItems((await api.list({ pageSize: 100 })).items) }
catch (err) { toast.add({ title: 'Failed to load templates', description: getApiError(err), type: 'error' }) }
finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [load])
async function toggle(t: RecurringExpenseResponse) {
try {
await api.toggle(t.id, !t.isPaused)
await load()
} catch (err) {
toast.add({ title: 'Failed to toggle', description: getApiError(err), type: 'error' })
}
}
async function runDue() {
setRunBusy(true)
try {
const res = await api.runDue()
toast.add({ title: `Generated ${res.generated} expense(s)`, type: 'success' })
await load()
} catch (err) {
toast.add({ title: 'Failed to run', description: getApiError(err), type: 'error' })
} finally { setRunBusy(false) }
}
return (
<div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Recurring Expenses</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Templates that materialize into drafts (or posted expenses) when their next-run date arrives.
</p>
</div>
<div className="flex gap-2">
{canManage && (
<>
<Button variant="outline" disabled={runBusy} onClick={runDue}>
{runBusy ? 'Running…' : 'Run due now'}
</Button>
<Button onClick={() => setEditing('new')}>
<HugeiconsIcon icon={Add01Icon} size={16} /> New Template
</Button>
</>
)}
</div>
</div>
<div className="mt-6 rounded-md border">
{!loading && items.length === 0 ? (
<EmptyState icon={Calendar03Icon} title="No templates yet" description="Create a template for a recurring bill — rent, salaries, subscriptions." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Branch</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Frequency</TableHead>
<TableHead>Next run</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map(t => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-sm">{t.categoryName}</TableCell>
<TableCell className="text-sm">{t.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</TableCell>
<TableCell className="text-right font-mono">{fmtAmount(t.amount)}</TableCell>
<TableCell><Badge variant="outline">{t.frequency}{t.autoPost ? ' · auto-post' : ''}</Badge></TableCell>
<TableCell>{fmtDate(t.nextRunDate)}</TableCell>
<TableCell>{t.isPaused ? <Badge variant="secondary">Paused</Badge> : <Badge>Active</Badge>}</TableCell>
<TableCell className="text-right space-x-1">
{canManage && (
<>
<Button size="sm" variant="ghost" onClick={() => toggle(t)}>{t.isPaused ? 'Resume' : 'Pause'}</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(t)}>Edit</Button>
</>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<TemplateDialog
template={editing}
onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); load() }}
/>
</div>
)
}
function TemplateDialog({
template, onClose, onSaved,
}: {
template: RecurringExpenseResponse | 'new' | null
onClose: () => void
onSaved: () => void
}) {
const isNew = template === 'new'
const current = template !== 'new' ? template : null
const [cats, setCats] = useState<ExpenseCategoryResponse[]>([])
const [whs, setWhs] = useState<WarehouseResponse[]>([])
const [name, setName] = useState('')
const [categoryId, setCategoryId] = useState('')
const [titleId, setTitleId] = useState('')
const [warehouseId, setWarehouseId] = useState<string>('__company__')
const [amount, setAmount] = useState('')
const [frequency, setFrequency] = useState<RecurringFrequency>('Monthly')
const [dayOfMonth, setDayOfMonth] = useState<string>('1')
const [dayOfWeek, setDayOfWeek] = useState<string>('1')
const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10))
const [endDate, setEndDate] = useState('')
const [autoPost, setAutoPost] = useState(false)
const [description, setDescription] = useState('')
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!template) return
categoriesApi.tree().then(setCats).catch(() => {})
warehousesApi.list({ pageSize: 200 }).then(r => setWhs(getPaginatedItems(r))).catch(() => {})
if (current) {
setName(current.name)
// Figure out parent category for the title
categoriesApi.tree().then(tree => {
const parent = tree.find(c => c.children.some(ch => ch.id === current.categoryId))
setCategoryId(parent?.id ?? '')
setTitleId(current.categoryId)
})
setWarehouseId(current.warehouseId ?? '__company__')
setAmount(String(current.amount))
setFrequency(current.frequency)
setDayOfMonth(current.dayOfMonth ? String(current.dayOfMonth) : '1')
setDayOfWeek(current.dayOfWeek != null ? String(current.dayOfWeek) : '1')
setStartDate(current.startDate.slice(0, 10))
setEndDate(current.endDate?.slice(0, 10) ?? '')
setAutoPost(current.autoPost)
setDescription(current.description ?? '')
} else {
setName(''); setCategoryId(''); setTitleId(''); setWarehouseId('__company__'); setAmount('')
setFrequency('Monthly'); setDayOfMonth('1'); setDayOfWeek('1')
setStartDate(new Date().toISOString().slice(0, 10)); setEndDate(''); setAutoPost(false); setDescription('')
}
}, [template, current])
const titles = useMemo(() => cats.find(c => c.id === categoryId)?.children ?? [], [cats, categoryId])
if (!template) return null
async function submit() {
if (!name.trim() || !titleId) { toast.add({ title: 'Name and Title are required', type: 'error' }); return }
const amt = parseFloat(amount)
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return }
const body: CreateRecurringExpenseRequest = {
name: name.trim(),
categoryId: titleId,
warehouseId: warehouseId === '__company__' ? null : warehouseId,
amount: amt,
payee: null,
description: description || null,
frequency,
dayOfMonth: frequency === 'Weekly' ? null : parseInt(dayOfMonth),
dayOfWeek: frequency === 'Weekly' ? parseInt(dayOfWeek) : null,
startDate,
endDate: endDate || null,
autoPost,
}
setBusy(true)
try {
if (isNew) await api.create(body)
else if (current) await api.update(current.id, body)
toast.add({ title: 'Saved', type: 'success' })
onSaved()
} catch (err) {
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={!!template} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isNew ? 'New Recurring Template' : `Edit ${current?.name}`}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Field>
<FieldLabel>Name</FieldLabel>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Office rent — Colombo HQ" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Category</FieldLabel>
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
<SelectContent>{cats.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Title</FieldLabel>
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
<SelectTrigger><SelectValue placeholder="Pick a title" /></SelectTrigger>
<SelectContent>{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__company__">Company-wide</SelectItem>
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Amount</FieldLabel>
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field>
<FieldLabel>Frequency</FieldLabel>
<Select value={frequency} onValueChange={v => setFrequency((v ?? 'Monthly') as RecurringFrequency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Weekly">Weekly</SelectItem>
<SelectItem value="Monthly">Monthly</SelectItem>
<SelectItem value="Quarterly">Quarterly</SelectItem>
<SelectItem value="Yearly">Yearly</SelectItem>
</SelectContent>
</Select>
</Field>
{frequency === 'Weekly' ? (
<Field>
<FieldLabel>Day of week</FieldLabel>
<Select value={dayOfWeek} onValueChange={v => setDayOfWeek(v ?? '1')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d, i) =>
<SelectItem key={i} value={String(i)}>{d}</SelectItem>)}
</SelectContent>
</Select>
</Field>
) : (
<Field>
<FieldLabel>Day of month</FieldLabel>
<Input type="number" min="1" max="31" value={dayOfMonth} onChange={e => setDayOfMonth(e.target.value)} />
</Field>
)}
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={autoPost} onChange={e => setAutoPost(e.target.checked)} />
Auto-post (skip Draft)
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Start date</FieldLabel>
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
</Field>
<Field>
<FieldLabel>End date (optional)</FieldLabel>
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
</Field>
</div>
<Field>
<FieldLabel>Description</FieldLabel>
<Input value={description} onChange={e => setDescription(e.target.value)} />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy}>{busy ? 'Saving…' : 'Save'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,174 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { toast } from '@/components/ui/toast'
import { EmptyState } from '@/components/shared/empty-state'
import { useTopLoader } from '@/components/shared/top-loader'
import { useAuth } from '@/contexts/auth-context'
import { expenseVendors as api, getApiError } from '@/lib/api/index'
import type { ExpenseVendorResponse } from '@/types/expenses'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, UserGroup03Icon } from '@hugeicons/core-free-icons'
export default function ExpenseVendorsPage() {
const { hasPermission } = useAuth()
const canManage = hasPermission('expense.manage_categories')
const [items, setItems] = useState<ExpenseVendorResponse[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 20
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<ExpenseVendorResponse | 'new' | null>(null)
useTopLoader(loading)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await api.list({ page, pageSize, search: search || undefined })
setItems(res.items); setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load vendors', description: getApiError(err), type: 'error' })
} finally { setLoading(false) }
}, [page, search])
useEffect(() => { load() }, [load])
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Expense Vendors</h1>
<p className="text-sm text-muted-foreground mt-0.5">Lightweight vendor directory used by the Expenses module.</p>
</div>
{canManage && (
<Button onClick={() => setEditing('new')}>
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Vendor
</Button>
)}
</div>
<div className="mt-4 max-w-sm">
<Input placeholder="Search vendors…" value={search} onChange={e => { setPage(1); setSearch(e.target.value) }} />
</div>
<div className="mt-4 rounded-md border">
{!loading && items.length === 0 ? (
<EmptyState icon={UserGroup03Icon} title="No vendors yet" description="Add vendors to pick them when creating expenses." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Contact</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map(v => (
<TableRow key={v.id}>
<TableCell className="font-medium">{v.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{v.contactInfo ?? '—'}</TableCell>
<TableCell>{v.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}</TableCell>
<TableCell className="text-right">
{canManage && <Button size="sm" variant="ghost" onClick={() => setEditing(v)}>Edit</Button>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-muted-foreground">{total} vendor(s)</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</Button>
<span className="px-2 py-1">{page} / {pageCount}</span>
<Button variant="outline" size="sm" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>Next</Button>
</div>
</div>
<VendorDialog
vendor={editing}
onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); load() }}
/>
</div>
)
}
function VendorDialog({
vendor, onClose, onSaved,
}: {
vendor: ExpenseVendorResponse | 'new' | null
onClose: () => void
onSaved: () => void
}) {
const [name, setName] = useState('')
const [contact, setContact] = useState('')
const [active, setActive] = useState(true)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (vendor && vendor !== 'new') { setName(vendor.name); setContact(vendor.contactInfo ?? ''); setActive(vendor.isActive) }
else if (vendor === 'new') { setName(''); setContact(''); setActive(true) }
}, [vendor])
if (!vendor) return null
async function submit() {
if (!vendor || !name.trim()) return
setBusy(true)
try {
if (vendor === 'new') await api.create({ name: name.trim(), contactInfo: contact || null })
else await api.update(vendor.id, { name: name.trim(), contactInfo: contact || null, isActive: active })
toast.add({ title: 'Saved', type: 'success' })
onSaved()
} catch (err) {
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={!!vendor} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{vendor === 'new' ? 'Add Vendor' : `Edit ${vendor.name}`}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Field>
<FieldLabel>Name</FieldLabel>
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field>
<FieldLabel>Contact</FieldLabel>
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
</Field>
{vendor !== 'new' && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
Active
</label>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,6 @@
import { ExpenseViewClient } from '../_components/expense-view-client'
export default async function ExpenseViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <ExpenseViewClient expenseId={id} />
}
@@ -0,0 +1,255 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
import { useTopLoader } from '@/components/shared/top-loader'
import { useAuth } from '@/contexts/auth-context'
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
import { PayExpenseDialog } from './pay-expense-dialog'
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Draft: 'secondary', Posted: 'outline', Paid: 'default', Void: 'destructive',
}
const fmtDate = (v: string | null) =>
v ? new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
const fmtAmount = (n: number) =>
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
export function ExpenseViewClient({ expenseId }: { expenseId: string }) {
const router = useRouter()
const { hasPermission } = useAuth()
const canPost = hasPermission('expense.post')
const canPay = hasPermission('expense.pay')
const canVoid = hasPermission('expense.void')
const canCreate = hasPermission('expense.create')
const [expense, setExpense] = useState<ExpenseResponse | null>(null)
const [loading, setLoading] = useState(true)
const [payOpen, setPayOpen] = useState(false)
const [voidOpen, setVoidOpen] = useState(false)
const [voidReason, setVoidReason] = useState('')
const [busy, setBusy] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
useTopLoader(loading || busy)
const load = useCallback(async () => {
setLoading(true)
try { setExpense(await expensesApi.get(expenseId)) }
catch (err) {
toast.add({ title: 'Failed to load expense', description: getApiError(err), type: 'error' })
router.push('/payments/expenses')
} finally { setLoading(false) }
}, [expenseId, router])
useEffect(() => { load() }, [load])
async function handlePost() {
setBusy(true)
try { await expensesApi.post(expenseId); toast.add({ title: 'Expense posted', type: 'success' }); await load() }
catch (err) { toast.add({ title: 'Failed to post', description: getApiError(err), type: 'error' }) }
finally { setBusy(false) }
}
async function confirmVoid() {
const reason = voidReason.trim()
if (!reason) return
setBusy(true)
try {
await expensesApi.void(expenseId, reason)
toast.add({ title: 'Expense voided', type: 'success' })
setVoidOpen(false)
setVoidReason('')
await load()
}
catch (err) { toast.add({ title: 'Failed to void', description: getApiError(err), type: 'error' }) }
finally { setBusy(false) }
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setBusy(true)
try {
await expensesApi.uploadAttachment(expenseId, file)
toast.add({ title: 'Attachment uploaded', type: 'success' })
await load()
} catch (err) {
toast.add({ title: 'Upload failed', description: getApiError(err), type: 'error' })
} finally {
setBusy(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
async function handleDeleteAttachment(id: string) {
if (!window.confirm('Delete this attachment?')) return
setBusy(true)
try { await expensesApi.deleteAttachment(id); await load() }
catch (err) { toast.add({ title: 'Delete failed', description: getApiError(err), type: 'error' }) }
finally { setBusy(false) }
}
if (loading && !expense) return null
if (!expense) return null
return (
<div>
<div className="flex items-start justify-between gap-4">
<div>
<nav className="text-sm text-muted-foreground mb-2">
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
<span className="mx-1">/</span>
<span className="text-foreground">{expense.categoryName}</span>
</nav>
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
{fmtAmount(expense.amount)}
<Badge variant={STATUS_VARIANT[expense.status]}>{expense.status}</Badge>
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{expense.parentCategoryName ? `${expense.parentCategoryName} → ${expense.categoryName}` : expense.categoryName}
{' · '}
{expense.warehouseName ?? 'Company-wide'}
</p>
</div>
<div className="flex gap-2">
{expense.status === 'Draft' && canPost && (
<Button variant="outline" disabled={busy} onClick={handlePost}>Post</Button>
)}
{expense.status === 'Posted' && canPay && (
<Button disabled={busy} onClick={() => setPayOpen(true)}>Pay</Button>
)}
{expense.status !== 'Void' && expense.status !== 'Paid' && canVoid && (
<Button variant="outline" disabled={busy} onClick={() => { setVoidReason(''); setVoidOpen(true) }}>Void</Button>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-4 rounded-2xl border p-6 sm:grid-cols-4">
<Stat label="Bill date" value={fmtDate(expense.billDate)} />
<Stat label="Due date" value={fmtDate(expense.dueDate)} />
<Stat label="Period" value={expense.periodCovered ? new Date(expense.periodCovered).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) : '—'} />
<Stat label="Paid at" value={fmtDate(expense.paidAt)} />
<Stat label="Vendor" value={expense.vendorName ?? '—'} />
<Stat label="Payee" value={expense.payee ?? '—'} />
<Stat label="Bill ref" value={expense.billRef ?? '—'} />
<Stat label="Method" value={expense.method ?? '—'} />
</div>
{expense.description && (
<div className="mt-4 rounded-2xl border p-4">
<p className="text-xs text-muted-foreground mb-1">Description</p>
<p className="text-sm text-foreground whitespace-pre-wrap">{expense.description}</p>
</div>
)}
<div className="mt-6 rounded-2xl border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Attachments</h3>
{canCreate && (
<>
<input
ref={fileInputRef} type="file" className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.webp"
onChange={handleFile}
/>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={busy}>
Upload
</Button>
</>
)}
</div>
{expense.attachments.length === 0 ? (
<p className="text-sm text-muted-foreground">No attachments.</p>
) : (
<ul className="divide-y border rounded-md">
{expense.attachments.map(a => (
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
<div>
<a
href={expensesApi.downloadAttachmentUrl(a.id)}
target="_blank" rel="noreferrer"
className="font-medium hover:underline"
>
{a.originalFileName}
</a>
<span className="ml-2 text-xs text-muted-foreground">
{(a.sizeBytes / 1024).toFixed(1)} KB · {a.contentType}
</span>
</div>
{canCreate && (
<Button size="sm" variant="ghost" onClick={() => handleDeleteAttachment(a.id)}>
Delete
</Button>
)}
</li>
))}
</ul>
)}
</div>
<PayExpenseDialog
open={payOpen}
onClose={() => setPayOpen(false)}
amount={expense.amount}
onConfirm={async (payment) => {
setBusy(true)
try {
await expensesApi.pay(expenseId, payment)
toast.add({ title: 'Expense paid', type: 'success' })
setPayOpen(false)
await load()
} catch (err) {
toast.add({ title: 'Payment failed', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}}
/>
<Dialog open={voidOpen} onOpenChange={v => !v && setVoidOpen(false)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Void expense</DialogTitle>
<DialogDescription>This marks the expense as void. Enter a reason — it will be stored on the record.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel>Reason <span className="text-destructive">*</span></FieldLabel>
<Input
autoFocus
value={voidReason}
onChange={e => setVoidReason(e.target.value)}
placeholder="e.g. Duplicate entry"
/>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setVoidOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="destructive" onClick={confirmVoid} disabled={busy || !voidReason.trim()}>
{busy ? 'Voiding…' : 'Void'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
)
}
@@ -0,0 +1,152 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, ReceiptDollarIcon } from '@hugeicons/core-free-icons'
import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { EmptyState } from '@/components/shared/empty-state'
import { useTopLoader } from '@/components/shared/top-loader'
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
import { useAuth } from '@/contexts/auth-context'
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
Draft: 'secondary',
Posted: 'outline',
Paid: 'default',
Void: 'destructive',
}
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const truncId = (id: string) => id.substring(0, 8)
export function ExpensesClient() {
const router = useRouter()
const { hasPermission } = useAuth()
const canCreate = hasPermission('expense.create')
const [data, setData] = useState<ExpenseResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
useTopLoader(loading)
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await expensesApi.list({ page: p, pageSize: ps })
setData(res.items)
setTotal(res.total)
} catch (err) {
toast.add({ title: 'Failed to load expenses', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchData(page, pageSize) }, [fetchData, page, pageSize])
const tableConfig = useMemo<TableConfig<ExpenseResponse>>(() => ({
rowKey: 'id',
serverSide: true,
stickyHeader: true,
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
emptyState: (
<EmptyState
icon={ReceiptDollarIcon}
title="No expenses yet"
description="Record company expenses like salaries, utilities and rent. Drafts can be approved then paid."
action={canCreate ? (
<Button size="sm" onClick={() => router.push('/payments/expenses/new')}>
<HugeiconsIcon icon={Add01Icon} size={14} />
New Expense
</Button>
) : undefined}
/>
),
columns: [
{
key: 'billDate', title: 'Bill Date', fields: ['billDate'], width: 120,
cellRender: (r) => fmtDate(r.billDate),
},
{
key: 'category', title: 'Category', fields: [{ key: 'categoryName', primary: true }], minWidth: 180,
cellRender: (r) => (
<div className="flex flex-col">
<span className="text-sm font-medium">{r.categoryName}</span>
{r.parentCategoryName && (
<span className="text-xs text-muted-foreground">{r.parentCategoryName}</span>
)}
</div>
),
},
{
key: 'vendor', title: 'Vendor / Payee', fields: ['vendorName'], minWidth: 160,
cellRender: (r) => <span className="text-sm">{r.vendorName ?? r.payee ?? '—'}</span>,
},
{
key: 'amount', title: 'Amount', fields: ['amount'], width: 140,
align: 'right',
cellRender: (r) => <span className="text-sm font-medium">{fmtAmount(r.amount)}</span>,
},
{
key: 'status', title: 'Status', fields: ['status'], width: 120,
cellRender: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{r.status}</Badge>,
},
{
key: 'branch', title: 'Branch', fields: ['warehouseName'], width: 160,
cellRender: (r) => <span className="text-sm">{r.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</span>,
},
{
key: 'id', title: 'Ref', fields: ['id'], width: 110,
cellRender: (r) => <span className="font-mono text-xs text-muted-foreground">{truncId(r.id)}</span>,
},
],
rowActions: {
actions: () => [
{
action: 'view',
label: 'View expense',
onClick: (r: ExpenseResponse) => router.push(`/payments/expenses/${r.id}`),
},
],
},
}), [pageSize, router, canCreate])
return (
<>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Expenses</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Record and track company expenses — salaries, utilities, rent, fuel and more
</p>
</div>
{canCreate && (
<Button onClick={() => router.push('/payments/expenses/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
New Expense
</Button>
)}
</div>
<DataTable
config={tableConfig}
data={data}
loading={loading}
totalCount={total}
onServerSideChange={({ page: p }) => { setPage(p.page); setPageSize(p.pageSize) }}
/>
</>
)
}
@@ -0,0 +1,379 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { useTopLoader } from '@/components/shared/top-loader'
import { useAuth } from '@/contexts/auth-context'
import {
expenseCategories as categoriesApi,
expenseVendors as vendorsApi,
expenses as expensesApi,
warehouses as warehousesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type {
CreateExpenseCategoryRequest,
CreateExpenseRequest,
CreateExpenseVendorRequest,
ExpenseAction,
ExpenseCategoryResponse,
ExpensePaymentRequest,
ExpenseVendorResponse,
} from '@/types/expenses'
import type { WarehouseResponse } from '@/types/api'
import { PayExpenseDialog } from './pay-expense-dialog'
export function NewExpenseClient() {
const router = useRouter()
const { hasPermission } = useAuth()
const canPost = hasPermission('expense.post')
const canPay = hasPermission('expense.pay') && canPost
const canManageCats = hasPermission('expense.manage_categories')
const [categories, setCategories] = useState<ExpenseCategoryResponse[]>([])
const [vendors, setVendors] = useState<ExpenseVendorResponse[]>([])
const [whs, setWhs] = useState<WarehouseResponse[]>([])
const [categoryId, setCategoryId] = useState('')
const [titleId, setTitleId] = useState('')
const [warehouseId, setWarehouseId] = useState<string>('__company__')
const [amount, setAmount] = useState('')
const [billDate, setBillDate] = useState(() => new Date().toISOString().slice(0, 10))
const [dueDate, setDueDate] = useState('')
const [periodCovered, setPeriodCovered] = useState('')
const [vendorId, setVendorId] = useState<string>('')
const [payee, setPayee] = useState('')
const [billRef, setBillRef] = useState('')
const [description, setDescription] = useState('')
const [submitting, setSubmitting] = useState(false)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const [addVendorOpen, setAddVendorOpen] = useState(false)
useTopLoader(submitting)
const loadCategories = useCallback(async () => {
try { setCategories(getPaginatedItems(await categoriesApi.tree())) }
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
}, [])
useEffect(() => {
loadCategories()
vendorsApi.list({ pageSize: 200 })
.then(r => setVendors(getPaginatedItems(r)))
.catch(() => {})
warehousesApi.list({ pageSize: 200 })
.then(r => setWhs(getPaginatedItems(r)))
.catch(() => {})
}, [loadCategories])
const titles = useMemo(
() => categories.find(c => c.id === categoryId)?.children ?? [],
[categoryId, categories],
)
function buildBody(): CreateExpenseRequest | null {
if (!titleId) { toast.add({ title: 'Title is required', type: 'error' }); return null }
const amt = parseFloat(amount)
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return null }
if (!billDate) { toast.add({ title: 'Bill date is required', type: 'error' }); return null }
return {
categoryId: titleId,
warehouseId: warehouseId === '__company__' ? null : warehouseId,
amount: amt,
billDate,
dueDate: dueDate || null,
periodCovered: periodCovered ? `${periodCovered}-01` : null,
vendorId: vendorId || null,
payee: payee || null,
billRef: billRef || null,
description: description || null,
}
}
async function submit(action: ExpenseAction, payment?: ExpensePaymentRequest) {
const body = buildBody()
if (!body) return
if (action === 'Pay') body.payment = payment
setSubmitting(true)
try {
const created = await expensesApi.create(body, action)
toast.add({ title: `Expense ${action === 'Draft' ? 'saved as draft' : action === 'Post' ? 'posted' : 'posted and paid'}`, type: 'success' })
router.push(`/payments/expenses/${created.id}`)
} catch (err) {
toast.add({ title: 'Failed to save expense', description: getApiError(err), type: 'error' })
} finally {
setSubmitting(false)
}
}
return (
<div>
<nav className="text-sm text-muted-foreground mb-2">
<span>Accounts</span> <span className="mx-1">/</span>
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
<span className="mx-1">/</span>
<span className="text-foreground">New</span>
</nav>
<h1 className="text-2xl font-semibold text-foreground">New Expense</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Record a bill — pick its category and title, then save it as a draft, post it, or post and pay it in one step.
</p>
<div className="mt-6 rounded-2xl border p-6 space-y-5">
<Field>
<FieldLabel>Branch</FieldLabel>
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__company__">Company-wide</SelectItem>
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Category <span className="text-destructive">*</span></FieldLabel>
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
<SelectContent>
{categories.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Title <span className="text-destructive">*</span></FieldLabel>
{canManageCats && categoryId && (
<button
type="button"
onClick={() => setAddTitleOpen(true)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
</button>
)}
</div>
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
<SelectTrigger><SelectValue placeholder={categoryId ? 'Pick a title' : 'Pick a category first'} /></SelectTrigger>
<SelectContent>
{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field>
<FieldLabel>Amount <span className="text-destructive">*</span></FieldLabel>
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
</Field>
<Field>
<FieldLabel>Bill date <span className="text-destructive">*</span></FieldLabel>
<Input type="date" value={billDate} onChange={e => setBillDate(e.target.value)} />
</Field>
<Field>
<FieldLabel>Due date</FieldLabel>
<Input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field>
<div className="flex items-center justify-between">
<FieldLabel>Vendor (optional)</FieldLabel>
{canManageCats && (
<button
type="button"
onClick={() => setAddVendorOpen(true)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<HugeiconsIcon icon={Add01Icon} size={12} /> Add vendor
</button>
)}
</div>
<Select value={vendorId} onValueChange={v => setVendorId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="— none —" /></SelectTrigger>
<SelectContent>
{vendors.map(v => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Payee</FieldLabel>
<Input value={payee} onChange={e => setPayee(e.target.value)} placeholder="Name on the cheque / cash paid to" />
</Field>
<Field>
<FieldLabel>Bill / account ref</FieldLabel>
<Input value={billRef} onChange={e => setBillRef(e.target.value)} placeholder="Bill no, meter reading, …" />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>Period covered</FieldLabel>
<Input type="month" value={periodCovered} onChange={e => setPeriodCovered(e.target.value)} />
</Field>
<Field>
<FieldLabel>Description</FieldLabel>
<Input value={description} onChange={e => setDescription(e.target.value)} />
</Field>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => router.push('/payments/expenses')}>Cancel</Button>
<Button variant="outline" disabled={submitting} onClick={() => submit('Draft')}>
{submitting ? 'Saving…' : 'Save draft'}
</Button>
{canPost && (
<Button variant="outline" disabled={submitting} onClick={() => submit('Post')}>
Save & post
</Button>
)}
{canPay && (
<Button disabled={submitting} onClick={() => { if (buildBody()) setPayDialogOpen(true) }}>
Save & pay
</Button>
)}
</div>
<PayExpenseDialog
open={payDialogOpen}
onClose={() => setPayDialogOpen(false)}
amount={parseFloat(amount) || 0}
onConfirm={async (payment) => { setPayDialogOpen(false); await submit('Pay', payment) }}
/>
<AddTitleDialog
open={addTitleOpen}
onClose={() => setAddTitleOpen(false)}
parentCategoryId={categoryId}
onCreated={async (createdId) => { await loadCategories(); setTitleId(createdId) }}
/>
<AddVendorDialog
open={addVendorOpen}
onClose={() => setAddVendorOpen(false)}
onCreated={(v) => { setVendors(prev => [...prev, v]); setVendorId(v.id) }}
/>
</div>
)
}
// ─── inline sub-components kept here to avoid file explosion ──────────────────
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
function AddTitleDialog({
open, onClose, parentCategoryId, onCreated,
}: {
open: boolean
onClose: () => void
parentCategoryId: string
onCreated: (createdId: string) => void
}) {
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!name.trim()) return
setBusy(true)
try {
const body: CreateExpenseCategoryRequest = { name: name.trim(), parentCategoryId }
const created = await categoriesApi.create(body)
toast.add({ title: 'Title added', type: 'success' })
onCreated(created.id)
setName('')
onClose()
} catch (err) {
toast.add({ title: 'Failed to add title', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={open} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Add Title</DialogTitle>
<DialogDescription>Create a new title under the selected category.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel>Name</FieldLabel>
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function AddVendorDialog({
open, onClose, onCreated,
}: {
open: boolean
onClose: () => void
onCreated: (v: ExpenseVendorResponse) => void
}) {
const [name, setName] = useState('')
const [contact, setContact] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!name.trim()) return
setBusy(true)
try {
const body: CreateExpenseVendorRequest = { name: name.trim(), contactInfo: contact || null }
const created = await vendorsApi.create(body)
toast.add({ title: 'Vendor added', type: 'success' })
onCreated(created)
setName(''); setContact('')
onClose()
} catch (err) {
toast.add({ title: 'Failed to add vendor', description: getApiError(err), type: 'error' })
} finally { setBusy(false) }
}
return (
<Dialog open={open} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Add Vendor</DialogTitle>
<DialogDescription>Vendors are shared across the Expenses module only.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field>
<FieldLabel>Name</FieldLabel>
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
</Field>
<Field>
<FieldLabel>Contact (optional)</FieldLabel>
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,156 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Field, FieldLabel } from '@/components/ui/field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import {
bankAccounts as bankAccountsApi,
chequeBooks as chequeBooksApi,
getApiError,
} from '@/lib/api/index'
import {
BankAccountType,
ChequePageIssueStatus,
type BankAccountResponse,
type ChequePageResponse,
} from '@/types/payments'
import type { ExpensePaymentRequest } from '@/types/expenses'
type Method = 'Cash' | 'Cheque'
/**
* Shared dialog for both "Save & pay" (on new expense) and "Pay" (on view).
* Cash → pick a BankAccount (Petty Cash is a Cash account; one-click button up top).
* Cheque → pick an Unused page from an Active cheque book.
*/
export function PayExpenseDialog({
open, onClose, amount, onConfirm,
}: {
open: boolean
onClose: () => void
amount: number
onConfirm: (payment: ExpensePaymentRequest) => void | Promise<void>
}) {
const [method, setMethod] = useState<Method>('Cash')
const [bankAccountId, setBankAccountId] = useState('')
const [chequePageId, setChequePageId] = useState('')
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
const [pages, setPages] = useState<ChequePageResponse[]>([])
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
setMethod('Cash'); setBankAccountId(''); setChequePageId('')
bankAccountsApi.list()
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
.catch(() => {})
}, [open])
useEffect(() => {
if (!open || method !== 'Cheque') return
chequeBooksApi.list({ status: 'Active' as never, page: 1, pageSize: 50 })
.then(async res => {
const expanded = await Promise.all(res.items.map(b => chequeBooksApi.get(b.chequeBookNo, true)))
const unused: ChequePageResponse[] = []
expanded.forEach(book => book.pages.filter(p => p.issueStatus === ChequePageIssueStatus.Unused).forEach(p => unused.push(p)))
setPages(unused)
})
.catch(err => toast.add({ title: 'Failed to load cheque pages', description: getApiError(err), type: 'error' }))
}, [open, method])
const defaultPettyCash = useMemo(
() => accounts.find(a => a.accountType === BankAccountType.Cash && a.isDefault),
[accounts],
)
async function submit() {
if (method === 'Cash' && !bankAccountId) { toast.add({ title: 'Pick an account', type: 'error' }); return }
if (method === 'Cheque' && !chequePageId) { toast.add({ title: 'Pick a cheque page', type: 'error' }); return }
setBusy(true)
try {
await onConfirm({
method,
bankAccountId: method === 'Cash' ? bankAccountId : null,
chequePageId: method === 'Cheque' ? chequePageId : null,
})
} finally { setBusy(false) }
}
return (
<Dialog open={open} onOpenChange={v => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Pay Expense</DialogTitle>
<DialogDescription>
{amount > 0 ? `Settling Rs. ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}.` : 'Pick the payment source.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{defaultPettyCash && method === 'Cash' && !bankAccountId && (
<Button
type="button" variant="outline" className="w-full justify-start"
onClick={() => setBankAccountId(defaultPettyCash.accountId)}
>
Use Petty Cash — {defaultPettyCash.accountName}
</Button>
)}
<Field>
<FieldLabel>Method</FieldLabel>
<Select value={method} onValueChange={v => setMethod((v ?? 'Cash') as Method)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
</SelectContent>
</Select>
</Field>
{method === 'Cash' && (
<Field>
<FieldLabel>Account</FieldLabel>
<Select value={bankAccountId} onValueChange={v => setBankAccountId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="Pick a cash or bank account" /></SelectTrigger>
<SelectContent>
{accounts.map(a => (
<SelectItem key={a.accountId} value={a.accountId}>
{a.accountName}{a.bankName ? ` (${a.bankName})` : ''}
{a.accountType === BankAccountType.Cash && a.isDefault ? ' — default' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{method === 'Cheque' && (
<Field>
<FieldLabel>Cheque Page</FieldLabel>
<Select value={chequePageId} onValueChange={v => setChequePageId(v ?? '')}>
<SelectTrigger><SelectValue placeholder="Pick an unused cheque page" /></SelectTrigger>
<SelectContent>
{pages.map(p => (
<SelectItem key={p.id} value={p.id}>
{p.chequeBookNo} / {p.chequeNo}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={submit} disabled={busy}>{busy ? 'Paying…' : 'Confirm payment'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,5 @@
import { NewExpenseClient } from '../_components/new-expense-client'
export default function NewExpensePage() {
return <NewExpenseClient />
}
@@ -0,0 +1,5 @@
import { ExpensesClient } from './_components/expenses-client'
export default function ExpensesPage() {
return <ExpensesClient />
}
+15 -1
View File
@@ -27,6 +27,10 @@ import {
BankIcon, BankIcon,
PackageMoving01Icon, PackageMoving01Icon,
Settings01Icon, Settings01Icon,
ReceiptDollarIcon,
Folder01Icon,
Calendar03Icon,
UserGroup03Icon,
} from '@hugeicons/core-free-icons' } from '@hugeicons/core-free-icons'
export interface NavItem { export interface NavItem {
@@ -127,7 +131,7 @@ export const NAV_GROUPS: NavGroup[] = [
], ],
}, },
{ {
title: 'Accounts', title: 'Accounting',
icon: BankIcon, icon: BankIcon,
items: [ items: [
{ title: 'Analytics', href: '/payments/analytics', icon: BankIcon, description: 'Overview of bank accounts, cheque books and received cheques' }, { title: 'Analytics', href: '/payments/analytics', icon: BankIcon, description: 'Overview of bank accounts, cheque books and received cheques' },
@@ -136,6 +140,16 @@ export const NAV_GROUPS: NavGroup[] = [
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' }, { title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
], ],
}, },
{
title: 'Expenses',
icon: ReceiptDollarIcon,
items: [
{ title: 'Expenses', href: '/payments/expenses', icon: ReceiptDollarIcon, description: 'Record and track company expenses — salaries, utilities, rent and more', permission: 'expense.view' },
{ title: 'Recurring Expenses', href: '/payments/expense-recurring', icon: Calendar03Icon, description: 'Templates that generate expenses on a schedule', permission: 'expense.view' },
{ title: 'Expense Categories', href: '/payments/expense-categories', icon: Folder01Icon, description: 'Two-level category tree used to classify expenses', permission: 'expense.view' },
{ title: 'Expense Vendors', href: '/payments/expense-vendors', icon: UserGroup03Icon, description: 'Vendor directory used by the Expenses module', permission: 'expense.view' },
],
},
{ {
title: 'Returns & Claims', title: 'Returns & Claims',
icon: ClipboardListIcon, icon: ClipboardListIcon,
+13
View File
@@ -99,6 +99,19 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
'compliance.manage', 'compliance.manage',
], ],
}, },
{
id: 'expenses',
label: 'Expenses',
codes: [
'expense.view',
'expense.create',
'expense.post',
'expense.pay',
'expense.void',
'expense.manage_categories',
'expense.manage_recurring',
],
},
{ {
id: 'users', id: 'users',
label: 'User Management', label: 'User Management',
+117
View File
@@ -0,0 +1,117 @@
import { api } from '@/lib/api'
import type {
CreateExpenseCategoryRequest,
CreateExpenseRequest,
CreateExpenseVendorRequest,
CreateRecurringExpenseRequest,
ExpenseAction,
ExpenseAttachmentResponse,
ExpenseCategoryResponse,
ExpensePaymentRequest,
ExpenseResponse,
ExpenseStatus,
ExpenseVendorResponse,
PagedExpenseVendorsResponse,
PagedExpensesResponse,
PagedRecurringExpensesResponse,
RecurringExpenseResponse,
RunDueResponse,
UpdateExpenseCategoryRequest,
UpdateExpenseDraftRequest,
UpdateExpenseVendorRequest,
UpdateRecurringExpenseRequest,
} from '@/types/expenses'
// Categories
export const expenseCategories = {
tree: () =>
api.get<ExpenseCategoryResponse[]>('/expense-categories').then(r => r.data),
create: (body: CreateExpenseCategoryRequest) =>
api.post<ExpenseCategoryResponse>('/expense-categories', body).then(r => r.data),
update: (id: string, body: UpdateExpenseCategoryRequest) =>
api.put<ExpenseCategoryResponse>(`/expense-categories/${id}`, body).then(r => r.data),
}
// Vendors
export const expenseVendors = {
list: (params?: { search?: string; page?: number; pageSize?: number }) =>
api.get<PagedExpenseVendorsResponse>('/expense-vendors', { params }).then(r => r.data),
create: (body: CreateExpenseVendorRequest) =>
api.post<ExpenseVendorResponse>('/expense-vendors', body).then(r => r.data),
update: (id: string, body: UpdateExpenseVendorRequest) =>
api.put<ExpenseVendorResponse>(`/expense-vendors/${id}`, body).then(r => r.data),
}
// Expenses
export interface ExpenseListParams {
status?: ExpenseStatus
categoryId?: string
warehouseId?: string
from?: string
to?: string
page?: number
pageSize?: number
}
export const expenses = {
list: (params?: ExpenseListParams) =>
api.get<PagedExpensesResponse>('/expenses', { params }).then(r => r.data),
get: (id: string) =>
api.get<ExpenseResponse>(`/expenses/${id}`).then(r => r.data),
create: (body: CreateExpenseRequest, action: ExpenseAction = 'Draft') =>
api.post<ExpenseResponse>('/expenses', body, { params: { action } }).then(r => r.data),
updateDraft: (id: string, body: UpdateExpenseDraftRequest) =>
api.put<ExpenseResponse>(`/expenses/${id}`, body).then(r => r.data),
post: (id: string) =>
api.post<ExpenseResponse>(`/expenses/${id}/post`).then(r => r.data),
pay: (id: string, body: ExpensePaymentRequest) =>
api.post<ExpenseResponse>(`/expenses/${id}/pay`, body).then(r => r.data),
void: (id: string, reason?: string) =>
api.post<ExpenseResponse>(`/expenses/${id}/void`, { reason }).then(r => r.data),
uploadAttachment: (expenseId: string, file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<ExpenseAttachmentResponse>(`/expenses/${expenseId}/attachments`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
}).then(r => r.data)
},
downloadAttachmentUrl: (attachmentId: string) =>
`${api.defaults.baseURL}/expense-attachments/${attachmentId}/download`,
deleteAttachment: (attachmentId: string) =>
api.delete(`/expense-attachments/${attachmentId}`),
}
// Recurring
export const recurringExpenses = {
list: (params?: { page?: number; pageSize?: number }) =>
api.get<PagedRecurringExpensesResponse>('/recurring-expenses', { params }).then(r => r.data),
create: (body: CreateRecurringExpenseRequest) =>
api.post<RecurringExpenseResponse>('/recurring-expenses', body).then(r => r.data),
update: (id: string, body: UpdateRecurringExpenseRequest) =>
api.put<RecurringExpenseResponse>(`/recurring-expenses/${id}`, body).then(r => r.data),
toggle: (id: string, paused: boolean) =>
api.post<RecurringExpenseResponse>(`/recurring-expenses/${id}/toggle`, { paused }).then(r => r.data),
runDue: () =>
api.post<RunDueResponse>('/recurring-expenses/run-due').then(r => r.data),
}
+1
View File
@@ -17,6 +17,7 @@ export { chequeBooks, chequePages } from './cheques'
export { receivedCheques } from './received-cheques' export { receivedCheques } from './received-cheques'
export { supplierPayments } from './supplier-payments' export { supplierPayments } from './supplier-payments'
export { notifications } from './notifications' export { notifications } from './notifications'
export { expenseCategories, expenseVendors, expenses, recurringExpenses } from './expenses'
export { getPaginatedItems } from './paginated' export { getPaginatedItems } from './paginated'
export type { PageParams } from './master' export type { PageParams } from './master'
+184
View File
@@ -0,0 +1,184 @@
export type ExpenseStatus = 'Draft' | 'Posted' | 'Paid' | 'Void'
export type ExpenseAction = 'Draft' | 'Post' | 'Pay'
export type RecurringFrequency = 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly'
export interface ExpenseCategoryResponse {
id: string
name: string
code: string | null
parentCategoryId: string | null
isActive: boolean
children: ExpenseCategoryResponse[]
}
export interface CreateExpenseCategoryRequest {
name: string
code?: string | null
parentCategoryId?: string | null
}
export interface UpdateExpenseCategoryRequest {
name: string
code?: string | null
isActive: boolean
}
export interface ExpenseVendorResponse {
id: string
name: string
contactInfo: string | null
isActive: boolean
}
export interface PagedExpenseVendorsResponse {
items: ExpenseVendorResponse[]
page: number
pageSize: number
total: number
}
export interface CreateExpenseVendorRequest {
name: string
contactInfo?: string | null
}
export interface UpdateExpenseVendorRequest {
name: string
contactInfo?: string | null
isActive: boolean
}
export interface ExpenseAttachmentResponse {
id: string
originalFileName: string
contentType: string
sizeBytes: number
createdAt: string
}
export interface ExpensePaymentRequest {
method: 'Cash' | 'Cheque'
bankAccountId?: string | null
chequePageId?: string | null
}
export interface ExpenseResponse {
id: string
categoryId: string
categoryName: string
parentCategoryId: string | null
parentCategoryName: string | null
warehouseId: string | null
warehouseName: string | null
amount: number
billDate: string
dueDate: string | null
periodCovered: string | null
vendorId: string | null
vendorName: string | null
payee: string | null
billRef: string | null
description: string | null
status: ExpenseStatus
method: 'Cash' | 'Cheque' | 'Credit' | null
bankAccountId: string | null
chequePageId: string | null
chequeNo: string | null
paidAt: string | null
recordedByUserId: string
postedByUserId: string | null
paidByUserId: string | null
voidedByUserId: string | null
voidReason: string | null
recurringTemplateId: string | null
attachments: ExpenseAttachmentResponse[]
createdAt: string
}
export interface PagedExpensesResponse {
items: ExpenseResponse[]
page: number
pageSize: number
total: number
}
export interface CreateExpenseRequest {
categoryId: string
warehouseId?: string | null
amount: number
billDate: string
dueDate?: string | null
periodCovered?: string | null
vendorId?: string | null
payee?: string | null
billRef?: string | null
description?: string | null
payment?: ExpensePaymentRequest
}
export interface UpdateExpenseDraftRequest {
categoryId: string
warehouseId?: string | null
amount: number
billDate: string
dueDate?: string | null
periodCovered?: string | null
vendorId?: string | null
payee?: string | null
billRef?: string | null
description?: string | null
}
export interface RecurringExpenseResponse {
id: string
name: string
categoryId: string
categoryName: string
warehouseId: string | null
warehouseName: string | null
vendorId: string | null
vendorName: string | null
amount: number
payee: string | null
description: string | null
frequency: RecurringFrequency
dayOfMonth: number | null
dayOfWeek: number | null
startDate: string
endDate: string | null
nextRunDate: string
autoPost: boolean
isPaused: boolean
isActive: boolean
createdAt: string
}
export interface PagedRecurringExpensesResponse {
items: RecurringExpenseResponse[]
page: number
pageSize: number
total: number
}
export interface CreateRecurringExpenseRequest {
name: string
categoryId: string
warehouseId?: string | null
vendorId?: string | null
amount: number
payee?: string | null
description?: string | null
frequency: RecurringFrequency
dayOfMonth?: number | null
dayOfWeek?: number | null
startDate: string
endDate?: string | null
autoPost: boolean
}
export type UpdateRecurringExpenseRequest = CreateRecurringExpenseRequest
export interface RunDueResponse {
generated: number
expenseIds: string[]
}