Phase G: Add Recurring Transactions (BackgroundService + CRUD UI)
- RecurringTemplate entity with Frequency/IntervalCount/NextFireDate/EndDate/MaxOccurrences/TemplateData JSON - RecurringFrequency + RecurringTemplateType enums - RecurringTransactionService BackgroundService: hourly check, creates Draft bills or immediate expenses, advances NextFireDate, auto-deactivates on limits - RecurringTemplatesController: Index/Create/Edit/ToggleActive/Delete/GenerateNow (on-demand fire) - Three views + external JS for type-toggle and dynamic bill line items - Finance sidebar nav: Recurring Transactions - Migration: AddRecurringTemplates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton background service that wakes hourly and generates bills or expenses for any
|
||||
/// <see cref="RecurringTemplate"/> whose <c>NextFireDate</c> is today or in the past.
|
||||
/// Bills are created as Draft so users can review; Expenses are recorded immediately.
|
||||
/// NextFireDate is advanced after each successful fire. Templates are deactivated automatically
|
||||
/// when <c>MaxOccurrences</c> is reached or <c>EndDate</c> has passed.
|
||||
/// </summary>
|
||||
public class RecurringTransactionService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<RecurringTransactionService> _logger;
|
||||
|
||||
public RecurringTransactionService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<RecurringTransactionService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loops forever, sleeping one hour between passes.
|
||||
/// Uses <see cref="IServiceScopeFactory"/> to resolve scoped services (DbContext) from the
|
||||
/// singleton because BackgroundService lives for the application lifetime.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("RecurringTransactionService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RecurringTransactionService run failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("RecurringTransactionService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all active templates whose NextFireDate is on or before today and fires each one.
|
||||
/// Uses IgnoreQueryFilters to bypass the HTTP-context-dependent tenant filter.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
var due = await db.RecurringTemplates
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => !t.IsDeleted && t.IsActive && t.NextFireDate.Date <= today)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (due.Count == 0) return;
|
||||
|
||||
_logger.LogInformation("RecurringTransactionService: {Count} template(s) due.", due.Count);
|
||||
|
||||
foreach (var template in due)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
await FireTemplateAsync(db, template, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires a single template: creates the document, updates OccurrenceCount + NextFireDate,
|
||||
/// and deactivates the template when limits are reached. Errors are captured in LastError
|
||||
/// so the service loop continues to process other templates.
|
||||
/// </summary>
|
||||
private async Task FireTemplateAsync(
|
||||
ApplicationDbContext db,
|
||||
RecurringTemplate template,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (template.TemplateType == RecurringTemplateType.Bill)
|
||||
await CreateBillAsync(db, template, ct);
|
||||
else
|
||||
await CreateExpenseAsync(db, template, ct);
|
||||
|
||||
template.OccurrenceCount++;
|
||||
template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount);
|
||||
template.LastError = null;
|
||||
|
||||
// Deactivate when limits reached
|
||||
if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value)
|
||||
{
|
||||
template.IsActive = false;
|
||||
_logger.LogInformation("Template {Id} ({Name}) deactivated: MaxOccurrences reached.", template.Id, template.Name);
|
||||
}
|
||||
else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date)
|
||||
{
|
||||
template.IsActive = false;
|
||||
_logger.LogInformation("Template {Id} ({Name}) deactivated: EndDate passed.", template.Id, template.Name);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fire recurring template {Id} ({Name}).", template.Id, template.Name);
|
||||
template.LastError = ex.Message;
|
||||
try { await db.SaveChangesAsync(ct); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bill creation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the template's JSON payload and inserts a Draft <see cref="Bill"/> with
|
||||
/// its line items. GL posting is deferred — the user posts the Draft bill manually after review.
|
||||
/// </summary>
|
||||
private async Task CreateBillAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<BillTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid bill template data.");
|
||||
|
||||
var bill = new Bill
|
||||
{
|
||||
BillNumber = await NextBillNumberAsync(db, ct),
|
||||
VendorId = data.VendorId,
|
||||
APAccountId = data.APAccountId,
|
||||
BillDate = DateTime.UtcNow,
|
||||
DueDate = data.Terms != null ? ParseDueDate(data.Terms) : null,
|
||||
Status = BillStatus.Draft,
|
||||
Terms = data.Terms,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0,
|
||||
TaxPercent = data.TaxPercent,
|
||||
TaxAmount = 0,
|
||||
Total = 0,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * bill.TaxPercent / 100, 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
db.Bills.Add(bill);
|
||||
await db.SaveChangesAsync(ct); // get bill.Id
|
||||
|
||||
int order = 1;
|
||||
foreach (var line in data.LineItems ?? [])
|
||||
{
|
||||
db.BillLineItems.Add(new BillLineItem
|
||||
{
|
||||
BillId = bill.Id,
|
||||
AccountId = line.AccountId,
|
||||
Description = line.Description,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Amount = Math.Round(line.Quantity * line.UnitPrice, 2),
|
||||
DisplayOrder = order++,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Recurring bill {BillNumber} created for template {Id}.", bill.BillNumber, template.Id);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Expense creation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the template's JSON payload and inserts an <see cref="Expense"/> immediately.
|
||||
/// Expenses are already-paid transactions so no user review is required.
|
||||
/// </summary>
|
||||
private async Task CreateExpenseAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<ExpenseTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid expense template data.");
|
||||
|
||||
var expense = new Expense
|
||||
{
|
||||
ExpenseNumber = await NextExpenseNumberAsync(db, ct),
|
||||
Date = DateTime.UtcNow,
|
||||
VendorId = data.VendorId == 0 ? null : data.VendorId,
|
||||
ExpenseAccountId = data.ExpenseAccountId,
|
||||
PaymentAccountId = data.PaymentAccountId,
|
||||
PaymentMethod = (PaymentMethod)data.PaymentMethod,
|
||||
Amount = data.Amount,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Expenses.Add(expense);
|
||||
|
||||
_logger.LogInformation("Recurring expense {ExpenseNumber} created for template {Id}.", expense.ExpenseNumber, template.Id);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Advances a date by one period (Frequency × IntervalCount).</summary>
|
||||
private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval)
|
||||
{
|
||||
return freq switch
|
||||
{
|
||||
RecurringFrequency.Daily => date.AddDays(interval),
|
||||
RecurringFrequency.Weekly => date.AddDays(7 * interval),
|
||||
RecurringFrequency.BiWeekly => date.AddDays(14 * interval),
|
||||
RecurringFrequency.Monthly => date.AddMonths(interval),
|
||||
RecurringFrequency.Quarterly => date.AddMonths(3 * interval),
|
||||
RecurringFrequency.Annually => date.AddYears(interval),
|
||||
_ => date.AddMonths(interval)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential bill number (BILL-YYMM-####).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted bills are included in the sequence scan.
|
||||
/// </summary>
|
||||
private static async Task<string> NextBillNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||
{
|
||||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||
var last = await db.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential expense number (EXP-YYMM-####).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted expenses are included in the sequence scan.
|
||||
/// </summary>
|
||||
private static async Task<string> NextExpenseNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||
{
|
||||
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||
var last = await db.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||
.OrderByDescending(e => e.ExpenseNumber)
|
||||
.Select(e => e.ExpenseNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>Best-effort due date from a payment terms string (delegates to the same patterns as PaymentTermsParser).</summary>
|
||||
private static DateTime? ParseDueDate(string terms)
|
||||
{
|
||||
var t = terms.Trim().ToUpperInvariant();
|
||||
if (t is "DUE ON RECEIPT" or "COD" or "IMMEDIATE") return DateTime.UtcNow.Date;
|
||||
|
||||
// "Net 30", "NET30", "2/10 Net 30" → extract trailing number
|
||||
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var last = parts.LastOrDefault();
|
||||
if (last != null && int.TryParse(last, out int days) && days > 0)
|
||||
return DateTime.UtcNow.Date.AddDays(days);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON payload records (must match RecurringTemplatesController serialization)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal sealed record BillTemplateData(
|
||||
int VendorId,
|
||||
int APAccountId,
|
||||
string? Terms,
|
||||
string? Memo,
|
||||
decimal TaxPercent,
|
||||
List<BillLineData>? LineItems);
|
||||
|
||||
internal sealed record BillLineData(
|
||||
int? AccountId,
|
||||
string Description,
|
||||
decimal Quantity,
|
||||
decimal UnitPrice);
|
||||
|
||||
internal sealed record ExpenseTemplateData(
|
||||
int VendorId,
|
||||
int ExpenseAccountId,
|
||||
int PaymentAccountId,
|
||||
int PaymentMethod,
|
||||
decimal Amount,
|
||||
string? Memo);
|
||||
}
|
||||
Reference in New Issue
Block a user