Compare commits
41 Commits
61866e1d1e
...
27bfd4db4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd | |||
| 4ca90f561e | |||
| f95397204c | |||
| 31d305b66a | |||
| 42a8c089d5 | |||
| 2c353f2e7f | |||
| c02a5584b4 | |||
| 17da692dce | |||
| 656f830898 | |||
| dde66c807f | |||
| feff0fa73d | |||
| 59beba2e15 | |||
| 959e323f3a | |||
| e2f9e9ae4f | |||
| 328b195127 | |||
| f6d457fe0e | |||
| c65445b94e | |||
| ccb094e57a | |||
| 0204430fa5 | |||
| 4fd9c52aaf | |||
| fde24b09c9 | |||
| a255893ada | |||
| d94612cc9c | |||
| 14026818e2 | |||
| 42eff3357e | |||
| d3a5d827f9 | |||
| 1229081436 | |||
| cf9dcfb4c1 | |||
| a33687f7bd | |||
| 0afb474c3e | |||
| 7e1676cfd7 | |||
| 379b0de885 | |||
| edd7389d7d |
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
||||
public string? RecommendedAction { get; set; }
|
||||
public string? BillNumber { get; set; }
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||
|
||||
public class BankRecMatchItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||
public int EntityId { get; set; }
|
||||
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||
}
|
||||
|
||||
public class AutoMatchRequest
|
||||
{
|
||||
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal StatementEndingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class AutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; } // 0.0–1.0
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AutoMatchResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||
public class ClaudeAutoMatchResponse
|
||||
{
|
||||
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeAutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||
|
||||
public class OpenInvoiceSummary
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string? DueDateIso { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
public class LatePaymentCustomerData
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public decimal TotalOwed { get; set; }
|
||||
public double AvgDaysToPay { get; set; } // historical average
|
||||
public int TotalInvoicesAllTime { get; set; }
|
||||
public int LateInvoicesAllTime { get; set; }
|
||||
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||
public class ClaudeLatePaymentResponse
|
||||
{
|
||||
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeLatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||
|
||||
public class MonthlyFinancialSummary
|
||||
{
|
||||
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||
public decimal Revenue { get; set; }
|
||||
public decimal Expenses { get; set; }
|
||||
public decimal NetIncome { get; set; }
|
||||
}
|
||||
|
||||
public class FinancialQueryContext
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string AsOfDate { get; set; } = string.Empty;
|
||||
public decimal TotalRevenueYtd { get; set; }
|
||||
public decimal TotalExpensesYtd { get; set; }
|
||||
public decimal NetIncomeYtd { get; set; }
|
||||
public decimal ArOutstanding { get; set; }
|
||||
public decimal ApOutstanding { get; set; }
|
||||
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryRequest
|
||||
{
|
||||
public string Question { get; set; } = string.Empty;
|
||||
public FinancialQueryContext Context { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||
public class ClaudeFinancialQueryResponse
|
||||
{
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||
|
||||
public class RecurringBillHistoryItem
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string DateIso { get; set; } = string.Empty;
|
||||
public string? Memo { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecurringBillPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||
public class ClaudeRecurringBillResponse
|
||||
{
|
||||
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeRecurringPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,158 @@ using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Accounting;
|
||||
|
||||
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Cash Flow Statement ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
|
||||
/// Investing and Financing sections contain line items derived from account-level changes.
|
||||
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
|
||||
/// </summary>
|
||||
public class CashFlowStatementDto
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public AccountingMethod Method { get; set; }
|
||||
|
||||
// ── Operating (direct / cash method) ───────────────────────────────────
|
||||
/// <summary>Customer invoice payments received in the period.</summary>
|
||||
public decimal CashFromCustomers { get; set; }
|
||||
/// <summary>Vendor bill payments made in the period.</summary>
|
||||
public decimal CashToVendors { get; set; }
|
||||
/// <summary>Direct expense payments made in the period (not via bills).</summary>
|
||||
public decimal CashForExpenses { get; set; }
|
||||
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
|
||||
|
||||
// ── Investing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
|
||||
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Financing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
|
||||
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
public decimal BeginningCash { get; set; }
|
||||
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
|
||||
public decimal EndingCash => BeginningCash + NetChangeInCash;
|
||||
}
|
||||
|
||||
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
|
||||
public class CashFlowLineDto
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
public List<ApAgingVendorDto> Vendors { get; set; } = new();
|
||||
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingVendorDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public List<ApAgingBillDto> Bills { get; set; } = new();
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingBillDto
|
||||
{
|
||||
public int BillId { get; set; }
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class TrialBalanceDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<TrialBalanceLine> Lines { get; set; } = new();
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
|
||||
}
|
||||
|
||||
public class TrialBalanceLine
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
public decimal DebitBalance { get; set; }
|
||||
public decimal CreditBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class ProfitAndLossDto
|
||||
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
||||
public decimal TotalRevenue { get; set; }
|
||||
@@ -40,6 +193,7 @@ public class BalanceSheetDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
// Assets
|
||||
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
||||
|
||||
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public IEnumerable<T> Items { get; set; } = new List<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
|
||||
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
|
||||
/// are copied from the grid so the model carries full state for view binding.
|
||||
/// </summary>
|
||||
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = grid.PageNumber,
|
||||
PageSize = grid.PageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = grid.SortColumn,
|
||||
SortDirection = grid.SortDirection,
|
||||
SearchTerm = grid.SearchTerm
|
||||
};
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
||||
public bool WizardCompleted { get; set; }
|
||||
public DateTime? WizardCompletedAt { get; set; }
|
||||
public string? WizardCompletedByName { get; set; }
|
||||
|
||||
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||
public int HealthScore { get; set; }
|
||||
public string HealthRisk { get; set; } = "Healthy";
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? TimeZone { get; set; }
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
public bool HasLogo { get; set; }
|
||||
|
||||
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
|
||||
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
|
||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||
}
|
||||
|
||||
// At least one contact method is required (Email OR Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
||||
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Please provide at least one contact method (Email or Phone)",
|
||||
new[] { nameof(Email), nameof(Phone) });
|
||||
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||
}
|
||||
|
||||
// Validate each address in comma-separated email fields
|
||||
|
||||
@@ -82,6 +82,10 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||
public PaymentMethod RefundMethod { get; set; }
|
||||
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? Reference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
||||
public bool CanManageMaintenance { get; set; }
|
||||
public bool CanManageInvoices { get; set; }
|
||||
public bool CanViewReports { get; set; }
|
||||
public bool CanManageBills { get; set; }
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
|
||||
[Display(Name = "Send Welcome Email")]
|
||||
public bool SendWelcomeEmail { get; set; } = true;
|
||||
}
|
||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
||||
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ public class CreateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; } = false;
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; }
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; }
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
||||
/// Returns a ranked list of flagged items with recommended actions.
|
||||
/// </summary>
|
||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
|
||||
/// a statement. Returns a ranked list of suggestions with confidence scores based on
|
||||
/// amount/date patterns and the gap between the current cleared balance and the
|
||||
/// statement ending balance.
|
||||
/// </summary>
|
||||
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Predicts likelihood of late payment for each open AR customer using their historical
|
||||
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
|
||||
/// Returns risk levels (high/medium/low) and estimated days to collection.
|
||||
/// </summary>
|
||||
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
|
||||
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
|
||||
/// and an optional follow-up question suggestion.
|
||||
/// </summary>
|
||||
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of bill history to detect recurring payment patterns per vendor.
|
||||
/// Returns detected patterns with frequency, typical amount, next expected date, and
|
||||
/// suggested actions (e.g. set a reminder, create a template).
|
||||
/// </summary>
|
||||
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
|
||||
/// Read-only service for financial aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// The <paramref name="method"/> parameter overrides the company's stored preference when
|
||||
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
|
||||
/// </summary>
|
||||
public interface IFinancialReportService
|
||||
{
|
||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
|
||||
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
|
||||
@@ -23,4 +26,27 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
|
||||
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
|
||||
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
|
||||
/// operating activities. Investing and Financing sections are derived from account-level data.
|
||||
/// BeginningCash is computed from all cash/bank account credits and debits prior to
|
||||
/// <paramref name="from"/>; EndingCash adds the net change during the period.
|
||||
/// </summary>
|
||||
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IJobItemAssemblyService
|
||||
{
|
||||
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
}
|
||||
@@ -42,6 +42,9 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IQuotePricingAssemblyService
|
||||
{
|
||||
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
|
||||
|
||||
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared file validation and content-type resolution used across all blob storage services.
|
||||
/// </summary>
|
||||
public static class BlobFileHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates an uploaded file against an extension allowlist and a maximum size.
|
||||
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
|
||||
/// </summary>
|
||||
public static (bool IsValid, string Extension, string Error) ValidateUpload(
|
||||
IFormFile? file,
|
||||
string[] allowedExtensions,
|
||||
long maxBytes)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > maxBytes)
|
||||
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
|
||||
|
||||
return (true, extension, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a file extension to its MIME content type, covering common image formats and
|
||||
/// document types. Falls back to <c>application/octet-stream</c>.
|
||||
/// </summary>
|
||||
public static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
|
||||
/// them with underscores to produce a safe blob path segment.
|
||||
/// </summary>
|
||||
public static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
var blobId = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// The correct content type is required so that browsers render the image
|
||||
/// inline rather than triggering a download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Sanitize filename — replace OS-invalid characters with underscores to
|
||||
// prevent path traversal and blob naming errors in Azure.
|
||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
fileName = "manual";
|
||||
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||||
|
||||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||||
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Correct MIME types are required so browsers open PDFs inline and
|
||||
/// Word documents prompt a compatible application rather than a raw download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(pricing);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var firstCoat = source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.FirstOrDefault();
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = source.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c =>
|
||||
{
|
||||
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
|
||||
return BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = appearance.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = appearance.ColorCode,
|
||||
Finish = appearance.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = source.ColorName,
|
||||
ColorCode = source.ColorCode,
|
||||
Finish = source.Finish,
|
||||
SurfaceArea = source.SurfaceArea,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
{
|
||||
JobId = jobId,
|
||||
Description = seed.Description,
|
||||
Quantity = seed.Quantity,
|
||||
ColorName = seed.ColorName,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
SurfaceArea = seed.SurfaceArea,
|
||||
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
|
||||
CatalogItemId = seed.CatalogItemId,
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
UnitPrice = seed.UnitPrice,
|
||||
TotalPrice = seed.TotalPrice,
|
||||
LaborCost = seed.LaborCost,
|
||||
RequiresSandblasting = seed.RequiresSandblasting,
|
||||
RequiresMasking = seed.RequiresMasking,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
Notes = seed.Notes,
|
||||
IncludePrepCost = seed.IncludePrepCost,
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
CoatName = seed.CoatName,
|
||||
Sequence = seed.Sequence,
|
||||
InventoryItemId = seed.InventoryItemId,
|
||||
ColorName = seed.ColorName,
|
||||
VendorId = seed.VendorId,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
|
||||
TransferEfficiency = seed.TransferEfficiency,
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
.Select(seed => new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
PrepServiceId = seed.PrepServiceId,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
BlastSetupId = seed.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
return storedPowderToOrder;
|
||||
|
||||
if (surfaceAreaSqFt <= 0)
|
||||
return null;
|
||||
|
||||
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
|
||||
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
string? finish,
|
||||
InventoryItem? inventoryItem)
|
||||
{
|
||||
if (inventoryItem == null)
|
||||
return (colorName, colorCode, finish);
|
||||
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public decimal Quantity { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal? SurfaceArea { get; init; }
|
||||
public decimal SurfaceAreaSqFt { get; init; }
|
||||
public int? CatalogItemId { get; init; }
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
public decimal TotalPrice { get; init; }
|
||||
public decimal LaborCost { get; init; }
|
||||
public bool RequiresSandblasting { get; init; }
|
||||
public bool RequiresMasking { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool IncludePrepCost { get; init; }
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
public int Sequence { get; init; }
|
||||
public int? InventoryItemId { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public int? VendorId { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal CoverageSqFtPerLb { get; init; }
|
||||
public decimal TransferEfficiency { get; init; }
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public int? BlastSetupId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
|
||||
string? caption = null,
|
||||
JobPhotoType photoType = JobPhotoType.Progress)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
||||
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2357,4 +2357,356 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
|
||||
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
|
||||
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#b91c1c";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||||
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
|
||||
});
|
||||
|
||||
if (!dto.Vendors.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("All bills are paid — no outstanding balances.")
|
||||
.FontSize(11).FontColor("#16a34a");
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
|
||||
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
|
||||
{
|
||||
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
|
||||
|
||||
vendCol.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
|
||||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||||
{
|
||||
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
|
||||
: bill.DaysOverdue <= 30 ? "#ca8a04"
|
||||
: bill.DaysOverdue <= 60 ? "#ea580c"
|
||||
: bill.DaysOverdue <= 90 ? "#dc2626"
|
||||
: "#7f1d1d";
|
||||
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||||
|
||||
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
|
||||
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||||
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
|
||||
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||||
table.Cell().Background("#f1f5f9");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
|
||||
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
|
||||
/// totals and a balanced/unbalanced indicator.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#1a56db";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
|
||||
dto.IsBalanced ? "#16a34a" : "#dc2626");
|
||||
});
|
||||
|
||||
if (!dto.Lines.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("No active accounts with balances found.")
|
||||
.FontSize(11).FontColor(Colors.Grey.Darken1);
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.ConstantColumn(70);
|
||||
cols.RelativeColumn(4);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var line in dto.Lines)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||||
/// visually distinguish it from the other financial statements.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#0891b2";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Spacing(4);
|
||||
|
||||
// ── Operating Activities ──────────────────────────────────────
|
||||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.InvestingLines.Count == 0)
|
||||
CfRow(t, "No investing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.InvestingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.FinancingLines.Count == 0)
|
||||
CfRow(t, "No financing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.FinancingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
col.Item().PaddingTop(12).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
|
||||
void SumRow(string label, decimal amount, bool bold = false)
|
||||
{
|
||||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||||
if (bold) lText.Bold();
|
||||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
if (bold) vText.Bold();
|
||||
}
|
||||
|
||||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
|
||||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||||
{
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6)
|
||||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
|
||||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||||
{
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||||
.Text(label).Bold().FontSize(9);
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
QuoteItemPricingResult itemResult;
|
||||
|
||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
||||
if (item.CatalogItemId.HasValue)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
||||
// (which already includes the catalog base price + coat costs)
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use simple catalog default price
|
||||
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = catalogItemTotal,
|
||||
UnitPrice = catalogItem.DefaultPrice,
|
||||
TotalPrice = catalogItemTotal
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Catalog item not found, create zero result
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = 0,
|
||||
UnitPrice = 0,
|
||||
TotalPrice = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the full pricing calculation
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
|
||||
itemResults.Add(itemResult);
|
||||
}
|
||||
|
||||
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
string userId,
|
||||
int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old photos for this user with different extensions
|
||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||
|
||||
// Blob path mirrors former filesystem path
|
||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
||||
IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var tempId = Guid.NewGuid().ToString("N");
|
||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||
var contentType = GetContentType(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
||||
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
return (false, string.Empty, "Failed to read temp photo.");
|
||||
|
||||
using var ms = new MemoryStream(download.Content);
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
|
||||
if (!upload.Success)
|
||||
return (false, string.Empty, "Failed to save permanent photo.");
|
||||
|
||||
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetContentType(string ext) => ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<QuotePricingAssemblyService> _logger;
|
||||
|
||||
public QuotePricingAssemblyService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPricingCalculationService pricingService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<QuotePricingAssemblyService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_pricingService = pricingService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
|
||||
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, itemPricing);
|
||||
return;
|
||||
}
|
||||
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||
? itemDto.PowderCostOverride.Value
|
||||
: catalogItem.DefaultPrice;
|
||||
item.UnitPrice = unitPrice;
|
||||
item.TotalPrice = unitPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
return [];
|
||||
|
||||
var coats = new List<QuoteItemCoat>();
|
||||
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
companyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
coats.Add(coat);
|
||||
}
|
||||
|
||||
return coats;
|
||||
}
|
||||
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
return [];
|
||||
|
||||
return itemDto.PrepServices
|
||||
.Select(ps => new QuoteItemPrepService
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes,
|
||||
BlastSetupId = ps.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
IsSalesItem = itemDto.IsSalesItem,
|
||||
Sku = itemDto.Sku,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Notes = itemDto.Notes,
|
||||
Complexity = itemDto.Complexity,
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
{
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
item.TotalPrice = pricing.TotalPrice;
|
||||
item.ItemMaterialCost = pricing.MaterialCost;
|
||||
item.ItemLaborCost = pricing.LaborCost;
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
coatDto.PowderCostPerLb = null;
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -150,9 +154,305 @@ public class Expense : BaseEntity
|
||||
public string? Memo { get; set; }
|
||||
public string? ReceiptFilePath { get; set; }
|
||||
|
||||
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||
public virtual Account PaymentAccount { get; set; } = null!;
|
||||
public virtual Job? Job { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
|
||||
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
|
||||
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
|
||||
/// </summary>
|
||||
public class JournalEntry : BaseEntity
|
||||
{
|
||||
public string EntryNumber { get; set; } = string.Empty;
|
||||
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Reference { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
|
||||
|
||||
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
|
||||
public bool IsReversal { get; set; } = false;
|
||||
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
|
||||
public int? ReversalOfId { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public string? PostedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
|
||||
public virtual JournalEntry? ReversalOf { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
|
||||
/// should be non-zero per line (not both). LineOrder controls display sequence.
|
||||
/// </summary>
|
||||
public class JournalEntryLine : BaseEntity
|
||||
{
|
||||
public int JournalEntryId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
public decimal DebitAmount { get; set; }
|
||||
public decimal CreditAmount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int LineOrder { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bank reconciliation session for a single bank/cash account against a statement.
|
||||
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
|
||||
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
|
||||
/// </summary>
|
||||
public class BankReconciliation : BaseEntity
|
||||
{
|
||||
/// <summary>Must be a bank/cash subtype account.</summary>
|
||||
public int AccountId { get; set; }
|
||||
public DateTime StatementDate { get; set; }
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal EndingBalance { get; set; }
|
||||
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
|
||||
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
|
||||
/// Numbering: VC-YYMM-####
|
||||
/// </summary>
|
||||
public class VendorCredit : BaseEntity
|
||||
{
|
||||
public string CreditNumber { get; set; } = string.Empty;
|
||||
public int VendorId { get; set; }
|
||||
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
|
||||
public int APAccountId { get; set; }
|
||||
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
|
||||
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
|
||||
public decimal Total { get; set; }
|
||||
public decimal RemainingAmount { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||
public DateTime? PostedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
public virtual Account APAccount { get; set; } = null!;
|
||||
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
|
||||
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
|
||||
/// </summary>
|
||||
public class VendorCreditLineItem : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
/// <summary>Expense/COGS account being reversed by this line.</summary>
|
||||
public int? AccountId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Account? Account { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the application of a vendor credit against a specific vendor bill.
|
||||
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
|
||||
/// </summary>
|
||||
public class VendorCreditApplication : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
public int BillId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
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
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
|
||||
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
|
||||
/// to auto-post monthly depreciation journal entries.
|
||||
/// </summary>
|
||||
public class FixedAsset : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
public decimal PurchaseCost { get; set; }
|
||||
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
|
||||
public int UsefulLifeMonths { get; set; }
|
||||
/// <summary>Running total of depreciation posted so far.</summary>
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
|
||||
// Computed — not persisted
|
||||
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
|
||||
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
|
||||
/// <summary>Straight-line monthly depreciation amount.</summary>
|
||||
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
|
||||
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
|
||||
|
||||
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
|
||||
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
|
||||
public int? AssetAccountId { get; set; }
|
||||
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account? AssetAccount { get; set; }
|
||||
public virtual Account? DepreciationExpenseAccount { get; set; }
|
||||
public virtual Account? AccumDepreciationAccount { get; set; }
|
||||
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
|
||||
/// month/year combination; linked to the JournalEntry that was created so the posting
|
||||
/// can be traced back through the GL.
|
||||
/// </summary>
|
||||
public class FixedAssetDepreciationEntry : BaseEntity
|
||||
{
|
||||
public int FixedAssetId { get; set; }
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
|
||||
public int? JournalEntryId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual FixedAsset FixedAsset { get; set; } = null!;
|
||||
public virtual JournalEntry? JournalEntry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named annual budget. Contains one BudgetLine per account per month. Supports
|
||||
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
|
||||
/// one is marked IsDefault for the Budget vs. Actual report.
|
||||
/// </summary>
|
||||
public class Budget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly budget amount for one account within a Budget. Jan–Dec stored as separate
|
||||
/// columns so the grid editor can write them in a single POST without a line-item loop.
|
||||
/// Annual is a computed property summing all twelve months.
|
||||
/// </summary>
|
||||
public class BudgetLine : BaseEntity
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
|
||||
public virtual Budget Budget { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed year-end close. The close posts a JE that zeroes all
|
||||
/// Revenue and Expense account balances into Retained Earnings, and marks
|
||||
/// the year as closed so it cannot be closed again.
|
||||
/// </summary>
|
||||
public class YearEndClose : BaseEntity
|
||||
{
|
||||
public int ClosedYear { get; set; }
|
||||
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? ClosedBy { get; set; }
|
||||
public int JournalEntryId { get; set; }
|
||||
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
|
||||
public bool CanManageMaintenance { get; set; } = false;
|
||||
public bool CanManageInvoices { get; set; } = false;
|
||||
public bool CanViewReports { get; set; } = false;
|
||||
public bool CanManageBills { get; set; } = false;
|
||||
public bool CanManageAccounting { get; set; } = false;
|
||||
|
||||
// Profile Photo (filesystem storage)
|
||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||
|
||||
@@ -105,6 +105,19 @@ public class Company : BaseEntity
|
||||
public bool MarketingEmailOptOut { get; set; } = false;
|
||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether financial reports (P&L, Balance Sheet, Cash Flow) use
|
||||
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
|
||||
/// re-posting occurs. Default is Accrual (standard for most businesses).
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
/// <summary>
|
||||
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
|
||||
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
|
||||
/// </summary>
|
||||
public DateTime? BookLockedThrough { get; set; }
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
|
||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
||||
public string? Notes { get; set; }
|
||||
public string? RecordedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// Applied to invoice when invoice is created
|
||||
public int? AppliedToInvoiceId { get; set; }
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
@@ -42,6 +42,19 @@ public class Invoice : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -28,6 +28,7 @@ public class Job : BaseEntity
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
public decimal OvenBatchCost { get; set; }
|
||||
public decimal ShopSuppliesAmount { get; set; }
|
||||
public decimal ShopSuppliesPercent { get; set; }
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
|
||||
public int? CatalogItemId { get; set; }
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public decimal? ManualUnitPrice { get; set; }
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
|
||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
||||
/// </summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Invoice Invoice { get; set; } = null!;
|
||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||
|
||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
||||
public DateTime? IssuedDate { get; set; }
|
||||
public string? IssuedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// For store-credit refunds: the CreditMemo created on their behalf
|
||||
public int? CreditMemoId { get; set; }
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
|
||||
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
// 1099 Contractor tracking
|
||||
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||
|
||||
@@ -66,3 +66,61 @@ public enum BillStatus
|
||||
Paid = 3,
|
||||
Voided = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Company-level accounting method preference. Affects how financial reports
|
||||
/// (P&L, Balance Sheet, Cash Flow) query and present data. Switching this
|
||||
/// setting never re-posts historical GL entries — it is a report-time choice only.
|
||||
/// </summary>
|
||||
public enum AccountingMethod
|
||||
{
|
||||
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
|
||||
Cash = 0,
|
||||
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
|
||||
Accrual = 1
|
||||
}
|
||||
|
||||
public enum BankReconciliationStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Completed = 1
|
||||
}
|
||||
|
||||
public enum VendorCreditStatus
|
||||
{
|
||||
Open = 0,
|
||||
PartiallyApplied = 1,
|
||||
Applied = 2,
|
||||
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
|
||||
{
|
||||
/// <summary>Not yet posted — can still be edited or deleted.</summary>
|
||||
Draft = 0,
|
||||
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
|
||||
Posted = 1,
|
||||
/// <summary>A reversal JE has been created and posted for this entry.</summary>
|
||||
Reversed = 2
|
||||
}
|
||||
|
||||
@@ -91,6 +91,35 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<BillPayment> BillPayments { get; }
|
||||
IRepository<Expense> Expenses { get; }
|
||||
|
||||
// Manual Journal Entries
|
||||
IRepository<JournalEntry> JournalEntries { get; }
|
||||
IRepository<JournalEntryLine> JournalEntryLines { get; }
|
||||
|
||||
// Vendor Credits
|
||||
IRepository<VendorCredit> VendorCredits { get; }
|
||||
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
|
||||
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
|
||||
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Recurring Transactions
|
||||
IRepository<RecurringTemplate> RecurringTemplates { get; }
|
||||
|
||||
// Fixed Assets
|
||||
IRepository<FixedAsset> FixedAssets { get; }
|
||||
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
|
||||
|
||||
// Budgeting
|
||||
IRepository<Budget> Budgets { get; }
|
||||
IRepository<BudgetLine> BudgetLines { get; }
|
||||
|
||||
// Year-End Close
|
||||
IRepository<YearEndClose> YearEndCloses { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
||||
|
||||
/// <summary>
|
||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||
/// </summary>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -324,6 +324,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Expense> Expenses { get; set; }
|
||||
|
||||
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<JournalEntry> JournalEntries { get; set; }
|
||||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||||
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <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>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||||
|
||||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Budget> Budgets { get; set; }
|
||||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<YearEndClose> YearEndCloses { 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>
|
||||
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
|
||||
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||||
@@ -614,6 +647,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
|
||||
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Bank Reconciliation: tenant-filtered
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
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));
|
||||
|
||||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AssetAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AssetAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||||
.HasOne(e => e.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||||
modelBuilder.Entity<BudgetLine>()
|
||||
.HasOne(bl => bl.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(bl => bl.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// YearEndClose: tenant-filtered; links to a specific JE
|
||||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<YearEndClose>()
|
||||
.HasOne(y => y.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(y => y.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany(vc => vc.Applications)
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -633,6 +753,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(a => a.ParentAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// JournalEntry self-referencing reversal link
|
||||
modelBuilder.Entity<JournalEntry>()
|
||||
.HasOne(je => je.ReversalOf)
|
||||
.WithMany()
|
||||
.HasForeignKey(je => je.ReversalOfId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BankReconciliation → Account (no cascade)
|
||||
modelBuilder.Entity<BankReconciliation>()
|
||||
.HasOne(br => br.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(br => br.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCredit → APAccount (no cascade)
|
||||
modelBuilder.Entity<VendorCredit>()
|
||||
.HasOne(vc => vc.APAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(vc => vc.APAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCreditLineItem → Account (nullable, no cascade)
|
||||
modelBuilder.Entity<VendorCreditLineItem>()
|
||||
.HasOne(li => li.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(li => li.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor → DefaultExpenseAccount (no cascade)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasOne(s => s.DefaultExpenseAccount)
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260510011252_AddJobTemplateItemSalesFields.Designer.cs
Generated
+9552
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobTemplateItemSalesFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountingMethod : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9715
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJournalEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
EntryNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
EntryDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Reference = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
IsReversal = table.Column<bool>(type: "bit", nullable: false),
|
||||
ReversalOfId = table.Column<int>(type: "int", nullable: true),
|
||||
PostedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
PostedBy = 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_JournalEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntries_JournalEntries_ReversalOfId",
|
||||
column: x => x.ReversalOfId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntryLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
DebitAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CreditAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LineOrder = table.Column<int>(type: "int", nullable: false),
|
||||
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_JournalEntryLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntries_ReversalOfId",
|
||||
table: "JournalEntries",
|
||||
column: "ReversalOfId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_AccountId",
|
||||
table: "JournalEntryLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_JournalEntryId",
|
||||
table: "JournalEntryLines",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntryLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntries");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9951
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCredits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCredits",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CreditNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
APAccountId = table.Column<int>(type: "int", nullable: false),
|
||||
CreditDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
RemainingAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Memo = 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_VendorCredits", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Accounts_APAccountId",
|
||||
column: x => x.APAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
BillId = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
AppliedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
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_VendorCreditApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditLineItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
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_VendorCreditLineItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_AccountId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_VendorCreditId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_APAccountId",
|
||||
table: "VendorCredits",
|
||||
column: "APAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_VendorId",
|
||||
table: "VendorCredits",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditLineItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCredits");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBankReconciliation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Payments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Payments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Expenses",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BankReconciliations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = 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_BankReconciliations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BankReconciliations_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BankReconciliations_AccountId",
|
||||
table: "BankReconciliations",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BankReconciliations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10105
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentTermsAndTaxRates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TaxRates",
|
||||
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),
|
||||
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
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_TaxRates", x => x.Id);
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TaxRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10177
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropOrphanVendorCreditId1 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
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, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
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_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10366
File diff suppressed because it is too large
Load Diff
+199
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFixedAssetsLockAnd1099 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssets",
|
||||
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),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
|
||||
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
|
||||
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
AssetAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
AccumDepreciationAccountId = table.Column<int>(type: "int", 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_FixedAssets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
|
||||
column: x => x.AccumDepreciationAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AssetAccountId",
|
||||
column: x => x.AssetAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
|
||||
column: x => x.DepreciationExpenseAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssetDepreciationEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FixedAssetId = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodYear = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodMonth = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
JournalEntryId = table.Column<int>(type: "int", 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_FixedAssetDepreciationEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
|
||||
column: x => x.FixedAssetId,
|
||||
principalTable: "FixedAssets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "FixedAssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "JournalEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AccumDepreciationAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AccumDepreciationAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AssetAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AssetAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_DepreciationExpenseAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "DepreciationExpenseAccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssetDepreciationEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10585
File diff suppressed because it is too large
Load Diff
+185
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBudgetsAndYearEndClose : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Budgets",
|
||||
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),
|
||||
FiscalYear = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
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_Budgets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "YearEndCloses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClosedYear = table.Column<int>(type: "int", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ClosedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
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_YearEndCloses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_YearEndCloses_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BudgetLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BudgetId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
Jan = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Feb = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Mar = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Apr = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
May = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jun = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jul = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Aug = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Sep = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Oct = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Nov = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Dec = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
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_BudgetLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Budgets_BudgetId",
|
||||
column: x => x.BudgetId,
|
||||
principalTable: "Budgets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_AccountId",
|
||||
table: "BudgetLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_BudgetId",
|
||||
table: "BudgetLines",
|
||||
column: "BudgetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_YearEndCloses_JournalEntryId",
|
||||
table: "YearEndCloses",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BudgetLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "YearEndCloses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Budgets");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10591
File diff suppressed because it is too large
Load Diff
+90
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountantRolePermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CanManageAccounting",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CanManageBills",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// Grant both new permissions to all existing CompanyAdmin users so they don't lose access
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE AspNetUsers
|
||||
SET CanManageBills = 1, CanManageAccounting = 1
|
||||
WHERE CompanyRole = 'CompanyAdmin'
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CanManageAccounting",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CanManageBills",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobOvenBatchCost : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "OvenBatchCost",
|
||||
table: "Jobs",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OvenBatchCost",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMissingPlatformSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Conditional inserts — safe to run against a DB that already has some of these keys set manually.
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'SmsEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('SmsEnabled','false','SMS Enabled','Platform-level switch for outbound SMS. When off, no SMS messages are sent regardless of company settings.','Notifications');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'TrialsEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('TrialsEnabled','true','Trials Enabled','Allow new companies to register with a free trial period. When off, registration requires a paid plan immediately.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodDays')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('GracePeriodDays','14','Grace Period (days)','Days after subscription expiry before access is fully cut off. Gives companies time to renew without an abrupt lockout.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodAppliesToTrials')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('GracePeriodAppliesToTrials','false','Grace Period Applies to Trials','When enabled, trial companies also receive the grace period after expiry rather than being cut off immediately.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'MaxTenants')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('MaxTenants','-1','Max Tenants','Maximum number of active tenant companies allowed on the platform. Set to -1 for no limit.','Subscriptions');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'AiCatalogPriceCheckEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('AiCatalogPriceCheckEnabled','true','AI Catalog Price Check','Platform-level switch for the AI catalog price review feature. When off, the feature is disabled for all companies regardless of their settings.','AI');
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+95
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedSalesDiscountsAccount : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Insert the 4950 Sales Discounts contra-revenue account for every company that does
|
||||
// not already have it. The account is credit-normal (AccountType=4 Revenue,
|
||||
// AccountSubType=32 OtherIncome) and is debited when invoice discounts are applied so
|
||||
// the GL balances (DR Sales Discounts / gap between CR Revenue and DR AR).
|
||||
// Idempotent: the WHERE NOT EXISTS guard means re-running the migration is safe.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted,
|
||||
CurrentBalance, OpeningBalance)
|
||||
SELECT
|
||||
'4950',
|
||||
'Sales Discounts',
|
||||
4, -- AccountType.Revenue
|
||||
32, -- AccountSubType.OtherIncome
|
||||
1, -- IsSystem = true
|
||||
1, -- IsActive = true
|
||||
'Contra-revenue for invoice discounts granted to customers',
|
||||
c.Id,
|
||||
GETUTCDATE(),
|
||||
0, -- IsDeleted = false
|
||||
0, -- CurrentBalance
|
||||
0 -- OpeningBalance
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Accounts a
|
||||
WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '4950'
|
||||
AND a.IsDeleted = 0
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AccountingGapsPhase2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "PostedDate",
|
||||
table: "VendorCredits",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DepositAccountId",
|
||||
table: "Refunds",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
// Seed the Gift Certificate Liability account (2500) for every company that doesn't
|
||||
// already have it. Credit-normal OtherCurrentLiability account; credited when a GC is
|
||||
// issued and debited when redeemed or voided. Idempotent guard prevents double-seeding.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted,
|
||||
CurrentBalance, OpeningBalance)
|
||||
SELECT
|
||||
'2500',
|
||||
'Gift Certificate Liability',
|
||||
2, -- AccountType.Liability
|
||||
12, -- AccountSubType.OtherCurrentLiability
|
||||
1, -- IsSystem = true
|
||||
1, -- IsActive = true
|
||||
'Outstanding gift certificate obligations owed to certificate holders',
|
||||
c.Id,
|
||||
GETUTCDATE(),
|
||||
0, -- IsDeleted = false
|
||||
0, -- CurrentBalance
|
||||
0 -- OpeningBalance
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Accounts a
|
||||
WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2500'
|
||||
AND a.IsDeleted = 0
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PostedDate",
|
||||
table: "VendorCredits");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DepositAccountId",
|
||||
table: "Refunds");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10603
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AccountingDepositsGL : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "DepositAccountId",
|
||||
table: "Deposits",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
// Seed account 2300 "Customer Deposits" (Liability / OtherCurrentLiability) for every
|
||||
// company that doesn't already have it. Credited when a deposit is taken; debited when
|
||||
// the deposit is applied to an invoice. Idempotent guard prevents double-seeding.
|
||||
migrationBuilder.Sql(@"
|
||||
INSERT INTO Accounts
|
||||
(AccountNumber, Name, AccountType, AccountSubType,
|
||||
IsSystem, IsActive, Description,
|
||||
CompanyId, CreatedAt, IsDeleted,
|
||||
CurrentBalance, OpeningBalance)
|
||||
SELECT
|
||||
'2300',
|
||||
'Customer Deposits',
|
||||
2, -- AccountType.Liability
|
||||
12, -- AccountSubType.OtherCurrentLiability
|
||||
1, -- IsSystem = true
|
||||
1, -- IsActive = true
|
||||
'Deposits received from customers before an invoice is created; cleared when deposit is applied to invoice',
|
||||
c.Id,
|
||||
GETUTCDATE(),
|
||||
0, -- IsDeleted = false
|
||||
0, -- CurrentBalance
|
||||
0 -- OpeningBalance
|
||||
FROM Companies c
|
||||
WHERE c.IsDeleted = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM Accounts a
|
||||
WHERE a.CompanyId = c.Id
|
||||
AND a.AccountNumber = '2300'
|
||||
AND a.IsDeleted = 0
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DepositAccountId",
|
||||
table: "Deposits");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -142,6 +142,29 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<BillPayment>? _billPayments;
|
||||
private IRepository<Expense>? _expenses;
|
||||
|
||||
// Manual Journal Entries
|
||||
private IRepository<JournalEntry>? _journalEntries;
|
||||
private IRepository<JournalEntryLine>? _journalEntryLines;
|
||||
|
||||
// Vendor Credits
|
||||
private IRepository<VendorCredit>? _vendorCredits;
|
||||
private IRepository<VendorCreditLineItem>? _vendorCreditLineItems;
|
||||
private IRepository<VendorCreditApplication>? _vendorCreditApplications;
|
||||
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
// Tax Rates
|
||||
private IRepository<TaxRate>? _taxRates;
|
||||
|
||||
// Recurring Transactions
|
||||
private IRepository<RecurringTemplate>? _recurringTemplates;
|
||||
private IRepository<FixedAsset>? _fixedAssets;
|
||||
private IRepository<FixedAssetDepreciationEntry>? _fixedAssetDepreciationEntries;
|
||||
private IRepository<Budget>? _budgets;
|
||||
private IRepository<BudgetLine>? _budgetLines;
|
||||
private IRepository<YearEndClose>? _yearEndCloses;
|
||||
|
||||
/// <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
|
||||
@@ -513,6 +536,53 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<Expense> Expenses =>
|
||||
_expenses ??= new Repository<Expense>(_context);
|
||||
|
||||
// Manual Journal Entries
|
||||
/// <summary>Repository for <see cref="JournalEntry"/> double-entry manual journal entries; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JournalEntry> JournalEntries =>
|
||||
_journalEntries ??= new Repository<JournalEntry>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JournalEntryLine"/> individual debit/credit lines within a journal entry.</summary>
|
||||
public IRepository<JournalEntryLine> JournalEntryLines =>
|
||||
_journalEntryLines ??= new Repository<JournalEntryLine>(_context);
|
||||
|
||||
// Vendor Credits
|
||||
/// <summary>Repository for <see cref="VendorCredit"/> credit notes received from vendors; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<VendorCredit> VendorCredits =>
|
||||
_vendorCredits ??= new Repository<VendorCredit>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="VendorCreditLineItem"/> expense-reversal lines on a vendor credit.</summary>
|
||||
public IRepository<VendorCreditLineItem> VendorCreditLineItems =>
|
||||
_vendorCreditLineItems ??= new Repository<VendorCreditLineItem>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="VendorCreditApplication"/> records linking a vendor credit to a specific bill.</summary>
|
||||
public IRepository<VendorCreditApplication> VendorCreditApplications =>
|
||||
_vendorCreditApplications ??= new Repository<VendorCreditApplication>(_context);
|
||||
|
||||
// Bank Reconciliation
|
||||
/// <summary>Repository for <see cref="BankReconciliation"/> sessions reconciling a bank account against a statement.</summary>
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
// Tax Rates
|
||||
/// <summary>Repository for <see cref="TaxRate"/> named tax rates used to pre-fill invoice tax percent by jurisdiction.</summary>
|
||||
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);
|
||||
public IRepository<FixedAsset> FixedAssets =>
|
||||
_fixedAssets ??= new Repository<FixedAsset>(_context);
|
||||
public IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries =>
|
||||
_fixedAssetDepreciationEntries ??= new Repository<FixedAssetDepreciationEntry>(_context);
|
||||
public IRepository<Budget> Budgets =>
|
||||
_budgets ??= new Repository<Budget>(_context);
|
||||
public IRepository<BudgetLine> BudgetLines =>
|
||||
_budgetLines ??= new Repository<BudgetLine>(_context);
|
||||
public IRepository<YearEndClose> YearEndCloses =>
|
||||
_yearEndCloses ??= new Repository<YearEndClose>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
|
||||
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
|
||||
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
|
||||
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
|
||||
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
|
||||
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
|
||||
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
|
||||
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
|
||||
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
|
||||
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
|
||||
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||
}
|
||||
|
||||
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
|
||||
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
|
||||
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
|
||||
/// decide the direction of the balance adjustment.
|
||||
/// </summary>
|
||||
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
|
||||
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
|
||||
/// between the current running balance and the statement ending balance. The items list
|
||||
/// includes both deposits and payments with their direction tag so Claude can reason about
|
||||
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
|
||||
/// target ending balance — items that together sum close to the required difference score
|
||||
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
|
||||
/// compact because we only need entity-type/id pairs plus a short reason per item.
|
||||
/// </summary>
|
||||
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
|
||||
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
|
||||
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
|
||||
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""suggestedCleared"": [
|
||||
{
|
||||
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
|
||||
""entityId"": number,
|
||||
""confidence"": number (0.0 to 1.0),
|
||||
""reason"": ""string — one sentence why this item should be cleared""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
|
||||
- Difference needed = statementEndingBalance - beginningBalance
|
||||
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
|
||||
- confidence 0.6-0.89: likely but not certain
|
||||
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
|
||||
- insights: 2-4 observations about patterns or items that need manual review
|
||||
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
|
||||
|
||||
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
|
||||
var needed = request.StatementEndingBalance - request.BeginningBalance;
|
||||
|
||||
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
|
||||
|
||||
Beginning Balance: {request.BeginningBalance:F2}
|
||||
Statement Ending Balance: {request.StatementEndingBalance:F2}
|
||||
Difference needed (deposits - payments): {needed:F2}
|
||||
|
||||
Uncleared transactions:
|
||||
{itemsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new AutoMatchResult
|
||||
{
|
||||
Success = true,
|
||||
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
|
||||
{
|
||||
EntityType = s.EntityType,
|
||||
EntityId = s.EntityId,
|
||||
Confidence = s.Confidence,
|
||||
Reason = s.Reason
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running bank rec auto-match with AI");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Predicts payment risk per open AR customer by combining current overdue status with
|
||||
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
|
||||
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 0–1 ratio rather than
|
||||
/// raw counts, which produces more consistent confidence scoring across customers with very
|
||||
/// different invoice volumes. Risk levels are validated against the three allowed values and
|
||||
/// default to "medium" when Claude returns anything outside the expected set.
|
||||
/// </summary>
|
||||
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
|
||||
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""predictions"": [
|
||||
{
|
||||
""customerName"": ""string"",
|
||||
""riskLevel"": ""high"" | ""medium"" | ""low"",
|
||||
""estimatedDaysToPayment"": number,
|
||||
""reasoning"": ""string — one sentence explaining the prediction""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
|
||||
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
|
||||
- riskLevel ""low"": customer typically pays on time and is not severely overdue
|
||||
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
|
||||
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
|
||||
- Only include predictions for customers with open invoices";
|
||||
|
||||
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
|
||||
{
|
||||
c.CustomerName,
|
||||
c.TotalOwed,
|
||||
c.AvgDaysToPay,
|
||||
LatePaymentRate = c.TotalInvoicesAllTime > 0
|
||||
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
|
||||
: 0,
|
||||
c.OpenInvoices
|
||||
}));
|
||||
|
||||
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
|
||||
|
||||
Customer data (includes historical payment behavior):
|
||||
{customersJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validRiskLevels = new[] { "high", "medium", "low" };
|
||||
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
|
||||
{
|
||||
CustomerName = p.CustomerName,
|
||||
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
|
||||
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
|
||||
Reasoning = p.Reasoning
|
||||
}).ToList();
|
||||
|
||||
return new LatePaymentPredictionResult
|
||||
{
|
||||
Success = true,
|
||||
Predictions = predictions,
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error predicting late payments with AI");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
|
||||
/// financial data. The context object is serialized to JSON and embedded in the user prompt
|
||||
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
|
||||
/// system prompt explicitly constrains Claude to the data provided and forbids it from
|
||||
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
|
||||
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
|
||||
/// to justify the answer, displayed below the answer in the UI so users can verify.
|
||||
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
|
||||
/// </summary>
|
||||
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
|
||||
Answer plain-English financial questions using ONLY the data provided in the context.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""answer"": ""string — direct, plain-English answer to the question"",
|
||||
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
|
||||
""relevantFacts"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- answer: be direct and specific with dollar amounts and percentages from the data
|
||||
- If the data does not contain enough information to answer the question, say so clearly in the answer
|
||||
- Do NOT invent or estimate figures that are not in the provided data
|
||||
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
|
||||
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
|
||||
- Keep the answer under 100 words — be concise";
|
||||
|
||||
var contextJson = JsonSerializer.Serialize(request.Context);
|
||||
var userPrompt = $@"Question: {request.Question}
|
||||
|
||||
Financial context:
|
||||
{contextJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new FinancialQueryResult
|
||||
{
|
||||
Success = true,
|
||||
Answer = parsed.Answer ?? string.Empty,
|
||||
FollowUpSuggestion = parsed.FollowUpSuggestion,
|
||||
RelevantFacts = parsed.RelevantFacts ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error answering financial query with AI");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of historical bills to detect recurring payment patterns per vendor.
|
||||
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
|
||||
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
|
||||
/// regular the cadence is — a bill appearing every 28–32 days for 6 consecutive months is
|
||||
/// high confidence; 2–3 occurrences at similar amounts is medium. NextExpectedDateIso is
|
||||
/// calculated by Claude from the pattern's most recent date plus the detected period length.
|
||||
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
|
||||
/// </summary>
|
||||
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
|
||||
Analyze the provided bill history to detect recurring payment patterns per vendor.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""patterns"": [
|
||||
{
|
||||
""vendorName"": ""string"",
|
||||
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
|
||||
""typicalAmount"": number,
|
||||
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
|
||||
""confidence"": ""high"" | ""medium"" | ""low"",
|
||||
""description"": ""string — one sentence describing the pattern"",
|
||||
""suggestedAction"": ""string — one specific action to take, or null""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only report patterns with at least 2 occurrences
|
||||
- monthly: bills occurring every 25–35 days
|
||||
- quarterly: bills occurring every 80–100 days
|
||||
- biannual: bills occurring every 170–195 days
|
||||
- annual: bills occurring roughly once per year
|
||||
- irregular: a vendor bills regularly but the cadence is inconsistent
|
||||
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
|
||||
- confidence ""medium"": 2–3 occurrences with consistent timing, or 4+ with variable timing
|
||||
- confidence ""low"": pattern is weak but worth monitoring
|
||||
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
|
||||
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
|
||||
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
|
||||
- If no recurring patterns are found, return an empty patterns array";
|
||||
|
||||
// Group bills by vendor for clarity in the prompt
|
||||
var grouped = request.Bills
|
||||
.GroupBy(b => b.VendorName)
|
||||
.Select(g => new
|
||||
{
|
||||
VendorName = g.Key,
|
||||
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
|
||||
});
|
||||
|
||||
var billsJson = JsonSerializer.Serialize(grouped);
|
||||
|
||||
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
|
||||
Data covers the last 6–12 months of bills, grouped by vendor.
|
||||
|
||||
Bill history by vendor:
|
||||
{billsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validConfidence = new[] { "high", "medium", "low" };
|
||||
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
|
||||
|
||||
return new RecurringBillDetectionResult
|
||||
{
|
||||
Success = true,
|
||||
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
|
||||
{
|
||||
VendorName = p.VendorName,
|
||||
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
|
||||
TypicalAmount = p.TypicalAmount,
|
||||
NextExpectedDateIso = p.NextExpectedDateIso,
|
||||
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
|
||||
Description = p.Description,
|
||||
SuggestedAction = p.SuggestedAction
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error detecting recurring bills with AI");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for double-entry sign conventions shared by
|
||||
/// <see cref="AccountBalanceService"/> and <see cref="LedgerService"/>.
|
||||
/// Centralised here so that adding a new AccountSubType only requires
|
||||
/// one edit rather than two independently maintained switch expressions.
|
||||
/// </summary>
|
||||
internal static class AccountingRules
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for sub-types whose normal balance is a debit
|
||||
/// (Assets, COGS, Expenses). Sub-type is used rather than AccountType
|
||||
/// because it is constrained to a known enum set and cannot be
|
||||
/// misconfigured by a user. Expense enum values are ≥ 50 by convention,
|
||||
/// allowing a catch-all range match for any future expense sub-types.
|
||||
/// </summary>
|
||||
internal static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
// Asset subtypes → normal debit balance
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
// COGS → normal debit balance
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -435,7 +435,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
||||
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
||||
}
|
||||
|
||||
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
|
||||
string coatingSpeedLine;
|
||||
if (coatingRate > 0)
|
||||
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
|
||||
else
|
||||
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
|
||||
|
||||
var rateInstruction = (blastRate > 0 || coatingRate > 0)
|
||||
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
|
||||
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
|
||||
|
||||
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
||||
|
||||
@@ -453,7 +461,7 @@ Company operating costs for your reference:
|
||||
{shopSpeedLine}
|
||||
{coatingSpeedLine}
|
||||
|
||||
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
|
||||
{rateInstruction}
|
||||
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
||||
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
||||
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
||||
@@ -547,9 +555,9 @@ Respond with the JSON object only.";
|
||||
_ => 0
|
||||
};
|
||||
|
||||
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
|
||||
// The unit price × quantity must equal the total batch labor cost.
|
||||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
||||
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
|
||||
// Unit price is per item; the caller multiplies by quantity for the line total.
|
||||
var rawPerItemMinutes = aiResult.EstimatedMinutes;
|
||||
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
||||
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
||||
var laborHours = perItemMinutes / 60m;
|
||||
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
|
||||
CoatCount = request.CoatCount,
|
||||
MaterialCost = Math.Round(materialCost, 2),
|
||||
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
||||
EstimatedMinutes = (int)Math.Round(perItemMinutes),
|
||||
EstimatedMinutes = perItemMinutes,
|
||||
MaterialMinMinutes = materialMinMinutes,
|
||||
MinFloorApplied = minFloorApplied,
|
||||
LaborCost = Math.Round(laborCost, 2),
|
||||
|
||||
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
|
||||
identity.AddClaim(new Claim("Permission", "ViewReports"));
|
||||
}
|
||||
|
||||
if (user.CanManageBills)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageBills"));
|
||||
}
|
||||
|
||||
if (user.CanManageAccounting)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ public class CompanyListService : ICompanyListService
|
||||
/// <inheritdoc/>
|
||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var d30 = now.AddDays(-30);
|
||||
var d90 = now.AddDays(-90);
|
||||
|
||||
var jobCounts = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||
@@ -98,6 +102,32 @@ public class CompanyListService : ICompanyListService
|
||||
x => x.CompanyId,
|
||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
||||
|
||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
|
||||
var jobs30 = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d30)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var jobs90 = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d90)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var lastLoginRaw = await _context.Users
|
||||
.IgnoreQueryFilters()
|
||||
.Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null)
|
||||
.GroupBy(u => u.CompanyId)
|
||||
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
|
||||
.ToListAsync();
|
||||
|
||||
var lastLogins = lastLoginRaw.ToDictionary(
|
||||
x => x.CompanyId,
|
||||
x => x.Last);
|
||||
|
||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo,
|
||||
jobs30, jobs90, lastLogins);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
var depositedDeposits = await _context.Deposits
|
||||
.Where(d => d.DepositAccountId == accountId
|
||||
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var d in depositedDeposits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate,
|
||||
Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit",
|
||||
Description = d.Notes ?? d.Reference,
|
||||
Debit = d.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Jobs",
|
||||
LinkId = d.JobId
|
||||
});
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
var refundsPaidFrom = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.DepositAccountId == accountId
|
||||
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in refundsPaidFrom)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = 0,
|
||||
Credit = r.Amount,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||
// e.g. Checking account used to pay an expense
|
||||
var expensesPaidFrom = await _context.Expenses
|
||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Invoices",
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Credit memo applications reduce open AR (CREDIT)
|
||||
var arCreditMemos = await _context.CreditMemoApplications
|
||||
.Include(a => a.Invoice)
|
||||
.Include(a => a.CreditMemo)
|
||||
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cm in arCreditMemos)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = cm.AppliedDate,
|
||||
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
|
||||
Source = "Credit Memo",
|
||||
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = cm.AmountApplied,
|
||||
LinkController = "Invoices",
|
||||
LinkId = cm.InvoiceId
|
||||
});
|
||||
|
||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||
var arRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in arRefunds)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = r.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
@@ -296,8 +375,126 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Bills",
|
||||
LinkId = bp.BillId
|
||||
});
|
||||
|
||||
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
|
||||
var apVendorCredits = await _context.VendorCreditApplications
|
||||
.Include(vca => vca.VendorCredit)
|
||||
.Include(vca => vca.Bill)
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId
|
||||
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var vca in apVendorCredits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = vca.AppliedDate,
|
||||
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
|
||||
Source = "Vendor Credit",
|
||||
Description = $"Credit applied to {vca.Bill?.BillNumber}",
|
||||
Debit = vca.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "VendorCredits",
|
||||
LinkId = vca.VendorCreditId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
|
||||
// CR when GC is issued; DR when redeemed or voided with remaining balance.
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
var gcIssued = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcIssued)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.IssueDate, Reference = gc.CertificateCode,
|
||||
Source = "Gift Certificate", Description = "GC issued",
|
||||
Debit = 0, Credit = gc.OriginalAmount,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
|
||||
var gcRedemptions = await _context.GiftCertificateRedemptions
|
||||
.Include(r => r.GiftCertificate)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var r in gcRedemptions)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||
Source = "GC Redemption", Description = "GC applied to invoice",
|
||||
Debit = r.AmountRedeemed, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
|
||||
});
|
||||
|
||||
var gcVoided = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
|
||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcVoided)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
|
||||
Source = "GC Voided", Description = "Breakage income",
|
||||
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Customer Deposits liability (account 2300) ────────────────────
|
||||
// CR when deposit is recorded; DR when deposit is applied to an invoice.
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
var depositsRecorded = await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsRecorded)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
|
||||
Debit = 0, Credit = d.Amount,
|
||||
LinkController = "Jobs", LinkId = d.JobId
|
||||
});
|
||||
|
||||
var depositsApplied = await _context.Deposits
|
||||
.Include(d => d.AppliedToInvoice)
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
|
||||
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsApplied)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
|
||||
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
|
||||
Debit = d.Amount, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||
var jeLines = await _context.JournalEntryLines
|
||||
.Include(l => l.JournalEntry)
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate >= fromDate
|
||||
&& l.JournalEntry.EntryDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var line in jeLines)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = line.JournalEntry.EntryDate,
|
||||
Reference = line.JournalEntry.EntryNumber,
|
||||
Source = "Journal Entry",
|
||||
Description = line.Description ?? line.JournalEntry.Description,
|
||||
Debit = line.DebitAmount,
|
||||
Credit = line.CreditAmount,
|
||||
LinkController = "JournalEntries",
|
||||
LinkId = line.JournalEntry.Id
|
||||
});
|
||||
|
||||
// ── Sort and compute running balance ──────────────────────────────────
|
||||
entries = entries
|
||||
.OrderBy(e => e.Date)
|
||||
@@ -306,7 +503,7 @@ public class LedgerService : ILedgerService
|
||||
|
||||
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
|
||||
// since users could misconfigure AccountType while SubType is picked from a constrained list).
|
||||
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType);
|
||||
bool normalDebitBalance = AccountingRules.IsNormalDebitBalance(account.AccountSubType);
|
||||
|
||||
// Compute the balance before the selected period
|
||||
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
|
||||
@@ -338,36 +535,6 @@ public class LedgerService : ILedgerService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
|
||||
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
|
||||
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
|
||||
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
|
||||
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
|
||||
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
|
||||
/// </summary>
|
||||
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
|
||||
{
|
||||
// Asset subtypes → normal debit balance
|
||||
AccountSubType.Cash
|
||||
or AccountSubType.Checking
|
||||
or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable
|
||||
or AccountSubType.Inventory
|
||||
or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset
|
||||
or AccountSubType.OtherAsset => true,
|
||||
|
||||
// COGS → normal debit balance
|
||||
AccountSubType.CostOfGoodsSold => true,
|
||||
|
||||
// Expense subtypes (enum values ≥ 50) → normal debit balance
|
||||
var st when (int)st >= 50 => true,
|
||||
|
||||
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
|
||||
/// by summing all activity prior to that date across every transaction source and adding
|
||||
@@ -375,7 +542,7 @@ public class LedgerService : ILedgerService
|
||||
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
|
||||
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
|
||||
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
|
||||
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
|
||||
/// The sign convention follows <see cref="AccountingRules.IsNormalDebitBalance"/>: debits increase debit-normal
|
||||
/// accounts and credits increase credit-normal accounts.
|
||||
/// </summary>
|
||||
private async Task<decimal> ComputePriorBalanceAsync(
|
||||
@@ -390,6 +557,16 @@ public class LedgerService : ILedgerService
|
||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
credits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
|
||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||
credits += await _context.Expenses
|
||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||
@@ -442,6 +619,14 @@ public class LedgerService : ILedgerService
|
||||
credits += await _context.Payments
|
||||
.Where(p => p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
credits += await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
debits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
@@ -457,8 +642,51 @@ public class LedgerService : ILedgerService
|
||||
debits += await _context.BillPayments
|
||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
|
||||
debits += await _context.VendorCreditApplications
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
|
||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 11. GC Liability (account 2500)
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
credits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
|
||||
debits += await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||
debits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
|
||||
}
|
||||
|
||||
// 12. Customer Deposits liability (account 2300)
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
credits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 10. Posted journal entry lines touching this account (prior to period)
|
||||
debits += await _context.JournalEntryLines
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate < beforeDate)
|
||||
.SumAsync(l => (decimal?)l.DebitAmount) ?? 0;
|
||||
|
||||
credits += await _context.JournalEntryLines
|
||||
.Where(l => l.AccountId == accountId
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate < beforeDate)
|
||||
.SumAsync(l => (decimal?)l.CreditAmount) ?? 0;
|
||||
|
||||
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
|
||||
|
||||
// Apply the opening balance if it was established on or before the end of the viewed period.
|
||||
|
||||
@@ -70,6 +70,10 @@ public partial class SeedDataService
|
||||
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
||||
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
|
||||
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||
// reducing net revenue to match the discounted AR amount that was posted.
|
||||
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||
@@ -96,4 +100,44 @@ public partial class SeedDataService
|
||||
|
||||
return accounts.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
|
||||
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
|
||||
/// call repeatedly from the "Seed Lookup Tables" flow.
|
||||
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
|
||||
/// companies get all accounts in one pass while existing companies receive only the missing ones.
|
||||
/// </summary>
|
||||
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
|
||||
private async Task<int> EnsureSystemAccountsAsync(Company company)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
int added = 0;
|
||||
|
||||
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
|
||||
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
|
||||
var has4950 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
|
||||
|
||||
if (!has4950)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "4950",
|
||||
Name = "Sales Discounts",
|
||||
AccountType = AccountType.Revenue,
|
||||
AccountSubType = AccountSubType.OtherIncome,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Contra-revenue for invoice discounts granted to customers",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
|
||||
result.ItemsSeeded += accountsSeeded;
|
||||
}
|
||||
|
||||
// Backfill any system accounts added after the initial seed (idempotent).
|
||||
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||
if (systemAccountsAdded > 0)
|
||||
{
|
||||
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||
result.ItemsSeeded += systemAccountsAdded;
|
||||
}
|
||||
|
||||
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||
result.Details = details;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public static class AppConstants
|
||||
{
|
||||
public const string CompanyAdmin = "CompanyAdmin";
|
||||
public const string Manager = "Manager";
|
||||
public const string Accountant = "Accountant";
|
||||
public const string Worker = "Worker";
|
||||
public const string Viewer = "Viewer";
|
||||
}
|
||||
@@ -58,6 +59,8 @@ public static class AppConstants
|
||||
public const string CanManageMaintenance = "CanManageMaintenance";
|
||||
public const string CanManageInvoices = "CanManageInvoices";
|
||||
public const string CanViewReports = "CanViewReports";
|
||||
public const string CanManageBills = "CanManageBills";
|
||||
public const string CanManageAccounting = "CanManageAccounting";
|
||||
}
|
||||
|
||||
public static class FileUpload
|
||||
@@ -101,8 +104,12 @@ public static class AppConstants
|
||||
public const string AccountSuggest = "AccountSuggest";
|
||||
public const string ArFollowUp = "ArFollowUp";
|
||||
public const string FinancialSummary = "FinancialSummary";
|
||||
public const string CashFlowForecast = "CashFlowForecast";
|
||||
public const string AnomalyDetection = "AnomalyDetection";
|
||||
public const string CashFlowForecast = "CashFlowForecast";
|
||||
public const string AnomalyDetection = "AnomalyDetection";
|
||||
public const string BankRecAutoMatch = "BankRecAutoMatch";
|
||||
public const string LatePaymentPrediction = "LatePaymentPrediction";
|
||||
public const string FinancialQuery = "FinancialQuery";
|
||||
public const string RecurringBillDetection = "RecurringBillDetection";
|
||||
}
|
||||
|
||||
public static class Legal
|
||||
@@ -134,4 +141,42 @@ public static class AppConstants
|
||||
public const int Layer3MinJobs = 150; // Minimum jobs with actual powder data before Layer 3 predictive features unlock
|
||||
public const int Layer2MinJobs = 10; // Minimum for efficiency trending to be meaningful
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// String codes stored in the JobStatusLookup and QuoteStatusLookup tables.
|
||||
/// Using constants here means a DB code rename only requires one code change,
|
||||
/// not a grep-and-replace across every controller.
|
||||
/// </summary>
|
||||
public static class StatusCodes
|
||||
{
|
||||
public static class Job
|
||||
{
|
||||
public const string Pending = "PENDING";
|
||||
public const string Quoted = "QUOTED";
|
||||
public const string Approved = "APPROVED";
|
||||
public const string InPreparation = "IN_PREPARATION";
|
||||
public const string Sandblasting = "SANDBLASTING";
|
||||
public const string MaskingTaping = "MASKING_TAPING";
|
||||
public const string Cleaning = "CLEANING";
|
||||
public const string InOven = "IN_OVEN";
|
||||
public const string Coating = "COATING";
|
||||
public const string Curing = "CURING";
|
||||
public const string QualityCheck = "QUALITY_CHECK";
|
||||
public const string Completed = "COMPLETED";
|
||||
public const string ReadyForPickup = "READY_FOR_PICKUP";
|
||||
public const string Delivered = "DELIVERED";
|
||||
public const string OnHold = "ON_HOLD";
|
||||
public const string Cancelled = "CANCELLED";
|
||||
}
|
||||
|
||||
public static class Quote
|
||||
{
|
||||
public const string Draft = "DRAFT";
|
||||
public const string Sent = "SENT";
|
||||
public const string Approved = "APPROVED";
|
||||
public const string Rejected = "REJECTED";
|
||||
public const string Converted = "CONVERTED";
|
||||
public const string Expired = "EXPIRED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OfficeOpenXml;
|
||||
using OfficeOpenXml.Style;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Drawing;
|
||||
@@ -207,6 +208,81 @@ public class AccountDataExportController : Controller
|
||||
writer.Write(content);
|
||||
}
|
||||
|
||||
// ── Data fetchers (single query per entity, superset of includes for both XLSX + CSV) ─────
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted customers for the company, including <c>PricingTier</c> (needed by
|
||||
/// the CSV path; harmlessly unused by the XLSX path). Bypasses the global tenant filter via
|
||||
/// <c>IgnoreQueryFilters</c> because <c>ITenantContext</c> may be null for expired accounts.
|
||||
/// </summary>
|
||||
private Task<List<Customer>> FetchCustomersAsync(int companyId) =>
|
||||
_db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(c => c.PricingTier)
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
||||
.OrderBy(c => c.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted jobs with Customer, JobStatus, and JobPriority included.</summary>
|
||||
private Task<List<Job>> FetchJobsAsync(int companyId) =>
|
||||
_db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted quotes with Customer and QuoteStatus included.
|
||||
/// The XLSX path only needs QuoteStatus; the CSV path also uses Customer — the superset here
|
||||
/// avoids a second query when both formats include this sheet in the same request.
|
||||
/// </summary>
|
||||
private Task<List<Quote>> FetchQuotesAsync(int companyId) =>
|
||||
_db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(q => q.Customer).Include(q => q.QuoteStatus)
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted invoices with Customer included.</summary>
|
||||
private Task<List<Invoice>> FetchInvoicesAsync(int companyId) =>
|
||||
_db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.Customer)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all non-deleted inventory items with PrimaryVendor and InventoryCategory included.
|
||||
/// Only the CSV path uses these navigations; XLSX reads only scalar fields but the join is cheap.
|
||||
/// </summary>
|
||||
private Task<List<InventoryItem>> FetchInventoryAsync(int companyId) =>
|
||||
_db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.PrimaryVendor).Include(i => i.InventoryCategory)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.OrderBy(i => i.Name).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted equipment records for the company.</summary>
|
||||
private Task<List<Equipment>> FetchEquipmentAsync(int companyId) =>
|
||||
_db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted)
|
||||
.OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted vendors for the company.</summary>
|
||||
private Task<List<Vendor>> FetchVendorsAsync(int companyId) =>
|
||||
_db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||
/// </summary>
|
||||
private Task<List<ApplicationUser>> FetchUsersAsync(int companyId) =>
|
||||
_db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId)
|
||||
.OrderBy(u => u.LastName).ToListAsync();
|
||||
|
||||
// ── Sheet builders ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -226,16 +302,9 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[1, 1, 3, 1].Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Customers" worksheet with one row per non-deleted customer belonging to the
|
||||
/// authenticated user's company. <c>IgnoreQueryFilters()</c> bypasses the global EF
|
||||
/// multi-tenancy filter (which relies on <c>ITenantContext</c>) in favour of the explicit
|
||||
/// <c>CompanyId == companyId</c> predicate, making the filter independent of middleware state.
|
||||
/// </summary>
|
||||
private async Task AddCustomersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Customers");
|
||||
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
|
||||
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
|
||||
@@ -256,18 +325,13 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Jobs" worksheet with one row per non-deleted job belonging to the company.
|
||||
/// Job status and priority are lookup-table entities (not enums) stored in
|
||||
/// <c>JobStatusLookup</c> and a parallel priority table; they are eagerly loaded so their
|
||||
/// <c>DisplayName</c> property is available without additional queries.
|
||||
/// If a lookup navigation is null (data anomaly), the raw FK integer is written as a fallback.
|
||||
/// Adds a "Jobs" worksheet. Job status and priority are lookup-table entities (not enums);
|
||||
/// they are eagerly loaded by <see cref="FetchJobsAsync"/> so their <c>DisplayName</c> is
|
||||
/// available without N+1 queries. Falls back to the raw FK integer on data anomalies.
|
||||
/// </summary>
|
||||
private async Task AddJobsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Jobs");
|
||||
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
|
||||
"Description", "Due Date", "Final Price", "Created At" };
|
||||
@@ -289,16 +353,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Quotes" worksheet with one row per non-deleted quote belonging to the company.
|
||||
/// Prospect-only quotes (before they are linked to a customer record) show
|
||||
/// <c>ProspectCompanyName</c>; fully linked quotes fall back to the customer FK integer when
|
||||
/// the navigation cannot be resolved — ensuring no row has a blank identifier column.
|
||||
/// Adds a "Quotes" worksheet. Prospect-only quotes show <c>ProspectCompanyName</c>;
|
||||
/// fully linked quotes fall back to <c>Customer #{id}</c> when the navigation is null.
|
||||
/// </summary>
|
||||
private async Task AddQuotesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Quotes");
|
||||
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
|
||||
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
|
||||
@@ -317,16 +377,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice belonging to the company.
|
||||
/// <c>BalanceDue</c> is a computed property (<c>Total - AmountPaid</c>) reflecting partial
|
||||
/// payment state without an additional aggregation query.
|
||||
/// Eagerly loads <c>Customer</c> so the customer name is available for the display column.
|
||||
/// Adds an "Invoices" worksheet. <c>BalanceDue</c> is a computed property on the entity
|
||||
/// (<c>Total - AmountPaid</c>) so no extra aggregation query is needed.
|
||||
/// </summary>
|
||||
private async Task AddInvoicesSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Invoices");
|
||||
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
|
||||
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
@@ -348,15 +404,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Inventory" worksheet with one row per non-deleted inventory item for the company.
|
||||
/// Items are ordered alphabetically so the exported list matches the order users typically
|
||||
/// see in the application's inventory index view.
|
||||
/// </summary>
|
||||
private async Task AddInventorySheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
|
||||
var data = await FetchInventoryAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Inventory");
|
||||
var headers = new[] { "ID", "Name", "SKU", "Category", "Qty on Hand",
|
||||
"Unit", "Unit Cost", "Reorder Point", "Manufacturer", "Color" };
|
||||
@@ -373,14 +423,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Equipment" worksheet with one row per non-deleted equipment record for the company.
|
||||
/// Equipment status is stored as an enum and serialised via <c>ToString()</c> for a readable label.
|
||||
/// </summary>
|
||||
private async Task AddEquipmentSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
var data = await FetchEquipmentAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Equipment");
|
||||
var headers = new[] { "ID", "Name", "Type", "Serial Number", "Model",
|
||||
"Status", "Purchase Date", "Purchase Price", "Next Maintenance" };
|
||||
@@ -398,13 +443,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Vendors" worksheet with one row per non-deleted vendor for the company.
|
||||
/// </summary>
|
||||
private async Task AddVendorsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
|
||||
var data = await FetchVendorsAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Vendors");
|
||||
var headers = new[] { "ID", "Company Name", "Contact", "Email", "Phone", "City", "State", "Preferred", "Active" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -421,13 +462,9 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the company.
|
||||
/// </summary>
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -443,16 +480,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Users" worksheet with one row per user belonging to the company.
|
||||
/// The <c>IsDeleted</c> predicate is intentionally omitted because ASP.NET Identity users
|
||||
/// use <c>IsActive = false</c> as their soft-deletion mechanism, not the base-entity
|
||||
/// <c>IsDeleted</c> flag. All users (active and inactive) are included so the export
|
||||
/// provides a complete workforce record for compliance and audit purposes.
|
||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||
/// </summary>
|
||||
private async Task AddUsersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
|
||||
var data = await FetchUsersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Users");
|
||||
var headers = new[] { "ID", "First Name", "Last Name", "Email", "Role", "Active", "Hire Date", "Last Login", "Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
@@ -472,15 +505,12 @@ public class AccountDataExportController : Controller
|
||||
// ── CSV builders ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds the customers CSV string for the company.
|
||||
/// Column names match <see cref="CustomerImportDto"/> exactly so the file can be re-imported
|
||||
/// Column names match <c>CustomerImportDto</c> exactly so the file can be re-imported
|
||||
/// via Tools → Bulk Import without any manual header editing.
|
||||
/// </summary>
|
||||
private async Task<string> BuildCustomersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(c => c.PricingTier)
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
|
||||
foreach (var c in data)
|
||||
@@ -492,16 +522,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the jobs CSV string for the company.
|
||||
/// Column names match <see cref="JobImportDto"/> exactly so the file can be re-imported.
|
||||
/// CustomerEmail is included (not the display name) because the importer resolves the customer FK by email.
|
||||
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
|
||||
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
|
||||
/// </summary>
|
||||
private async Task<string> BuildJobsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
foreach (var j in data)
|
||||
@@ -514,15 +540,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the quotes CSV string for the company.
|
||||
/// Column names match <see cref="QuoteImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>QuoteImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildQuotesCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
foreach (var q in data)
|
||||
@@ -536,15 +557,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the invoices CSV string for the company, ordered newest-first.
|
||||
/// Customer name resolution mirrors the XLSX sheet: company name preferred, with
|
||||
/// first+last name concatenation as the fallback for non-commercial customers.
|
||||
/// </summary>
|
||||
private async Task<string> BuildInvoicesCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Invoices.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
foreach (var inv in data)
|
||||
@@ -557,16 +575,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inventory CSV string for the company.
|
||||
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>InventoryItemImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildInventoryCsv(int companyId)
|
||||
{
|
||||
var data = await _db.InventoryItems.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(i => i.PrimaryVendor)
|
||||
.Include(i => i.InventoryCategory)
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted).OrderBy(i => i.Name).ToListAsync();
|
||||
var data = await FetchInventoryAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("SKU,ItemName,Description,CategoryName,Manufacturer,ManufacturerPartNumber,ColorName,ColorCode,Finish,VendorName,VendorPartNumber,QuantityInStock,UnitOfMeasure,UnitCost,LastPurchasePrice,ReorderPoint,ReorderQuantity,MinimumStock,MaximumStock,CoverageSqFtPerLb,TransferEfficiencyPct,Location,IsActive,Notes");
|
||||
foreach (var i in data)
|
||||
@@ -577,14 +589,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the equipment CSV string for the company.
|
||||
/// Column names match <see cref="EquipmentImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>EquipmentImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildEquipmentCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Equipment.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted).OrderBy(e => e.EquipmentName).ToListAsync();
|
||||
var data = await FetchEquipmentAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("EquipmentName,EquipmentNumber,EquipmentType,Manufacturer,Model,SerialNumber,PurchaseDate,PurchasePrice,WarrantyExpiration,Location,RecommendedMaintenanceIntervalDays,Status,IsActive,Notes");
|
||||
foreach (var e in data)
|
||||
@@ -592,14 +600,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the vendors CSV string for the company.
|
||||
/// Column names match <see cref="VendorImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>VendorImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildVendorsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Vendors.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted).OrderBy(s => s.CompanyName).ToListAsync();
|
||||
var data = await FetchVendorsAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactName,Email,Phone,Address,City,State,ZipCode,Country,Website,AccountNumber,TaxId,PaymentTerms,CreditLimit,IsPreferred,IsActive,Notes");
|
||||
foreach (var s in data)
|
||||
@@ -607,14 +611,10 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shop workers CSV string for the company.
|
||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
@@ -623,15 +623,12 @@ public class AccountDataExportController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the users CSV string for the company.
|
||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> predicate is omitted because
|
||||
/// Identity users use <c>IsActive</c> for soft-deletion; all users are exported for
|
||||
/// completeness and compliance.
|
||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||
/// </summary>
|
||||
private async Task<string> BuildUsersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.Users.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(u => u.CompanyId == companyId).OrderBy(u => u.LastName).ToListAsync();
|
||||
var data = await FetchUsersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,First Name,Last Name,Email,Role,Active,Hire Date,Last Login,Created At");
|
||||
foreach (var u in data)
|
||||
|
||||
@@ -427,6 +427,186 @@ public class AccountsController : Controller
|
||||
return View(ledger);
|
||||
}
|
||||
|
||||
// ── Year-End Close ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET: landing page showing close history and a form to initiate the current year close.
|
||||
/// Companyid is resolved from tenant context; year defaults to the prior fiscal year
|
||||
/// (the most common use case — close last year after final entries are posted).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> YearEndClose()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
||||
.OrderByDescending(y => y.ClosedYear)
|
||||
.ToList();
|
||||
|
||||
ViewBag.History = history;
|
||||
ViewBag.SuggestedYear = DateTime.Now.Year - 1;
|
||||
ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST: executes the year-end close for the specified fiscal year.
|
||||
/// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances
|
||||
/// (debit-normal), computes net income, posts a JE that zeroes them into Retained
|
||||
/// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has
|
||||
/// already been closed is rejected.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> CloseYear(int year)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Idempotency check
|
||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Load all active accounts with balances
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
||||
|
||||
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||
var expenseAccounts = accounts.Where(a =>
|
||||
a.AccountType == AccountType.Expense ||
|
||||
a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList();
|
||||
|
||||
// Find or locate the Retained Earnings account
|
||||
var retainedEarnings = accounts.FirstOrDefault(a =>
|
||||
a.AccountSubType == AccountSubType.RetainedEarnings);
|
||||
|
||||
if (retainedEarnings == null)
|
||||
{
|
||||
TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Net income = total revenue credits − total expense debits
|
||||
var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance);
|
||||
var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance);
|
||||
var netIncome = totalRevenue - totalExpenses;
|
||||
|
||||
if (totalRevenue == 0 && totalExpenses == 0)
|
||||
{
|
||||
TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
int newJeId = 0;
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var lines = new List<JournalEntryLine>();
|
||||
|
||||
// Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0)
|
||||
foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0)
|
||||
foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Plug the net into Retained Earnings: CR if profit, DR if loss
|
||||
if (netIncome > 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
CreditAmount = netIncome,
|
||||
Description = $"Net income {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome);
|
||||
}
|
||||
else if (netIncome < 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
DebitAmount = Math.Abs(netIncome),
|
||||
Description = $"Net loss {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome));
|
||||
}
|
||||
|
||||
// Post the JE
|
||||
var prefix = $"JE-{year % 100:D2}12-";
|
||||
var existing2 = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
int next = existing2.Any()
|
||||
? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||
: 1;
|
||||
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = $"{prefix}{next:D4}",
|
||||
EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
Description = $"Year-end close — {year}",
|
||||
Reference = $"CLOSE-{year}",
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = User.Identity?.Name,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = lines
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Record the close
|
||||
var close = new YearEndClose
|
||||
{
|
||||
ClosedYear = year,
|
||||
ClosedAt = DateTime.UtcNow,
|
||||
ClosedBy = User.Identity?.Name,
|
||||
JournalEntryId = je.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.YearEndCloses.AddAsync(close);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
newJeId = je.Id;
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " +
|
||||
$"See Journal Entry for details.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -102,7 +102,7 @@ public class AiQuickQuoteController : Controller
|
||||
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
|
||||
|
||||
// Draft status — nullable FK, gracefully absent if lookup not seeded
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
|
||||
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -117,14 +117,7 @@ public class AppointmentsController : Controller
|
||||
// Map to DTOs
|
||||
var appointmentDtos = _mapper.Map<List<AppointmentListDto>>(items);
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<AppointmentListDto>
|
||||
{
|
||||
Items = appointmentDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<AppointmentListDto>.From(gridRequest, appointmentDtos, totalCount);
|
||||
|
||||
// Set ViewBag
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -550,7 +543,7 @@ public class AppointmentsController : Controller
|
||||
j => j.Customer,
|
||||
j => j.JobStatus);
|
||||
|
||||
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
|
||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||
var jobsInRange = allJobs.Where(j =>
|
||||
!terminalCodes.Contains(j.JobStatus.StatusCode) &&
|
||||
((j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date >= start.Date && j.ScheduledDate.Value.Date <= end.Date) ||
|
||||
@@ -625,16 +618,16 @@ public class AppointmentsController : Controller
|
||||
|
||||
return statusCode switch
|
||||
{
|
||||
"PENDING" or "QUOTED" => "#6c757d", // Gray
|
||||
AppConstants.StatusCodes.Job.Pending or "QUOTED" => "#6c757d", // Gray
|
||||
"APPROVED" => "#0dcaf0", // Cyan
|
||||
"IN_PREPARATION" or "SANDBLASTING" or
|
||||
"MASKING_TAPING" or "CLEANING" => "#0d6efd", // Blue
|
||||
"IN_OVEN" or "CURING" => "#fd7e14", // Orange
|
||||
"COATING" => "#6610f2", // Indigo
|
||||
"QUALITY_CHECK" => "#20c997", // Teal
|
||||
"COMPLETED" or "DELIVERED" or "READY_FOR_PICKUP" => "#198754", // Green
|
||||
"ON_HOLD" => "#ffc107", // Yellow
|
||||
"CANCELLED" => "#adb5bd", // Light gray
|
||||
AppConstants.StatusCodes.Job.InPreparation or AppConstants.StatusCodes.Job.Sandblasting or
|
||||
AppConstants.StatusCodes.Job.MaskingTaping or AppConstants.StatusCodes.Job.Cleaning => "#0d6efd", // Blue
|
||||
AppConstants.StatusCodes.Job.InOven or AppConstants.StatusCodes.Job.Curing => "#fd7e14", // Orange
|
||||
AppConstants.StatusCodes.Job.Coating => "#6610f2", // Indigo
|
||||
AppConstants.StatusCodes.Job.QualityCheck => "#20c997", // Teal
|
||||
AppConstants.StatusCodes.Job.Completed or AppConstants.StatusCodes.Job.Delivered or AppConstants.StatusCodes.Job.ReadyForPickup => "#198754", // Green
|
||||
AppConstants.StatusCodes.Job.OnHold => "#ffc107", // Yellow
|
||||
AppConstants.StatusCodes.Job.Cancelled => "#adb5bd", // Light gray
|
||||
_ => "#0d6efd"
|
||||
};
|
||||
}
|
||||
@@ -752,7 +745,7 @@ public class AppointmentsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
|
||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
||||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||||
|
||||
@@ -876,27 +869,18 @@ public class AppointmentsController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateAppointmentNumberAsync()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var prefix = $"APT-{now:yyMM}-";
|
||||
|
||||
// Get all appointments for current month (including soft-deleted)
|
||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(ignoreQueryFilters: true);
|
||||
|
||||
var monthAppointments = allAppointments
|
||||
.Where(a => a.AppointmentNumber.StartsWith(prefix))
|
||||
var prefix = $"APT-{DateTime.UtcNow:yyMM}-";
|
||||
var last = (await _unitOfWork.Appointments.FindAsync(
|
||||
a => a.AppointmentNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(a => a.AppointmentNumber)
|
||||
.ToList();
|
||||
.Select(a => a.AppointmentNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
var lastNumber = 0;
|
||||
if (monthAppointments.Any())
|
||||
{
|
||||
var lastAppointmentNumber = monthAppointments.First().AppointmentNumber;
|
||||
var numberPart = lastAppointmentNumber.Split('-').Last();
|
||||
int.TryParse(numberPart, out lastNumber);
|
||||
}
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
next = num + 1;
|
||||
|
||||
var newNumber = lastNumber + 1;
|
||||
return $"{prefix}{newNumber:D4}";
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||
public class BankReconciliationsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IAccountingAiService _accountingAi;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
|
||||
public BankReconciliationsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
IAccountingAiService accountingAi,
|
||||
IAiUsageLogger usageLogger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_accountingAi = accountingAi;
|
||||
_usageLogger = usageLogger;
|
||||
}
|
||||
|
||||
private bool AllowAccounting() =>
|
||||
User.IsInRole("SuperAdmin") || User.IsInRole("Administrator") || User.IsInRole("Manager");
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists all reconciliation sessions for the company, newest first.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var all = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId,
|
||||
false,
|
||||
br => br.Account))
|
||||
.OrderByDescending(br => br.StatementDate)
|
||||
.ThenByDescending(br => br.Id)
|
||||
.ToList();
|
||||
|
||||
return View(all);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
await PopulateAccountDropdownAsync();
|
||||
return View(new BankReconciliation { StatementDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BankReconciliation model)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Set beginning balance from last completed reconciliation for this account, or 0
|
||||
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId
|
||||
&& br.AccountId == model.AccountId
|
||||
&& br.Status == BankReconciliationStatus.Completed))
|
||||
.OrderByDescending(br => br.StatementDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
model.BeginningBalance = lastCompleted?.EndingBalance ?? 0;
|
||||
model.Status = BankReconciliationStatus.InProgress;
|
||||
|
||||
await _unitOfWork.BankReconciliations.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Reconciliation started.";
|
||||
return RedirectToAction(nameof(Reconcile), new { id = model.Id });
|
||||
}
|
||||
|
||||
// ── Reconcile (Working View) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Main working view. Shows all uncleared transactions for the account up to StatementDate
|
||||
/// in two sections (deposits/credits and payments/debits) with checkboxes.
|
||||
/// Running cleared balance and difference update via JS as the user checks items.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Reconcile(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == id,
|
||||
false,
|
||||
br => br.Account))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (recon == null) return NotFound();
|
||||
if (recon.Status == BankReconciliationStatus.Completed)
|
||||
return RedirectToAction(nameof(Report), new { id });
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
var statementDate = recon.StatementDate;
|
||||
|
||||
// Customer payments deposited to this account
|
||||
var deposits = (await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate))
|
||||
.Select(p => new ReconciliationItem
|
||||
{
|
||||
EntityType = "Payment",
|
||||
EntityId = p.Id,
|
||||
Date = p.PaymentDate,
|
||||
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||
Description = $"Payment #{p.InvoiceId}",
|
||||
Amount = p.Amount,
|
||||
IsCleared = p.IsCleared
|
||||
}).ToList();
|
||||
|
||||
// Bill payments out of this account (debits — shown as negative in deposits)
|
||||
var billPayments = (await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate))
|
||||
.Select(bp => new ReconciliationItem
|
||||
{
|
||||
EntityType = "BillPayment",
|
||||
EntityId = bp.Id,
|
||||
Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber,
|
||||
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||
Amount = bp.Amount,
|
||||
IsCleared = bp.IsCleared
|
||||
}).ToList();
|
||||
|
||||
// Direct expenses out of this account
|
||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.Date <= statementDate))
|
||||
.Select(e => new ReconciliationItem
|
||||
{
|
||||
EntityType = "Expense",
|
||||
EntityId = e.Id,
|
||||
Date = e.Date,
|
||||
Reference = e.ExpenseNumber,
|
||||
Description = e.Memo ?? string.Empty,
|
||||
Amount = e.Amount,
|
||||
IsCleared = e.IsCleared
|
||||
}).ToList();
|
||||
|
||||
ViewBag.Recon = recon;
|
||||
ViewBag.Deposits = deposits;
|
||||
ViewBag.Payments = billPayments.Concat(expenses).OrderBy(p => p.Date).ToList();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
// ── ToggleCleared (AJAX) ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint. Marks a Payment, BillPayment, or Expense as cleared/uncleared.
|
||||
/// Returns updated running totals as JSON.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleCleared(
|
||||
int reconId, string entityType, int entityId, bool isCleared)
|
||||
{
|
||||
if (!AllowAccounting()) return Forbid();
|
||||
|
||||
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(reconId);
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var now = isCleared ? DateTime.UtcNow : (DateTime?)null;
|
||||
|
||||
switch (entityType)
|
||||
{
|
||||
case "Payment":
|
||||
var payment = await _unitOfWork.Payments.GetByIdAsync(entityId);
|
||||
if (payment != null) { payment.IsCleared = isCleared; payment.ClearedDate = now; }
|
||||
break;
|
||||
case "BillPayment":
|
||||
var bp = await _unitOfWork.BillPayments.GetByIdAsync(entityId);
|
||||
if (bp != null) { bp.IsCleared = isCleared; bp.ClearedDate = now; }
|
||||
break;
|
||||
case "Expense":
|
||||
var exp = await _unitOfWork.Expenses.GetByIdAsync(entityId);
|
||||
if (exp != null) { exp.IsCleared = isCleared; exp.ClearedDate = now; }
|
||||
break;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
// ── Complete ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Complete(int id, decimal difference)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
if (Math.Abs(difference) > 0.005m)
|
||||
{
|
||||
TempData["Error"] = $"Cannot complete: difference is {difference:C}. Must be $0.00.";
|
||||
return RedirectToAction(nameof(Reconcile), new { id });
|
||||
}
|
||||
|
||||
var recon = await _unitOfWork.BankReconciliations.GetByIdAsync(id);
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
recon.Status = BankReconciliationStatus.Completed;
|
||||
recon.CompletedAt = DateTime.UtcNow;
|
||||
recon.CompletedBy = User.Identity?.Name;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Reconciliation completed.";
|
||||
return RedirectToAction(nameof(Report), new { id });
|
||||
}
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Printable view of a completed reconciliation.</summary>
|
||||
public async Task<IActionResult> Report(int id)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == id,
|
||||
false,
|
||||
br => br.Account))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
|
||||
var clearedDeposits = (await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.IsCleared && p.PaymentDate <= recon.StatementDate))
|
||||
.ToList();
|
||||
|
||||
var clearedPayments = new List<ReconciliationItem>();
|
||||
(await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.IsCleared && bp.PaymentDate <= recon.StatementDate))
|
||||
.ToList()
|
||||
.ForEach(bp => clearedPayments.Add(new ReconciliationItem
|
||||
{
|
||||
EntityType = "BillPayment", EntityId = bp.Id, Date = bp.PaymentDate,
|
||||
Reference = bp.PaymentNumber, Amount = bp.Amount, IsCleared = true
|
||||
}));
|
||||
(await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.IsCleared && e.Date <= recon.StatementDate))
|
||||
.ToList()
|
||||
.ForEach(e => clearedPayments.Add(new ReconciliationItem
|
||||
{
|
||||
EntityType = "Expense", EntityId = e.Id, Date = e.Date,
|
||||
Reference = e.ExpenseNumber, Amount = e.Amount, IsCleared = true
|
||||
}));
|
||||
|
||||
ViewBag.ClearedDeposits = clearedDeposits;
|
||||
ViewBag.ClearedPayments = clearedPayments.OrderBy(p => p.Date).ToList();
|
||||
|
||||
return View(recon);
|
||||
}
|
||||
|
||||
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
|
||||
/// to mark as cleared. The controller assembles all three transaction types (deposits,
|
||||
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
|
||||
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
|
||||
/// suggestions client-side by auto-checking the corresponding table rows.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiSuggestMatches(int reconId)
|
||||
{
|
||||
if (!AllowAccounting()) return Forbid();
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == reconId, false, br => br.Account))
|
||||
.FirstOrDefault();
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
var statementDate = recon.StatementDate;
|
||||
|
||||
var items = new List<BankRecMatchItem>();
|
||||
|
||||
(await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(p => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "Payment",
|
||||
EntityId = p.Id,
|
||||
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
|
||||
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||
Description = $"Payment #{p.InvoiceId}",
|
||||
Amount = p.Amount,
|
||||
Direction = "deposit"
|
||||
}));
|
||||
|
||||
(await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(bp => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "BillPayment",
|
||||
EntityId = bp.Id,
|
||||
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
|
||||
Reference = bp.PaymentNumber,
|
||||
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||
Amount = bp.Amount,
|
||||
Direction = "payment"
|
||||
}));
|
||||
|
||||
(await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(e => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "Expense",
|
||||
EntityId = e.Id,
|
||||
Date = e.Date.ToString("yyyy-MM-dd"),
|
||||
Reference = e.ExpenseNumber,
|
||||
Description = e.Memo ?? string.Empty,
|
||||
Amount = e.Amount,
|
||||
Direction = "payment"
|
||||
}));
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
|
||||
|
||||
var request = new AutoMatchRequest
|
||||
{
|
||||
UnclearedItems = items,
|
||||
BeginningBalance = recon.BeginningBalance,
|
||||
StatementEndingBalance = recon.EndingBalance
|
||||
};
|
||||
|
||||
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive
|
||||
&& (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Savings
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
|
||||
ViewBag.AccountSelectList = accounts
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem
|
||||
{
|
||||
Value = a.Id.ToString(),
|
||||
Text = $"{a.AccountNumber} – {a.Name}"
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>View model for a single reconcileable transaction row.</summary>
|
||||
public class ReconciliationItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public bool IsCleared { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
@@ -15,6 +16,7 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Application.DTOs.PurchaseOrder;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -56,13 +58,13 @@ public class BillsController : Controller
|
||||
_usageLogger = usageLogger;
|
||||
}
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
// -- Index ----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
||||
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
||||
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
||||
@@ -110,7 +112,7 @@ public class BillsController : Controller
|
||||
}));
|
||||
}
|
||||
|
||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
||||
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
||||
{
|
||||
var expSearch = search;
|
||||
@@ -158,13 +160,13 @@ public class BillsController : Controller
|
||||
return View(pagedEntries);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
// -- Create ---------------------------------------------------------------
|
||||
|
||||
// ── Create from Purchase Order ────────────────────────────────────────────
|
||||
// -- Create from Purchase Order --------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
||||
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
||||
/// Line items are copied from PO items (using inventory item names where available), and
|
||||
@@ -172,7 +174,7 @@ public class BillsController : Controller
|
||||
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
||||
/// first active Expense/COGS account when the vendor has no default configured.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
@@ -246,7 +248,7 @@ public class BillsController : Controller
|
||||
return View("Create", dto);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
// -- Create ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
||||
@@ -255,7 +257,7 @@ public class BillsController : Controller
|
||||
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
||||
/// so the double-entry pair is ready without manual lookup.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Create(int? vendorId)
|
||||
{
|
||||
var dto = new CreateBillDto
|
||||
@@ -289,14 +291,14 @@ public class BillsController : Controller
|
||||
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
||||
/// before validation to avoid spurious errors when the browser submits blank rows.
|
||||
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||
/// useful for entering historical bills that were already settled. Account balance side
|
||||
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
||||
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
||||
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
||||
bool payNow = false,
|
||||
DateTime? paymentDate = null,
|
||||
@@ -320,82 +322,102 @@ public class BillsController : Controller
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var bill = _mapper.Map<Bill>(dto);
|
||||
bill.BillNumber = await GenerateBillNumberAsync();
|
||||
bill.Status = BillStatus.Open;
|
||||
bill.CompanyId = currentUser!.CompanyId;
|
||||
bill.CreatedBy = currentUser.Email;
|
||||
|
||||
// Calculate financials
|
||||
int order = 0;
|
||||
foreach (var li in bill.LineItems)
|
||||
// Period lock check — block if the bill date is in a locked period
|
||||
if (currentUser != null)
|
||||
{
|
||||
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
|
||||
li.DisplayOrder = order++;
|
||||
li.CompanyId = currentUser.CompanyId;
|
||||
}
|
||||
|
||||
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Attach receipt file if provided
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
if (IsValidReceiptFile(receiptFile, out var fileError))
|
||||
bill.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||||
else
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// Link bill back to source PO if created from one
|
||||
if (dto.PurchaseOrderId > 0)
|
||||
{
|
||||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||||
if (po != null)
|
||||
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
|
||||
{
|
||||
po.BillId = bill.Id;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Record payment immediately if "already paid" was checked
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
|
||||
Bill? bill = null;
|
||||
|
||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||
// atomically so a payNow failure cannot leave a bill with no payment record.
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var payment = new BillPayment
|
||||
bill = _mapper.Map<Bill>(dto);
|
||||
bill.BillNumber = await GenerateBillNumberAsync();
|
||||
bill.Status = BillStatus.Open;
|
||||
bill.CompanyId = currentUser!.CompanyId;
|
||||
bill.CreatedBy = currentUser.Email;
|
||||
|
||||
// Calculate financials
|
||||
int order = 0;
|
||||
foreach (var li in bill.LineItems)
|
||||
{
|
||||
BillId = bill.Id,
|
||||
VendorId = bill.VendorId,
|
||||
PaymentNumber = await GeneratePaymentNumberAsync(),
|
||||
PaymentDate = paymentDate ?? DateTime.Today,
|
||||
Amount = bill.Total,
|
||||
PaymentMethod = (PaymentMethod)paymentMethod.Value,
|
||||
BankAccountId = bankAccountId.Value,
|
||||
CheckNumber = checkNumber,
|
||||
Memo = paymentMemo,
|
||||
CompanyId = bill.CompanyId,
|
||||
CreatedBy = currentUser.Email
|
||||
};
|
||||
li.Amount = Math.Round(li.Quantity * li.UnitPrice, 2);
|
||||
li.DisplayOrder = order++;
|
||||
li.CompanyId = currentUser.CompanyId;
|
||||
}
|
||||
|
||||
bill.AmountPaid = payment.Amount;
|
||||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||||
bill.SubTotal = bill.LineItems.Sum(li => li.Amount);
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * (dto.TaxPercent / 100m), 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
await _unitOfWork.Bills.AddAsync(bill);
|
||||
await _unitOfWork.CompleteAsync(); // flush to get bill.Id
|
||||
|
||||
// Link bill back to source PO
|
||||
if (dto.PurchaseOrderId > 0)
|
||||
{
|
||||
var po = await _unitOfWork.PurchaseOrders.GetByIdAsync(dto.PurchaseOrderId!.Value);
|
||||
if (po != null)
|
||||
{
|
||||
po.BillId = bill.Id;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
// Record payment immediately if "already paid" was checked
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue)
|
||||
{
|
||||
var payment = new BillPayment
|
||||
{
|
||||
BillId = bill.Id,
|
||||
VendorId = bill.VendorId,
|
||||
PaymentNumber = await GeneratePaymentNumberAsync(),
|
||||
PaymentDate = paymentDate ?? DateTime.Today,
|
||||
Amount = bill.Total,
|
||||
PaymentMethod = (PaymentMethod)paymentMethod.Value,
|
||||
BankAccountId = bankAccountId.Value,
|
||||
CheckNumber = checkNumber,
|
||||
Memo = paymentMemo,
|
||||
CompanyId = bill.CompanyId,
|
||||
CreatedBy = currentUser.Email
|
||||
};
|
||||
|
||||
bill.AmountPaid = payment.Amount;
|
||||
bill.Status = bill.AmountPaid >= bill.Total ? BillStatus.Paid : BillStatus.PartiallyPaid;
|
||||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||||
}
|
||||
|
||||
await _unitOfWork.BillPayments.AddAsync(payment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} saved and marked as paid.";
|
||||
}
|
||||
else
|
||||
// Receipt upload after the transaction commits — bill.Id is set and core data
|
||||
// is secure. A blob failure here leaves the bill intact without an attachment.
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
TempData["Success"] = $"Bill {bill.BillNumber} created.";
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (receiptValid)
|
||||
{
|
||||
bill!.ReceiptFilePath = await UploadReceiptAsync(receiptFile, bill.Id, currentUser.CompanyId);
|
||||
await _unitOfWork.Bills.UpdateAsync(bill);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
else
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id = bill.Id });
|
||||
TempData["Success"] = payNow && paymentMethod.HasValue && bankAccountId.HasValue
|
||||
? $"Bill {bill!.BillNumber} saved and marked as paid."
|
||||
: $"Bill {bill!.BillNumber} created.";
|
||||
return RedirectToAction(nameof(Details), new { id = bill!.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -406,7 +428,7 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── Details ──────────────────────────────────────────────────────────────
|
||||
// -- Details --------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Displays full bill detail including line items, payments, and the payment entry form.
|
||||
@@ -432,7 +454,7 @@ public class BillsController : Controller
|
||||
.ToList();
|
||||
|
||||
ViewBag.BankAccounts = bankAccounts
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
@@ -442,7 +464,7 @@ public class BillsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// ── Edit ─────────────────────────────────────────────────────────────────
|
||||
// -- Edit -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
||||
@@ -450,7 +472,7 @@ public class BillsController : Controller
|
||||
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
||||
/// audit trail.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Edit(int? id)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
@@ -501,7 +523,7 @@ public class BillsController : Controller
|
||||
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
||||
{
|
||||
if (id != dto.Id) return NotFound();
|
||||
@@ -571,7 +593,8 @@ public class BillsController : Controller
|
||||
// Handle receipt file replacement
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
if (IsValidReceiptFile(receiptFile, out var fileError))
|
||||
var (receiptValid, _, receiptError) = BlobFileHelper.ValidateUpload(receiptFile, AllowedReceiptTypes, MaxReceiptBytes);
|
||||
if (receiptValid)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(bill.ReceiptFilePath))
|
||||
await _blobStorage.DeleteAsync(_storageSettings.Containers.ReceiptImages, bill.ReceiptFilePath);
|
||||
@@ -579,7 +602,7 @@ public class BillsController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {fileError}";
|
||||
TempData["Warning"] = $"Bill saved but receipt not uploaded: {receiptError}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,7 +620,7 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark Open (Draft → Open) ─────────────────────────────────────────────
|
||||
// -- Mark Open (Draft ? Open) ---------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
||||
@@ -608,7 +631,7 @@ public class BillsController : Controller
|
||||
/// deferred from bill creation to give users a review window without polluting the ledger.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> MarkOpen(int id)
|
||||
{
|
||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
||||
@@ -646,7 +669,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Record Payment ───────────────────────────────────────────────────────
|
||||
// -- Record Payment -------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
||||
@@ -658,7 +681,7 @@ public class BillsController : Controller
|
||||
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
@@ -729,7 +752,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// ── Delete Payment ───────────────────────────────────────────────────────
|
||||
// -- Delete Payment -------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reverses a previously recorded payment. All double-entry effects of
|
||||
@@ -739,7 +762,7 @@ public class BillsController : Controller
|
||||
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
||||
{
|
||||
try
|
||||
@@ -786,7 +809,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = billId });
|
||||
}
|
||||
|
||||
// ── Edit Payment ─────────────────────────────────────────────────────────
|
||||
// -- Edit Payment ---------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
||||
@@ -795,7 +818,7 @@ public class BillsController : Controller
|
||||
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
@@ -840,11 +863,11 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// ── Void ─────────────────────────────────────────────────────────────────
|
||||
// -- Void -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
||||
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
||||
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
||||
@@ -899,7 +922,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── AJAX: Vendor default expense account ────────────────────────────────
|
||||
// -- AJAX: Vendor default expense account --------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
||||
@@ -917,7 +940,7 @@ public class BillsController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
// -- Helpers --------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
||||
@@ -927,48 +950,13 @@ public class BillsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.IsActive);
|
||||
ViewBag.Vendors = vendors
|
||||
.OrderBy(s => s.CompanyName)
|
||||
.Select(s => new SelectListItem(s.CompanyName, s.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
|
||||
ViewBag.APAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.ExpenseAccounts = allAccounts
|
||||
.Where(a => a.AccountType == AccountType.Expense ||
|
||||
a.AccountType == AccountType.CostOfGoods ||
|
||||
a.AccountType == AccountType.Asset)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.BankAccounts = allAccounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
||||
a.AccountSubType == AccountSubType.Checking ||
|
||||
a.AccountSubType == AccountSubType.Savings ||
|
||||
a.AccountSubType == AccountSubType.CreditCard)
|
||||
.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();
|
||||
|
||||
ViewBag.Jobs = (await _unitOfWork.Jobs.FindAsync(j =>
|
||||
j.JobStatus.StatusCode != "COMPLETED" &&
|
||||
j.JobStatus.StatusCode != "CANCELLED" &&
|
||||
j.JobStatus.StatusCode != "DELIVERED"))
|
||||
.OrderBy(j => j.JobNumber)
|
||||
.Select(j => new SelectListItem($"{j.JobNumber} – {j.Description ?? "No description"}", j.Id.ToString()))
|
||||
.ToList();
|
||||
var dd = await AccountingDropdownHelper.LoadAsync(_unitOfWork);
|
||||
ViewBag.Vendors = dd.Vendors;
|
||||
ViewBag.APAccounts = dd.ApAccounts;
|
||||
ViewBag.ExpenseAccounts = dd.ExpenseAndAssetAccounts;
|
||||
ViewBag.BankAccounts = dd.BankAccounts;
|
||||
ViewBag.PaymentMethods = dd.PaymentMethods;
|
||||
ViewBag.Jobs = dd.ActiveJobs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -991,7 +979,7 @@ public class BillsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||
/// records are included in the scan so payment numbers are never reused.
|
||||
/// </summary>
|
||||
private async Task<string> GeneratePaymentNumberAsync()
|
||||
@@ -1006,7 +994,7 @@ public class BillsController : Controller
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
// ── Receipt File: Download / Remove ─────────────────────────────────────
|
||||
// -- Receipt File: Download / Remove -------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
||||
@@ -1023,7 +1011,7 @@ public class BillsController : Controller
|
||||
if (!result.Success) return NotFound();
|
||||
|
||||
var ext = Path.GetExtension(bill.ReceiptFilePath).ToLowerInvariant();
|
||||
var contentType = MimeFromExt(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
var fileName = $"receipt-{bill.BillNumber}{ext}";
|
||||
return File(result.Content, contentType, fileName);
|
||||
}
|
||||
@@ -1034,7 +1022,7 @@ public class BillsController : Controller
|
||||
/// window where the UI shows a broken attachment link.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> RemoveReceipt(int id)
|
||||
{
|
||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||||
@@ -1051,7 +1039,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
|
||||
// -- AI: Receipt Scanning -------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
||||
@@ -1063,7 +1051,7 @@ public class BillsController : Controller
|
||||
/// model can match categories to the company's specific chart of accounts.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
||||
{
|
||||
@@ -1104,7 +1092,7 @@ public class BillsController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── AI: Account Suggestion ────────────────────────────────────────────────
|
||||
// -- AI: Account Suggestion ------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
||||
@@ -1115,7 +1103,7 @@ public class BillsController : Controller
|
||||
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
||||
{
|
||||
@@ -1148,7 +1136,69 @@ public class BillsController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── Receipt File Helpers ──────────────────────────────────────────────────
|
||||
// -- AI: Recurring Bill Detection ------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
|
||||
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
||||
/// </summary>
|
||||
public IActionResult RecurringDetection() => View();
|
||||
|
||||
/// <summary>
|
||||
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
|
||||
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
||||
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
||||
/// Results are returned as JSON for client-side rendering in the view.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RunRecurringDetection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var cutoff = DateTime.Today.AddMonths(-12);
|
||||
|
||||
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||||
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||
.ToList();
|
||||
|
||||
if (!bills.Any())
|
||||
return Json(new RecurringBillDetectionResult
|
||||
{
|
||||
Success = true,
|
||||
Insights = new List<string> { "No bill history found in the last 12 months." }
|
||||
});
|
||||
|
||||
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
|
||||
|
||||
var request = new RecurringBillDetectionRequest
|
||||
{
|
||||
CompanyName = companyName,
|
||||
Bills = bills.Select(b => new RecurringBillHistoryItem
|
||||
{
|
||||
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
|
||||
BillNumber = b.BillNumber,
|
||||
Amount = b.Total,
|
||||
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
|
||||
Memo = b.Memo
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var result = await _accountingAi.DetectRecurringBillsAsync(request);
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
|
||||
return Json(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running recurring bill detection");
|
||||
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
|
||||
}
|
||||
}
|
||||
|
||||
// -- Receipt File Helpers --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a receipt file to Azure Blob Storage under the path
|
||||
@@ -1161,41 +1211,8 @@ public class BillsController : Controller
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
var blobName = $"{companyId}/bill-receipts/{billId}/{Guid.NewGuid()}{ext}";
|
||||
var contentType = MimeFromExt(ext);
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, contentType);
|
||||
var result = await _blobStorage.UploadAsync(_storageSettings.Containers.ReceiptImages, blobName, stream, BlobFileHelper.GetContentType(ext));
|
||||
return result.Success ? blobName : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a receipt file upload against the allowed extension list and the 10 MB size cap.
|
||||
/// Returns <c>false</c> and populates <paramref name="error"/> with a user-friendly message
|
||||
/// when the file fails either check; returns <c>true</c> and sets <paramref name="error"/> to
|
||||
/// an empty string when the file is acceptable.
|
||||
/// </summary>
|
||||
private static bool IsValidReceiptFile(IFormFile file, out string error)
|
||||
{
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedReceiptTypes.Contains(ext))
|
||||
{
|
||||
error = $"File type '{ext}' is not allowed. Accepted: {string.Join(", ", AllowedReceiptTypes)}";
|
||||
return false;
|
||||
}
|
||||
if (file.Length > MaxReceiptBytes)
|
||||
{
|
||||
error = "Receipt file must be 10 MB or smaller.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string MimeFromExt(string ext) => ext switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages annual budgets. Each budget has one BudgetLine per active GL account with
|
||||
/// monthly amounts (Jan–Dec). The Budget vs. Actual report compares these to real activity.
|
||||
/// Only one budget per year is marked IsDefault — that one feeds the variance report automatically.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class BudgetsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public BudgetsController(IUnitOfWork unitOfWork, ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
// ── Index ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
||||
.OrderByDescending(b => b.FiscalYear)
|
||||
.ThenBy(b => b.Name)
|
||||
.ToList();
|
||||
|
||||
return View(budgets);
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
return View(new BudgetCreateVm
|
||||
{
|
||||
FiscalYear = DateTime.Now.Year,
|
||||
Lines = accounts.Select(a => new BudgetLineVm { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, AccountType = a.AccountType }).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BudgetCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// If this is marked default, clear the flag on other budgets for the same year
|
||||
if (vm.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: null);
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Name = vm.Name,
|
||||
FiscalYear = vm.FiscalYear,
|
||||
Notes = vm.Notes,
|
||||
IsDefault = vm.IsDefault,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(budget);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" created for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = budget.Id });
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
var lineMap = budget.Lines.ToDictionary(l => l.AccountId);
|
||||
|
||||
var vm = new BudgetCreateVm
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
FiscalYear = budget.FiscalYear,
|
||||
Notes = budget.Notes,
|
||||
IsDefault = budget.IsDefault,
|
||||
Lines = accounts.Select(a =>
|
||||
{
|
||||
lineMap.TryGetValue(a.Id, out var existing);
|
||||
return new BudgetLineVm
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
AccountType = a.AccountType,
|
||||
Jan = existing?.Jan ?? 0, Feb = existing?.Feb ?? 0, Mar = existing?.Mar ?? 0,
|
||||
Apr = existing?.Apr ?? 0, May = existing?.May ?? 0, Jun = existing?.Jun ?? 0,
|
||||
Jul = existing?.Jul ?? 0, Aug = existing?.Aug ?? 0, Sep = existing?.Sep ?? 0,
|
||||
Oct = existing?.Oct ?? 0, Nov = existing?.Nov ?? 0, Dec = existing?.Dec ?? 0
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, BudgetCreateVm vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (vm.IsDefault && !budget.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: id);
|
||||
|
||||
budget.Name = vm.Name;
|
||||
budget.Notes = vm.Notes;
|
||||
budget.IsDefault = vm.IsDefault;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Delete old lines and replace with new set (simpler than merge)
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
budget.Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" saved.";
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
// ── Copy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of an existing budget for a new fiscal year — common workflow for
|
||||
/// rolling forward last year's budget as a starting point.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Copy(int id, int newYear)
|
||||
{
|
||||
var source = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (source == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var copy = new Budget
|
||||
{
|
||||
Name = $"{source.Name} ({newYear})",
|
||||
FiscalYear = newYear,
|
||||
Notes = source.Notes,
|
||||
IsDefault = false,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = source.Lines.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(copy);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget copied to {newYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = copy.Id });
|
||||
}
|
||||
|
||||
// ── SetDefault ────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetDefault(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
await ClearDefaultFlagAsync(companyId, budget.FiscalYear, excludeId: null);
|
||||
|
||||
budget.IsDefault = true;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"\"{budget.Name}\" is now the default budget for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
await _unitOfWork.Budgets.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||
}
|
||||
|
||||
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||
{
|
||||
var others = await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||
foreach (var b in others)
|
||||
{
|
||||
b.IsDefault = false;
|
||||
b.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
if (others.Any())
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── View Models ───────────────────────────────────────────────────────────────
|
||||
|
||||
public class BudgetCreateVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; } = DateTime.Now.Year;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = true;
|
||||
public List<BudgetLineVm> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BudgetLineVm
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
public bool HasAnyAmount => Annual != 0;
|
||||
}
|
||||
@@ -82,19 +82,38 @@ public class CompaniesController : Controller
|
||||
{
|
||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||
var companyById = companies.ToDictionary(c => c.Id);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var dto in companyDtos)
|
||||
{
|
||||
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
|
||||
|
||||
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
|
||||
{
|
||||
dto.WizardCompleted = true;
|
||||
dto.WizardCompletedAt = w.CompletedAt;
|
||||
dto.WizardCompleted = true;
|
||||
dto.WizardCompletedAt = w.CompletedAt;
|
||||
dto.WizardCompletedByName = w.CompletedByName;
|
||||
}
|
||||
|
||||
// Health badge
|
||||
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
|
||||
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
|
||||
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
|
||||
|
||||
if (companyById.TryGetValue(dto.Id, out var co))
|
||||
{
|
||||
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
|
||||
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
|
||||
&& dto.CreatedAt < now.AddDays(-7);
|
||||
dto.HealthScore = score;
|
||||
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
|
||||
}
|
||||
|
||||
dto.LastLoginDate = lastLogin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +202,8 @@ public class CompaniesController : Controller
|
||||
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||
c => c.Users,
|
||||
c => c.Customers,
|
||||
c => c.Jobs);
|
||||
c => c.Jobs,
|
||||
c => c.Preferences!);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -196,6 +216,51 @@ public class CompaniesController : Controller
|
||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
||||
|
||||
// Health data
|
||||
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
|
||||
var now = DateTime.UtcNow;
|
||||
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
|
||||
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
|
||||
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
|
||||
var totalJobs = companyDto.JobCount;
|
||||
var totalCust = companyDto.CustomerCount;
|
||||
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
|
||||
|
||||
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
|
||||
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
|
||||
&& company.CreatedAt < now.AddDays(-7);
|
||||
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
|
||||
|
||||
ViewBag.HealthScore = healthScore;
|
||||
ViewBag.HealthRisk = riskLevel.ToString();
|
||||
ViewBag.HealthSignals = healthSignals;
|
||||
ViewBag.Jobs30 = j30;
|
||||
ViewBag.Jobs90 = j90;
|
||||
ViewBag.LastLoginDate = lastLogin;
|
||||
|
||||
// Onboarding data (from Preferences)
|
||||
var prefs = company.Preferences;
|
||||
int steps = 0;
|
||||
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||
|
||||
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.CompanyName ?? "",
|
||||
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
|
||||
OnboardingPath = prefs?.OnboardingPath,
|
||||
StepsCompleted = steps,
|
||||
TotalSteps = 3,
|
||||
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||
};
|
||||
|
||||
return View(companyDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -118,15 +118,12 @@ public class CompanyHealthController : Controller
|
||||
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
||||
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
||||
|
||||
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||
|
||||
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||
&& c.CreatedAt < now.AddDays(-7);
|
||||
|
||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
||||
: score >= 75 ? ChurnRisk.Healthy
|
||||
: score >= 45 ? ChurnRisk.AtRisk
|
||||
: ChurnRisk.Critical;
|
||||
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||
|
||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||
@@ -187,112 +184,10 @@ public class CompanyHealthController : Controller
|
||||
return View(all);
|
||||
}
|
||||
|
||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a
|
||||
/// single company based on its subscription status, login recency, and job activity.
|
||||
/// <para>
|
||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
||||
/// <list type="bullet">
|
||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
||||
/// <item>Subscription within grace period: −30 pts.</item>
|
||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
||||
/// </list>
|
||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static (int score, List<string> signals) ComputeHealth(
|
||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
||||
int j30, int j90, int totalJobs, DateTime now)
|
||||
{
|
||||
var score = 100;
|
||||
var signals = new List<string>();
|
||||
|
||||
if (!c.IsActive)
|
||||
{
|
||||
signals.Add("Account disabled");
|
||||
return (0, signals);
|
||||
}
|
||||
|
||||
// Subscription health (skip for comped)
|
||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||
{
|
||||
score -= 50;
|
||||
signals.Add("Subscription expired");
|
||||
}
|
||||
else if (daysUntil < 0)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("In grace period");
|
||||
}
|
||||
else if (daysUntil <= 7)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
else if (daysUntil <= 14)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
}
|
||||
|
||||
// Login activity
|
||||
if (daysSinceLogin == -1)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("Never logged in");
|
||||
}
|
||||
else if (daysSinceLogin >= 90)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 60)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 30)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
|
||||
// Job activity
|
||||
if (totalJobs == 0)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add("No jobs ever");
|
||||
}
|
||||
else if (j90 == 0)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add("No jobs in 90d");
|
||||
}
|
||||
else if (j30 == 0)
|
||||
{
|
||||
score -= 5;
|
||||
signals.Add("No jobs in 30d");
|
||||
}
|
||||
|
||||
return (Math.Max(0, score), signals);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View models ────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||
|
||||
public class CompanyHealthDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||
|
||||
/// <summary>
|
||||
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||
/// </summary>
|
||||
public static class CompanyHealthHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a single
|
||||
/// company based on its subscription status, login recency, and job activity.
|
||||
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||
/// </summary>
|
||||
public static (int Score, List<string> Signals) ComputeHealth(
|
||||
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||
{
|
||||
var score = 100;
|
||||
var signals = new List<string>();
|
||||
|
||||
if (!c.IsActive)
|
||||
{
|
||||
signals.Add("Account disabled");
|
||||
return (0, signals);
|
||||
}
|
||||
|
||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||
{
|
||||
score -= 50;
|
||||
signals.Add("Subscription expired");
|
||||
}
|
||||
else if (daysUntil < 0)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("In grace period");
|
||||
}
|
||||
else if (daysUntil <= 7)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
else if (daysUntil <= 14)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
}
|
||||
|
||||
if (daysSinceLogin == -1)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("Never logged in");
|
||||
}
|
||||
else if (daysSinceLogin >= 90)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 60)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 30)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
|
||||
if (totalJobs == 0)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add("No jobs ever");
|
||||
}
|
||||
else if (j90 == 0)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add("No jobs in 90d");
|
||||
}
|
||||
else if (j30 == 0)
|
||||
{
|
||||
score -= 5;
|
||||
signals.Add("No jobs in 30d");
|
||||
}
|
||||
|
||||
return (Math.Max(0, score), signals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||
/// </summary>
|
||||
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||
neverActivated ? ChurnRisk.NeverActivated
|
||||
: score >= 75 ? ChurnRisk.Healthy
|
||||
: score >= 45 ? ChurnRisk.AtRisk
|
||||
: ChurnRisk.Critical;
|
||||
}
|
||||
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
|
||||
UpdatedAt = t.UpdatedAt
|
||||
}).ToList();
|
||||
|
||||
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
|
||||
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
|
||||
: null;
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (FormatException fex)
|
||||
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the books through the given date, preventing new or edited accounting entries
|
||||
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return BadRequest();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
company.BookLockedThrough = lockThrough.HasValue
|
||||
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
|
||||
: null;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = lockThrough.HasValue
|
||||
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
|
||||
: "Period lock cleared — all periods are now open.";
|
||||
|
||||
return RedirectToAction(nameof(Index), null, "company-info");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
||||
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
||||
|
||||
@@ -174,14 +174,7 @@ public class CompanyUsersController : Controller
|
||||
LastLoginDate = u.LastLoginDate
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<CompanyUserListDto>
|
||||
{
|
||||
Items = userDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<CompanyUserListDto>.From(gridRequest, userDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting and filters
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -284,6 +277,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin,
|
||||
AppConstants.CompanyRoles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant,
|
||||
AppConstants.CompanyRoles.Worker,
|
||||
AppConstants.CompanyRoles.Viewer
|
||||
};
|
||||
@@ -336,7 +330,9 @@ public class CompanyUsersController : Controller
|
||||
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
||||
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
||||
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
||||
CanViewReports = forceAllPermissions || model.CanViewReports
|
||||
CanViewReports = forceAllPermissions || model.CanViewReports,
|
||||
CanManageBills = forceAllPermissions || model.CanManageBills,
|
||||
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, model.Password);
|
||||
@@ -348,6 +344,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
|
||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||
_ => AppConstants.Roles.ReadOnly
|
||||
};
|
||||
@@ -461,7 +458,9 @@ public class CompanyUsersController : Controller
|
||||
CanManageVendors = user.CanManageVendors,
|
||||
CanManageMaintenance = user.CanManageMaintenance,
|
||||
CanManageInvoices = user.CanManageInvoices,
|
||||
CanViewReports = user.CanViewReports
|
||||
CanViewReports = user.CanViewReports,
|
||||
CanManageBills = user.CanManageBills,
|
||||
CanManageAccounting = user.CanManageAccounting
|
||||
};
|
||||
|
||||
ViewBag.ReturnUrl = returnUrl;
|
||||
@@ -545,6 +544,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin,
|
||||
AppConstants.CompanyRoles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant,
|
||||
AppConstants.CompanyRoles.Worker,
|
||||
AppConstants.CompanyRoles.Viewer
|
||||
};
|
||||
@@ -615,6 +615,8 @@ public class CompanyUsersController : Controller
|
||||
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
||||
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
||||
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
||||
user.CanManageBills = forceAllPermissions || model.CanManageBills;
|
||||
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
|
||||
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
||||
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||
/// customer.CreditBalance atomically inside a transaction.
|
||||
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
|
||||
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
|
||||
/// a revenue deduction and an AR reduction.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class CreditMemosController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<CreditMemosController> _logger;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public CreditMemosController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<CreditMemosController> logger,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(string? status, string? search)
|
||||
{
|
||||
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => true, false,
|
||||
m => m.Customer);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
memos = memos.Where(m =>
|
||||
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
|
||||
memos = memos.Where(m => m.Status == parsed).ToList();
|
||||
|
||||
ViewBag.Status = status ?? "";
|
||||
ViewBag.Search = search ?? "";
|
||||
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
|
||||
ViewBag.OutstandingBalance = memos
|
||||
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
|
||||
.Sum(m => m.RemainingBalance);
|
||||
|
||||
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
|
||||
id, false,
|
||||
m => m.Customer,
|
||||
m => m.OriginalInvoice,
|
||||
m => m.IssuedBy);
|
||||
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
|
||||
a => a.CreditMemoId == id, false,
|
||||
a => a.Invoice,
|
||||
a => a.AppliedBy);
|
||||
|
||||
var openInvoices = await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.CustomerId == memo.CustomerId
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff);
|
||||
|
||||
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
|
||||
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
|
||||
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
|
||||
&& memo.RemainingBalance > 0;
|
||||
|
||||
return View(memo);
|
||||
}
|
||||
|
||||
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
|
||||
{
|
||||
string? linkedInvoiceNumber = null;
|
||||
if (invoiceId.HasValue)
|
||||
{
|
||||
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
|
||||
if (inv != null)
|
||||
{
|
||||
linkedInvoiceNumber = inv.InvoiceNumber;
|
||||
customerId ??= inv.CustomerId;
|
||||
}
|
||||
}
|
||||
|
||||
await PopulateCustomersAsync(customerId);
|
||||
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
|
||||
|
||||
return View(new CreditMemoCreateVm
|
||||
{
|
||||
CustomerId = customerId ?? 0,
|
||||
OriginalInvoiceId = invoiceId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
|
||||
/// credit is visible on the customer account before it is applied to any specific invoice.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCustomersAsync(vm.CustomerId);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
|
||||
if (customer == null)
|
||||
{
|
||||
ModelState.AddModelError("CustomerId", "Customer not found.");
|
||||
await PopulateCustomersAsync(null);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var memoNumber = await GenerateMemoNumberAsync(companyId);
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var memo = new CreditMemo
|
||||
{
|
||||
MemoNumber = memoNumber,
|
||||
CustomerId = vm.CustomerId,
|
||||
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
|
||||
Amount = vm.Amount,
|
||||
AmountApplied = 0,
|
||||
IssueDate = DateTime.UtcNow,
|
||||
ExpiryDate = vm.ExpiryDate.HasValue
|
||||
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
|
||||
: null,
|
||||
Reason = vm.Reason,
|
||||
Notes = vm.Notes,
|
||||
Status = CreditMemoStatus.Active,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.CreditMemos.AddAsync(memo);
|
||||
|
||||
customer.CreditBalance += vm.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
|
||||
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
|
||||
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
|
||||
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
|
||||
if (invoice == null)
|
||||
{
|
||||
TempData["Error"] = "Invoice not found.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is not available to apply.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
|
||||
if (applyAmount <= 0)
|
||||
{
|
||||
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
|
||||
{
|
||||
CreditMemoId = id,
|
||||
InvoiceId = invoiceId,
|
||||
AmountApplied = applyAmount,
|
||||
AppliedDate = DateTime.UtcNow,
|
||||
AppliedById = currentUser?.Id,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
invoice.CreditApplied += applyAmount;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
|
||||
memo.AmountApplied += applyAmount;
|
||||
memo.Status = memo.AmountApplied >= memo.Amount
|
||||
? CreditMemoStatus.FullyApplied
|
||||
: CreditMemoStatus.PartiallyApplied;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (invoice.Customer != null)
|
||||
{
|
||||
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
|
||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||
}
|
||||
|
||||
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
|
||||
{
|
||||
invoice.Status = InvoiceStatus.Paid;
|
||||
invoice.PaidDate = DateTime.UtcNow;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
||||
// The dynamic report computation attributes credit memo applications to both
|
||||
// accounts already; this call keeps Account.CurrentBalance in sync for
|
||||
// RecalculateAllAsync and any tools that read it directly.
|
||||
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "4950" && a.IsActive)
|
||||
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive
|
||||
&& a.Name.ToLower().Contains("discount"));
|
||||
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
|
||||
TempData["Error"] = "An error occurred applying the credit.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
|
||||
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
|
||||
/// settled and form part of the immutable audit trail.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Void(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
if (memo.Status == CreditMemoStatus.Voided)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is already voided.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var remaining = memo.Amount - memo.AmountApplied;
|
||||
memo.Status = CreditMemoStatus.Voided;
|
||||
memo.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (remaining > 0 && memo.Customer != null)
|
||||
{
|
||||
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
|
||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
private async Task PopulateCustomersAsync(int? selectedId)
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
ViewBag.Customers = customers
|
||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||
.Select(c => new SelectListItem
|
||||
{
|
||||
Value = c.Id.ToString(),
|
||||
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
|
||||
Selected = c.Id == selectedId
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential memo number in CM-YYMM-#### format.
|
||||
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateMemoNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
var existing = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
|
||||
.Select(m => m.MemoNumber)
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
{
|
||||
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
|
||||
if (int.TryParse(suffix, out int n) && n > maxNum)
|
||||
maxNum = n;
|
||||
}
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Customer? c) =>
|
||||
c == null ? string.Empty :
|
||||
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
}
|
||||
|
||||
public class CreditMemoCreateVm
|
||||
{
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
|
||||
public int? OriginalInvoiceId { get; set; }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class CustomersController : Controller
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
|
||||
public CustomersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -34,7 +35,8 @@ public class CustomersController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IFinancialReportService financialReports)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -43,6 +45,7 @@ public class CustomersController : Controller
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_financialReports = financialReports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -123,14 +126,7 @@ public class CustomersController : Controller
|
||||
LastContactDate = c.LastContactDate
|
||||
}).ToList();
|
||||
|
||||
// Create paged result
|
||||
var pagedResult = new PagedResult<CustomerListDto>
|
||||
{
|
||||
Items = customerDtos,
|
||||
PageNumber = gridRequest.PageNumber,
|
||||
PageSize = gridRequest.PageSize,
|
||||
TotalCount = totalCount
|
||||
};
|
||||
var pagedResult = PagedResult<CustomerListDto>.From(gridRequest, customerDtos, totalCount);
|
||||
|
||||
// Set ViewBag for sorting
|
||||
ViewBag.SearchTerm = searchTerm;
|
||||
@@ -942,6 +938,30 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||
@@ -950,13 +970,18 @@ public class CustomersController : Controller
|
||||
/// </summary>
|
||||
private async Task<string> GenerateCreditMemoNumberAsync()
|
||||
{
|
||||
var allMemos = await _unitOfWork.CreditMemos.GetAllAsync(true);
|
||||
var prefix = $"CM-{DateTime.Now:yyMM}-";
|
||||
var maxNum = allMemos
|
||||
.Where(m => m.MemoNumber.StartsWith(prefix))
|
||||
.Select(m => { int.TryParse(m.MemoNumber.Replace(prefix, ""), out int n); return n; })
|
||||
.DefaultIfEmpty(0).Max();
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
var last = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.MemoNumber.StartsWith(prefix), ignoreQueryFilters: true))
|
||||
.OrderByDescending(m => m.MemoNumber)
|
||||
.Select(m => m.MemoNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int num))
|
||||
next = num + 1;
|
||||
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user