42eff3357e
- 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>
320 lines
13 KiB
C#
320 lines
13 KiB
C#
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);
|
||
}
|