using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using System.Text.Json; namespace PowderCoating.Web.BackgroundServices; /// /// Singleton background service that wakes hourly and generates bills or expenses for any /// whose NextFireDate 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 MaxOccurrences is reached or EndDate has passed. /// public class RecurringTransactionService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public RecurringTransactionService( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } /// /// Loops forever, sleeping one hour between passes. /// Uses to resolve scoped services (DbContext) from the /// singleton because BackgroundService lives for the application lifetime. /// 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."); } /// /// 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. /// private async Task RunAsync(CancellationToken ct) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); 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); } } /// /// 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. /// 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 // ------------------------------------------------------------------------- /// /// Deserializes the template's JSON payload and inserts a Draft with /// its line items. GL posting is deferred — the user posts the Draft bill manually after review. /// private async Task CreateBillAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct) { var data = JsonSerializer.Deserialize(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 // ------------------------------------------------------------------------- /// /// Deserializes the template's JSON payload and inserts an immediately. /// Expenses are already-paid transactions so no user review is required. /// private async Task CreateExpenseAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct) { var data = JsonSerializer.Deserialize(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 // ------------------------------------------------------------------------- /// Advances a date by one period (Frequency × IntervalCount). 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) }; } /// /// Generates the next sequential bill number (BILL-YYMM-####). /// Uses IgnoreQueryFilters so soft-deleted bills are included in the sequence scan. /// private static async Task 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}"; } /// /// Generates the next sequential expense number (EXP-YYMM-####). /// Uses IgnoreQueryFilters so soft-deleted expenses are included in the sequence scan. /// private static async Task 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}"; } /// Best-effort due date from a payment terms string (delegates to the same patterns as PaymentTermsParser). 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? 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); }