8acbc8605d
Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
22 KiB
C#
505 lines
22 KiB
C#
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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
|
|
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Create
|
|
// -------------------------------------------------------------------------
|
|
|
|
[HttpGet]
|
|
public async Task<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.
|
|
/// <para>
|
|
/// INTENTIONAL EXCEPTION to the no-DbContext-in-controllers rule: this action must
|
|
/// execute the same multi-step entity creation that <see cref="RecurringTransactionService"/>
|
|
/// does in a background scope. Creating an inner DI scope here avoids duplicating that
|
|
/// service's interface (which would require exposing a public synchronous Fire method on a
|
|
/// singleton BackgroundService, which is unsafe). The scope is disposed after the action
|
|
/// completes, so no DbContext leak occurs. This pattern mirrors how BackgroundService
|
|
/// itself resolves scoped services.
|
|
/// </para>
|
|
/// </summary>
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> GenerateNow(int id)
|
|
{
|
|
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
|
if (template == null) return NotFound();
|
|
|
|
try
|
|
{
|
|
// Intentional: create a fresh DI scope so the inner DbContext is isolated from the
|
|
// request's IUnitOfWork context. See summary above for rationale.
|
|
using var scope = HttpContext.RequestServices.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<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 companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
|
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
|
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
|
|
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
|
ViewBag.APAccounts = accounts
|
|
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
|
.OrderBy(a => a.AccountNumber)
|
|
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
|
|
|
ViewBag.ExpenseAccounts = accounts
|
|
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods)
|
|
.OrderBy(a => a.AccountNumber)
|
|
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
|
|
|
ViewBag.BankAccounts = accounts
|
|
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
|
|
or AccountSubType.Cash or AccountSubType.CreditCard)
|
|
.OrderBy(a => a.AccountNumber)
|
|
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
|
|
|
ViewBag.AllExpenseAccounts = accounts
|
|
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods or AccountType.Asset)
|
|
.OrderBy(a => a.AccountNumber)
|
|
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
|
|
|
ViewBag.PaymentMethods = Enum.GetValues<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; }
|
|
}
|