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.
This commit is contained in:
Kalana B. Thilakarathna
2026-10-22 17:47:49 +05:30
parent 6e4bebb89b
commit c775146b64
52 changed files with 12003 additions and 53 deletions
@@ -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");
}
}
}