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);
}
@@ -0,0 +1,491 @@
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;
/// <summary>
/// Manages recurring transaction templates. Each template is a saved recipe that the
/// <see cref="RecurringTransactionService"/> uses to auto-generate bills or expenses on a schedule.
/// Bills are created as Draft for user review; Expenses are recorded immediately.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class RecurringTemplatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<RecurringTemplatesController> _logger;
public RecurringTemplatesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ILogger<RecurringTemplatesController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_logger = logger;
}
// -------------------------------------------------------------------------
// Index
// -------------------------------------------------------------------------
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
}
// -------------------------------------------------------------------------
// Create
// -------------------------------------------------------------------------
[HttpGet]
public async Task<IActionResult> Create()
{
await PopulateDropDownsAsync();
return View(new RecurringTemplateViewModel { StartDate = DateTime.Today.AddDays(1) });
}
/// <summary>
/// Saves a new recurring template. Serializes the type-specific fields to JSON in TemplateData.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> Edit(int id)
{
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
await PopulateDropDownsAsync();
return View(ToViewModel(template));
}
/// <summary>
/// Updates a recurring template. Re-serializes TemplateData from form values.
/// NextFireDate is not touched here — only StartDate on first save drives it.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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
// -------------------------------------------------------------------------
/// <summary>Pauses or resumes a recurring template.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>Soft-deletes a recurring template.</summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateNow(int id)
{
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
try
{
using var scope = HttpContext.RequestServices.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PowderCoating.Infrastructure.Data.ApplicationDbContext>();
// 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<RecurringTransactionService>
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RecurringTransactionService>.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
// -------------------------------------------------------------------------
/// <summary>
/// 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).
/// </summary>
private static async Task FireTemplateSynchronouslyAsync(
PowderCoating.Infrastructure.Data.ApplicationDbContext db,
RecurringTemplate template)
{
if (template.TemplateType == RecurringTemplateType.Bill)
{
var data = JsonSerializer.Deserialize<RecurringTransactionService.BillTemplateData>(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<RecurringTransactionService.ExpenseTemplateData>(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)
};
/// <summary>
/// Serializes type-specific form fields to the TemplateData JSON blob.
/// Only bill or expense fields are included — unused type's fields are discarded.
/// </summary>
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);
}
}
/// <summary>Converts a saved <see cref="RecurringTemplate"/> entity back to the form ViewModel.</summary>
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<RecurringTransactionService.BillTemplateData>(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<RecurringTransactionService.ExpenseTemplateData>(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;
}
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync()
{
var vendors = await _unitOfWork.Vendors.GetAllAsync();
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
var accounts = await _unitOfWork.Accounts.GetAllAsync();
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<PaymentMethod>()
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString())).ToList();
}
}
// -------------------------------------------------------------------------
// ViewModel
// -------------------------------------------------------------------------
/// <summary>Flat form model for Create and Edit. Type-specific sections are shown/hidden by JS.</summary>
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<LineItemInput> 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; }
}
/// <summary>A single bill line item in the recurring template form.</summary>
public class LineItemInput
{
public int? AccountId { get; set; }
public string? Description { get; set; }
public decimal Quantity { get; set; } = 1;
public decimal UnitPrice { get; set; }
}
+1
View File
@@ -239,6 +239,7 @@ builder.Services.AddHostedService<SubscriptionExpiryBackgroundService>();
builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -0,0 +1,251 @@
@model PowderCoating.Web.Controllers.RecurringTemplateViewModel
@using PowderCoating.Core.Enums
@using Microsoft.AspNetCore.Mvc.Rendering
@{
ViewData["Title"] = "New Recurring Template";
}
<div class="d-flex align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary me-3"><i class="bi bi-arrow-left"></i></a>
<div>
<h4 class="fw-bold mb-0">New Recurring Template</h4>
<p class="text-muted small mb-0">Schedule automatic bill or expense generation.</p>
</div>
</div>
<form asp-action="Create" method="post" id="recurringForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div class="row g-4">
<!-- Left column: schedule settings -->
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-calendar3 me-2"></i>Schedule</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Name" class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" placeholder="e.g. Monthly Office Rent" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Type</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="TemplateType" id="typeBill"
value="1" @(Model.TemplateType == RecurringTemplateType.Bill ? "checked" : "") />
<label class="form-check-label" for="typeBill">
<i class="bi bi-receipt me-1 text-primary"></i>Bill (Draft, for review)
</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="TemplateType" id="typeExpense"
value="2" @(Model.TemplateType == RecurringTemplateType.Expense ? "checked" : "") />
<label class="form-check-label" for="typeExpense">
<i class="bi bi-credit-card me-1 text-warning"></i>Expense (Immediate)
</label>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-7">
<label asp-for="Frequency" class="form-label fw-semibold">Frequency</label>
<select asp-for="Frequency" asp-items="Html.GetEnumSelectList<RecurringFrequency>()" class="form-select"></select>
</div>
<div class="col-5">
<label asp-for="IntervalCount" class="form-label fw-semibold">Every</label>
<div class="input-group">
<input asp-for="IntervalCount" type="number" min="1" max="99" class="form-control" />
<span class="input-group-text">period(s)</span>
</div>
</div>
</div>
<div class="mb-3">
<label asp-for="StartDate" class="form-label fw-semibold">First Occurrence</label>
<input asp-for="StartDate" type="date" class="form-control"
value="@Model.StartDate.ToString("yyyy-MM-dd")" />
</div>
<div class="mb-3">
<label asp-for="EndDate" class="form-label fw-semibold">End Date <span class="text-muted">(optional)</span></label>
<input asp-for="EndDate" type="date" class="form-control"
value="@Model.EndDate?.ToString("yyyy-MM-dd")" />
<div class="form-text">Leave blank for no end date.</div>
</div>
<div class="mb-3">
<label asp-for="MaxOccurrences" class="form-label fw-semibold">Max Occurrences <span class="text-muted">(optional)</span></label>
<input asp-for="MaxOccurrences" type="number" min="1" class="form-control" placeholder="Unlimited" />
</div>
</div>
</div>
</div>
<!-- Right column: document details -->
<div class="col-lg-7">
<!-- Bill section -->
<div id="billSection" class="card shadow-sm mb-3">
<div class="card-header fw-semibold"><i class="bi bi-receipt me-2 text-primary"></i>Bill Details</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Vendor</label>
<select asp-for="BillVendorId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.Vendors)"
class="form-select">
<option value="">— Select vendor —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">AP Account</label>
<select asp-for="APAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.APAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Terms</label>
<select asp-for="BillTerms" class="form-select">
<option value="">— None —</option>
<option>Net 15</option>
<option>Net 30</option>
<option>Net 45</option>
<option>Net 60</option>
<option>Due on Receipt</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Tax %</label>
<div class="input-group">
<input asp-for="BillTaxPercent" type="number" step="0.01" min="0" class="form-control" />
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Memo / Description</label>
<input asp-for="BillMemo" class="form-control" placeholder="e.g. Monthly office rent" />
</div>
<!-- Line items -->
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-semibold small">Line Items</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="addBillLine()">
<i class="bi bi-plus"></i> Add Line
</button>
</div>
<div id="billLines">
@for (int i = 0; i < Model.LineItems.Count; i++)
{
<div class="row g-2 mb-2 bill-line">
<div class="col-4">
<select name="LineItems[@i].AccountId" class="form-select form-select-sm">
<option value="">— Account —</option>
@foreach (var a in (IEnumerable<SelectListItem>)ViewBag.AllExpenseAccounts)
{
if (a.Value == Model.LineItems[i].AccountId?.ToString())
{
<option value="@a.Value" selected="selected">@a.Text</option>
}
else
{
<option value="@a.Value">@a.Text</option>
}
}
</select>
</div>
<div class="col-4">
<input name="LineItems[@i].Description" type="text" class="form-control form-control-sm"
placeholder="Description" value="@Model.LineItems[i].Description" />
</div>
<div class="col-1">
<input name="LineItems[@i].Quantity" type="number" step="0.01" min="0" class="form-control form-control-sm"
placeholder="Qty" value="@Model.LineItems[i].Quantity" />
</div>
<div class="col-2">
<input name="LineItems[@i].UnitPrice" type="number" step="0.01" min="0" class="form-control form-control-sm"
placeholder="Price" value="@Model.LineItems[i].UnitPrice" />
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.bill-line').remove()">
<i class="bi bi-x"></i>
</button>
</div>
</div>
}
</div>
</div>
</div>
<!-- Expense section -->
<div id="expenseSection" class="card shadow-sm mb-3 d-none">
<div class="card-header fw-semibold"><i class="bi bi-credit-card me-2 text-warning"></i>Expense Details</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Vendor <span class="text-muted">(optional)</span></label>
<select asp-for="ExpenseVendorId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.Vendors)"
class="form-select">
<option value="">— None —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Amount</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="ExpenseAmount" type="number" step="0.01" min="0" class="form-control" />
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Expense Account</label>
<select asp-for="ExpenseAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.ExpenseAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Payment Account</label>
<select asp-for="PaymentAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.BankAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Payment Method</label>
<select asp-for="ExpensePaymentMethod" asp-items="@((IEnumerable<SelectListItem>)ViewBag.PaymentMethods)"
class="form-select"></select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Memo</label>
<input asp-for="ExpenseMemo" class="form-control" placeholder="e.g. Monthly internet" />
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save Template</button>
</div>
</div>
</div>
</form>
<script src="~/js/recurring-template-form.js"></script>
@{
var accountsJson = System.Text.Json.JsonSerializer.Serialize(
((IEnumerable<SelectListItem>)ViewBag.AllExpenseAccounts)
.Select(a => new { value = a.Value, text = a.Text }));
}
<script>
window.allExpenseAccounts = @Html.Raw(accountsJson);
initRecurringForm(@((int)Model.TemplateType));
</script>
@@ -0,0 +1,250 @@
@model PowderCoating.Web.Controllers.RecurringTemplateViewModel
@using PowderCoating.Core.Enums
@using Microsoft.AspNetCore.Mvc.Rendering
@{
ViewData["Title"] = "Edit Recurring Template";
}
<div class="d-flex align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary me-3"><i class="bi bi-arrow-left"></i></a>
<div>
<h4 class="fw-bold mb-0">Edit: @Model.Name</h4>
<p class="text-muted small mb-0">Changes take effect on the next scheduled fire.</p>
</div>
</div>
<form asp-action="Edit" method="post" id="recurringForm">
@Html.AntiForgeryToken()
<input type="hidden" asp-for="Id" />
<div class="row g-4">
<!-- Left column: schedule settings -->
<div class="col-lg-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-calendar3 me-2"></i>Schedule</div>
<div class="card-body">
<div class="mb-3">
<label asp-for="Name" class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger small"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Type</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="TemplateType" id="typeBill"
value="1" @(Model.TemplateType == RecurringTemplateType.Bill ? "checked" : "") />
<label class="form-check-label" for="typeBill">
<i class="bi bi-receipt me-1 text-primary"></i>Bill
</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="TemplateType" id="typeExpense"
value="2" @(Model.TemplateType == RecurringTemplateType.Expense ? "checked" : "") />
<label class="form-check-label" for="typeExpense">
<i class="bi bi-credit-card me-1 text-warning"></i>Expense
</label>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-7">
<label asp-for="Frequency" class="form-label fw-semibold">Frequency</label>
<select asp-for="Frequency" asp-items="Html.GetEnumSelectList<RecurringFrequency>()" class="form-select"></select>
</div>
<div class="col-5">
<label asp-for="IntervalCount" class="form-label fw-semibold">Every</label>
<div class="input-group">
<input asp-for="IntervalCount" type="number" min="1" max="99" class="form-control" />
<span class="input-group-text">period(s)</span>
</div>
</div>
</div>
<div class="mb-3">
<label asp-for="StartDate" class="form-label fw-semibold">Next Fire Date</label>
<input asp-for="StartDate" type="date" class="form-control"
value="@Model.StartDate.ToString("yyyy-MM-dd")" />
<div class="form-text">Editing this advances or delays the next occurrence.</div>
</div>
<div class="mb-3">
<label asp-for="EndDate" class="form-label fw-semibold">End Date <span class="text-muted">(optional)</span></label>
<input asp-for="EndDate" type="date" class="form-control"
value="@Model.EndDate?.ToString("yyyy-MM-dd")" />
</div>
<div class="mb-3">
<label asp-for="MaxOccurrences" class="form-label fw-semibold">Max Occurrences <span class="text-muted">(optional)</span></label>
<input asp-for="MaxOccurrences" type="number" min="1" class="form-control" placeholder="Unlimited" />
</div>
</div>
</div>
</div>
<!-- Right column -->
<div class="col-lg-7">
<!-- Bill section -->
<div id="billSection" class="card shadow-sm mb-3">
<div class="card-header fw-semibold"><i class="bi bi-receipt me-2 text-primary"></i>Bill Details</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Vendor</label>
<select asp-for="BillVendorId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.Vendors)"
class="form-select">
<option value="">— Select vendor —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">AP Account</label>
<select asp-for="APAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.APAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Terms</label>
<select asp-for="BillTerms" class="form-select">
<option value="">— None —</option>
<option>Net 15</option>
<option>Net 30</option>
<option>Net 45</option>
<option>Net 60</option>
<option>Due on Receipt</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Tax %</label>
<div class="input-group">
<input asp-for="BillTaxPercent" type="number" step="0.01" min="0" class="form-control" />
<span class="input-group-text">%</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Memo</label>
<input asp-for="BillMemo" class="form-control" />
</div>
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="fw-semibold small">Line Items</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="addBillLine()">
<i class="bi bi-plus"></i> Add Line
</button>
</div>
<div id="billLines">
@for (int i = 0; i < Model.LineItems.Count; i++)
{
<div class="row g-2 mb-2 bill-line">
<div class="col-4">
<select name="LineItems[@i].AccountId" class="form-select form-select-sm">
<option value="">— Account —</option>
@foreach (var a in (IEnumerable<SelectListItem>)ViewBag.AllExpenseAccounts)
{
if (a.Value == Model.LineItems[i].AccountId?.ToString())
{
<option value="@a.Value" selected="selected">@a.Text</option>
}
else
{
<option value="@a.Value">@a.Text</option>
}
}
</select>
</div>
<div class="col-4">
<input name="LineItems[@i].Description" type="text" class="form-control form-control-sm"
placeholder="Description" value="@Model.LineItems[i].Description" />
</div>
<div class="col-1">
<input name="LineItems[@i].Quantity" type="number" step="0.01" min="0" class="form-control form-control-sm"
placeholder="Qty" value="@Model.LineItems[i].Quantity" />
</div>
<div class="col-2">
<input name="LineItems[@i].UnitPrice" type="number" step="0.01" min="0" class="form-control form-control-sm"
placeholder="Price" value="@Model.LineItems[i].UnitPrice" />
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="this.closest('.bill-line').remove()">
<i class="bi bi-x"></i>
</button>
</div>
</div>
}
</div>
</div>
</div>
<!-- Expense section -->
<div id="expenseSection" class="card shadow-sm mb-3 d-none">
<div class="card-header fw-semibold"><i class="bi bi-credit-card me-2 text-warning"></i>Expense Details</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Vendor <span class="text-muted">(optional)</span></label>
<select asp-for="ExpenseVendorId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.Vendors)"
class="form-select">
<option value="">— None —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Amount</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="ExpenseAmount" type="number" step="0.01" min="0" class="form-control" />
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Expense Account</label>
<select asp-for="ExpenseAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.ExpenseAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Payment Account</label>
<select asp-for="PaymentAccountId" asp-items="@((IEnumerable<SelectListItem>)ViewBag.BankAccounts)"
class="form-select">
<option value="">— Select —</option>
</select>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">Payment Method</label>
<select asp-for="ExpensePaymentMethod" asp-items="@((IEnumerable<SelectListItem>)ViewBag.PaymentMethods)"
class="form-select"></select>
</div>
<div class="col-6">
<label class="form-label fw-semibold">Memo</label>
<input asp-for="ExpenseMemo" class="form-control" />
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Save Changes</button>
</div>
</div>
</div>
</form>
<script src="~/js/recurring-template-form.js"></script>
@{
var accountsJson = System.Text.Json.JsonSerializer.Serialize(
((IEnumerable<SelectListItem>)ViewBag.AllExpenseAccounts)
.Select(a => new { value = a.Value, text = a.Text }));
}
<script>
window.allExpenseAccounts = @Html.Raw(accountsJson);
initRecurringForm(@((int)Model.TemplateType));
</script>
@@ -0,0 +1,160 @@
@model List<PowderCoating.Core.Entities.RecurringTemplate>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Recurring Transactions";
}
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h4 class="fw-bold mb-0"><i class="bi bi-arrow-repeat me-2 text-primary"></i>Recurring Transactions</h4>
<p class="text-muted small mb-0">Templates that auto-generate bills or expenses on a schedule.</p>
</div>
<a asp-action="Create" class="btn btn-primary"><i class="bi bi-plus-lg me-1"></i>New Template</a>
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent alert-dismissible fade show">
<i class="bi bi-check-circle-fill me-2"></i>@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent alert-dismissible fade show">
<i class="bi bi-exclamation-triangle-fill me-2"></i>@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!Model.Any())
{
<div class="text-center py-5">
<i class="bi bi-arrow-repeat display-4 text-muted"></i>
<h5 class="mt-3 text-muted">No recurring templates yet</h5>
<p class="text-muted">Create a template to automatically generate bills or expenses on a schedule.</p>
<a asp-action="Create" class="btn btn-primary mt-2"><i class="bi bi-plus-lg me-1"></i>Create Template</a>
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Frequency</th>
<th>Next Fire</th>
<th>Occurrences</th>
<th>Status</th>
<th>Last Error</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model)
{
<tr class="@(t.IsActive ? "" : "table-secondary text-muted")">
<td class="fw-semibold">@t.Name</td>
<td>
@if (t.TemplateType == RecurringTemplateType.Bill)
{
<span class="badge bg-primary-subtle text-primary"><i class="bi bi-receipt me-1"></i>Bill</span>
}
else
{
<span class="badge bg-warning-subtle text-warning"><i class="bi bi-credit-card me-1"></i>Expense</span>
}
</td>
<td>
@{
var freqLabel = t.IntervalCount == 1
? t.Frequency.ToString()
: $"Every {t.IntervalCount} × {t.Frequency}";
}
<span class="text-body-secondary small">@freqLabel</span>
</td>
<td>
@if (t.IsActive)
{
var isOverdue = t.NextFireDate.Date < DateTime.Today;
<span class="@(isOverdue ? "text-danger fw-semibold" : "")">
@t.NextFireDate.ToString("MM/dd/yyyy")
@if (isOverdue) { <i class="bi bi-exclamation-circle ms-1"></i> }
</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary">@t.OccurrenceCount</span>
@if (t.MaxOccurrences.HasValue)
{
<span class="text-muted small"> / @t.MaxOccurrences</span>
}
</td>
<td>
@if (t.IsActive)
{
<span class="badge bg-success"><i class="bi bi-play-fill me-1"></i>Active</span>
}
else
{
<span class="badge bg-secondary"><i class="bi bi-pause-fill me-1"></i>Paused</span>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(t.LastError))
{
<span class="text-danger small" title="@t.LastError" data-bs-toggle="tooltip">
<i class="bi bi-exclamation-triangle-fill me-1"></i>Error
</span>
}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<a asp-action="Edit" asp-route-id="@t.Id" class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form asp-action="ToggleActive" asp-route-id="@t.Id" method="post">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm @(t.IsActive ? "btn-outline-warning" : "btn-outline-success")"
title="@(t.IsActive ? "Pause" : "Resume")">
<i class="bi @(t.IsActive ? "bi-pause" : "bi-play")"></i>
</button>
</form>
@if (t.IsActive)
{
<form asp-action="GenerateNow" asp-route-id="@t.Id" method="post"
onsubmit="return confirm('Generate one occurrence of this template now?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-primary" title="Generate Now">
<i class="bi bi-lightning-charge"></i>
</button>
</form>
}
<form asp-action="Delete" asp-route-id="@t.Id" method="post"
onsubmit="return confirm('Delete this recurring template? Generated documents will not be affected.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-muted small mt-2">
<i class="bi bi-info-circle me-1"></i>
The background service checks hourly and auto-generates due templates.
Bills are created as Draft; Expenses are recorded immediately.
<i class="bi bi-lightning-charge ms-2 text-primary"></i> Generate Now fires one occurrence immediately.
</p>
}
@@ -1142,6 +1142,10 @@
<i class="bi bi-bank2"></i>
<span>Bank Reconciliation</span>
</a>
<a asp-controller="RecurringTemplates" asp-action="Index" class="nav-link">
<i class="bi bi-arrow-repeat"></i>
<span>Recurring Transactions</span>
</a>
if (hasReports)
{
<a asp-controller="AccountingExport" asp-action="Index" class="nav-link">
@@ -0,0 +1,56 @@
// Recurring template create/edit form — type toggle + dynamic bill line items
function initRecurringForm(initialType) {
const radios = document.querySelectorAll('input[name="TemplateType"]');
radios.forEach(r => r.addEventListener('change', () => updateSections(parseInt(r.value))));
updateSections(initialType);
}
function updateSections(type) {
const billSection = document.getElementById('billSection');
const expSection = document.getElementById('expenseSection');
if (type === 1) {
billSection.classList.remove('d-none');
expSection.classList.add('d-none');
} else {
billSection.classList.add('d-none');
expSection.classList.remove('d-none');
}
}
function addBillLine() {
const container = document.getElementById('billLines');
const idx = container.querySelectorAll('.bill-line').length;
const accountOptions = (window.allExpenseAccounts || [])
.map(a => `<option value="${a.value}">${a.text}</option>`)
.join('');
const html = `
<div class="row g-2 mb-2 bill-line">
<div class="col-4">
<select name="LineItems[${idx}].AccountId" class="form-select form-select-sm">
<option value="">— Account —</option>${accountOptions}
</select>
</div>
<div class="col-4">
<input name="LineItems[${idx}].Description" type="text" class="form-control form-control-sm"
placeholder="Description" />
</div>
<div class="col-1">
<input name="LineItems[${idx}].Quantity" type="number" step="0.01" min="0"
class="form-control form-control-sm" placeholder="Qty" value="1" />
</div>
<div class="col-2">
<input name="LineItems[${idx}].UnitPrice" type="number" step="0.01" min="0"
class="form-control form-control-sm" placeholder="Price" />
</div>
<div class="col-1">
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="this.closest('.bill-line').remove()">
<i class="bi bi-x"></i>
</button>
</div>
</div>`;
container.insertAdjacentHTML('beforeend', html);
}