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:
@@ -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));
|
||||
|
||||
Generated
+10186
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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user