Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 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? RecommendedAction { get; set; }
|
||||||
public string? BillNumber { 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;
|
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 ─────────────────────────────────────────────────────────────
|
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class ProfitAndLossDto
|
public class ProfitAndLossDto
|
||||||
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
|
|||||||
public DateTime From { get; set; }
|
public DateTime From { get; set; }
|
||||||
public DateTime To { get; set; }
|
public DateTime To { get; set; }
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||||
|
|
||||||
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
||||||
public decimal TotalRevenue { get; set; }
|
public decimal TotalRevenue { get; set; }
|
||||||
@@ -40,6 +193,7 @@ public class BalanceSheetDto
|
|||||||
{
|
{
|
||||||
public DateTime AsOf { get; set; }
|
public DateTime AsOf { get; set; }
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
|
|||||||
public class PagedResult<T>
|
public class PagedResult<T>
|
||||||
{
|
{
|
||||||
public IEnumerable<T> Items { get; set; } = new List<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 PageNumber { get; set; }
|
||||||
public int PageSize { get; set; }
|
public int PageSize { get; set; }
|
||||||
public int TotalCount { get; set; }
|
public int TotalCount { get; set; }
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
|||||||
public bool WizardCompleted { get; set; }
|
public bool WizardCompleted { get; set; }
|
||||||
public DateTime? WizardCompletedAt { get; set; }
|
public DateTime? WizardCompletedAt { get; set; }
|
||||||
public string? WizardCompletedByName { 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>
|
/// <summary>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
|||||||
// Blank Work Order PDF Template
|
// Blank Work Order PDF Template
|
||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
public string? WoTerms { get; set; }
|
public string? WoTerms { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateAppDefaultsDto
|
public class UpdateAppDefaultsDto
|
||||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
|||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class UpdateKioskSettingsDto
|
||||||
|
{
|
||||||
|
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||||
|
[Required]
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
public string? ZipCode { get; set; }
|
public string? ZipCode { get; set; }
|
||||||
public string? TimeZone { get; set; }
|
public string? TimeZone { get; set; }
|
||||||
|
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||||
public bool HasLogo { get; set; }
|
public bool HasLogo { get; set; }
|
||||||
|
|
||||||
public CompanyOperatingCostsDto? OperatingCosts { 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")]
|
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
|
||||||
public string? TimeZone { get; set; }
|
public string? TimeZone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
|
||||||
|
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
|
|||||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one contact method is required (Email OR Phone)
|
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||||
{
|
{
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
"Please provide at least one contact method (Email or Phone)",
|
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||||
new[] { nameof(Email), nameof(Phone) });
|
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each address in comma-separated email fields
|
// Validate each address in comma-separated email fields
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
|||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public string? CustomerPhone { get; set; }
|
public string? CustomerPhone { get; set; }
|
||||||
|
public string? CustomerMobilePhone { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; }
|
public bool CustomerNotifyByEmail { get; set; }
|
||||||
|
public bool CustomerNotifyBySms { get; set; }
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
public string? PreparedByName { get; set; }
|
public string? PreparedByName { get; set; }
|
||||||
public InvoiceStatus Status { get; set; }
|
public InvoiceStatus Status { get; set; }
|
||||||
@@ -82,6 +84,10 @@ public class CreateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { 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();
|
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
|||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||||
public PaymentMethod RefundMethod { get; set; }
|
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 Reason { get; set; } = string.Empty;
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||||
|
|
||||||
|
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||||
|
public class SendRemoteLinkDto
|
||||||
|
{
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskContactDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, Phone]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskJobDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(2000)]
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||||
|
public class SubmitKioskTermsDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||||
|
public class KioskSessionListDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public Guid SessionToken { get; set; }
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; }
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
|
||||||
|
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||||
|
public string JobDescriptionSnippet =>
|
||||||
|
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||||
|
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||||
|
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||||
|
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
|||||||
public bool CanManageMaintenance { get; set; }
|
public bool CanManageMaintenance { get; set; }
|
||||||
public bool CanManageInvoices { get; set; }
|
public bool CanManageInvoices { get; set; }
|
||||||
public bool CanViewReports { get; set; }
|
public bool CanViewReports { get; set; }
|
||||||
|
public bool CanManageBills { get; set; }
|
||||||
|
public bool CanManageAccounting { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
|||||||
[Display(Name = "Can View Reports")]
|
[Display(Name = "Can View Reports")]
|
||||||
public bool CanViewReports { get; set; }
|
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")]
|
[Display(Name = "Send Welcome Email")]
|
||||||
public bool SendWelcomeEmail { get; set; } = true;
|
public bool SendWelcomeEmail { get; set; } = true;
|
||||||
}
|
}
|
||||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
|||||||
|
|
||||||
[Display(Name = "Can View Reports")]
|
[Display(Name = "Can View Reports")]
|
||||||
public bool CanViewReports { get; set; }
|
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")]
|
[Display(Name = "Preferred Vendor")]
|
||||||
public bool IsPreferred { get; set; } = false;
|
public bool IsPreferred { get; set; } = false;
|
||||||
|
|
||||||
|
[Display(Name = "1099 Vendor")]
|
||||||
|
public bool Is1099Vendor { get; set; } = false;
|
||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
}
|
}
|
||||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
|||||||
[Display(Name = "Preferred Vendor")]
|
[Display(Name = "Preferred Vendor")]
|
||||||
public bool IsPreferred { get; set; }
|
public bool IsPreferred { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "1099 Vendor")]
|
||||||
|
public bool Is1099Vendor { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
|||||||
/// Returns a ranked list of flagged items with recommended actions.
|
/// Returns a ranked list of flagged items with recommended actions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
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.Application.DTOs.Accounting;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
namespace PowderCoating.Application.Interfaces;
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
|
|||||||
/// Read-only service for financial aggregate reports. All methods query the database
|
/// Read-only service for financial aggregate reports. All methods query the database
|
||||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
/// 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>
|
/// </summary>
|
||||||
public interface IFinancialReportService
|
public interface IFinancialReportService
|
||||||
{
|
{
|
||||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
/// <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>
|
/// <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>
|
/// <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);
|
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>
|
/// <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);
|
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);
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
|||||||
/// Notify customer when an invoice has been sent.
|
/// Notify customer when an invoice has been sent.
|
||||||
/// Optionally includes an online payment link in the email body.
|
/// Optionally includes an online payment link in the email body.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ public interface IPdfService
|
|||||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||||
|
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||||
|
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||||
|
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||||
|
|
||||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||||
GiftCertificateDto cert,
|
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);
|
||||||
|
}
|
||||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
|||||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||||
|
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
|||||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||||
: null))
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||||
|
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||||
|
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||||
: null))
|
: null))
|
||||||
|
|||||||
@@ -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? existingImagePath,
|
||||||
string? existingThumbnailPath)
|
string? existingThumbnailPath)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||||
return (false, string.Empty, string.Empty, "No file provided.");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, string.Empty, validationError);
|
||||||
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 container = _settings.Containers.CatalogImages;
|
var container = _settings.Containers.CatalogImages;
|
||||||
var blobId = Guid.NewGuid().ToString("N");
|
var blobId = Guid.NewGuid().ToString("N");
|
||||||
|
|||||||
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
|
|||||||
/// </returns>
|
/// </returns>
|
||||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||||
return (false, string.Empty, "No file provided");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, error);
|
||||||
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)}");
|
|
||||||
|
|
||||||
// Delete old logo (any extension) before saving new one
|
// Delete old logo (any extension) before saving new one
|
||||||
await DeleteOldLogosAsync(companyId, extension);
|
await DeleteOldLogosAsync(companyId, extension);
|
||||||
|
|
||||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||||
var contentType = GetContentType(extension);
|
var contentType = BlobFileHelper.GetContentType(extension);
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
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>
|
/// </returns>
|
||||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||||
return (false, string.Empty, "No file provided");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, error);
|
||||||
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)}");
|
|
||||||
|
|
||||||
// Sanitize filename — replace OS-invalid characters with underscores to
|
// Sanitize filename — replace OS-invalid characters with underscores to
|
||||||
// prevent path traversal and blob naming errors in Azure.
|
// prevent path traversal and blob naming errors in Azure.
|
||||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
|
||||||
fileName = "manual";
|
|
||||||
|
|
||||||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||||
var contentType = GetContentType(extension);
|
var contentType = BlobFileHelper.GetContentType(extension);
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
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);
|
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,
|
string? caption = null,
|
||||||
JobPhotoType photoType = JobPhotoType.Progress)
|
JobPhotoType photoType = JobPhotoType.Progress)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||||
return (false, string.Empty, "No file was uploaded.");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, error);
|
||||||
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.");
|
|
||||||
|
|
||||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||||
var contentType = GetContentType(extension);
|
var contentType = BlobFileHelper.GetContentType(extension);
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
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);
|
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();
|
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;
|
QuoteItemPricingResult itemResult;
|
||||||
|
|
||||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||||
if (item.CatalogItemId.HasValue)
|
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||||
{
|
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
itemResults.Add(itemResult);
|
itemResults.Add(itemResult);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
|
|||||||
string userId,
|
string userId,
|
||||||
int companyId)
|
int companyId)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||||
return (false, string.Empty, "No file was uploaded.");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, error);
|
||||||
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.");
|
|
||||||
|
|
||||||
// Delete old photos for this user with different extensions
|
// Delete old photos for this user with different extensions
|
||||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||||
|
|
||||||
// Blob path mirrors former filesystem path
|
// Blob path mirrors former filesystem path
|
||||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||||
var contentType = GetContentType(extension);
|
var contentType = BlobFileHelper.GetContentType(extension);
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
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(
|
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
||||||
IFormFile file, int companyId)
|
IFormFile file, int companyId)
|
||||||
{
|
{
|
||||||
if (file == null || file.Length == 0)
|
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||||
return (false, string.Empty, string.Empty, "No file provided.");
|
if (!isValid)
|
||||||
|
return (false, string.Empty, string.Empty, validationError);
|
||||||
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 tempId = Guid.NewGuid().ToString("N");
|
var tempId = Guid.NewGuid().ToString("N");
|
||||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||||
var contentType = GetContentType(ext);
|
var contentType = BlobFileHelper.GetContentType(ext);
|
||||||
|
|
||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
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.");
|
return (false, string.Empty, "Failed to read temp photo.");
|
||||||
|
|
||||||
using var ms = new MemoryStream(download.Content);
|
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)
|
if (!upload.Success)
|
||||||
return (false, string.Empty, "Failed to save permanent photo.");
|
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? CheckNumber { get; set; }
|
||||||
public string? Memo { 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
|
// Navigation
|
||||||
public virtual Bill Bill { get; set; } = null!;
|
public virtual Bill Bill { get; set; } = null!;
|
||||||
public virtual Vendor Vendor { 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? Memo { get; set; }
|
||||||
public string? ReceiptFilePath { 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
|
// Navigation
|
||||||
public virtual Vendor? Vendor { get; set; }
|
public virtual Vendor? Vendor { get; set; }
|
||||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||||
public virtual Account PaymentAccount { get; set; } = null!;
|
public virtual Account PaymentAccount { get; set; } = null!;
|
||||||
public virtual Job? Job { get; set; }
|
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 CanManageMaintenance { get; set; } = false;
|
||||||
public bool CanManageInvoices { get; set; } = false;
|
public bool CanManageInvoices { get; set; } = false;
|
||||||
public bool CanViewReports { 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)
|
// Profile Photo (filesystem storage)
|
||||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||||
|
|||||||
@@ -105,11 +105,34 @@ public class Company : BaseEntity
|
|||||||
public bool MarketingEmailOptOut { get; set; } = false;
|
public bool MarketingEmailOptOut { get; set; } = false;
|
||||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
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
|
// Settings
|
||||||
public string? TimeZone { get; set; } = "America/New_York";
|
public string? TimeZone { get; set; } = "America/New_York";
|
||||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||||
|
|
||||||
|
// Kiosk
|
||||||
|
/// <summary>
|
||||||
|
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||||
|
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||||
|
/// tablet can serve the intake form without requiring a logged-in user.
|
||||||
|
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||||
|
/// </summary>
|
||||||
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
|||||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||||
public string? QbMigrationStateJson { get; set; }
|
public string? QbMigrationStateJson { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
/// <summary>
|
||||||
|
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||||
|
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||||
|
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||||
|
/// </summary>
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
|
||||||
// Guided activation / first-workflow onboarding
|
// Guided activation / first-workflow onboarding
|
||||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||||
public string? OnboardingPath { get; set; }
|
public string? OnboardingPath { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? RecordedById { 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
|
// Applied to invoice when invoice is created
|
||||||
public int? AppliedToInvoiceId { get; set; }
|
public int? AppliedToInvoiceId { get; set; }
|
||||||
public DateTime? AppliedDate { get; set; }
|
public DateTime? AppliedDate { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
|||||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||||
|
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||||
|
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicViewToken { get; set; }
|
||||||
|
|
||||||
// Online payments (Stripe Connect)
|
// Online payments (Stripe Connect)
|
||||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||||
@@ -42,6 +49,19 @@ public class Invoice : BaseEntity
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { 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>
|
/// <summary>
|
||||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class Job : BaseEntity
|
|||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal OvenBatchCost { get; set; }
|
||||||
public decimal ShopSuppliesAmount { get; set; }
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
public decimal ShopSuppliesPercent { get; set; }
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
|
|||||||
public int? CatalogItemId { get; set; }
|
public int? CatalogItemId { get; set; }
|
||||||
public bool IsGenericItem { get; set; }
|
public bool IsGenericItem { get; set; }
|
||||||
public bool IsLaborItem { get; set; }
|
public bool IsLaborItem { get; set; }
|
||||||
|
public bool IsSalesItem { get; set; }
|
||||||
|
public string? Sku { get; set; }
|
||||||
public decimal? ManualUnitPrice { get; set; }
|
public decimal? ManualUnitPrice { get; set; }
|
||||||
public bool RequiresSandblasting { get; set; }
|
public bool RequiresSandblasting { get; set; }
|
||||||
public bool RequiresMasking { get; set; }
|
public bool RequiresMasking { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||||
|
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||||
|
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskSession : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||||
|
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||||
|
|
||||||
|
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
|
||||||
|
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
|
||||||
|
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
public DateTime? AgreedToTermsAt { get; set; }
|
||||||
|
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
|
||||||
|
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
public DateTime? RemoteLinkSentAt { get; set; }
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
public virtual Customer? LinkedCustomer { get; set; }
|
||||||
|
public virtual Job? LinkedJob { get; set; }
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? DepositAccountId { get; set; }
|
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
|
// Navigation
|
||||||
public virtual Invoice Invoice { get; set; } = null!;
|
public virtual Invoice Invoice { get; set; } = null!;
|
||||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
|||||||
public DateTime? IssuedDate { get; set; }
|
public DateTime? IssuedDate { get; set; }
|
||||||
public string? IssuedById { 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
|
// For store-credit refunds: the CreditMemo created on their behalf
|
||||||
public int? CreditMemoId { get; set; }
|
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>
|
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
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
|
// Navigation
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||||
|
|||||||
@@ -66,3 +66,61 @@ public enum BillStatus
|
|||||||
Paid = 3,
|
Paid = 3,
|
||||||
Voided = 4
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
public enum KioskSessionType
|
||||||
|
{
|
||||||
|
InPerson = 0,
|
||||||
|
Remote = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum KioskSessionStatus
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Submitted = 1,
|
||||||
|
Expired = 2,
|
||||||
|
Cancelled = 3
|
||||||
|
}
|
||||||
@@ -91,6 +91,35 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<BillPayment> BillPayments { get; }
|
IRepository<BillPayment> BillPayments { get; }
|
||||||
IRepository<Expense> Expenses { 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
|
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||||
INotificationLogRepository NotificationLogs { get; }
|
INotificationLogRepository NotificationLogs { get; }
|
||||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||||
@@ -125,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
/// 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>
|
/// </summary>
|
||||||
public record CompanyCountSummary(
|
public record CompanyCountSummary(
|
||||||
IReadOnlyDictionary<int, int> JobCounts,
|
IReadOnlyDictionary<int, int> JobCounts,
|
||||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
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>
|
/// <summary>
|
||||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||||
/// total unfiltered count for pagination.
|
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||||
|
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||||
|
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||||
|
|||||||
@@ -324,6 +324,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Expense> Expenses { get; set; }
|
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
|
// Job Templates
|
||||||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||||||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||||||
@@ -334,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -614,6 +651,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!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
|
// Purchase Orders
|
||||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
@@ -626,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||||
|
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||||
|
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasIndex(e => e.SessionToken)
|
||||||
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedCustomer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedCustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedJob)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
@@ -633,6 +775,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.HasForeignKey(a => a.ParentAccountId)
|
.HasForeignKey(a => a.ParentAccountId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.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)
|
// Vendor → DefaultExpenseAccount (no cascade)
|
||||||
modelBuilder.Entity<Vendor>()
|
modelBuilder.Entity<Vendor>()
|
||||||
.HasOne(s => s.DefaultExpenseAccount)
|
.HasOne(s => s.DefaultExpenseAccount)
|
||||||
|
|||||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
},
|
},
|
||||||
new NotificationTemplate
|
new NotificationTemplate
|
||||||
|
{
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
DisplayName = "Invoice Sent (SMS)",
|
||||||
|
Subject = null,
|
||||||
|
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||||
|
IsActive = true,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new NotificationTemplate
|
||||||
{
|
{
|
||||||
NotificationType = NotificationType.PaymentReceived,
|
NotificationType = NotificationType.PaymentReceived,
|
||||||
Channel = NotificationChannel.Email,
|
Channel = NotificationChannel.Email,
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10732
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddKioskIntakeSession : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskActivationToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KioskSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SessionType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
LinkedJobId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", 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_KioskSessions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KioskSessions_Customers_LinkedCustomerId",
|
||||||
|
column: x => x.LinkedCustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KioskSessions_Jobs_LinkedJobId",
|
||||||
|
column: x => x.LinkedJobId,
|
||||||
|
principalTable: "Jobs",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_LinkedCustomerId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "LinkedCustomerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_LinkedJobId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "LinkedJobId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_SessionToken",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "SessionToken",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KioskSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskActivationToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10735
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoicePublicViewToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PublicViewToken",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublicViewToken",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10742
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 AddKioskIntakeOutputSetting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<GiftCertificate>? _giftCertificates;
|
private IRepository<GiftCertificate>? _giftCertificates;
|
||||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -142,6 +145,29 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<BillPayment>? _billPayments;
|
private IRepository<BillPayment>? _billPayments;
|
||||||
private IRepository<Expense>? _expenses;
|
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>
|
/// <summary>
|
||||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||||
/// The context is shared across all repositories created by this instance so that
|
/// The context is shared across all repositories created by this instance so that
|
||||||
@@ -437,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
||||||
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
// Job Templates
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
@@ -513,6 +543,53 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<Expense> Expenses =>
|
public IRepository<Expense> Expenses =>
|
||||||
_expenses ??= new Repository<Expense>(_context);
|
_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>
|
/// <summary>
|
||||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||||
/// Returns the number of state entries written.
|
/// Returns the number of state entries written.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
|
|||||||
|
|
||||||
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
|
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
|
||||||
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
|
// 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);
|
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
|
|||||||
|
|
||||||
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
|
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
|
||||||
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
|
// 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);
|
await _unitOfWork.Accounts.UpdateAsync(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
|
|||||||
await _unitOfWork.CompleteAsync();
|
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." };
|
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";
|
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.
|
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}
|
{shopSpeedLine}
|
||||||
{coatingSpeedLine}
|
{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).
|
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.
|
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.
|
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
|
_ => 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
|
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
|
||||||
// The unit price × quantity must equal the total batch labor cost.
|
// Unit price is per item; the caller multiplies by quantity for the line total.
|
||||||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
var rawPerItemMinutes = aiResult.EstimatedMinutes;
|
||||||
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
||||||
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
||||||
var laborHours = perItemMinutes / 60m;
|
var laborHours = perItemMinutes / 60m;
|
||||||
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
|
|||||||
CoatCount = request.CoatCount,
|
CoatCount = request.CoatCount,
|
||||||
MaterialCost = Math.Round(materialCost, 2),
|
MaterialCost = Math.Round(materialCost, 2),
|
||||||
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
||||||
EstimatedMinutes = (int)Math.Round(perItemMinutes),
|
EstimatedMinutes = perItemMinutes,
|
||||||
MaterialMinMinutes = materialMinMinutes,
|
MaterialMinMinutes = materialMinMinutes,
|
||||||
MinFloorApplied = minFloorApplied,
|
MinFloorApplied = minFloorApplied,
|
||||||
LaborCost = Math.Round(laborCost, 2),
|
LaborCost = Math.Round(laborCost, 2),
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
|
|||||||
identity.AddClaim(new Claim("Permission", "ViewReports"));
|
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;
|
return identity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true)
|
||||||
{
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
// Always count churned regardless of hideChurned so the banner can show a number.
|
||||||
|
var churnedCount = await _context.Companies
|
||||||
|
.AsNoTracking()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(c => !c.IsDeleted
|
||||||
|
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
var query = _context.Companies
|
var query = _context.Companies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (hideChurned)
|
||||||
|
query = query.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var s = searchTerm.ToLower();
|
var s = searchTerm.ToLower();
|
||||||
@@ -61,12 +81,16 @@ public class CompanyListService : ICompanyListService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return (companies, totalCount);
|
return (companies, totalCount, churnedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
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
|
var jobCounts = await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||||
@@ -98,6 +122,32 @@ public class CompanyListService : ICompanyListService
|
|||||||
x => x.CompanyId,
|
x => x.CompanyId,
|
||||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
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
|
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) ────────────────
|
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||||
// e.g. Checking account used to pay an expense
|
// e.g. Checking account used to pay an expense
|
||||||
var expensesPaidFrom = await _context.Expenses
|
var expensesPaidFrom = await _context.Expenses
|
||||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Invoices",
|
LinkController = "Invoices",
|
||||||
LinkId = p.InvoiceId
|
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 ────────────────────────────────────────────────
|
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||||
@@ -296,8 +375,126 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Bills",
|
LinkController = "Bills",
|
||||||
LinkId = bp.BillId
|
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 ──────────────────────────────────
|
// ── Sort and compute running balance ──────────────────────────────────
|
||||||
entries = entries
|
entries = entries
|
||||||
.OrderBy(e => e.Date)
|
.OrderBy(e => e.Date)
|
||||||
@@ -306,7 +503,7 @@ public class LedgerService : ILedgerService
|
|||||||
|
|
||||||
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
|
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
|
||||||
// since users could misconfigure AccountType while SubType is picked from a constrained list).
|
// 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
|
// Compute the balance before the selected period
|
||||||
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
|
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>
|
/// <summary>
|
||||||
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
|
/// 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
|
/// 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.
|
/// 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.
|
/// 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.
|
/// 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.
|
/// accounts and credits increase credit-normal accounts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<decimal> ComputePriorBalanceAsync(
|
private async Task<decimal> ComputePriorBalanceAsync(
|
||||||
@@ -390,6 +557,16 @@ public class LedgerService : ILedgerService
|
|||||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.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)
|
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||||
credits += await _context.Expenses
|
credits += await _context.Expenses
|
||||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||||
@@ -442,6 +619,14 @@ public class LedgerService : ILedgerService
|
|||||||
credits += await _context.Payments
|
credits += await _context.Payments
|
||||||
.Where(p => p.PaymentDate < beforeDate)
|
.Where(p => p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.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
|
// 9. Accounts Payable
|
||||||
@@ -457,8 +642,51 @@ public class LedgerService : ILedgerService
|
|||||||
debits += await _context.BillPayments
|
debits += await _context.BillPayments
|
||||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
.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;
|
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
|
||||||
|
|
||||||
// Apply the opening balance if it was established on or before the end of the viewed period.
|
// Apply the opening balance if it was established on or before the end of the viewed period.
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
|
|||||||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||||||
/// standard "here is your invoice" message with no payment CTA.
|
/// standard "here is your invoice" message with no payment CTA.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
|
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
|
|||||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||||||
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
|
||||||
|
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
|
||||||
|
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
|
||||||
|
if (sendSms)
|
||||||
|
{
|
||||||
|
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||||
|
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||||
|
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["companyName"] = companyName,
|
||||||
|
["invoiceNumber"] = invoice.InvoiceNumber,
|
||||||
|
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||||||
|
["viewUrl"] = urlForSms
|
||||||
|
};
|
||||||
|
|
||||||
|
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
|
||||||
|
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
|
||||||
|
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||||
|
|
||||||
|
await WriteLog(new NotificationLog
|
||||||
|
{
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||||
|
RecipientName = customerName,
|
||||||
|
Recipient = smsPhone,
|
||||||
|
Message = message,
|
||||||
|
ErrorMessage = smsError,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
CompanyId = invoice.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
|
||||||
|
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
|
|||||||
"Invoice {{invoiceNumber}} from {{companyName}}",
|
"Invoice {{invoiceNumber}} from {{companyName}}",
|
||||||
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
),
|
),
|
||||||
|
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
|
||||||
|
null,
|
||||||
|
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
|
||||||
|
),
|
||||||
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
||||||
"Payment Received — Invoice {{invoiceNumber}}",
|
"Payment Received — Invoice {{invoiceNumber}}",
|
||||||
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
|
|||||||
@@ -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 = "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 = "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 },
|
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 ────────────────────────────────────────────
|
// ── 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 },
|
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;
|
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;
|
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.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||||
result.Details = details;
|
result.Details = details;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user