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