10.
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user