using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; using PowderCoating.Web.BackgroundServices; using System.Text.Json; namespace PowderCoating.Web.Controllers; /// /// Manages recurring transaction templates. Each template is a saved recipe that the /// uses to auto-generate bills or expenses on a schedule. /// Bills are created as Draft for user review; Expenses are recorded immediately. /// [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class RecurringTemplatesController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly ITenantContext _tenantContext; private readonly UserManager _userManager; private readonly ILogger _logger; public RecurringTemplatesController( IUnitOfWork unitOfWork, ITenantContext tenantContext, UserManager userManager, ILogger logger) { _unitOfWork = unitOfWork; _tenantContext = tenantContext; _userManager = userManager; _logger = logger; } // ------------------------------------------------------------------------- // Index // ------------------------------------------------------------------------- /// Lists all recurring templates for the current company, active first then by name. public async Task Index() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId); return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList()); } // ------------------------------------------------------------------------- // Create // ------------------------------------------------------------------------- [HttpGet] public async Task Create() { await PopulateDropDownsAsync(); return View(new RecurringTemplateViewModel { StartDate = DateTime.Today.AddDays(1) }); } /// /// Saves a new recurring template. Serializes the type-specific fields to JSON in TemplateData. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Create(RecurringTemplateViewModel vm) { if (!ModelState.IsValid) { await PopulateDropDownsAsync(); return View(vm); } var user = await _userManager.GetUserAsync(User); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var template = new RecurringTemplate { Name = vm.Name, TemplateType = vm.TemplateType, Frequency = vm.Frequency, IntervalCount = Math.Max(1, vm.IntervalCount), NextFireDate = vm.StartDate.Date, EndDate = vm.EndDate?.Date, MaxOccurrences = vm.MaxOccurrences > 0 ? vm.MaxOccurrences : null, IsActive = true, TemplateData = BuildTemplateJson(vm), CompanyId = companyId, CreatedBy = user?.Email }; await _unitOfWork.RecurringTemplates.AddAsync(template); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Recurring template \"{template.Name}\" created."; return RedirectToAction(nameof(Index)); } // ------------------------------------------------------------------------- // Edit // ------------------------------------------------------------------------- [HttpGet] public async Task Edit(int id) { var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id); if (template == null) return NotFound(); await PopulateDropDownsAsync(); return View(ToViewModel(template)); } /// /// Updates a recurring template. Re-serializes TemplateData from form values. /// NextFireDate is not touched here — only StartDate on first save drives it. /// [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(int id, RecurringTemplateViewModel vm) { if (id != vm.Id) return BadRequest(); if (!ModelState.IsValid) { await PopulateDropDownsAsync(); return View(vm); } var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id); if (template == null) return NotFound(); template.Name = vm.Name; template.TemplateType = vm.TemplateType; template.Frequency = vm.Frequency; template.IntervalCount = Math.Max(1, vm.IntervalCount); template.EndDate = vm.EndDate?.Date; template.MaxOccurrences = vm.MaxOccurrences > 0 ? vm.MaxOccurrences : null; template.TemplateData = BuildTemplateJson(vm); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Recurring template \"{template.Name}\" updated."; return RedirectToAction(nameof(Index)); } // ------------------------------------------------------------------------- // ToggleActive / Delete / GenerateNow // ------------------------------------------------------------------------- /// Pauses or resumes a recurring template. [HttpPost] [ValidateAntiForgeryToken] public async Task ToggleActive(int id) { var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id); if (template == null) return NotFound(); template.IsActive = !template.IsActive; await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Template \"{template.Name}\" is now {(template.IsActive ? "active" : "paused")}."; return RedirectToAction(nameof(Index)); } /// Soft-deletes a recurring template. [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) { var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id); if (template == null) return NotFound(); var name = template.Name; await _unitOfWork.RecurringTemplates.SoftDeleteAsync(id); await _unitOfWork.CompleteAsync(); TempData["Success"] = $"Template \"{name}\" deleted."; return RedirectToAction(nameof(Index)); } /// /// Forces immediate generation of the next occurrence and advances NextFireDate, /// regardless of the scheduled date. Useful for testing a new template or catching up /// after a configuration change. /// /// INTENTIONAL EXCEPTION to the no-DbContext-in-controllers rule: this action must /// execute the same multi-step entity creation that /// does in a background scope. Creating an inner DI scope here avoids duplicating that /// service's interface (which would require exposing a public synchronous Fire method on a /// singleton BackgroundService, which is unsafe). The scope is disposed after the action /// completes, so no DbContext leak occurs. This pattern mirrors how BackgroundService /// itself resolves scoped services. /// /// [HttpPost] [ValidateAntiForgeryToken] public async Task GenerateNow(int id) { var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id); if (template == null) return NotFound(); try { // Intentional: create a fresh DI scope so the inner DbContext is isolated from the // request's IUnitOfWork context. See summary above for rationale. using var scope = HttpContext.RequestServices.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); // Reload the tracked entity from this scope's DbContext var tracked = await db.RecurringTemplates.FindAsync(template.Id); if (tracked == null) return NotFound(); var svc = new RecurringTransactionService(null!, _logger as ILogger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); // Use reflection-free approach: call directly via internal method pattern await FireTemplateSynchronouslyAsync(db, tracked); TempData["Success"] = $"Template \"{template.Name}\" generated on demand. Next fire: {tracked.NextFireDate:MM/dd/yyyy}."; } catch (Exception ex) { _logger.LogError(ex, "GenerateNow failed for template {Id}.", id); TempData["Error"] = $"Generation failed: {ex.Message}"; } return RedirectToAction(nameof(Index)); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /// /// Inline implementation of the fire logic so GenerateNow doesn't need to instantiate /// the BackgroundService (which has a singleton scope that may not be accessible here). /// private static async Task FireTemplateSynchronouslyAsync( PowderCoating.Infrastructure.Data.ApplicationDbContext db, RecurringTemplate template) { if (template.TemplateType == RecurringTemplateType.Bill) { var data = JsonSerializer.Deserialize(template.TemplateData) ?? throw new InvalidOperationException("Invalid bill template data."); var prefix = $"BILL-{DateTime.Now:yyMM}-"; var lastBill = await db.Bills .IgnoreQueryFilters() .Where(b => b.BillNumber.StartsWith(prefix)) .OrderByDescending(b => b.BillNumber) .Select(b => b.BillNumber) .FirstOrDefaultAsync(); int nextN = 1; if (lastBill != null && int.TryParse(lastBill[prefix.Length..], out int ln)) nextN = ln + 1; var bill = new Bill { BillNumber = $"{prefix}{nextN:D4}", VendorId = data.VendorId, APAccountId = data.APAccountId, BillDate = DateTime.UtcNow, Status = BillStatus.Draft, Terms = data.Terms, Memo = $"[Recurring] {data.Memo}".Trim(), SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0, TaxPercent = data.TaxPercent, CompanyId = template.CompanyId, CreatedBy = "Recurring (on-demand)", 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(); 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 }); } } else { var data = JsonSerializer.Deserialize(template.TemplateData) ?? throw new InvalidOperationException("Invalid expense template data."); var prefix = $"EXP-{DateTime.Now:yyMM}-"; var lastExp = await db.Expenses .IgnoreQueryFilters() .Where(e => e.ExpenseNumber.StartsWith(prefix)) .OrderByDescending(e => e.ExpenseNumber) .Select(e => e.ExpenseNumber) .FirstOrDefaultAsync(); int nextN = 1; if (lastExp != null && int.TryParse(lastExp[prefix.Length..], out int ln)) nextN = ln + 1; db.Expenses.Add(new Expense { ExpenseNumber = $"{prefix}{nextN:D4}", 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 (on-demand)", CreatedAt = DateTime.UtcNow }); } template.OccurrenceCount++; template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount); template.LastError = null; if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value) template.IsActive = false; else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date) template.IsActive = false; await db.SaveChangesAsync(); } private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval) => 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) }; /// /// Serializes type-specific form fields to the TemplateData JSON blob. /// Only bill or expense fields are included — unused type's fields are discarded. /// private static string BuildTemplateJson(RecurringTemplateViewModel vm) { if (vm.TemplateType == RecurringTemplateType.Bill) { var data = new RecurringTransactionService.BillTemplateData( VendorId: vm.BillVendorId ?? 0, APAccountId: vm.APAccountId ?? 0, Terms: vm.BillTerms, Memo: vm.BillMemo, TaxPercent: vm.BillTaxPercent, LineItems: vm.LineItems.Select(l => new RecurringTransactionService.BillLineData( AccountId: l.AccountId, Description: l.Description ?? string.Empty, Quantity: l.Quantity == 0 ? 1 : l.Quantity, UnitPrice: l.UnitPrice)).ToList()); return JsonSerializer.Serialize(data); } else { var data = new RecurringTransactionService.ExpenseTemplateData( VendorId: vm.ExpenseVendorId ?? 0, ExpenseAccountId: vm.ExpenseAccountId ?? 0, PaymentAccountId: vm.PaymentAccountId ?? 0, PaymentMethod: (int)(vm.ExpensePaymentMethod ?? PaymentMethod.Cash), Amount: vm.ExpenseAmount, Memo: vm.ExpenseMemo); return JsonSerializer.Serialize(data); } } /// Converts a saved entity back to the form ViewModel. private static RecurringTemplateViewModel ToViewModel(RecurringTemplate t) { var vm = new RecurringTemplateViewModel { Id = t.Id, Name = t.Name, TemplateType = t.TemplateType, Frequency = t.Frequency, IntervalCount = t.IntervalCount, StartDate = t.NextFireDate, EndDate = t.EndDate, MaxOccurrences = t.MaxOccurrences }; if (t.TemplateType == RecurringTemplateType.Bill) { var data = JsonSerializer.Deserialize(t.TemplateData); if (data != null) { vm.BillVendorId = data.VendorId; vm.APAccountId = data.APAccountId; vm.BillTerms = data.Terms; vm.BillMemo = data.Memo; vm.BillTaxPercent = data.TaxPercent; vm.LineItems = (data.LineItems ?? []).Select(l => new LineItemInput { AccountId = l.AccountId, Description = l.Description, Quantity = l.Quantity, UnitPrice = l.UnitPrice }).ToList(); } } else { var data = JsonSerializer.Deserialize(t.TemplateData); if (data != null) { vm.ExpenseVendorId = data.VendorId == 0 ? null : data.VendorId; vm.ExpenseAccountId = data.ExpenseAccountId; vm.PaymentAccountId = data.PaymentAccountId; vm.ExpensePaymentMethod = (PaymentMethod)data.PaymentMethod; vm.ExpenseAmount = data.Amount; vm.ExpenseMemo = data.Memo; } } return vm; } /// Loads dropdowns for vendors, accounts, and payment methods into ViewBag. private async Task PopulateDropDownsAsync() { var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList(); var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); ViewBag.APAccounts = accounts .Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList(); ViewBag.ExpenseAccounts = accounts .Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList(); ViewBag.BankAccounts = accounts .Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.Cash or AccountSubType.CreditCard) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList(); ViewBag.AllExpenseAccounts = accounts .Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods or AccountType.Asset) .OrderBy(a => a.AccountNumber) .Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList(); ViewBag.PaymentMethods = Enum.GetValues() .Select(m => new SelectListItem(m.ToString(), ((int)m).ToString())).ToList(); } } // ------------------------------------------------------------------------- // ViewModel // ------------------------------------------------------------------------- /// Flat form model for Create and Edit. Type-specific sections are shown/hidden by JS. public class RecurringTemplateViewModel { public int Id { get; set; } [System.ComponentModel.DataAnnotations.Required] public string Name { get; set; } = string.Empty; public RecurringTemplateType TemplateType { get; set; } = RecurringTemplateType.Bill; public RecurringFrequency Frequency { get; set; } = RecurringFrequency.Monthly; public int IntervalCount { get; set; } = 1; public DateTime StartDate { get; set; } = DateTime.Today.AddDays(1); public DateTime? EndDate { get; set; } public int? MaxOccurrences { get; set; } // Bill-specific public int? BillVendorId { get; set; } public int? APAccountId { get; set; } public string? BillTerms { get; set; } public string? BillMemo { get; set; } public decimal BillTaxPercent { get; set; } public List LineItems { get; set; } = [new()]; // Expense-specific public int? ExpenseVendorId { get; set; } public int? ExpenseAccountId { get; set; } public int? PaymentAccountId { get; set; } public PaymentMethod? ExpensePaymentMethod { get; set; } = PaymentMethod.Cash; public decimal ExpenseAmount { get; set; } public string? ExpenseMemo { get; set; } } /// A single bill line item in the recurring template form. public class LineItemInput { public int? AccountId { get; set; } public string? Description { get; set; } public decimal Quantity { get; set; } = 1; public decimal UnitPrice { get; set; } }