c775146b64
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.
184 lines
7.6 KiB
C#
184 lines
7.6 KiB
C#
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");
|
|
}
|
|
}
|
|
}
|