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:
2026-05-10 11:08:36 -04:00
parent d3a5d827f9
commit 42eff3357e
16 changed files with 12046 additions and 6 deletions
@@ -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);
}