Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| 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 |
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
|||||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||||
|
|
||||||
|
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||||
|
|
||||||
|
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||||
|
|
||||||
|
| Flag | Effect if missing on JobItem |
|
||||||
|
|------|------------------------------|
|
||||||
|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||||
|
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||||
|
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||||
|
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||||
|
|
||||||
|
**Checklist when adding a new pricing routing flag:**
|
||||||
|
1. Add the property to `QuoteItem` (Core/Entities)
|
||||||
|
2. Add the property to `JobItem` (Core/Entities)
|
||||||
|
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||||
|
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||||
|
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||||
|
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||||
|
7. Add a migration if the field is new on a persisted entity
|
||||||
|
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||||
|
|
||||||
### Branding
|
### Branding
|
||||||
- Application name: **Powder Coating Logix**
|
- Application name: **Powder Coating Logix**
|
||||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,92 @@ namespace PowderCoating.Application.DTOs.Accounting;
|
|||||||
// without needing a separate round-trip to the company settings.
|
// 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 ──────────────────────────────────────────────────────────────────
|
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class ApAgingReportDto
|
public class ApAgingReportDto
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
|||||||
public GiftCertificateStatus Status { get; set; }
|
public GiftCertificateStatus Status { get; set; }
|
||||||
public DateTime IssueDate { get; set; }
|
public DateTime IssueDate { get; set; }
|
||||||
public DateTime? ExpiryDate { get; set; }
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GiftCertificateDto : GiftCertificateListDto
|
public class GiftCertificateDto : GiftCertificateListDto
|
||||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
|||||||
[Range(0.01, 9999.99)]
|
[Range(0.01, 9999.99)]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BulkCreateGiftCertificateDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||||
|
[Display(Name = "Number of Certificates")]
|
||||||
|
public int Quantity { get; set; } = 25;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||||
|
[Display(Name = "Face Value (each)")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Issued Reason")]
|
||||||
|
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||||
|
|
||||||
|
[Display(Name = "Expiry Date (optional)")]
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
|
public int? OvenCostId { get; set; }
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,4 +35,18 @@ public interface IFinancialReportService
|
|||||||
|
|
||||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -44,10 +44,17 @@ public interface IPdfService
|
|||||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||||
|
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||||
|
|
||||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||||
GiftCertificateDto cert,
|
GiftCertificateDto cert,
|
||||||
byte[]? companyLogo,
|
byte[]? companyLogo,
|
||||||
string? companyLogoContentType,
|
string? companyLogoContentType,
|
||||||
CompanyInfoDto companyInfo);
|
CompanyInfoDto companyInfo);
|
||||||
|
|
||||||
|
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -106,6 +107,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = seed.IsGenericItem,
|
IsGenericItem = seed.IsGenericItem,
|
||||||
IsLaborItem = seed.IsLaborItem,
|
IsLaborItem = seed.IsLaborItem,
|
||||||
IsSalesItem = seed.IsSalesItem,
|
IsSalesItem = seed.IsSalesItem,
|
||||||
|
IsAiItem = seed.IsAiItem,
|
||||||
Sku = seed.Sku,
|
Sku = seed.Sku,
|
||||||
ManualUnitPrice = seed.ManualUnitPrice,
|
ManualUnitPrice = seed.ManualUnitPrice,
|
||||||
PowderCostOverride = seed.PowderCostOverride,
|
PowderCostOverride = seed.PowderCostOverride,
|
||||||
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public bool IsGenericItem { get; init; }
|
public bool IsGenericItem { get; init; }
|
||||||
public bool IsLaborItem { get; init; }
|
public bool IsLaborItem { get; init; }
|
||||||
public bool IsSalesItem { get; init; }
|
public bool IsSalesItem { get; init; }
|
||||||
|
public bool IsAiItem { get; init; }
|
||||||
public string? Sku { get; init; }
|
public string? Sku { get; init; }
|
||||||
public decimal? ManualUnitPrice { get; init; }
|
public decimal? ManualUnitPrice { get; init; }
|
||||||
public decimal? PowderCostOverride { get; init; }
|
public decimal? PowderCostOverride { get; init; }
|
||||||
|
|||||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||||
|
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||||
|
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo)
|
||||||
|
{
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
const string accent = "#7c3aed";
|
||||||
|
const string gold = "#b45309";
|
||||||
|
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var doc = Document.Create(container =>
|
||||||
|
{
|
||||||
|
foreach (var cert in certs)
|
||||||
|
{
|
||||||
|
container.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.Letter);
|
||||||
|
page.Margin(0.75f, Unit.Inch);
|
||||||
|
page.PageColor(Colors.White);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
|
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(text =>
|
||||||
|
{
|
||||||
|
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
|
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.GeneratePdf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||||
@@ -2593,4 +2637,120 @@ public class PdfService : IPdfService
|
|||||||
return document.GeneratePdf();
|
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.
|
||||||
{
|
|
||||||
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||||
if (catalogItem != null)
|
if (catalogItem != null)
|
||||||
{
|
{
|
||||||
item.UnitPrice = catalogItem.DefaultPrice;
|
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
? 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);
|
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
|
|||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
public decimal RemainingAmount { get; set; }
|
public decimal RemainingAmount { get; set; }
|
||||||
public string? Memo { 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
|
// Navigation
|
||||||
public virtual Vendor Vendor { get; set; } = null!;
|
public virtual Vendor Vendor { get; set; } = null!;
|
||||||
@@ -285,3 +287,172 @@ public class VendorCreditApplication : BaseEntity
|
|||||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||||
public virtual Bill Bill { 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")
|
||||||
|
|||||||
@@ -112,11 +112,27 @@ public class Company : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
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; }
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
|||||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||||
public int? SourceInvoiceItemId { get; set; }
|
public int? SourceInvoiceItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Customer? RecipientCustomer { get; set; }
|
public virtual Customer? RecipientCustomer { get; set; }
|
||||||
public virtual Customer? PurchasingCustomer { get; set; }
|
public virtual Customer? PurchasingCustomer { 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.
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ public class Job : BaseEntity
|
|||||||
// Selected oven (carried over from quote; null = company default rate)
|
// Selected oven (carried over from quote; null = company default rate)
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
|
|
||||||
|
// Oven scheduling (carried over from quote)
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
|
|
||||||
// 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; }
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
|||||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||||
public string? Complexity { get; set; }
|
public string? Complexity { get; set; }
|
||||||
|
|
||||||
|
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||||
|
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||||
public string? AiTags { get; set; }
|
public string? AiTags { 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; }
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -94,6 +94,26 @@ public enum VendorCreditStatus
|
|||||||
Voided = 3
|
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>
|
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||||
public enum JournalEntryStatus
|
public enum JournalEntryStatus
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -103,6 +103,23 @@ public interface IUnitOfWork : IDisposable
|
|||||||
// Bank Reconciliation
|
// Bank Reconciliation
|
||||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
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; }
|
||||||
@@ -137,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
|
||||||
|
|||||||
@@ -332,6 +332,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
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>
|
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||||
@@ -349,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.
|
||||||
@@ -638,12 +660,84 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!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
|
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||||
modelBuilder.Entity<VendorCreditApplication>().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));
|
||||||
@@ -656,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
column: x => x.BillId,
|
column: x => x.BillId,
|
||||||
principalTable: "Bills",
|
principalTable: "Bills",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.NoAction);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||||
column: x => x.VendorCreditId,
|
column: x => x.VendorCreditId,
|
||||||
principalTable: "VendorCredits",
|
principalTable: "VendorCredits",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.NoAction);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobOvenBatchFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10751
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 AddJobItemIsAiItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10754
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 AddGiftCertificateBatchId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,6 +463,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("CanCreateQuotes")
|
b.Property<bool>("CanCreateQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageAccounting")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageBills")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("CanManageCalendar")
|
b.Property<bool>("CanManageCalendar")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -1269,6 +1275,139 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("BillPayments");
|
b.ToTable("BillPayments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("FiscalYear")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Budgets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("AccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Apr")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Aug")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("BudgetId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Dec")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Feb")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal>("Jan")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Jul")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Jun")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Mar")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("May")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Nov")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Oct")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Sep")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId");
|
||||||
|
|
||||||
|
b.HasIndex("BudgetId");
|
||||||
|
|
||||||
|
b.ToTable("BudgetLines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1633,6 +1772,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("AiPhotoQuotesEnabled")
|
b.Property<bool>("AiPhotoQuotesEnabled")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BookLockedThrough")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<string>("City")
|
b.Property<string>("City")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -1670,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskActivationToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("LogoContentType")
|
b.Property<string>("LogoContentType")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2108,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobRetentionYears")
|
b.Property<int>("JobRetentionYears")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("KioskIntakeOutput")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("LogRetentionDays")
|
b.Property<int>("LogRetentionDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -2750,6 +2899,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -2994,6 +3146,142 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Expenses");
|
b.ToTable("Expenses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("AccumDepreciationAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("AccumulatedDepreciation")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int?>("AssetAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepreciationExpenseAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DisposalDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDisposed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PurchaseCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("PurchaseDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("SalvageValue")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("UsefulLifeMonths")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AccumDepreciationAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("AssetAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("DepreciationExpenseAccountId");
|
||||||
|
|
||||||
|
b.ToTable("FixedAssets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("FixedAssetId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int?>("JournalEntryId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodMonth")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("PeriodYear")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FixedAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("JournalEntryId");
|
||||||
|
|
||||||
|
b.ToTable("FixedAssetDepreciationEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3002,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid?>("BatchId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("CertificateCode")
|
b.Property<string>("CertificateCode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -3586,6 +3877,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("DueDate")
|
b.Property<DateTime?>("DueDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("EarlyPaymentDiscountDays")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("EarlyPaymentDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("ExternalReference")
|
b.Property<string>("ExternalReference")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
@@ -3632,6 +3929,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("PublicViewToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("SalesTaxAccountId")
|
b.Property<int?>("SalesTaxAccountId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -3905,9 +4205,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("OriginalJobId")
|
b.Property<int?>("OriginalJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("OvenBatchCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("OvenBatches")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("OvenCycleMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4176,6 +4485,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAiItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -5274,6 +5586,118 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("JournalEntryLines");
|
b.ToTable("JournalEntryLines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AgreedToTerms")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AgreedToTermsAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerFirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerLastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerPhone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("HowDidYouHearAboutUs")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReturningCustomer")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("JobDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedCustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedJobId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedQuoteId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteLinkEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RemoteLinkSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid>("SessionToken")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("SessionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SignatureDataBase64")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("SmsOptIn")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SubmittedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedCustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedJobId");
|
||||||
|
|
||||||
|
b.HasIndex("SessionToken")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("KioskSessions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -6287,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6298,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6309,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
CreatedAt = new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7266,6 +7690,78 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("QuoteStatusLookups");
|
b.ToTable("QuoteStatusLookups");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.RecurringTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EndDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("Frequency")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("IntervalCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxOccurrences")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("NextFireDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("OccurrenceCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TemplateData")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("TemplateType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("RecurringTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -7295,6 +7791,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("InvoiceId")
|
b.Property<int>("InvoiceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -7742,6 +8241,62 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("SubscriptionPlanConfigs");
|
b.ToTable("SubscriptionPlanConfigs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.TaxRate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Rate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("TaxRates");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -7876,6 +8431,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("Is1099Vendor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -7966,6 +8524,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal>("RemainingAmount")
|
b.Property<decimal>("RemainingAmount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -8100,6 +8661,57 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("VendorCreditLineItems");
|
b.ToTable("VendorCreditLineItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClosedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ClosedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ClosedYear")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("JournalEntryId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("JournalEntryId");
|
||||||
|
|
||||||
|
b.ToTable("YearEndCloses");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -8313,6 +8925,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Vendor");
|
b.Navigation("Vendor");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Budget", "Budget")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("BudgetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
|
||||||
|
b.Navigation("Budget");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport")
|
b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport")
|
||||||
@@ -8569,6 +9200,48 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Vendor");
|
b.Navigation("Vendor");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", "AccumDepreciationAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AccumDepreciationAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", "AssetAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", "DepreciationExpenseAccount")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DepreciationExpenseAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("AccumDepreciationAccount");
|
||||||
|
|
||||||
|
b.Navigation("AssetAccount");
|
||||||
|
|
||||||
|
b.Navigation("DepreciationExpenseAccount");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FixedAsset", "FixedAsset")
|
||||||
|
.WithMany("DepreciationEntries")
|
||||||
|
.HasForeignKey("FixedAssetId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JournalEntryId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.Navigation("FixedAsset");
|
||||||
|
|
||||||
|
b.Navigation("JournalEntry");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||||
@@ -9182,6 +9855,23 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedCustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedJobId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("LinkedCustomer");
|
||||||
|
|
||||||
|
b.Navigation("LinkedJob");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||||
@@ -9740,13 +10430,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
|
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("BillId")
|
.HasForeignKey("BillId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
||||||
.WithMany("Applications")
|
.WithMany("Applications")
|
||||||
.HasForeignKey("VendorCreditId")
|
.HasForeignKey("VendorCreditId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.NoAction)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Bill");
|
b.Navigation("Bill");
|
||||||
@@ -9772,6 +10462,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("VendorCredit");
|
b.Navigation("VendorCredit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("JournalEntryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("JournalEntry");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillLineItems");
|
b.Navigation("BillLineItems");
|
||||||
@@ -9816,6 +10517,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Payments");
|
b.Navigation("Payments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Attachments");
|
b.Navigation("Attachments");
|
||||||
@@ -9878,6 +10584,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("OvenBatches");
|
b.Navigation("OvenBatches");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("DepreciationEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Redemptions");
|
b.Navigation("Redemptions");
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -154,6 +157,17 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
// Bank Reconciliation
|
// Bank Reconciliation
|
||||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
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
|
||||||
@@ -449,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 =>
|
||||||
@@ -552,6 +570,26 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<BankReconciliation> BankReconciliations =>
|
public IRepository<BankReconciliation> BankReconciliations =>
|
||||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
_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.
|
||||||
|
|||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
if (unlinkedRevenue > 0)
|
if (unlinkedRevenue > 0)
|
||||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||||
|
|
||||||
|
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||||
|
var periodDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
var periodCredits = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
var totalDeductions = periodDiscounts + periodCredits;
|
||||||
|
if (totalDeductions > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "4950",
|
||||||
|
AccountName = "Less: Sales Discounts & Credits",
|
||||||
|
Amount = -totalDeductions
|
||||||
|
});
|
||||||
|
|
||||||
|
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||||
|
var periodGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
if (periodGcReclassified > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "2500",
|
||||||
|
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
|
||||||
|
Amount = -periodGcReclassified
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||||
|
var periodGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||||
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
if (periodGcBreakage > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "—",
|
||||||
|
AccountName = "Gift Certificate Breakage Income",
|
||||||
|
Amount = periodGcBreakage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
||||||
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||||
|
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var taxByAcct = await _context.Invoices
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||||
|
arCredits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
||||||
|
arCredits -= await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
|
||||||
// Retained earnings = net P&L from inception through asOf
|
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||||
|
var refundsByAcctBs = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
var depositsByAcctDepBs = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// Retained earnings = net P&L from inception through asOf, covering four sources:
|
||||||
|
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
|
||||||
|
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||||
|
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||||
var lifetimeRevenue = await _context.InvoiceItems
|
var lifetimeRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
var lifetimeCogs = await _context.Expenses
|
var lifetimeDiscounts = isCash ? 0m
|
||||||
|
: (await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||||
|
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
||||||
|
var lifetimeCreditMemos = isCash ? 0m
|
||||||
|
: (await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
||||||
|
var lifetimeDirectExp = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.Date <= asOfEnd)
|
||||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||||
var lifetimeBillCosts = await _context.BillLineItems
|
var lifetimeBillCosts = await _context.BillLineItems
|
||||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
|
||||||
|
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||||
|
var revenueAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
var expCogsAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId
|
||||||
|
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
|
||||||
|
&& !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
|
||||||
|
var jeRevNet = revenueAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => revenueAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
|
||||||
|
var jeExpNet = expCogsAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => expCogsAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||||
|
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||||
|
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
|
||||||
|
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||||
|
- lifetimeDiscounts
|
||||||
|
- lifetimeCreditMemos
|
||||||
|
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||||
|
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||||
|
- lifetimeDirectExp
|
||||||
|
- lifetimeBillCosts
|
||||||
|
- jeExpNet;
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.IsActive)
|
.Where(a => a.IsActive)
|
||||||
@@ -248,6 +415,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
{
|
{
|
||||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebitsBs; // deposits applied → DR liability
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Balances are computed dynamically from transaction tables using the same pre-computed
|
||||||
|
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
|
||||||
|
/// date is respected. This replaces the previous implementation that read the denormalised
|
||||||
|
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
|
||||||
|
/// what date was selected.
|
||||||
|
/// </remarks>
|
||||||
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||||
{
|
{
|
||||||
|
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||||
var companyName = await GetCompanyNameAsync(companyId);
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
|
||||||
|
|
||||||
|
// Bank/cash: customer payments deposited here (DR)
|
||||||
|
var depositsByAcct = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||||
|
// issues a credit note and it is matched against a specific bill.
|
||||||
|
var vcByApAcct = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: expenses paid from here (CR)
|
||||||
|
var expFromByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.PaymentAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: bill payments made from here (CR)
|
||||||
|
var bpFromByAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.BankAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bills increase AP (CR)
|
||||||
|
var billsByApAcct = await _context.Bills
|
||||||
|
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(b => b.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bill payments reduce AP (DR)
|
||||||
|
var bpByApAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.Bill.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Tax liability: sales tax collected (CR)
|
||||||
|
var taxByAcct = await _context.Invoices
|
||||||
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Revenue accounts: invoice line items (CR)
|
||||||
|
var revenueByAcct = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.RevenueAccountId != null
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense accounts: direct expenses (DR)
|
||||||
|
var expenseByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense/COGS accounts: vendor bill line items (DR)
|
||||||
|
var billLinesByAcct = await _context.BillLineItems
|
||||||
|
.Where(bli => bli.AccountId != null
|
||||||
|
&& bli.Bill.Status != BillStatus.Draft
|
||||||
|
&& bli.Bill.Status != BillStatus.Voided
|
||||||
|
&& bli.Bill.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(bli => bli.AccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||||
|
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||||
|
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||||
|
var discountAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
discountAcctId ??= await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
|
||||||
|
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
var cmApplied = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
|
||||||
|
var discountsByAcct = new Dictionary<int, decimal>();
|
||||||
|
if (discountAcctId.HasValue)
|
||||||
|
{
|
||||||
|
var totalDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.DiscountAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft
|
||||||
|
&& i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
if (totalDiscounts + cmApplied > 0)
|
||||||
|
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JE lines: posted entries debit/credit all account types
|
||||||
|
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AR totals (single AR account assumed per standard small-business chart of accounts).
|
||||||
|
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||||
|
// when a customer credit is applied against a specific invoice).
|
||||||
|
var arTotalDebits = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||||
|
var arTotalCredits = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||||
|
|
||||||
|
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
||||||
|
var refundTotal = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
arTotalCredits -= refundTotal;
|
||||||
|
|
||||||
|
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||||
|
var refundsByAcct = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||||
|
var depositsByAcctDep = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// ── Per-account balance computation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var lines = new List<TrialBalanceLine>();
|
decimal ComputeAsOfBalance(Account a)
|
||||||
|
{
|
||||||
|
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
|
||||||
|
decimal debits = 0m, credits = 0m;
|
||||||
|
|
||||||
|
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||||
|
{
|
||||||
|
debits = arTotalDebits;
|
||||||
|
credits = arTotalCredits;
|
||||||
|
}
|
||||||
|
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
|
{
|
||||||
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// All other accounts: sum contributions from each transaction source that can
|
||||||
|
// post to this account. Dictionaries only contain entries for relevant account IDs,
|
||||||
|
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
|
||||||
|
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += revenueByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebits; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCredits; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebits; // deposits applied → DR liability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||||
|
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
|
||||||
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
|
? a.OpeningBalance : 0m;
|
||||||
|
decimal net = isDebitNormal ? debits - credits : credits - debits;
|
||||||
|
return opening + net;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<TrialBalanceLine>();
|
||||||
foreach (var acct in accounts)
|
foreach (var acct in accounts)
|
||||||
{
|
{
|
||||||
if (acct.CurrentBalance == 0) continue;
|
var balance = ComputeAsOfBalance(acct);
|
||||||
|
if (balance == 0m) continue;
|
||||||
|
|
||||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||||
var line = new TrialBalanceLine
|
var line = new TrialBalanceLine
|
||||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
if (isDebitNormal)
|
if (isDebitNormal)
|
||||||
{
|
{
|
||||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.DebitBalance = balance;
|
||||||
else line.CreditBalance = -acct.CurrentBalance;
|
else line.CreditBalance = -balance;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.CreditBalance = balance;
|
||||||
else line.DebitBalance = -acct.CurrentBalance;
|
else line.DebitBalance = -balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.Add(line);
|
lines.Add(line);
|
||||||
@@ -713,6 +1150,326 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
return method ?? AccountingMethod.Accrual;
|
return method ?? AccountingMethod.Accrual;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||||
|
var fromEnd = from.AddTicks(-1); // exclusive upper bound for pre-period queries
|
||||||
|
|
||||||
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
var customer = await _context.Customers
|
||||||
|
.Where(c => c.Id == customerId && c.CompanyId == companyId)
|
||||||
|
.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
if (customer == null) return new CustomerStatementDto { CompanyName = companyName, From = from, To = to };
|
||||||
|
|
||||||
|
var customerName = customer.IsCommercial
|
||||||
|
? customer.CompanyName ?? string.Empty
|
||||||
|
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||||
|
|
||||||
|
var address = string.Join(", ", new[] { customer.Address, customer.City, customer.State, customer.ZipCode }
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||||
|
|
||||||
|
// Opening balance: invoiced − paid before period start
|
||||||
|
var preInvoiced = await _context.Invoices
|
||||||
|
.Where(i => i.CustomerId == customerId
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate < from)
|
||||||
|
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||||
|
var prePaid = await _context.Payments
|
||||||
|
.Where(p => p.Invoice.CustomerId == customerId
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.PaymentDate < from)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
var preCredits = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
|
||||||
|
var openingBalance = preInvoiced - prePaid - preCredits;
|
||||||
|
|
||||||
|
// In-period activity — gather, then sort, then compute running balance
|
||||||
|
var lines = new List<StatementLineDto>();
|
||||||
|
|
||||||
|
var periodInvoices = await _context.Invoices
|
||||||
|
.Where(i => i.CustomerId == customerId
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var inv in periodInvoices)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = inv.InvoiceDate,
|
||||||
|
Type = "Invoice",
|
||||||
|
Reference = inv.InvoiceNumber,
|
||||||
|
Description = "Invoice",
|
||||||
|
Debit = inv.Total,
|
||||||
|
});
|
||||||
|
|
||||||
|
var periodPayments = await _context.Payments
|
||||||
|
.Include(p => p.Invoice)
|
||||||
|
.Where(p => p.Invoice.CustomerId == customerId
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var pay in periodPayments)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = pay.PaymentDate,
|
||||||
|
Type = "Payment",
|
||||||
|
Reference = pay.Invoice.InvoiceNumber,
|
||||||
|
Description = pay.Notes ?? "Payment received",
|
||||||
|
Credit = pay.Amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
var periodCredits = await _context.CreditMemoApplications
|
||||||
|
.Include(a => a.Invoice)
|
||||||
|
.Include(a => a.CreditMemo)
|
||||||
|
.Where(a => a.Invoice.CustomerId == customerId
|
||||||
|
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cr in periodCredits)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = cr.AppliedDate,
|
||||||
|
Type = "Credit Applied",
|
||||||
|
Reference = cr.Invoice?.InvoiceNumber ?? string.Empty,
|
||||||
|
Description = $"Credit memo applied",
|
||||||
|
Credit = cr.AmountApplied,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by date then compute running balance
|
||||||
|
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
|
||||||
|
var running = openingBalance;
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
running += (line.Debit ?? 0) - (line.Credit ?? 0);
|
||||||
|
line.RunningBalance = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomerStatementDto
|
||||||
|
{
|
||||||
|
CustomerId = customerId,
|
||||||
|
CustomerName = customerName,
|
||||||
|
CustomerAddress = address,
|
||||||
|
CompanyName = companyName,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
OpeningBalance = openingBalance,
|
||||||
|
Lines = lines,
|
||||||
|
ClosingBalance = running,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||||
|
|
||||||
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
var vendor = await _context.Vendors
|
||||||
|
.Where(v => v.Id == vendorId && v.CompanyId == companyId)
|
||||||
|
.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
if (vendor == null) return new VendorStatementDto { CompanyName = companyName, From = from, To = to };
|
||||||
|
|
||||||
|
// Opening balance: bills − payments − credits before period start
|
||||||
|
var preBills = await _context.Bills
|
||||||
|
.Where(b => b.VendorId == vendorId
|
||||||
|
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||||
|
&& b.BillDate < from)
|
||||||
|
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
||||||
|
var prePayments = await _context.BillPayments
|
||||||
|
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
||||||
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||||
|
var preVcApplied = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
||||||
|
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||||
|
|
||||||
|
var openingBalance = preBills - prePayments - preVcApplied;
|
||||||
|
|
||||||
|
var lines = new List<StatementLineDto>();
|
||||||
|
|
||||||
|
var periodBills = await _context.Bills
|
||||||
|
.Where(b => b.VendorId == vendorId
|
||||||
|
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||||
|
&& b.BillDate >= from && b.BillDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var bill in periodBills)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = bill.BillDate,
|
||||||
|
Type = "Bill",
|
||||||
|
Reference = bill.BillNumber,
|
||||||
|
Description = bill.Memo ?? "Vendor bill",
|
||||||
|
Debit = bill.Total,
|
||||||
|
});
|
||||||
|
|
||||||
|
var periodPayments = await _context.BillPayments
|
||||||
|
.Include(bp => bp.Bill)
|
||||||
|
.Where(bp => bp.Bill.VendorId == vendorId
|
||||||
|
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var pay in periodPayments)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = pay.PaymentDate,
|
||||||
|
Type = "Payment",
|
||||||
|
Reference = pay.Bill.BillNumber,
|
||||||
|
Description = pay.Memo ?? "Bill payment",
|
||||||
|
Credit = pay.Amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
var periodVcApplied = await _context.VendorCreditApplications
|
||||||
|
.Include(vca => vca.VendorCredit)
|
||||||
|
.Include(vca => vca.Bill)
|
||||||
|
.Where(vca => vca.Bill.VendorId == vendorId
|
||||||
|
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
|
||||||
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
foreach (var vca in periodVcApplied)
|
||||||
|
lines.Add(new StatementLineDto
|
||||||
|
{
|
||||||
|
Date = vca.AppliedDate,
|
||||||
|
Type = "Credit Applied",
|
||||||
|
Reference = vca.VendorCredit.CreditNumber,
|
||||||
|
Description = $"Vendor credit applied to {vca.Bill.BillNumber}",
|
||||||
|
Credit = vca.Amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
|
||||||
|
var running = openingBalance;
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
running += (line.Debit ?? 0) - (line.Credit ?? 0);
|
||||||
|
line.RunningBalance = running;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new VendorStatementDto
|
||||||
|
{
|
||||||
|
VendorId = vendorId,
|
||||||
|
VendorName = vendor.CompanyName,
|
||||||
|
CompanyName = companyName,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
OpeningBalance = openingBalance,
|
||||||
|
Lines = lines,
|
||||||
|
ClosingBalance = running,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method
|
||||||
|
/// for operating activities:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>CashFromCustomers</b> — sum of <see cref="Payment"/> amounts in the period.</item>
|
||||||
|
/// <item><b>CashToVendors</b> — sum of <see cref="BillPayment"/> amounts in the period.</item>
|
||||||
|
/// <item><b>CashForExpenses</b> — sum of <see cref="Expense"/> amounts in the period.</item>
|
||||||
|
/// </list>
|
||||||
|
/// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows
|
||||||
|
/// prior to <paramref name="from"/>. This is an approximation when cash accounts have
|
||||||
|
/// an OpeningBalance; it is the most accurate representation available without a dedicated
|
||||||
|
/// cash-tracking journal.
|
||||||
|
/// Investing and Financing sections are populated from the expense/asset account ledger
|
||||||
|
/// (FixedAsset purchases from Expense entries whose account is FixedAsset subtype) and
|
||||||
|
/// equity account changes respectively.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to)
|
||||||
|
{
|
||||||
|
var toEnd = to.Date.AddDays(1).AddTicks(-1);
|
||||||
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
var method = await GetCompanyAccountingMethodAsync(companyId);
|
||||||
|
|
||||||
|
// ── Operating — direct / cash ──────────────────────────────────────
|
||||||
|
var cashFromCustomers = await _context.Payments
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(p => p.CompanyId == companyId && !p.IsDeleted
|
||||||
|
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var cashToVendors = await _context.BillPayments
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted
|
||||||
|
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||||
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var cashForExpenses = await _context.Expenses
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.CompanyId == companyId && !e.IsDeleted
|
||||||
|
&& e.Date >= from && e.Date <= toEnd)
|
||||||
|
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||||
|
|
||||||
|
// ── Investing — fixed-asset purchases from Expense entries ─────────
|
||||||
|
var fixedAssetAccountIds = await _context.Accounts
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(a => a.CompanyId == companyId && !a.IsDeleted
|
||||||
|
&& a.AccountSubType == AccountSubType.FixedAsset)
|
||||||
|
.Select(a => a.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var capEx = fixedAssetAccountIds.Count > 0
|
||||||
|
? (await _context.Expenses
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.CompanyId == companyId && !e.IsDeleted
|
||||||
|
&& e.Date >= from && e.Date <= toEnd
|
||||||
|
&& fixedAssetAccountIds.Contains(e.ExpenseAccountId))
|
||||||
|
.SumAsync(e => (decimal?)e.Amount) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
var investingLines = new List<CashFlowLineDto>();
|
||||||
|
if (capEx != 0m)
|
||||||
|
investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx });
|
||||||
|
|
||||||
|
// ── Financing — placeholder (equity changes not explicitly tracked) ─
|
||||||
|
var financingLines = new List<CashFlowLineDto>();
|
||||||
|
|
||||||
|
// ── Beginning cash ─────────────────────────────────────────────────
|
||||||
|
// Cash account opening balances + pre-period payments in - pre-period payments out
|
||||||
|
var cashAccountOpeningBalance = await _context.Accounts
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(a => a.CompanyId == companyId && !a.IsDeleted
|
||||||
|
&& (a.AccountSubType == AccountSubType.Cash
|
||||||
|
|| a.AccountSubType == AccountSubType.Checking
|
||||||
|
|| a.AccountSubType == AccountSubType.Savings))
|
||||||
|
.SumAsync(a => (decimal?)a.OpeningBalance) ?? 0m;
|
||||||
|
|
||||||
|
var prePaymentsIn = await _context.Payments
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate < from)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var preBillPaymentsOut = await _context.BillPayments
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate < from)
|
||||||
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var preExpensesOut = await _context.Expenses
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date < from)
|
||||||
|
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||||
|
|
||||||
|
var beginningCash = cashAccountOpeningBalance + prePaymentsIn - preBillPaymentsOut - preExpensesOut;
|
||||||
|
|
||||||
|
return new CashFlowStatementDto
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
From = from,
|
||||||
|
To = to,
|
||||||
|
Method = method,
|
||||||
|
CashFromCustomers = cashFromCustomers,
|
||||||
|
CashToVendors = cashToVendors,
|
||||||
|
CashForExpenses = cashForExpenses,
|
||||||
|
InvestingLines = investingLines,
|
||||||
|
FinancingLines = financingLines,
|
||||||
|
BeginningCash = beginningCash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||||
/// Falls back to "Your Company" if the record is not found.
|
/// Falls back to "Your Company" if the record is not found.
|
||||||
|
|||||||
@@ -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,6 +375,102 @@ 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 ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
@@ -382,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)
|
||||||
@@ -434,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
|
||||||
@@ -449,6 +642,36 @@ 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)
|
// 10. Posted journal entry lines touching this account (prior to 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public static class AppConstants
|
|||||||
{
|
{
|
||||||
public const string CompanyAdmin = "CompanyAdmin";
|
public const string CompanyAdmin = "CompanyAdmin";
|
||||||
public const string Manager = "Manager";
|
public const string Manager = "Manager";
|
||||||
|
public const string Accountant = "Accountant";
|
||||||
public const string Worker = "Worker";
|
public const string Worker = "Worker";
|
||||||
public const string Viewer = "Viewer";
|
public const string Viewer = "Viewer";
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,8 @@ public static class AppConstants
|
|||||||
public const string CanManageMaintenance = "CanManageMaintenance";
|
public const string CanManageMaintenance = "CanManageMaintenance";
|
||||||
public const string CanManageInvoices = "CanManageInvoices";
|
public const string CanManageInvoices = "CanManageInvoices";
|
||||||
public const string CanViewReports = "CanViewReports";
|
public const string CanViewReports = "CanViewReports";
|
||||||
|
public const string CanManageBills = "CanManageBills";
|
||||||
|
public const string CanManageAccounting = "CanManageAccounting";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FileUpload
|
public static class FileUpload
|
||||||
@@ -103,6 +106,10 @@ public static class AppConstants
|
|||||||
public const string FinancialSummary = "FinancialSummary";
|
public const string FinancialSummary = "FinancialSummary";
|
||||||
public const string CashFlowForecast = "CashFlowForecast";
|
public const string CashFlowForecast = "CashFlowForecast";
|
||||||
public const string AnomalyDetection = "AnomalyDetection";
|
public const string AnomalyDetection = "AnomalyDetection";
|
||||||
|
public const string BankRecAutoMatch = "BankRecAutoMatch";
|
||||||
|
public const string LatePaymentPrediction = "LatePaymentPrediction";
|
||||||
|
public const string FinancialQuery = "FinancialQuery";
|
||||||
|
public const string RecurringBillDetection = "RecurringBillDetection";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Legal
|
public static class Legal
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton background service that wakes hourly and generates bills or expenses for any
|
||||||
|
/// <see cref="RecurringTemplate"/> whose <c>NextFireDate</c> is today or in the past.
|
||||||
|
/// Bills are created as Draft so users can review; Expenses are recorded immediately.
|
||||||
|
/// NextFireDate is advanced after each successful fire. Templates are deactivated automatically
|
||||||
|
/// when <c>MaxOccurrences</c> is reached or <c>EndDate</c> has passed.
|
||||||
|
/// </summary>
|
||||||
|
public class RecurringTransactionService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<RecurringTransactionService> _logger;
|
||||||
|
|
||||||
|
public RecurringTransactionService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<RecurringTransactionService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loops forever, sleeping one hour between passes.
|
||||||
|
/// Uses <see cref="IServiceScopeFactory"/> to resolve scoped services (DbContext) from the
|
||||||
|
/// singleton because BackgroundService lives for the application lifetime.
|
||||||
|
/// </summary>
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("RecurringTransactionService started.");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "RecurringTransactionService run failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("RecurringTransactionService stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads all active templates whose NextFireDate is on or before today and fires each one.
|
||||||
|
/// Uses IgnoreQueryFilters to bypass the HTTP-context-dependent tenant filter.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
var due = await db.RecurringTemplates
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(t => !t.IsDeleted && t.IsActive && t.NextFireDate.Date <= today)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (due.Count == 0) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("RecurringTransactionService: {Count} template(s) due.", due.Count);
|
||||||
|
|
||||||
|
foreach (var template in due)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
await FireTemplateAsync(db, template, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires a single template: creates the document, updates OccurrenceCount + NextFireDate,
|
||||||
|
/// and deactivates the template when limits are reached. Errors are captured in LastError
|
||||||
|
/// so the service loop continues to process other templates.
|
||||||
|
/// </summary>
|
||||||
|
private async Task FireTemplateAsync(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
RecurringTemplate template,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (template.TemplateType == RecurringTemplateType.Bill)
|
||||||
|
await CreateBillAsync(db, template, ct);
|
||||||
|
else
|
||||||
|
await CreateExpenseAsync(db, template, ct);
|
||||||
|
|
||||||
|
template.OccurrenceCount++;
|
||||||
|
template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount);
|
||||||
|
template.LastError = null;
|
||||||
|
|
||||||
|
// Deactivate when limits reached
|
||||||
|
if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value)
|
||||||
|
{
|
||||||
|
template.IsActive = false;
|
||||||
|
_logger.LogInformation("Template {Id} ({Name}) deactivated: MaxOccurrences reached.", template.Id, template.Name);
|
||||||
|
}
|
||||||
|
else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date)
|
||||||
|
{
|
||||||
|
template.IsActive = false;
|
||||||
|
_logger.LogInformation("Template {Id} ({Name}) deactivated: EndDate passed.", template.Id, template.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fire recurring template {Id} ({Name}).", template.Id, template.Name);
|
||||||
|
template.LastError = ex.Message;
|
||||||
|
try { await db.SaveChangesAsync(ct); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Bill creation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes the template's JSON payload and inserts a Draft <see cref="Bill"/> with
|
||||||
|
/// its line items. GL posting is deferred — the user posts the Draft bill manually after review.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CreateBillAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<BillTemplateData>(template.TemplateData)
|
||||||
|
?? throw new InvalidOperationException("Invalid bill template data.");
|
||||||
|
|
||||||
|
var bill = new Bill
|
||||||
|
{
|
||||||
|
BillNumber = await NextBillNumberAsync(db, ct),
|
||||||
|
VendorId = data.VendorId,
|
||||||
|
APAccountId = data.APAccountId,
|
||||||
|
BillDate = DateTime.UtcNow,
|
||||||
|
DueDate = data.Terms != null ? ParseDueDate(data.Terms) : null,
|
||||||
|
Status = BillStatus.Draft,
|
||||||
|
Terms = data.Terms,
|
||||||
|
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||||
|
SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0,
|
||||||
|
TaxPercent = data.TaxPercent,
|
||||||
|
TaxAmount = 0,
|
||||||
|
Total = 0,
|
||||||
|
CompanyId = template.CompanyId,
|
||||||
|
CreatedBy = "Recurring",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
bill.TaxAmount = Math.Round(bill.SubTotal * bill.TaxPercent / 100, 2);
|
||||||
|
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||||
|
|
||||||
|
db.Bills.Add(bill);
|
||||||
|
await db.SaveChangesAsync(ct); // get bill.Id
|
||||||
|
|
||||||
|
int order = 1;
|
||||||
|
foreach (var line in data.LineItems ?? [])
|
||||||
|
{
|
||||||
|
db.BillLineItems.Add(new BillLineItem
|
||||||
|
{
|
||||||
|
BillId = bill.Id,
|
||||||
|
AccountId = line.AccountId,
|
||||||
|
Description = line.Description,
|
||||||
|
Quantity = line.Quantity,
|
||||||
|
UnitPrice = line.UnitPrice,
|
||||||
|
Amount = Math.Round(line.Quantity * line.UnitPrice, 2),
|
||||||
|
DisplayOrder = order++,
|
||||||
|
CompanyId = template.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Recurring bill {BillNumber} created for template {Id}.", bill.BillNumber, template.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Expense creation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes the template's JSON payload and inserts an <see cref="Expense"/> immediately.
|
||||||
|
/// Expenses are already-paid transactions so no user review is required.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CreateExpenseAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<ExpenseTemplateData>(template.TemplateData)
|
||||||
|
?? throw new InvalidOperationException("Invalid expense template data.");
|
||||||
|
|
||||||
|
var expense = new Expense
|
||||||
|
{
|
||||||
|
ExpenseNumber = await NextExpenseNumberAsync(db, ct),
|
||||||
|
Date = DateTime.UtcNow,
|
||||||
|
VendorId = data.VendorId == 0 ? null : data.VendorId,
|
||||||
|
ExpenseAccountId = data.ExpenseAccountId,
|
||||||
|
PaymentAccountId = data.PaymentAccountId,
|
||||||
|
PaymentMethod = (PaymentMethod)data.PaymentMethod,
|
||||||
|
Amount = data.Amount,
|
||||||
|
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||||
|
CompanyId = template.CompanyId,
|
||||||
|
CreatedBy = "Recurring",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Expenses.Add(expense);
|
||||||
|
|
||||||
|
_logger.LogInformation("Recurring expense {ExpenseNumber} created for template {Id}.", expense.ExpenseNumber, template.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Advances a date by one period (Frequency × IntervalCount).</summary>
|
||||||
|
private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval)
|
||||||
|
{
|
||||||
|
return freq switch
|
||||||
|
{
|
||||||
|
RecurringFrequency.Daily => date.AddDays(interval),
|
||||||
|
RecurringFrequency.Weekly => date.AddDays(7 * interval),
|
||||||
|
RecurringFrequency.BiWeekly => date.AddDays(14 * interval),
|
||||||
|
RecurringFrequency.Monthly => date.AddMonths(interval),
|
||||||
|
RecurringFrequency.Quarterly => date.AddMonths(3 * interval),
|
||||||
|
RecurringFrequency.Annually => date.AddYears(interval),
|
||||||
|
_ => date.AddMonths(interval)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the next sequential bill number (BILL-YYMM-####).
|
||||||
|
/// Uses IgnoreQueryFilters so soft-deleted bills are included in the sequence scan.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> NextBillNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||||
|
var last = await db.Bills
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||||
|
.OrderByDescending(b => b.BillNumber)
|
||||||
|
.Select(b => b.BillNumber)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
int next = 1;
|
||||||
|
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||||
|
return $"{prefix}{next:D4}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the next sequential expense number (EXP-YYMM-####).
|
||||||
|
/// Uses IgnoreQueryFilters so soft-deleted expenses are included in the sequence scan.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<string> NextExpenseNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||||
|
var last = await db.Expenses
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||||
|
.OrderByDescending(e => e.ExpenseNumber)
|
||||||
|
.Select(e => e.ExpenseNumber)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
int next = 1;
|
||||||
|
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||||
|
return $"{prefix}{next:D4}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort due date from a payment terms string (delegates to the same patterns as PaymentTermsParser).</summary>
|
||||||
|
private static DateTime? ParseDueDate(string terms)
|
||||||
|
{
|
||||||
|
var t = terms.Trim().ToUpperInvariant();
|
||||||
|
if (t is "DUE ON RECEIPT" or "COD" or "IMMEDIATE") return DateTime.UtcNow.Date;
|
||||||
|
|
||||||
|
// "Net 30", "NET30", "2/10 Net 30" → extract trailing number
|
||||||
|
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var last = parts.LastOrDefault();
|
||||||
|
if (last != null && int.TryParse(last, out int days) && days > 0)
|
||||||
|
return DateTime.UtcNow.Date.AddDays(days);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// JSON payload records (must match RecurringTemplatesController serialization)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
internal sealed record BillTemplateData(
|
||||||
|
int VendorId,
|
||||||
|
int APAccountId,
|
||||||
|
string? Terms,
|
||||||
|
string? Memo,
|
||||||
|
decimal TaxPercent,
|
||||||
|
List<BillLineData>? LineItems);
|
||||||
|
|
||||||
|
internal sealed record BillLineData(
|
||||||
|
int? AccountId,
|
||||||
|
string Description,
|
||||||
|
decimal Quantity,
|
||||||
|
decimal UnitPrice);
|
||||||
|
|
||||||
|
internal sealed record ExpenseTemplateData(
|
||||||
|
int VendorId,
|
||||||
|
int ExpenseAccountId,
|
||||||
|
int PaymentAccountId,
|
||||||
|
int PaymentMethod,
|
||||||
|
decimal Amount,
|
||||||
|
string? Memo);
|
||||||
|
}
|
||||||
@@ -427,6 +427,186 @@ public class AccountsController : Controller
|
|||||||
return View(ledger);
|
return View(ledger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Year-End Close ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET: landing page showing close history and a form to initiate the current year close.
|
||||||
|
/// Companyid is resolved from tenant context; year defaults to the prior fiscal year
|
||||||
|
/// (the most common use case — close last year after final entries are posted).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> YearEndClose()
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
||||||
|
.OrderByDescending(y => y.ClosedYear)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
ViewBag.History = history;
|
||||||
|
ViewBag.SuggestedYear = DateTime.Now.Year - 1;
|
||||||
|
ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet();
|
||||||
|
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST: executes the year-end close for the specified fiscal year.
|
||||||
|
/// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances
|
||||||
|
/// (debit-normal), computes net income, posts a JE that zeroes them into Retained
|
||||||
|
/// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has
|
||||||
|
/// already been closed is rejected.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> CloseYear(int year)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
// Idempotency check
|
||||||
|
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||||
|
return RedirectToAction(nameof(YearEndClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all active accounts with balances
|
||||||
|
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
||||||
|
|
||||||
|
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||||
|
var expenseAccounts = accounts.Where(a =>
|
||||||
|
a.AccountType == AccountType.Expense ||
|
||||||
|
a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList();
|
||||||
|
|
||||||
|
// Find or locate the Retained Earnings account
|
||||||
|
var retainedEarnings = accounts.FirstOrDefault(a =>
|
||||||
|
a.AccountSubType == AccountSubType.RetainedEarnings);
|
||||||
|
|
||||||
|
if (retainedEarnings == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first.";
|
||||||
|
return RedirectToAction(nameof(YearEndClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Net income = total revenue credits − total expense debits
|
||||||
|
var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance);
|
||||||
|
var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance);
|
||||||
|
var netIncome = totalRevenue - totalExpenses;
|
||||||
|
|
||||||
|
if (totalRevenue == 0 && totalExpenses == 0)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close.";
|
||||||
|
return RedirectToAction(nameof(YearEndClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
int newJeId = 0;
|
||||||
|
|
||||||
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
|
{
|
||||||
|
var lines = new List<JournalEntryLine>();
|
||||||
|
|
||||||
|
// Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0)
|
||||||
|
foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0))
|
||||||
|
{
|
||||||
|
lines.Add(new JournalEntryLine
|
||||||
|
{
|
||||||
|
AccountId = acct.Id,
|
||||||
|
DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||||
|
CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||||
|
Description = $"Close {year} — {acct.Name}",
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||||
|
if (acct.CurrentBalance < 0)
|
||||||
|
await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0)
|
||||||
|
foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0))
|
||||||
|
{
|
||||||
|
lines.Add(new JournalEntryLine
|
||||||
|
{
|
||||||
|
AccountId = acct.Id,
|
||||||
|
DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||||
|
CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||||
|
Description = $"Close {year} — {acct.Name}",
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||||
|
if (acct.CurrentBalance < 0)
|
||||||
|
await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plug the net into Retained Earnings: CR if profit, DR if loss
|
||||||
|
if (netIncome > 0)
|
||||||
|
{
|
||||||
|
lines.Add(new JournalEntryLine
|
||||||
|
{
|
||||||
|
AccountId = retainedEarnings.Id,
|
||||||
|
CreditAmount = netIncome,
|
||||||
|
Description = $"Net income {year} → Retained Earnings",
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome);
|
||||||
|
}
|
||||||
|
else if (netIncome < 0)
|
||||||
|
{
|
||||||
|
lines.Add(new JournalEntryLine
|
||||||
|
{
|
||||||
|
AccountId = retainedEarnings.Id,
|
||||||
|
DebitAmount = Math.Abs(netIncome),
|
||||||
|
Description = $"Net loss {year} → Retained Earnings",
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post the JE
|
||||||
|
var prefix = $"JE-{year % 100:D2}12-";
|
||||||
|
var existing2 = await _unitOfWork.JournalEntries.FindAsync(
|
||||||
|
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||||
|
ignoreQueryFilters: true);
|
||||||
|
int next = existing2.Any()
|
||||||
|
? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
var je = new JournalEntry
|
||||||
|
{
|
||||||
|
EntryNumber = $"{prefix}{next:D4}",
|
||||||
|
EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Description = $"Year-end close — {year}",
|
||||||
|
Reference = $"CLOSE-{year}",
|
||||||
|
Status = JournalEntryStatus.Posted,
|
||||||
|
PostedBy = User.Identity?.Name,
|
||||||
|
PostedAt = DateTime.UtcNow,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Lines = lines
|
||||||
|
};
|
||||||
|
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// Record the close
|
||||||
|
var close = new YearEndClose
|
||||||
|
{
|
||||||
|
ClosedYear = year,
|
||||||
|
ClosedAt = DateTime.UtcNow,
|
||||||
|
ClosedBy = User.Identity?.Name,
|
||||||
|
JournalEntryId = je.Id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.YearEndCloses.AddAsync(close);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
newJeId = je.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " +
|
||||||
|
$"See Journal Entry for details.";
|
||||||
|
return RedirectToAction(nameof(YearEndClose));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.DTOs.AI;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IAccountingAiService _accountingAi;
|
||||||
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
|
||||||
public BankReconciliationsController(
|
public BankReconciliationsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext)
|
ITenantContext tenantContext,
|
||||||
|
IAccountingAiService accountingAi,
|
||||||
|
IAiUsageLogger usageLogger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
|
_accountingAi = accountingAi;
|
||||||
|
_usageLogger = usageLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AllowAccounting() =>
|
private bool AllowAccounting() =>
|
||||||
@@ -49,7 +56,7 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
public async Task<IActionResult> Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||||
@@ -58,7 +65,7 @@ public class BankReconciliationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(BankReconciliation model)
|
public async Task<IActionResult> Create(BankReconciliation model)
|
||||||
{
|
{
|
||||||
@@ -164,7 +171,7 @@ public class BankReconciliationsController : Controller
|
|||||||
/// Returns updated running totals as JSON.
|
/// Returns updated running totals as JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ToggleCleared(
|
public async Task<IActionResult> ToggleCleared(
|
||||||
int reconId, string entityType, int entityId, bool isCleared)
|
int reconId, string entityType, int entityId, bool isCleared)
|
||||||
@@ -200,7 +207,7 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Complete(int id, decimal difference)
|
public async Task<IActionResult> Complete(int id, decimal difference)
|
||||||
{
|
{
|
||||||
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
|
|||||||
return View(recon);
|
return View(recon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
|
||||||
|
/// to mark as cleared. The controller assembles all three transaction types (deposits,
|
||||||
|
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
|
||||||
|
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
|
||||||
|
/// suggestions client-side by auto-checking the corresponding table rows.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> AiSuggestMatches(int reconId)
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return Forbid();
|
||||||
|
|
||||||
|
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||||
|
br => br.Id == reconId, false, br => br.Account))
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (recon == null) return NotFound();
|
||||||
|
|
||||||
|
var accountId = recon.AccountId;
|
||||||
|
var statementDate = recon.StatementDate;
|
||||||
|
|
||||||
|
var items = new List<BankRecMatchItem>();
|
||||||
|
|
||||||
|
(await _unitOfWork.Payments.FindAsync(
|
||||||
|
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(p => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "Payment",
|
||||||
|
EntityId = p.Id,
|
||||||
|
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||||
|
Description = $"Payment #{p.InvoiceId}",
|
||||||
|
Amount = p.Amount,
|
||||||
|
Direction = "deposit"
|
||||||
|
}));
|
||||||
|
|
||||||
|
(await _unitOfWork.BillPayments.FindAsync(
|
||||||
|
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(bp => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "BillPayment",
|
||||||
|
EntityId = bp.Id,
|
||||||
|
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = bp.PaymentNumber,
|
||||||
|
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||||
|
Amount = bp.Amount,
|
||||||
|
Direction = "payment"
|
||||||
|
}));
|
||||||
|
|
||||||
|
(await _unitOfWork.Expenses.FindAsync(
|
||||||
|
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(e => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "Expense",
|
||||||
|
EntityId = e.Id,
|
||||||
|
Date = e.Date.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = e.ExpenseNumber,
|
||||||
|
Description = e.Memo ?? string.Empty,
|
||||||
|
Amount = e.Amount,
|
||||||
|
Direction = "payment"
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.Any())
|
||||||
|
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
|
||||||
|
|
||||||
|
var request = new AutoMatchRequest
|
||||||
|
{
|
||||||
|
UnclearedItems = items,
|
||||||
|
BeginningBalance = recon.BeginningBalance,
|
||||||
|
StatementEndingBalance = recon.EndingBalance
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
|
||||||
|
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task PopulateAccountDropdownAsync()
|
private async Task PopulateAccountDropdownAsync()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -58,13 +58,13 @@ public class BillsController : Controller
|
|||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Index ────────────────────────────────────────────────────────────────
|
// -- Index ----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
||||||
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
||||||
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
||||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||||
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
||||||
@@ -112,7 +112,7 @@ public class BillsController : Controller
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
||||||
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
||||||
{
|
{
|
||||||
var expSearch = search;
|
var expSearch = search;
|
||||||
@@ -160,13 +160,13 @@ public class BillsController : Controller
|
|||||||
return View(pagedEntries);
|
return View(pagedEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// -- Create ---------------------------------------------------------------
|
||||||
|
|
||||||
// ── Create from Purchase Order ────────────────────────────────────────────
|
// -- Create from Purchase Order --------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
||||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||||
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
||||||
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
||||||
/// Line items are copied from PO items (using inventory item names where available), and
|
/// Line items are copied from PO items (using inventory item names where available), and
|
||||||
@@ -174,7 +174,7 @@ public class BillsController : Controller
|
|||||||
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
||||||
/// first active Expense/COGS account when the vendor has no default configured.
|
/// first active Expense/COGS account when the vendor has no default configured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
||||||
{
|
{
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
@@ -248,7 +248,7 @@ public class BillsController : Controller
|
|||||||
return View("Create", dto);
|
return View("Create", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// -- Create ---------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
||||||
@@ -257,7 +257,7 @@ public class BillsController : Controller
|
|||||||
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
||||||
/// so the double-entry pair is ready without manual lookup.
|
/// so the double-entry pair is ready without manual lookup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Create(int? vendorId)
|
public async Task<IActionResult> Create(int? vendorId)
|
||||||
{
|
{
|
||||||
var dto = new CreateBillDto
|
var dto = new CreateBillDto
|
||||||
@@ -291,14 +291,14 @@ public class BillsController : Controller
|
|||||||
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
||||||
/// before validation to avoid spurious errors when the browser submits blank rows.
|
/// before validation to avoid spurious errors when the browser submits blank rows.
|
||||||
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
||||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||||
/// useful for entering historical bills that were already settled. Account balance side
|
/// useful for entering historical bills that were already settled. Account balance side
|
||||||
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
||||||
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
||||||
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
||||||
bool payNow = false,
|
bool payNow = false,
|
||||||
DateTime? paymentDate = null,
|
DateTime? paymentDate = null,
|
||||||
@@ -321,6 +321,19 @@ public class BillsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
|
// Period lock check — block if the bill date is in a locked period
|
||||||
|
if (currentUser != null)
|
||||||
|
{
|
||||||
|
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||||
|
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
|
||||||
|
await PopulateDropdownsAsync();
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Bill? bill = null;
|
Bill? bill = null;
|
||||||
|
|
||||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||||
@@ -386,7 +399,7 @@ public class BillsController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Receipt upload after the transaction commits — bill.Id is set and core data
|
// Receipt upload after the transaction commits — bill.Id is set and core data
|
||||||
// is secure. A blob failure here leaves the bill intact without an attachment.
|
// is secure. A blob failure here leaves the bill intact without an attachment.
|
||||||
if (receiptFile != null && receiptFile.Length > 0)
|
if (receiptFile != null && receiptFile.Length > 0)
|
||||||
{
|
{
|
||||||
@@ -415,7 +428,7 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Details ──────────────────────────────────────────────────────────────
|
// -- Details --------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays full bill detail including line items, payments, and the payment entry form.
|
/// Displays full bill detail including line items, payments, and the payment entry form.
|
||||||
@@ -441,7 +454,7 @@ public class BillsController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.BankAccounts = bankAccounts
|
ViewBag.BankAccounts = bankAccounts
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||||
@@ -451,7 +464,7 @@ public class BillsController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit ─────────────────────────────────────────────────────────────────
|
// -- Edit -----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
||||||
@@ -459,7 +472,7 @@ public class BillsController : Controller
|
|||||||
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
||||||
/// audit trail.
|
/// audit trail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Edit(int? id)
|
public async Task<IActionResult> Edit(int? id)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
@@ -510,7 +523,7 @@ public class BillsController : Controller
|
|||||||
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
||||||
{
|
{
|
||||||
if (id != dto.Id) return NotFound();
|
if (id != dto.Id) return NotFound();
|
||||||
@@ -607,7 +620,7 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mark Open (Draft → Open) ─────────────────────────────────────────────
|
// -- Mark Open (Draft ? Open) ---------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
||||||
@@ -618,7 +631,7 @@ public class BillsController : Controller
|
|||||||
/// deferred from bill creation to give users a review window without polluting the ledger.
|
/// deferred from bill creation to give users a review window without polluting the ledger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> MarkOpen(int id)
|
public async Task<IActionResult> MarkOpen(int id)
|
||||||
{
|
{
|
||||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
||||||
@@ -656,7 +669,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Record Payment ───────────────────────────────────────────────────────
|
// -- Record Payment -------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
||||||
@@ -668,7 +681,7 @@ public class BillsController : Controller
|
|||||||
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@@ -739,7 +752,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete Payment ───────────────────────────────────────────────────────
|
// -- Delete Payment -------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses a previously recorded payment. All double-entry effects of
|
/// Reverses a previously recorded payment. All double-entry effects of
|
||||||
@@ -749,7 +762,7 @@ public class BillsController : Controller
|
|||||||
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -796,7 +809,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = billId });
|
return RedirectToAction(nameof(Details), new { id = billId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit Payment ─────────────────────────────────────────────────────────
|
// -- Edit Payment ---------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
||||||
@@ -805,7 +818,7 @@ public class BillsController : Controller
|
|||||||
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@@ -850,11 +863,11 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Void ─────────────────────────────────────────────────────────────────
|
// -- Void -----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
||||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||||
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
||||||
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
||||||
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
||||||
@@ -909,7 +922,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AJAX: Vendor default expense account ────────────────────────────────
|
// -- AJAX: Vendor default expense account --------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
||||||
@@ -927,7 +940,7 @@ public class BillsController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// -- Helpers --------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
||||||
@@ -966,7 +979,7 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||||
/// records are included in the scan so payment numbers are never reused.
|
/// records are included in the scan so payment numbers are never reused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> GeneratePaymentNumberAsync()
|
private async Task<string> GeneratePaymentNumberAsync()
|
||||||
@@ -981,7 +994,7 @@ public class BillsController : Controller
|
|||||||
return $"{prefix}{next:D4}";
|
return $"{prefix}{next:D4}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receipt File: Download / Remove ─────────────────────────────────────
|
// -- Receipt File: Download / Remove -------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
||||||
@@ -1009,7 +1022,7 @@ public class BillsController : Controller
|
|||||||
/// window where the UI shows a broken attachment link.
|
/// window where the UI shows a broken attachment link.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> RemoveReceipt(int id)
|
public async Task<IActionResult> RemoveReceipt(int id)
|
||||||
{
|
{
|
||||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||||||
@@ -1026,7 +1039,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
|
// -- AI: Receipt Scanning -------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
||||||
@@ -1038,7 +1051,7 @@ public class BillsController : Controller
|
|||||||
/// model can match categories to the company's specific chart of accounts.
|
/// model can match categories to the company's specific chart of accounts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
||||||
{
|
{
|
||||||
@@ -1079,7 +1092,7 @@ public class BillsController : Controller
|
|||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI: Account Suggestion ────────────────────────────────────────────────
|
// -- AI: Account Suggestion ------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
||||||
@@ -1090,7 +1103,7 @@ public class BillsController : Controller
|
|||||||
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
||||||
{
|
{
|
||||||
@@ -1123,7 +1136,69 @@ public class BillsController : Controller
|
|||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receipt File Helpers ──────────────────────────────────────────────────
|
// -- AI: Recurring Bill Detection ------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
|
||||||
|
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult RecurringDetection() => View();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
|
||||||
|
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
||||||
|
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
||||||
|
/// Results are returned as JSON for client-side rendering in the view.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> RunRecurringDetection()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||||
|
var cutoff = DateTime.Today.AddMonths(-12);
|
||||||
|
|
||||||
|
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||||||
|
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!bills.Any())
|
||||||
|
return Json(new RecurringBillDetectionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Insights = new List<string> { "No bill history found in the last 12 months." }
|
||||||
|
});
|
||||||
|
|
||||||
|
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
|
||||||
|
|
||||||
|
var request = new RecurringBillDetectionRequest
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
Bills = bills.Select(b => new RecurringBillHistoryItem
|
||||||
|
{
|
||||||
|
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
|
||||||
|
BillNumber = b.BillNumber,
|
||||||
|
Amount = b.Total,
|
||||||
|
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
|
||||||
|
Memo = b.Memo
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _accountingAi.DetectRecurringBillsAsync(request);
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error running recurring bill detection");
|
||||||
|
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Receipt File Helpers --------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads a receipt file to Azure Blob Storage under the path
|
/// Uploads a receipt file to Azure Blob Storage under the path
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages annual budgets. Each budget has one BudgetLine per active GL account with
|
||||||
|
/// monthly amounts (Jan–Dec). The Budget vs. Actual report compares these to real activity.
|
||||||
|
/// Only one budget per year is marked IsDefault — that one feeds the variance report automatically.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public class BudgetsController : Controller
|
||||||
|
{
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
|
public BudgetsController(IUnitOfWork unitOfWork, ITenantContext tenantContext)
|
||||||
|
{
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
||||||
|
.OrderByDescending(b => b.FiscalYear)
|
||||||
|
.ThenBy(b => b.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return View(budgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Create()
|
||||||
|
{
|
||||||
|
var accounts = await GetBudgetableAccountsAsync();
|
||||||
|
return View(new BudgetCreateVm
|
||||||
|
{
|
||||||
|
FiscalYear = DateTime.Now.Year,
|
||||||
|
Lines = accounts.Select(a => new BudgetLineVm { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, AccountType = a.AccountType }).ToList()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Create(BudgetCreateVm vm)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid) return View(vm);
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
// If this is marked default, clear the flag on other budgets for the same year
|
||||||
|
if (vm.IsDefault)
|
||||||
|
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: null);
|
||||||
|
|
||||||
|
var budget = new Budget
|
||||||
|
{
|
||||||
|
Name = vm.Name,
|
||||||
|
FiscalYear = vm.FiscalYear,
|
||||||
|
Notes = vm.Notes,
|
||||||
|
IsDefault = vm.IsDefault,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Lines = vm.Lines
|
||||||
|
.Where(l => l.HasAnyAmount)
|
||||||
|
.Select(l => new BudgetLine
|
||||||
|
{
|
||||||
|
AccountId = l.AccountId,
|
||||||
|
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||||
|
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||||
|
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Budgets.AddAsync(budget);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"Budget \"{budget.Name}\" created for {budget.FiscalYear}.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = budget.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Edit(int id)
|
||||||
|
{
|
||||||
|
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||||
|
if (budget == null) return NotFound();
|
||||||
|
|
||||||
|
var accounts = await GetBudgetableAccountsAsync();
|
||||||
|
var lineMap = budget.Lines.ToDictionary(l => l.AccountId);
|
||||||
|
|
||||||
|
var vm = new BudgetCreateVm
|
||||||
|
{
|
||||||
|
Id = budget.Id,
|
||||||
|
Name = budget.Name,
|
||||||
|
FiscalYear = budget.FiscalYear,
|
||||||
|
Notes = budget.Notes,
|
||||||
|
IsDefault = budget.IsDefault,
|
||||||
|
Lines = accounts.Select(a =>
|
||||||
|
{
|
||||||
|
lineMap.TryGetValue(a.Id, out var existing);
|
||||||
|
return new BudgetLineVm
|
||||||
|
{
|
||||||
|
AccountId = a.Id,
|
||||||
|
AccountNumber = a.AccountNumber,
|
||||||
|
AccountName = a.Name,
|
||||||
|
AccountType = a.AccountType,
|
||||||
|
Jan = existing?.Jan ?? 0, Feb = existing?.Feb ?? 0, Mar = existing?.Mar ?? 0,
|
||||||
|
Apr = existing?.Apr ?? 0, May = existing?.May ?? 0, Jun = existing?.Jun ?? 0,
|
||||||
|
Jul = existing?.Jul ?? 0, Aug = existing?.Aug ?? 0, Sep = existing?.Sep ?? 0,
|
||||||
|
Oct = existing?.Oct ?? 0, Nov = existing?.Nov ?? 0, Dec = existing?.Dec ?? 0
|
||||||
|
};
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Edit(int id, BudgetCreateVm vm)
|
||||||
|
{
|
||||||
|
if (id != vm.Id) return BadRequest();
|
||||||
|
if (!ModelState.IsValid) return View(vm);
|
||||||
|
|
||||||
|
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||||
|
if (budget == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
if (vm.IsDefault && !budget.IsDefault)
|
||||||
|
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: id);
|
||||||
|
|
||||||
|
budget.Name = vm.Name;
|
||||||
|
budget.Notes = vm.Notes;
|
||||||
|
budget.IsDefault = vm.IsDefault;
|
||||||
|
budget.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Delete old lines and replace with new set (simpler than merge)
|
||||||
|
foreach (var line in budget.Lines.ToList())
|
||||||
|
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||||
|
|
||||||
|
budget.Lines = vm.Lines
|
||||||
|
.Where(l => l.HasAnyAmount)
|
||||||
|
.Select(l => new BudgetLine
|
||||||
|
{
|
||||||
|
AccountId = l.AccountId,
|
||||||
|
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||||
|
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||||
|
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"Budget \"{budget.Name}\" saved.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a copy of an existing budget for a new fiscal year — common workflow for
|
||||||
|
/// rolling forward last year's budget as a starting point.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Copy(int id, int newYear)
|
||||||
|
{
|
||||||
|
var source = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||||
|
if (source == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var copy = new Budget
|
||||||
|
{
|
||||||
|
Name = $"{source.Name} ({newYear})",
|
||||||
|
FiscalYear = newYear,
|
||||||
|
Notes = source.Notes,
|
||||||
|
IsDefault = false,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
Lines = source.Lines.Select(l => new BudgetLine
|
||||||
|
{
|
||||||
|
AccountId = l.AccountId,
|
||||||
|
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||||
|
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||||
|
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||||
|
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Budgets.AddAsync(copy);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"Budget copied to {newYear}.";
|
||||||
|
return RedirectToAction(nameof(Edit), new { id = copy.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SetDefault ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SetDefault(int id)
|
||||||
|
{
|
||||||
|
var budget = await _unitOfWork.Budgets.GetByIdAsync(id);
|
||||||
|
if (budget == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
await ClearDefaultFlagAsync(companyId, budget.FiscalYear, excludeId: null);
|
||||||
|
|
||||||
|
budget.IsDefault = true;
|
||||||
|
budget.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"\"{budget.Name}\" is now the default budget for {budget.FiscalYear}.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||||
|
if (budget == null) return NotFound();
|
||||||
|
|
||||||
|
foreach (var line in budget.Lines.ToList())
|
||||||
|
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||||
|
|
||||||
|
await _unitOfWork.Budgets.SoftDeleteAsync(id);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"Budget \"{budget.Name}\" deleted.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||||
|
{
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||||
|
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||||
|
{
|
||||||
|
var others = await _unitOfWork.Budgets.FindAsync(
|
||||||
|
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||||
|
foreach (var b in others)
|
||||||
|
{
|
||||||
|
b.IsDefault = false;
|
||||||
|
b.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
if (others.Any())
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View Models ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class BudgetCreateVm
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public int FiscalYear { get; set; } = DateTime.Now.Year;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsDefault { get; set; } = true;
|
||||||
|
public List<BudgetLineVm> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BudgetLineVm
|
||||||
|
{
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
public string AccountNumber { get; set; } = string.Empty;
|
||||||
|
public string AccountName { get; set; } = string.Empty;
|
||||||
|
public AccountType AccountType { get; set; }
|
||||||
|
|
||||||
|
public decimal Jan { get; set; }
|
||||||
|
public decimal Feb { get; set; }
|
||||||
|
public decimal Mar { get; set; }
|
||||||
|
public decimal Apr { get; set; }
|
||||||
|
public decimal May { get; set; }
|
||||||
|
public decimal Jun { get; set; }
|
||||||
|
public decimal Jul { get; set; }
|
||||||
|
public decimal Aug { get; set; }
|
||||||
|
public decimal Sep { get; set; }
|
||||||
|
public decimal Oct { get; set; }
|
||||||
|
public decimal Nov { get; set; }
|
||||||
|
public decimal Dec { get; set; }
|
||||||
|
|
||||||
|
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||||
|
public bool HasAnyAmount => Annual != 0;
|
||||||
|
}
|
||||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
|||||||
string sortColumn = "CompanyName",
|
string sortColumn = "CompanyName",
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 25)
|
int pageSize = 25,
|
||||||
|
bool showChurned = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||||
|
|
||||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||||
|
|
||||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||||
|
|
||||||
@@ -82,6 +83,8 @@ public class CompaniesController : Controller
|
|||||||
{
|
{
|
||||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||||
|
var companyById = companies.ToDictionary(c => c.Id);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var dto in companyDtos)
|
foreach (var dto in companyDtos)
|
||||||
{
|
{
|
||||||
@@ -95,6 +98,23 @@ public class CompaniesController : Controller
|
|||||||
dto.WizardCompletedAt = w.CompletedAt;
|
dto.WizardCompletedAt = w.CompletedAt;
|
||||||
dto.WizardCompletedByName = w.CompletedByName;
|
dto.WizardCompletedByName = w.CompletedByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health badge
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
|
||||||
|
if (companyById.TryGetValue(dto.Id, out var co))
|
||||||
|
{
|
||||||
|
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
|
||||||
|
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
|
||||||
|
&& dto.CreatedAt < now.AddDays(-7);
|
||||||
|
dto.HealthScore = score;
|
||||||
|
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.LastLoginDate = lastLogin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +129,8 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
return View(companyDtos);
|
return View(companyDtos);
|
||||||
}
|
}
|
||||||
@@ -183,7 +205,8 @@ public class CompaniesController : Controller
|
|||||||
.GetByIdAsync(id, ignoreQueryFilters: true,
|
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||||
c => c.Users,
|
c => c.Users,
|
||||||
c => c.Customers,
|
c => c.Customers,
|
||||||
c => c.Jobs);
|
c => c.Jobs,
|
||||||
|
c => c.Preferences!);
|
||||||
|
|
||||||
if (company == null)
|
if (company == null)
|
||||||
{
|
{
|
||||||
@@ -196,6 +219,51 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
||||||
|
|
||||||
|
// Health data
|
||||||
|
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
|
||||||
|
var totalJobs = companyDto.JobCount;
|
||||||
|
var totalCust = companyDto.CustomerCount;
|
||||||
|
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
|
||||||
|
|
||||||
|
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
|
||||||
|
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
|
||||||
|
&& company.CreatedAt < now.AddDays(-7);
|
||||||
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
|
||||||
|
|
||||||
|
ViewBag.HealthScore = healthScore;
|
||||||
|
ViewBag.HealthRisk = riskLevel.ToString();
|
||||||
|
ViewBag.HealthSignals = healthSignals;
|
||||||
|
ViewBag.Jobs30 = j30;
|
||||||
|
ViewBag.Jobs90 = j90;
|
||||||
|
ViewBag.LastLoginDate = lastLogin;
|
||||||
|
|
||||||
|
// Onboarding data (from Preferences)
|
||||||
|
var prefs = company.Preferences;
|
||||||
|
int steps = 0;
|
||||||
|
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||||
|
|
||||||
|
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
|
||||||
|
{
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyName = company.CompanyName ?? "",
|
||||||
|
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
|
||||||
|
OnboardingPath = prefs?.OnboardingPath,
|
||||||
|
StepsCompleted = steps,
|
||||||
|
TotalSteps = 3,
|
||||||
|
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||||
|
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||||
|
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||||
|
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||||
|
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||||
|
};
|
||||||
|
|
||||||
return View(companyDto);
|
return View(companyDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
|||||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
|
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var d30 = now.AddDays(-30);
|
var d30 = now.AddDays(-30);
|
||||||
var d90 = now.AddDays(-90);
|
var d90 = now.AddDays(-90);
|
||||||
|
var churnedCutoff = now.AddDays(-14);
|
||||||
|
|
||||||
// One query per signal — all keyed by CompanyId
|
// One query per signal — all keyed by CompanyId
|
||||||
var companies = await _db.Companies
|
var allCompanies = await _db.Companies
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var churnedCount = allCompanies.Count(c =>
|
||||||
|
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
|
||||||
|
|
||||||
|
var companies = showChurned
|
||||||
|
? allCompanies
|
||||||
|
: allCompanies.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var lastLogins = await _db.Users
|
var lastLogins = await _db.Users
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(u => u.LastLoginDate != null)
|
.Where(u => u.LastLoginDate != null)
|
||||||
@@ -118,15 +130,12 @@ public class CompanyHealthController : Controller
|
|||||||
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
||||||
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
||||||
|
|
||||||
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||||
|
|
||||||
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||||
&& c.CreatedAt < now.AddDays(-7);
|
&& c.CreatedAt < now.AddDays(-7);
|
||||||
|
|
||||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||||
: score >= 75 ? ChurnRisk.Healthy
|
|
||||||
: score >= 45 ? ChurnRisk.AtRisk
|
|
||||||
: ChurnRisk.Critical;
|
|
||||||
|
|
||||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||||
@@ -166,6 +175,8 @@ public class CompanyHealthController : Controller
|
|||||||
ViewBag.Risk = risk;
|
ViewBag.Risk = risk;
|
||||||
ViewBag.Search = search;
|
ViewBag.Search = search;
|
||||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
all = all.Where(h =>
|
all = all.Where(h =>
|
||||||
@@ -187,112 +198,10 @@ public class CompanyHealthController : Controller
|
|||||||
return View(all);
|
return View(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a
|
|
||||||
/// single company based on its subscription status, login recency, and job activity.
|
|
||||||
/// <para>
|
|
||||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
|
||||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
|
||||||
/// <item>Subscription within grace period: −30 pts.</item>
|
|
||||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
|
||||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
|
||||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
|
||||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
|
||||||
/// </list>
|
|
||||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
|
||||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private static (int score, List<string> signals) ComputeHealth(
|
|
||||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
|
||||||
int j30, int j90, int totalJobs, DateTime now)
|
|
||||||
{
|
|
||||||
var score = 100;
|
|
||||||
var signals = new List<string>();
|
|
||||||
|
|
||||||
if (!c.IsActive)
|
|
||||||
{
|
|
||||||
signals.Add("Account disabled");
|
|
||||||
return (0, signals);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription health (skip for comped)
|
|
||||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
|
||||||
{
|
|
||||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
|
||||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
|
||||||
{
|
|
||||||
score -= 50;
|
|
||||||
signals.Add("Subscription expired");
|
|
||||||
}
|
|
||||||
else if (daysUntil < 0)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("In grace period");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 7)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 14)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login activity
|
|
||||||
if (daysSinceLogin == -1)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("Never logged in");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 90)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 60)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 30)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job activity
|
|
||||||
if (totalJobs == 0)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add("No jobs ever");
|
|
||||||
}
|
|
||||||
else if (j90 == 0)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add("No jobs in 90d");
|
|
||||||
}
|
|
||||||
else if (j30 == 0)
|
|
||||||
{
|
|
||||||
score -= 5;
|
|
||||||
signals.Add("No jobs in 30d");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (Math.Max(0, score), signals);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View models ────────────────────────────────────────────────────────────────
|
// ── View models ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
|
||||||
|
|
||||||
public class CompanyHealthDto
|
public class CompanyHealthDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||||
|
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||||
|
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||||
|
/// </summary>
|
||||||
|
public static class CompanyHealthHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a 0–100 health score and a list of human-readable risk signals for a single
|
||||||
|
/// company based on its subscription status, login recency, and job activity.
|
||||||
|
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||||
|
/// </summary>
|
||||||
|
public static (int Score, List<string> Signals) ComputeHealth(
|
||||||
|
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||||
|
{
|
||||||
|
var score = 100;
|
||||||
|
var signals = new List<string>();
|
||||||
|
|
||||||
|
if (!c.IsActive)
|
||||||
|
{
|
||||||
|
signals.Add("Account disabled");
|
||||||
|
return (0, signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||||
|
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||||
|
{
|
||||||
|
score -= 50;
|
||||||
|
signals.Add("Subscription expired");
|
||||||
|
}
|
||||||
|
else if (daysUntil < 0)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("In grace period");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 7)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 14)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysSinceLogin == -1)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("Never logged in");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 90)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 60)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 30)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalJobs == 0)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add("No jobs ever");
|
||||||
|
}
|
||||||
|
else if (j90 == 0)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add("No jobs in 90d");
|
||||||
|
}
|
||||||
|
else if (j30 == 0)
|
||||||
|
{
|
||||||
|
score -= 5;
|
||||||
|
signals.Add("No jobs in 30d");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Math.Max(0, score), signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||||
|
/// </summary>
|
||||||
|
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||||
|
neverActivated ? ChurnRisk.NeverActivated
|
||||||
|
: score >= 75 ? ChurnRisk.Healthy
|
||||||
|
: score >= 45 ? ChurnRisk.AtRisk
|
||||||
|
: ChurnRisk.Critical;
|
||||||
|
}
|
||||||
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
|
|||||||
UpdatedAt = t.UpdatedAt
|
UpdatedAt = t.UpdatedAt
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
|
||||||
|
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
|
||||||
|
: null;
|
||||||
|
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (FormatException fex)
|
catch (FormatException fex)
|
||||||
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Locks the books through the given date, preventing new or edited accounting entries
|
||||||
|
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) return BadRequest();
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||||
|
if (company == null) return NotFound();
|
||||||
|
|
||||||
|
company.BookLockedThrough = lockThrough.HasValue
|
||||||
|
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
|
||||||
|
: null;
|
||||||
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Companies.UpdateAsync(company);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = lockThrough.HasValue
|
||||||
|
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
|
||||||
|
: "Period lock cleared — all periods are now open.";
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Index), null, "company-info");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
||||||
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
||||||
@@ -511,6 +543,15 @@ public class CompanySettingsController : Controller
|
|||||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
UpdatePreferences(dto, "Work order settings saved successfully.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
|
||||||
|
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
||||||
|
/// </summary>
|
||||||
|
// POST: CompanySettings/UpdateKioskSettings
|
||||||
|
[HttpPost]
|
||||||
|
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
|
||||||
|
UpdatePreferences(dto, "Kiosk settings saved successfully.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
||||||
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||||
@@ -2653,6 +2694,7 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
||||||
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
||||||
|
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == NotificationType.PaymentReceived)
|
if (type == NotificationType.PaymentReceived)
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin,
|
AppConstants.CompanyRoles.CompanyAdmin,
|
||||||
AppConstants.CompanyRoles.Manager,
|
AppConstants.CompanyRoles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant,
|
||||||
AppConstants.CompanyRoles.Worker,
|
AppConstants.CompanyRoles.Worker,
|
||||||
AppConstants.CompanyRoles.Viewer
|
AppConstants.CompanyRoles.Viewer
|
||||||
};
|
};
|
||||||
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
|
|||||||
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
||||||
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
||||||
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
||||||
CanViewReports = forceAllPermissions || model.CanViewReports
|
CanViewReports = forceAllPermissions || model.CanViewReports,
|
||||||
|
CanManageBills = forceAllPermissions || model.CanManageBills,
|
||||||
|
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(user, model.Password);
|
var result = await _userManager.CreateAsync(user, model.Password);
|
||||||
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
|
||||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||||
_ => AppConstants.Roles.ReadOnly
|
_ => AppConstants.Roles.ReadOnly
|
||||||
};
|
};
|
||||||
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
|
|||||||
CanManageVendors = user.CanManageVendors,
|
CanManageVendors = user.CanManageVendors,
|
||||||
CanManageMaintenance = user.CanManageMaintenance,
|
CanManageMaintenance = user.CanManageMaintenance,
|
||||||
CanManageInvoices = user.CanManageInvoices,
|
CanManageInvoices = user.CanManageInvoices,
|
||||||
CanViewReports = user.CanViewReports
|
CanViewReports = user.CanViewReports,
|
||||||
|
CanManageBills = user.CanManageBills,
|
||||||
|
CanManageAccounting = user.CanManageAccounting
|
||||||
};
|
};
|
||||||
|
|
||||||
ViewBag.ReturnUrl = returnUrl;
|
ViewBag.ReturnUrl = returnUrl;
|
||||||
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin,
|
AppConstants.CompanyRoles.CompanyAdmin,
|
||||||
AppConstants.CompanyRoles.Manager,
|
AppConstants.CompanyRoles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant,
|
||||||
AppConstants.CompanyRoles.Worker,
|
AppConstants.CompanyRoles.Worker,
|
||||||
AppConstants.CompanyRoles.Viewer
|
AppConstants.CompanyRoles.Viewer
|
||||||
};
|
};
|
||||||
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
|
|||||||
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
||||||
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
||||||
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
||||||
|
user.CanManageBills = forceAllPermissions || model.CanManageBills;
|
||||||
|
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
var result = await _userManager.UpdateAsync(user);
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
|
||||||
|
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
||||||
|
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||||
|
/// customer.CreditBalance atomically inside a transaction.
|
||||||
|
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
|
||||||
|
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
|
||||||
|
/// a revenue deduction and an AR reduction.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public class CreditMemosController : Controller
|
||||||
|
{
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly ILogger<CreditMemosController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
|
public CreditMemosController(
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
ILogger<CreditMemosController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
|
{
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_userManager = userManager;
|
||||||
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Index(string? status, string? search)
|
||||||
|
{
|
||||||
|
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||||
|
m => true, false,
|
||||||
|
m => m.Customer);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
memos = memos.Where(m =>
|
||||||
|
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
|
||||||
|
memos = memos.Where(m => m.Status == parsed).ToList();
|
||||||
|
|
||||||
|
ViewBag.Status = status ?? "";
|
||||||
|
ViewBag.Search = search ?? "";
|
||||||
|
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
|
||||||
|
ViewBag.OutstandingBalance = memos
|
||||||
|
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
|
||||||
|
.Sum(m => m.RemainingBalance);
|
||||||
|
|
||||||
|
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Details(int id)
|
||||||
|
{
|
||||||
|
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
|
||||||
|
id, false,
|
||||||
|
m => m.Customer,
|
||||||
|
m => m.OriginalInvoice,
|
||||||
|
m => m.IssuedBy);
|
||||||
|
|
||||||
|
if (memo == null) return NotFound();
|
||||||
|
|
||||||
|
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
|
||||||
|
a => a.CreditMemoId == id, false,
|
||||||
|
a => a.Invoice,
|
||||||
|
a => a.AppliedBy);
|
||||||
|
|
||||||
|
var openInvoices = await _unitOfWork.Invoices.FindAsync(
|
||||||
|
i => i.CustomerId == memo.CustomerId
|
||||||
|
&& i.Status != InvoiceStatus.Paid
|
||||||
|
&& i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.Status != InvoiceStatus.WrittenOff);
|
||||||
|
|
||||||
|
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
|
||||||
|
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
|
||||||
|
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
|
||||||
|
&& memo.RemainingBalance > 0;
|
||||||
|
|
||||||
|
return View(memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
|
||||||
|
{
|
||||||
|
string? linkedInvoiceNumber = null;
|
||||||
|
if (invoiceId.HasValue)
|
||||||
|
{
|
||||||
|
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
|
||||||
|
if (inv != null)
|
||||||
|
{
|
||||||
|
linkedInvoiceNumber = inv.InvoiceNumber;
|
||||||
|
customerId ??= inv.CustomerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await PopulateCustomersAsync(customerId);
|
||||||
|
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
|
||||||
|
|
||||||
|
return View(new CreditMemoCreateVm
|
||||||
|
{
|
||||||
|
CustomerId = customerId ?? 0,
|
||||||
|
OriginalInvoiceId = invoiceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
|
||||||
|
/// credit is visible on the customer account before it is applied to any specific invoice.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
await PopulateCustomersAsync(vm.CustomerId);
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
|
||||||
|
if (customer == null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError("CustomerId", "Customer not found.");
|
||||||
|
await PopulateCustomersAsync(null);
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var memoNumber = await GenerateMemoNumberAsync(companyId);
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
|
var memo = new CreditMemo
|
||||||
|
{
|
||||||
|
MemoNumber = memoNumber,
|
||||||
|
CustomerId = vm.CustomerId,
|
||||||
|
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
|
||||||
|
Amount = vm.Amount,
|
||||||
|
AmountApplied = 0,
|
||||||
|
IssueDate = DateTime.UtcNow,
|
||||||
|
ExpiryDate = vm.ExpiryDate.HasValue
|
||||||
|
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
|
||||||
|
: null,
|
||||||
|
Reason = vm.Reason,
|
||||||
|
Notes = vm.Notes,
|
||||||
|
Status = CreditMemoStatus.Active,
|
||||||
|
IssuedById = currentUser?.Id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.CreditMemos.AddAsync(memo);
|
||||||
|
|
||||||
|
customer.CreditBalance += vm.Amount;
|
||||||
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
|
||||||
|
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
|
||||||
|
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
|
||||||
|
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
|
||||||
|
if (memo == null) return NotFound();
|
||||||
|
|
||||||
|
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
|
||||||
|
if (invoice == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Invoice not found.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Credit memo is not available to apply.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
|
||||||
|
if (applyAmount <= 0)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
|
{
|
||||||
|
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
|
||||||
|
{
|
||||||
|
CreditMemoId = id,
|
||||||
|
InvoiceId = invoiceId,
|
||||||
|
AmountApplied = applyAmount,
|
||||||
|
AppliedDate = DateTime.UtcNow,
|
||||||
|
AppliedById = currentUser?.Id,
|
||||||
|
CompanyId = invoice.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
invoice.CreditApplied += applyAmount;
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
|
||||||
|
memo.AmountApplied += applyAmount;
|
||||||
|
memo.Status = memo.AmountApplied >= memo.Amount
|
||||||
|
? CreditMemoStatus.FullyApplied
|
||||||
|
: CreditMemoStatus.PartiallyApplied;
|
||||||
|
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||||
|
|
||||||
|
if (invoice.Customer != null)
|
||||||
|
{
|
||||||
|
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
|
||||||
|
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
|
||||||
|
{
|
||||||
|
invoice.Status = InvoiceStatus.Paid;
|
||||||
|
invoice.PaidDate = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
||||||
|
// The dynamic report computation attributes credit memo applications to both
|
||||||
|
// accounts already; this call keeps Account.CurrentBalance in sync for
|
||||||
|
// RecalculateAllAsync and any tools that read it directly.
|
||||||
|
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||||
|
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountNumber == "4950" && a.IsActive)
|
||||||
|
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountType == AccountType.Revenue && a.IsActive
|
||||||
|
&& a.Name.ToLower().Contains("discount"));
|
||||||
|
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
||||||
|
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
|
||||||
|
TempData["Error"] = "An error occurred applying the credit.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
|
||||||
|
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
|
||||||
|
/// settled and form part of the immutable audit trail.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Void(int id)
|
||||||
|
{
|
||||||
|
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
|
||||||
|
if (memo == null) return NotFound();
|
||||||
|
|
||||||
|
if (memo.Status == CreditMemoStatus.Voided)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Credit memo is already voided.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
var remaining = memo.Amount - memo.AmountApplied;
|
||||||
|
memo.Status = CreditMemoStatus.Voided;
|
||||||
|
memo.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||||
|
|
||||||
|
if (remaining > 0 && memo.Customer != null)
|
||||||
|
{
|
||||||
|
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
|
||||||
|
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PopulateCustomersAsync(int? selectedId)
|
||||||
|
{
|
||||||
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
|
ViewBag.Customers = customers
|
||||||
|
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||||
|
.Select(c => new SelectListItem
|
||||||
|
{
|
||||||
|
Value = c.Id.ToString(),
|
||||||
|
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
|
||||||
|
Selected = c.Id == selectedId
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates the next sequential memo number in CM-YYMM-#### format.
|
||||||
|
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GenerateMemoNumberAsync(int companyId)
|
||||||
|
{
|
||||||
|
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||||
|
var existing = (await _unitOfWork.CreditMemos.FindAsync(
|
||||||
|
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
|
||||||
|
.Select(m => m.MemoNumber)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var maxNum = 0;
|
||||||
|
foreach (var num in existing)
|
||||||
|
{
|
||||||
|
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
|
||||||
|
if (int.TryParse(suffix, out int n) && n > maxNum)
|
||||||
|
maxNum = n;
|
||||||
|
}
|
||||||
|
return $"{prefix}{(maxNum + 1):D4}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DisplayName(Customer? c) =>
|
||||||
|
c == null ? string.Empty :
|
||||||
|
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
|
||||||
|
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreditMemoCreateVm
|
||||||
|
{
|
||||||
|
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(2000)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
|
||||||
|
public int? OriginalInvoiceId { get; set; }
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public class CustomersController : Controller
|
|||||||
private readonly ISubscriptionService _subscriptionService;
|
private readonly ISubscriptionService _subscriptionService;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly IFinancialReportService _financialReports;
|
||||||
|
|
||||||
public CustomersController(
|
public CustomersController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -34,7 +35,8 @@ public class CustomersController : Controller
|
|||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
ISubscriptionService subscriptionService,
|
ISubscriptionService subscriptionService,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
UserManager<ApplicationUser> userManager)
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IFinancialReportService financialReports)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -43,6 +45,7 @@ public class CustomersController : Controller
|
|||||||
_subscriptionService = subscriptionService;
|
_subscriptionService = subscriptionService;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
_financialReports = financialReports;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -935,6 +938,30 @@ public class CustomersController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays or downloads a dated activity statement for a customer.
|
||||||
|
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||||
|
var toDate = to ?? DateTime.Today;
|
||||||
|
|
||||||
|
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
||||||
|
|
||||||
|
if (pdf)
|
||||||
|
{
|
||||||
|
var bytes = StatementPdfHelper.Generate(
|
||||||
|
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
||||||
|
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
||||||
|
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user