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
@@ -286,6 +286,38 @@ public class VendorCreditApplication : BaseEntity
public virtual Bill Bill { get; set; } = null!;
}
/// <summary>
/// A saved recipe for a document that should be automatically created on a recurring schedule.
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
/// <para>
/// Bills are created as Draft so the user can review before posting.
/// Expenses are created immediately (already-paid transactions).
/// </para>
/// Numbering: REC-YYMM-####
/// </summary>
public class RecurringTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public RecurringTemplateType TemplateType { get; set; }
public RecurringFrequency Frequency { get; set; }
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
public int IntervalCount { get; set; } = 1;
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
public DateTime NextFireDate { get; set; }
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
public int? MaxOccurrences { get; set; }
/// <summary>How many documents have been generated so far.</summary>
public int OccurrenceCount { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
public string TemplateData { get; set; } = "{}";
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
public string? LastError { get; set; }
}
/// <summary>
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
/// invoices when a taxable customer is selected. Companies can define multiple rates for
@@ -94,6 +94,26 @@ public enum VendorCreditStatus
Voided = 3
}
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
public enum RecurringTemplateType
{
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
Bill = 1,
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
Expense = 2
}
/// <summary>How often a recurring template fires.</summary>
public enum RecurringFrequency
{
Daily = 1,
Weekly = 2,
BiWeekly = 3,
Monthly = 4,
Quarterly = 5,
Annually = 6
}
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
public enum JournalEntryStatus
{
@@ -106,6 +106,9 @@ public interface IUnitOfWork : IDisposable
// Tax Rates
IRepository<TaxRate> TaxRates { get; }
// Recurring Transactions
IRepository<RecurringTemplate> RecurringTemplates { get; }
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
INotificationLogRepository NotificationLogs { get; }
IRepository<NotificationTemplate> NotificationTemplates { get; }
@@ -335,6 +335,9 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
public DbSet<TaxRate> TaxRates { get; set; }
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
public DbSet<VendorCredit> VendorCredits { get; set; }
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
@@ -645,6 +648,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Recurring Templates: tenant-filtered
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Vendor Credits: tenant-filtered; child rows soft-delete only
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRecurringTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications");
migrationBuilder.AddColumn<int>(
name: "VendorCreditId1",
table: "VendorCreditApplications",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "RecurringTemplates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
TemplateType = table.Column<int>(type: "int", nullable: false),
Frequency = table.Column<int>(type: "int", nullable: false),
IntervalCount = table.Column<int>(type: "int", nullable: false),
NextFireDate = table.Column<DateTime>(type: "datetime2", nullable: false),
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
MaxOccurrences = table.Column<int>(type: "int", nullable: true),
OccurrenceCount = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
TemplateData = table.Column<string>(type: "nvarchar(max)", nullable: false),
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RecurringTemplates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
migrationBuilder.CreateIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications",
column: "BillId",
principalTable: "Bills",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications",
column: "VendorCreditId",
principalTable: "VendorCredits",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications",
column: "VendorCreditId1",
principalTable: "VendorCredits",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications");
migrationBuilder.DropForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropTable(
name: "RecurringTemplates");
migrationBuilder.DropIndex(
name: "IX_VendorCreditApplications_VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.DropColumn(
name: "VendorCreditId1",
table: "VendorCreditApplications");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_Bills_BillId",
table: "VendorCreditApplications",
column: "BillId",
principalTable: "Bills",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
table: "VendorCreditApplications",
column: "VendorCreditId",
principalTable: "VendorCredits",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -6293,7 +6293,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903),
CreatedAt = new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6304,7 +6304,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909),
CreatedAt = new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6315,7 +6315,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910),
CreatedAt = new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7272,6 +7272,78 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("QuoteStatusLookups");
});
modelBuilder.Entity("PowderCoating.Core.Entities.RecurringTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("EndDate")
.HasColumnType("datetime2");
b.Property<int>("Frequency")
.HasColumnType("int");
b.Property<int>("IntervalCount")
.HasColumnType("int");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LastError")
.HasColumnType("nvarchar(max)");
b.Property<int?>("MaxOccurrences")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("NextFireDate")
.HasColumnType("datetime2");
b.Property<int>("OccurrenceCount")
.HasColumnType("int");
b.Property<string>("TemplateData")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("TemplateType")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("RecurringTemplates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b =>
{
b.Property<int>("Id")
@@ -8099,12 +8171,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("VendorCreditId")
.HasColumnType("int");
b.Property<int?>("VendorCreditId1")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("BillId");
b.HasIndex("VendorCreditId");
b.HasIndex("VendorCreditId1");
b.ToTable("VendorCreditApplications");
});
@@ -9802,15 +9879,19 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
.WithMany()
.HasForeignKey("BillId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
.WithMany("Applications")
.WithMany()
.HasForeignKey("VendorCreditId")
.OnDelete(DeleteBehavior.Cascade)
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.VendorCredit", null)
.WithMany("Applications")
.HasForeignKey("VendorCreditId1");
b.Navigation("Bill");
b.Navigation("VendorCredit");
@@ -157,6 +157,9 @@ public class UnitOfWork : IUnitOfWork
// Tax Rates
private IRepository<TaxRate>? _taxRates;
// Recurring Transactions
private IRepository<RecurringTemplate>? _recurringTemplates;
/// <summary>
/// Initialises the unit of work with the scoped <paramref name="context"/>.
/// The context is shared across all repositories created by this instance so that
@@ -560,6 +563,11 @@ public class UnitOfWork : IUnitOfWork
public IRepository<TaxRate> TaxRates =>
_taxRates ??= new Repository<TaxRate>(_context);
// Recurring Transactions
/// <summary>Repository for <see cref="RecurringTemplate"/> — saved recipes that auto-generate bills or expenses on a schedule.</summary>
public IRepository<RecurringTemplate> RecurringTemplates =>
_recurringTemplates ??= new Repository<RecurringTemplate>(_context);
/// <summary>
/// Flushes all pending changes in the EF Core change tracker to the database.
/// Returns the number of state entries written.
@@ -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);
}