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; }
}