Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd | |||
| 4ca90f561e | |||
| f95397204c | |||
| 31d305b66a | |||
| 42a8c089d5 | |||
| 2c353f2e7f | |||
| c02a5584b4 | |||
| 17da692dce | |||
| 656f830898 | |||
| dde66c807f | |||
| feff0fa73d | |||
| 59beba2e15 | |||
| 959e323f3a | |||
| e2f9e9ae4f | |||
| 328b195127 | |||
| f6d457fe0e | |||
| c65445b94e | |||
| ccb094e57a | |||
| 0204430fa5 | |||
| 4fd9c52aaf | |||
| fde24b09c9 | |||
| a255893ada | |||
| d94612cc9c | |||
| 14026818e2 | |||
| 42eff3357e | |||
| d3a5d827f9 |
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
||||
public string? RecommendedAction { get; set; }
|
||||
public string? BillNumber { get; set; }
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||
|
||||
public class BankRecMatchItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||
public int EntityId { get; set; }
|
||||
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||
}
|
||||
|
||||
public class AutoMatchRequest
|
||||
{
|
||||
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal StatementEndingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class AutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; } // 0.0–1.0
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AutoMatchResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||
public class ClaudeAutoMatchResponse
|
||||
{
|
||||
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeAutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||
|
||||
public class OpenInvoiceSummary
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string? DueDateIso { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
public class LatePaymentCustomerData
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public decimal TotalOwed { get; set; }
|
||||
public double AvgDaysToPay { get; set; } // historical average
|
||||
public int TotalInvoicesAllTime { get; set; }
|
||||
public int LateInvoicesAllTime { get; set; }
|
||||
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||
public class ClaudeLatePaymentResponse
|
||||
{
|
||||
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeLatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||
|
||||
public class MonthlyFinancialSummary
|
||||
{
|
||||
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||
public decimal Revenue { get; set; }
|
||||
public decimal Expenses { get; set; }
|
||||
public decimal NetIncome { get; set; }
|
||||
}
|
||||
|
||||
public class FinancialQueryContext
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string AsOfDate { get; set; } = string.Empty;
|
||||
public decimal TotalRevenueYtd { get; set; }
|
||||
public decimal TotalExpensesYtd { get; set; }
|
||||
public decimal NetIncomeYtd { get; set; }
|
||||
public decimal ArOutstanding { get; set; }
|
||||
public decimal ApOutstanding { get; set; }
|
||||
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryRequest
|
||||
{
|
||||
public string Question { get; set; } = string.Empty;
|
||||
public FinancialQueryContext Context { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||
public class ClaudeFinancialQueryResponse
|
||||
{
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||
|
||||
public class RecurringBillHistoryItem
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string DateIso { get; set; } = string.Empty;
|
||||
public string? Memo { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecurringBillPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||
public class ClaudeRecurringBillResponse
|
||||
{
|
||||
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeRecurringPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,92 @@ namespace PowderCoating.Application.DTOs.Accounting;
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Cash Flow Statement ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
|
||||
/// Investing and Financing sections contain line items derived from account-level changes.
|
||||
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
|
||||
/// </summary>
|
||||
public class CashFlowStatementDto
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public AccountingMethod Method { get; set; }
|
||||
|
||||
// ── Operating (direct / cash method) ───────────────────────────────────
|
||||
/// <summary>Customer invoice payments received in the period.</summary>
|
||||
public decimal CashFromCustomers { get; set; }
|
||||
/// <summary>Vendor bill payments made in the period.</summary>
|
||||
public decimal CashToVendors { get; set; }
|
||||
/// <summary>Direct expense payments made in the period (not via bills).</summary>
|
||||
public decimal CashForExpenses { get; set; }
|
||||
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
|
||||
|
||||
// ── Investing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
|
||||
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Financing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
|
||||
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
public decimal BeginningCash { get; set; }
|
||||
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
|
||||
public decimal EndingCash => BeginningCash + NetChangeInCash;
|
||||
}
|
||||
|
||||
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
|
||||
public class CashFlowLineDto
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
|
||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
||||
public bool WizardCompleted { get; set; }
|
||||
public DateTime? WizardCompletedAt { get; set; }
|
||||
public string? WizardCompletedByName { get; set; }
|
||||
|
||||
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||
public int HealthScore { get; set; }
|
||||
public string HealthRisk { get; set; } = "Healthy";
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[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) });
|
||||
}
|
||||
|
||||
// At least one contact method is required (Email OR Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
||||
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Please provide at least one contact method (Email or Phone)",
|
||||
new[] { nameof(Email), nameof(Phone) });
|
||||
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||
}
|
||||
|
||||
// Validate each address in comma-separated email fields
|
||||
|
||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
public string? PreparedByName { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
@@ -82,6 +84,10 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||
public PaymentMethod RefundMethod { get; set; }
|
||||
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? Reference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@@ -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 CanManageInvoices { get; set; }
|
||||
public bool CanViewReports { get; set; }
|
||||
public bool CanManageBills { get; set; }
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
|
||||
[Display(Name = "Send Welcome Email")]
|
||||
public bool SendWelcomeEmail { get; set; } = true;
|
||||
}
|
||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
||||
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ public class CreateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; } = false;
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; }
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; }
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
||||
/// Returns a ranked list of flagged items with recommended actions.
|
||||
/// </summary>
|
||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
|
||||
/// a statement. Returns a ranked list of suggestions with confidence scores based on
|
||||
/// amount/date patterns and the gap between the current cleared balance and the
|
||||
/// statement ending balance.
|
||||
/// </summary>
|
||||
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Predicts likelihood of late payment for each open AR customer using their historical
|
||||
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
|
||||
/// Returns risk levels (high/medium/low) and estimated days to collection.
|
||||
/// </summary>
|
||||
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
|
||||
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
|
||||
/// and an optional follow-up question suggestion.
|
||||
/// </summary>
|
||||
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of bill history to detect recurring payment patterns per vendor.
|
||||
/// Returns detected patterns with frequency, typical amount, next expected date, and
|
||||
/// suggested actions (e.g. set a reminder, create a template).
|
||||
/// </summary>
|
||||
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,18 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
|
||||
/// operating activities. Investing and Financing sections are derived from account-level data.
|
||||
/// BeginningCash is computed from all cash/bank account credits and debits prior to
|
||||
/// <paramref name="from"/>; EndingCash adds the net change during the period.
|
||||
/// </summary>
|
||||
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
||||
/// Notify customer when an invoice has been sent.
|
||||
/// Optionally includes an online payment link in the email body.
|
||||
/// </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>
|
||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||
|
||||
@@ -44,6 +44,7 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||
: 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.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||
: null))
|
||||
|
||||
@@ -2593,4 +2593,120 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||||
/// visually distinguish it from the other financial statements.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#0891b2";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Spacing(4);
|
||||
|
||||
// ── Operating Activities ──────────────────────────────────────
|
||||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.InvestingLines.Count == 0)
|
||||
CfRow(t, "No investing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.InvestingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.FinancingLines.Count == 0)
|
||||
CfRow(t, "No financing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.FinancingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
col.Item().PaddingTop(12).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
|
||||
void SumRow(string label, decimal amount, bool bold = false)
|
||||
{
|
||||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||||
if (bold) lText.Bold();
|
||||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
if (bold) vText.Bold();
|
||||
}
|
||||
|
||||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
|
||||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||||
{
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6)
|
||||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
|
||||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||||
{
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||||
.Text(label).Bold().FontSize(9);
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
QuoteItemPricingResult itemResult;
|
||||
|
||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
||||
if (item.CatalogItemId.HasValue)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
||||
// (which already includes the catalog base price + coat costs)
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
||||
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -104,8 +104,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
item.UnitPrice = catalogItem.DefaultPrice;
|
||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
||||
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||
? itemDto.PowderCostOverride.Value
|
||||
: catalogItem.DefaultPrice;
|
||||
item.UnitPrice = unitPrice;
|
||||
item.TotalPrice = unitPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
|
||||
public decimal Total { get; set; }
|
||||
public decimal RemainingAmount { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||
public DateTime? PostedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -285,3 +287,172 @@ public class VendorCreditApplication : BaseEntity
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A saved recipe for a document that should be automatically created on a recurring schedule.
|
||||
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
|
||||
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
|
||||
/// <para>
|
||||
/// Bills are created as Draft so the user can review before posting.
|
||||
/// Expenses are created immediately (already-paid transactions).
|
||||
/// </para>
|
||||
/// Numbering: REC-YYMM-####
|
||||
/// </summary>
|
||||
public class RecurringTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RecurringTemplateType TemplateType { get; set; }
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
|
||||
public int IntervalCount { get; set; } = 1;
|
||||
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
|
||||
public DateTime NextFireDate { get; set; }
|
||||
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
|
||||
public int? MaxOccurrences { get; set; }
|
||||
/// <summary>How many documents have been generated so far.</summary>
|
||||
public int OccurrenceCount { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
|
||||
public string TemplateData { get; set; } = "{}";
|
||||
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
|
||||
/// invoices when a taxable customer is selected. Companies can define multiple rates for
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
|
||||
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
|
||||
/// to auto-post monthly depreciation journal entries.
|
||||
/// </summary>
|
||||
public class FixedAsset : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
public decimal PurchaseCost { get; set; }
|
||||
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
|
||||
public int UsefulLifeMonths { get; set; }
|
||||
/// <summary>Running total of depreciation posted so far.</summary>
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
|
||||
// Computed — not persisted
|
||||
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
|
||||
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
|
||||
/// <summary>Straight-line monthly depreciation amount.</summary>
|
||||
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
|
||||
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
|
||||
|
||||
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
|
||||
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
|
||||
public int? AssetAccountId { get; set; }
|
||||
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account? AssetAccount { get; set; }
|
||||
public virtual Account? DepreciationExpenseAccount { get; set; }
|
||||
public virtual Account? AccumDepreciationAccount { get; set; }
|
||||
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
|
||||
/// month/year combination; linked to the JournalEntry that was created so the posting
|
||||
/// can be traced back through the GL.
|
||||
/// </summary>
|
||||
public class FixedAssetDepreciationEntry : BaseEntity
|
||||
{
|
||||
public int FixedAssetId { get; set; }
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
|
||||
public int? JournalEntryId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual FixedAsset FixedAsset { get; set; } = null!;
|
||||
public virtual JournalEntry? JournalEntry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named annual budget. Contains one BudgetLine per account per month. Supports
|
||||
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
|
||||
/// one is marked IsDefault for the Budget vs. Actual report.
|
||||
/// </summary>
|
||||
public class Budget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly budget amount for one account within a Budget. Jan–Dec stored as separate
|
||||
/// columns so the grid editor can write them in a single POST without a line-item loop.
|
||||
/// Annual is a computed property summing all twelve months.
|
||||
/// </summary>
|
||||
public class BudgetLine : BaseEntity
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
|
||||
public virtual Budget Budget { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed year-end close. The close posts a JE that zeroes all
|
||||
/// Revenue and Expense account balances into Retained Earnings, and marks
|
||||
/// the year as closed so it cannot be closed again.
|
||||
/// </summary>
|
||||
public class YearEndClose : BaseEntity
|
||||
{
|
||||
public int ClosedYear { get; set; }
|
||||
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? ClosedBy { get; set; }
|
||||
public int JournalEntryId { get; set; }
|
||||
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
|
||||
public bool CanManageMaintenance { get; set; } = false;
|
||||
public bool CanManageInvoices { get; set; } = false;
|
||||
public bool CanViewReports { get; set; } = false;
|
||||
public bool CanManageBills { get; set; } = false;
|
||||
public bool CanManageAccounting { get; set; } = false;
|
||||
|
||||
// Profile Photo (filesystem storage)
|
||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||
|
||||
@@ -112,11 +112,27 @@ public class Company : BaseEntity
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
/// <summary>
|
||||
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
|
||||
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
|
||||
/// </summary>
|
||||
public DateTime? BookLockedThrough { get; set; }
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||
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
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
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>
|
||||
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
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
||||
public string? Notes { get; set; }
|
||||
public string? RecordedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// Applied to invoice when invoice is created
|
||||
public int? AppliedToInvoiceId { get; set; }
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||
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)
|
||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||
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? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -28,6 +28,7 @@ public class Job : BaseEntity
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
public decimal OvenBatchCost { get; set; }
|
||||
public decimal ShopSuppliesAmount { get; set; }
|
||||
public decimal ShopSuppliesPercent { get; set; }
|
||||
|
||||
|
||||
@@ -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 string? IssuedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// For store-credit refunds: the CreditMemo created on their behalf
|
||||
public int? CreditMemoId { get; set; }
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
|
||||
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
// 1099 Contractor tracking
|
||||
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||
|
||||
@@ -94,6 +94,26 @@ public enum VendorCreditStatus
|
||||
Voided = 3
|
||||
}
|
||||
|
||||
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
|
||||
public enum RecurringTemplateType
|
||||
{
|
||||
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
|
||||
Bill = 1,
|
||||
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
|
||||
Expense = 2
|
||||
}
|
||||
|
||||
/// <summary>How often a recurring template fires.</summary>
|
||||
public enum RecurringFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
BiWeekly = 3,
|
||||
Monthly = 4,
|
||||
Quarterly = 5,
|
||||
Annually = 6
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||
public enum JournalEntryStatus
|
||||
{
|
||||
|
||||
@@ -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
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Recurring Transactions
|
||||
IRepository<RecurringTemplate> RecurringTemplates { get; }
|
||||
|
||||
// Fixed Assets
|
||||
IRepository<FixedAsset> FixedAssets { get; }
|
||||
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
|
||||
|
||||
// Budgeting
|
||||
IRepository<Budget> Budgets { get; }
|
||||
IRepository<BudgetLine> BudgetLines { get; }
|
||||
|
||||
// Year-End Close
|
||||
IRepository<YearEndClose> YearEndCloses { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
@@ -137,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
||||
|
||||
/// <summary>
|
||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||
/// </summary>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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>
|
||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
||||
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||
bool hideChurned = true);
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TaxRate> TaxRates { get; set; }
|
||||
|
||||
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
|
||||
|
||||
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||||
|
||||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Budget> Budgets { get; set; }
|
||||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<YearEndClose> YearEndCloses { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
@@ -349,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||
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>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -638,12 +660,84 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Recurring Templates: tenant-filtered
|
||||
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AssetAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AssetAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||||
.HasOne(e => e.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||||
modelBuilder.Entity<BudgetLine>()
|
||||
.HasOne(bl => bl.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(bl => bl.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// YearEndClose: tenant-filtered; links to a specific JE
|
||||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<YearEndClose>()
|
||||
.HasOne(y => y.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(y => y.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany(vc => vc.Applications)
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -656,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||
!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
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
|
||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
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,
|
||||
Channel = NotificationChannel.Email,
|
||||
|
||||
@@ -78,13 +78,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("CanCreateQuotes")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("CanManageAccounting")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("CanManageBills")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("CanManageCalendar")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -1269,6 +1275,139 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1633,6 +1772,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("AiPhotoQuotesEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("BookLockedThrough")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -1670,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("KioskActivationToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("LogoContentType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2108,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("JobRetentionYears")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("KioskIntakeOutput")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("LogRetentionDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -2750,6 +2899,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("DepositAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -2994,6 +3146,142 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3586,6 +3874,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("EarlyPaymentDiscountDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("EarlyPaymentDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ExternalReference")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
@@ -3632,6 +3926,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("SalesTaxAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3905,6 +4202,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("OriginalJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("OvenBatchCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("OvenCostId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5274,6 +5574,118 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -6287,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6298,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6309,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7266,6 +7678,78 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -7295,6 +7779,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("DepositAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("InvoiceId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7742,6 +8229,62 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -7876,6 +8419,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Is1099Vendor")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7966,6 +8512,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Memo")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("PostedDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("RemainingAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -8100,6 +8649,57 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
@@ -8313,6 +8913,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport")
|
||||
@@ -8569,6 +9188,48 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||
@@ -9182,6 +9843,23 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||
@@ -9740,13 +10418,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
|
||||
.WithMany()
|
||||
.HasForeignKey("BillId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
||||
.WithMany("Applications")
|
||||
.HasForeignKey("VendorCreditId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Bill");
|
||||
@@ -9772,6 +10450,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Navigation("BillLineItems");
|
||||
@@ -9816,6 +10505,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
@@ -9878,6 +10572,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("OvenBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||
{
|
||||
b.Navigation("DepreciationEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.Navigation("Redemptions");
|
||||
|
||||
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<GiftCertificate>? _giftCertificates;
|
||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||
|
||||
// Customer Intake Kiosk
|
||||
private IRepository<KioskSession>? _kioskSessions;
|
||||
|
||||
// Purchase Orders
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||
@@ -154,6 +157,17 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
// Tax Rates
|
||||
private IRepository<TaxRate>? _taxRates;
|
||||
|
||||
// Recurring Transactions
|
||||
private IRepository<RecurringTemplate>? _recurringTemplates;
|
||||
private IRepository<FixedAsset>? _fixedAssets;
|
||||
private IRepository<FixedAssetDepreciationEntry>? _fixedAssetDepreciationEntries;
|
||||
private IRepository<Budget>? _budgets;
|
||||
private IRepository<BudgetLine>? _budgetLines;
|
||||
private IRepository<YearEndClose>? _yearEndCloses;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||
/// The context is shared across all repositories created by this instance so that
|
||||
@@ -449,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
||||
_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
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
@@ -552,6 +570,26 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
// Tax Rates
|
||||
/// <summary>Repository for <see cref="TaxRate"/> named tax rates used to pre-fill invoice tax percent by jurisdiction.</summary>
|
||||
public IRepository<TaxRate> TaxRates =>
|
||||
_taxRates ??= new Repository<TaxRate>(_context);
|
||||
|
||||
// Recurring Transactions
|
||||
/// <summary>Repository for <see cref="RecurringTemplate"/> — saved recipes that auto-generate bills or expenses on a schedule.</summary>
|
||||
public IRepository<RecurringTemplate> RecurringTemplates =>
|
||||
_recurringTemplates ??= new Repository<RecurringTemplate>(_context);
|
||||
public IRepository<FixedAsset> FixedAssets =>
|
||||
_fixedAssets ??= new Repository<FixedAsset>(_context);
|
||||
public IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries =>
|
||||
_fixedAssetDepreciationEntries ??= new Repository<FixedAssetDepreciationEntry>(_context);
|
||||
public IRepository<Budget> Budgets =>
|
||||
_budgets ??= new Repository<Budget>(_context);
|
||||
public IRepository<BudgetLine> BudgetLines =>
|
||||
_budgetLines ??= new Repository<BudgetLine>(_context);
|
||||
public IRepository<YearEndClose> YearEndCloses =>
|
||||
_yearEndCloses ??= new Repository<YearEndClose>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
|
||||
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
|
||||
/// between the current running balance and the statement ending balance. The items list
|
||||
/// includes both deposits and payments with their direction tag so Claude can reason about
|
||||
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
|
||||
/// target ending balance — items that together sum close to the required difference score
|
||||
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
|
||||
/// compact because we only need entity-type/id pairs plus a short reason per item.
|
||||
/// </summary>
|
||||
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
|
||||
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
|
||||
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
|
||||
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""suggestedCleared"": [
|
||||
{
|
||||
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
|
||||
""entityId"": number,
|
||||
""confidence"": number (0.0 to 1.0),
|
||||
""reason"": ""string — one sentence why this item should be cleared""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
|
||||
- Difference needed = statementEndingBalance - beginningBalance
|
||||
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
|
||||
- confidence 0.6-0.89: likely but not certain
|
||||
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
|
||||
- insights: 2-4 observations about patterns or items that need manual review
|
||||
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
|
||||
|
||||
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
|
||||
var needed = request.StatementEndingBalance - request.BeginningBalance;
|
||||
|
||||
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
|
||||
|
||||
Beginning Balance: {request.BeginningBalance:F2}
|
||||
Statement Ending Balance: {request.StatementEndingBalance:F2}
|
||||
Difference needed (deposits - payments): {needed:F2}
|
||||
|
||||
Uncleared transactions:
|
||||
{itemsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new AutoMatchResult
|
||||
{
|
||||
Success = true,
|
||||
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
|
||||
{
|
||||
EntityType = s.EntityType,
|
||||
EntityId = s.EntityId,
|
||||
Confidence = s.Confidence,
|
||||
Reason = s.Reason
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running bank rec auto-match with AI");
|
||||
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Predicts payment risk per open AR customer by combining current overdue status with
|
||||
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
|
||||
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 0–1 ratio rather than
|
||||
/// raw counts, which produces more consistent confidence scoring across customers with very
|
||||
/// different invoice volumes. Risk levels are validated against the three allowed values and
|
||||
/// default to "medium" when Claude returns anything outside the expected set.
|
||||
/// </summary>
|
||||
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
|
||||
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""predictions"": [
|
||||
{
|
||||
""customerName"": ""string"",
|
||||
""riskLevel"": ""high"" | ""medium"" | ""low"",
|
||||
""estimatedDaysToPayment"": number,
|
||||
""reasoning"": ""string — one sentence explaining the prediction""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
|
||||
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
|
||||
- riskLevel ""low"": customer typically pays on time and is not severely overdue
|
||||
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
|
||||
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
|
||||
- Only include predictions for customers with open invoices";
|
||||
|
||||
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
|
||||
{
|
||||
c.CustomerName,
|
||||
c.TotalOwed,
|
||||
c.AvgDaysToPay,
|
||||
LatePaymentRate = c.TotalInvoicesAllTime > 0
|
||||
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
|
||||
: 0,
|
||||
c.OpenInvoices
|
||||
}));
|
||||
|
||||
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
|
||||
|
||||
Customer data (includes historical payment behavior):
|
||||
{customersJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validRiskLevels = new[] { "high", "medium", "low" };
|
||||
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
|
||||
{
|
||||
CustomerName = p.CustomerName,
|
||||
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
|
||||
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
|
||||
Reasoning = p.Reasoning
|
||||
}).ToList();
|
||||
|
||||
return new LatePaymentPredictionResult
|
||||
{
|
||||
Success = true,
|
||||
Predictions = predictions,
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error predicting late payments with AI");
|
||||
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
|
||||
/// financial data. The context object is serialized to JSON and embedded in the user prompt
|
||||
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
|
||||
/// system prompt explicitly constrains Claude to the data provided and forbids it from
|
||||
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
|
||||
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
|
||||
/// to justify the answer, displayed below the answer in the UI so users can verify.
|
||||
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
|
||||
/// </summary>
|
||||
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
|
||||
Answer plain-English financial questions using ONLY the data provided in the context.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""answer"": ""string — direct, plain-English answer to the question"",
|
||||
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
|
||||
""relevantFacts"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- answer: be direct and specific with dollar amounts and percentages from the data
|
||||
- If the data does not contain enough information to answer the question, say so clearly in the answer
|
||||
- Do NOT invent or estimate figures that are not in the provided data
|
||||
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
|
||||
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
|
||||
- Keep the answer under 100 words — be concise";
|
||||
|
||||
var contextJson = JsonSerializer.Serialize(request.Context);
|
||||
var userPrompt = $@"Question: {request.Question}
|
||||
|
||||
Financial context:
|
||||
{contextJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
return new FinancialQueryResult
|
||||
{
|
||||
Success = true,
|
||||
Answer = parsed.Answer ?? string.Empty,
|
||||
FollowUpSuggestion = parsed.FollowUpSuggestion,
|
||||
RelevantFacts = parsed.RelevantFacts ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error answering financial query with AI");
|
||||
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of historical bills to detect recurring payment patterns per vendor.
|
||||
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
|
||||
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
|
||||
/// regular the cadence is — a bill appearing every 28–32 days for 6 consecutive months is
|
||||
/// high confidence; 2–3 occurrences at similar amounts is medium. NextExpectedDateIso is
|
||||
/// calculated by Claude from the pattern's most recent date plus the detected period length.
|
||||
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
|
||||
/// </summary>
|
||||
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
|
||||
{
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
|
||||
Analyze the provided bill history to detect recurring payment patterns per vendor.
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||
|
||||
Schema:
|
||||
{
|
||||
""patterns"": [
|
||||
{
|
||||
""vendorName"": ""string"",
|
||||
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
|
||||
""typicalAmount"": number,
|
||||
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
|
||||
""confidence"": ""high"" | ""medium"" | ""low"",
|
||||
""description"": ""string — one sentence describing the pattern"",
|
||||
""suggestedAction"": ""string — one specific action to take, or null""
|
||||
}
|
||||
],
|
||||
""insights"": [""string"", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Only report patterns with at least 2 occurrences
|
||||
- monthly: bills occurring every 25–35 days
|
||||
- quarterly: bills occurring every 80–100 days
|
||||
- biannual: bills occurring every 170–195 days
|
||||
- annual: bills occurring roughly once per year
|
||||
- irregular: a vendor bills regularly but the cadence is inconsistent
|
||||
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
|
||||
- confidence ""medium"": 2–3 occurrences with consistent timing, or 4+ with variable timing
|
||||
- confidence ""low"": pattern is weak but worth monitoring
|
||||
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
|
||||
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
|
||||
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
|
||||
- If no recurring patterns are found, return an empty patterns array";
|
||||
|
||||
// Group bills by vendor for clarity in the prompt
|
||||
var grouped = request.Bills
|
||||
.GroupBy(b => b.VendorName)
|
||||
.Select(g => new
|
||||
{
|
||||
VendorName = g.Key,
|
||||
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
|
||||
});
|
||||
|
||||
var billsJson = JsonSerializer.Serialize(grouped);
|
||||
|
||||
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
|
||||
Data covers the last 6–12 months of bills, grouped by vendor.
|
||||
|
||||
Bill history by vendor:
|
||||
{billsJson}";
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageParams = new MessageParameters
|
||||
{
|
||||
Model = Model,
|
||||
MaxTokens = 1500,
|
||||
SystemMessage = systemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await SendAsync(client, messageParams);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||
|
||||
var raw = StripJsonFences(rawText);
|
||||
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
|
||||
if (parsed == null)
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||
|
||||
var validConfidence = new[] { "high", "medium", "low" };
|
||||
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
|
||||
|
||||
return new RecurringBillDetectionResult
|
||||
{
|
||||
Success = true,
|
||||
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
|
||||
{
|
||||
VendorName = p.VendorName,
|
||||
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
|
||||
TypicalAmount = p.TypicalAmount,
|
||||
NextExpectedDateIso = p.NextExpectedDateIso,
|
||||
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
|
||||
Description = p.Description,
|
||||
SuggestedAction = p.SuggestedAction
|
||||
}).ToList(),
|
||||
Insights = parsed.Insights ?? new()
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error detecting recurring bills with AI");
|
||||
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +435,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
||||
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
||||
}
|
||||
|
||||
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
|
||||
string coatingSpeedLine;
|
||||
if (coatingRate > 0)
|
||||
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
|
||||
else
|
||||
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
|
||||
|
||||
var rateInstruction = (blastRate > 0 || coatingRate > 0)
|
||||
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
|
||||
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
|
||||
|
||||
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
||||
|
||||
@@ -453,7 +461,7 @@ Company operating costs for your reference:
|
||||
{shopSpeedLine}
|
||||
{coatingSpeedLine}
|
||||
|
||||
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
|
||||
{rateInstruction}
|
||||
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
||||
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
||||
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
||||
@@ -547,9 +555,9 @@ Respond with the JSON object only.";
|
||||
_ => 0
|
||||
};
|
||||
|
||||
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
|
||||
// The unit price × quantity must equal the total batch labor cost.
|
||||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
||||
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
|
||||
// Unit price is per item; the caller multiplies by quantity for the line total.
|
||||
var rawPerItemMinutes = aiResult.EstimatedMinutes;
|
||||
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
||||
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
||||
var laborHours = perItemMinutes / 60m;
|
||||
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
|
||||
CoatCount = request.CoatCount,
|
||||
MaterialCost = Math.Round(materialCost, 2),
|
||||
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
||||
EstimatedMinutes = (int)Math.Round(perItemMinutes),
|
||||
EstimatedMinutes = perItemMinutes,
|
||||
MaterialMinMinutes = materialMinMinutes,
|
||||
MinFloorApplied = minFloorApplied,
|
||||
LaborCost = Math.Round(laborCost, 2),
|
||||
|
||||
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
|
||||
identity.AddClaim(new Claim("Permission", "ViewReports"));
|
||||
}
|
||||
|
||||
if (user.CanManageBills)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageBills"));
|
||||
}
|
||||
|
||||
if (user.CanManageAccounting)
|
||||
{
|
||||
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
|
||||
}
|
||||
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces.Services;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
||||
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
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
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.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))
|
||||
{
|
||||
var s = searchTerm.ToLower();
|
||||
@@ -61,12 +81,16 @@ public class CompanyListService : ICompanyListService
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (companies, totalCount);
|
||||
return (companies, totalCount, churnedCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var d30 = now.AddDays(-30);
|
||||
var d90 = now.AddDays(-90);
|
||||
|
||||
var jobCounts = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||
@@ -98,6 +122,32 @@ public class CompanyListService : ICompanyListService
|
||||
x => x.CompanyId,
|
||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
||||
|
||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
|
||||
var jobs30 = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d30)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var jobs90 = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d90)
|
||||
.GroupBy(j => j.CompanyId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
var lastLoginRaw = await _context.Users
|
||||
.IgnoreQueryFilters()
|
||||
.Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null)
|
||||
.GroupBy(u => u.CompanyId)
|
||||
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
|
||||
.ToListAsync();
|
||||
|
||||
var lastLogins = lastLoginRaw.ToDictionary(
|
||||
x => x.CompanyId,
|
||||
x => x.Last);
|
||||
|
||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo,
|
||||
jobs30, jobs90, lastLogins);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
if (unlinkedRevenue > 0)
|
||||
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
|
||||
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.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
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& 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.WrittenOff)
|
||||
.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
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.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)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems
|
||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||
.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
|
||||
.Where(a => a.IsActive)
|
||||
@@ -248,6 +415,7 @@ public class FinancialReportService : IFinancialReportService
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.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)
|
||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
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
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.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)
|
||||
{
|
||||
if (acct.CurrentBalance == 0) continue;
|
||||
var balance = ComputeAsOfBalance(acct);
|
||||
if (balance == 0m) continue;
|
||||
|
||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||
var line = new TrialBalanceLine
|
||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
if (isDebitNormal)
|
||||
{
|
||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
||||
else line.CreditBalance = -acct.CurrentBalance;
|
||||
if (balance >= 0m) line.DebitBalance = balance;
|
||||
else line.CreditBalance = -balance;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
||||
else line.DebitBalance = -acct.CurrentBalance;
|
||||
if (balance >= 0m) line.CreditBalance = balance;
|
||||
else line.DebitBalance = -balance;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
@@ -713,6 +1150,326 @@ public class FinancialReportService : IFinancialReportService
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
var depositedDeposits = await _context.Deposits
|
||||
.Where(d => d.DepositAccountId == accountId
|
||||
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var d in depositedDeposits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate,
|
||||
Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit",
|
||||
Description = d.Notes ?? d.Reference,
|
||||
Debit = d.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Jobs",
|
||||
LinkId = d.JobId
|
||||
});
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
var refundsPaidFrom = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.DepositAccountId == accountId
|
||||
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in refundsPaidFrom)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = 0,
|
||||
Credit = r.Amount,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||
// e.g. Checking account used to pay an expense
|
||||
var expensesPaidFrom = await _context.Expenses
|
||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Invoices",
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Credit memo applications reduce open AR (CREDIT)
|
||||
var arCreditMemos = await _context.CreditMemoApplications
|
||||
.Include(a => a.Invoice)
|
||||
.Include(a => a.CreditMemo)
|
||||
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cm in arCreditMemos)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = cm.AppliedDate,
|
||||
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
|
||||
Source = "Credit Memo",
|
||||
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = cm.AmountApplied,
|
||||
LinkController = "Invoices",
|
||||
LinkId = cm.InvoiceId
|
||||
});
|
||||
|
||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||
var arRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in arRefunds)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = r.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Bills",
|
||||
LinkId = bp.BillId
|
||||
});
|
||||
|
||||
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
|
||||
var apVendorCredits = await _context.VendorCreditApplications
|
||||
.Include(vca => vca.VendorCredit)
|
||||
.Include(vca => vca.Bill)
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId
|
||||
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var vca in apVendorCredits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = vca.AppliedDate,
|
||||
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
|
||||
Source = "Vendor Credit",
|
||||
Description = $"Credit applied to {vca.Bill?.BillNumber}",
|
||||
Debit = vca.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "VendorCredits",
|
||||
LinkId = vca.VendorCreditId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
|
||||
// CR when GC is issued; DR when redeemed or voided with remaining balance.
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
var gcIssued = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcIssued)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.IssueDate, Reference = gc.CertificateCode,
|
||||
Source = "Gift Certificate", Description = "GC issued",
|
||||
Debit = 0, Credit = gc.OriginalAmount,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
|
||||
var gcRedemptions = await _context.GiftCertificateRedemptions
|
||||
.Include(r => r.GiftCertificate)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var r in gcRedemptions)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||
Source = "GC Redemption", Description = "GC applied to invoice",
|
||||
Debit = r.AmountRedeemed, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
|
||||
});
|
||||
|
||||
var gcVoided = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
|
||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcVoided)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
|
||||
Source = "GC Voided", Description = "Breakage income",
|
||||
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Customer Deposits liability (account 2300) ────────────────────
|
||||
// CR when deposit is recorded; DR when deposit is applied to an invoice.
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
var depositsRecorded = await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsRecorded)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
|
||||
Debit = 0, Credit = d.Amount,
|
||||
LinkController = "Jobs", LinkId = d.JobId
|
||||
});
|
||||
|
||||
var depositsApplied = await _context.Deposits
|
||||
.Include(d => d.AppliedToInvoice)
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
|
||||
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsApplied)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
|
||||
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
|
||||
Debit = d.Amount, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
|
||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
credits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
|
||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||
credits += await _context.Expenses
|
||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
|
||||
credits += await _context.Payments
|
||||
.Where(p => p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
credits += await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
debits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
|
||||
debits += await _context.BillPayments
|
||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
|
||||
debits += await _context.VendorCreditApplications
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
|
||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 11. GC Liability (account 2500)
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
credits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
|
||||
debits += await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||
debits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
|
||||
}
|
||||
|
||||
// 12. Customer Deposits liability (account 2300)
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
credits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 10. Posted journal entry lines touching this account (prior to period)
|
||||
|
||||
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
|
||||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||||
/// standard "here is your invoice" message with no payment CTA.
|
||||
/// </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
|
||||
{
|
||||
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
|
||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||||
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)
|
||||
{
|
||||
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
|
||||
"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>"
|
||||
),
|
||||
[(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)] = (
|
||||
"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>"
|
||||
|
||||
@@ -70,6 +70,10 @@ public partial class SeedDataService
|
||||
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
||||
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
|
||||
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||
// reducing net revenue to match the discounted AR amount that was posted.
|
||||
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||
@@ -96,4 +100,44 @@ public partial class SeedDataService
|
||||
|
||||
return accounts.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
|
||||
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
|
||||
/// call repeatedly from the "Seed Lookup Tables" flow.
|
||||
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
|
||||
/// companies get all accounts in one pass while existing companies receive only the missing ones.
|
||||
/// </summary>
|
||||
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
|
||||
private async Task<int> EnsureSystemAccountsAsync(Company company)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
int added = 0;
|
||||
|
||||
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
|
||||
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
|
||||
var has4950 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
|
||||
|
||||
if (!has4950)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "4950",
|
||||
Name = "Sales Discounts",
|
||||
AccountType = AccountType.Revenue,
|
||||
AccountSubType = AccountSubType.OtherIncome,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Contra-revenue for invoice discounts granted to customers",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
|
||||
result.ItemsSeeded += accountsSeeded;
|
||||
}
|
||||
|
||||
// Backfill any system accounts added after the initial seed (idempotent).
|
||||
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||
if (systemAccountsAdded > 0)
|
||||
{
|
||||
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||
result.ItemsSeeded += systemAccountsAdded;
|
||||
}
|
||||
|
||||
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||
result.Details = details;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ public static class AppConstants
|
||||
{
|
||||
public const string CompanyAdmin = "CompanyAdmin";
|
||||
public const string Manager = "Manager";
|
||||
public const string Accountant = "Accountant";
|
||||
public const string Worker = "Worker";
|
||||
public const string Viewer = "Viewer";
|
||||
}
|
||||
@@ -58,6 +59,8 @@ public static class AppConstants
|
||||
public const string CanManageMaintenance = "CanManageMaintenance";
|
||||
public const string CanManageInvoices = "CanManageInvoices";
|
||||
public const string CanViewReports = "CanViewReports";
|
||||
public const string CanManageBills = "CanManageBills";
|
||||
public const string CanManageAccounting = "CanManageAccounting";
|
||||
}
|
||||
|
||||
public static class FileUpload
|
||||
@@ -103,6 +106,10 @@ public static class AppConstants
|
||||
public const string FinancialSummary = "FinancialSummary";
|
||||
public const string CashFlowForecast = "CashFlowForecast";
|
||||
public const string AnomalyDetection = "AnomalyDetection";
|
||||
public const string BankRecAutoMatch = "BankRecAutoMatch";
|
||||
public const string LatePaymentPrediction = "LatePaymentPrediction";
|
||||
public const string FinancialQuery = "FinancialQuery";
|
||||
public const string RecurringBillDetection = "RecurringBillDetection";
|
||||
}
|
||||
|
||||
public static class Legal
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── Year-End Close ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET: landing page showing close history and a form to initiate the current year close.
|
||||
/// Companyid is resolved from tenant context; year defaults to the prior fiscal year
|
||||
/// (the most common use case — close last year after final entries are posted).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> YearEndClose()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
||||
.OrderByDescending(y => y.ClosedYear)
|
||||
.ToList();
|
||||
|
||||
ViewBag.History = history;
|
||||
ViewBag.SuggestedYear = DateTime.Now.Year - 1;
|
||||
ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST: executes the year-end close for the specified fiscal year.
|
||||
/// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances
|
||||
/// (debit-normal), computes net income, posts a JE that zeroes them into Retained
|
||||
/// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has
|
||||
/// already been closed is rejected.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> CloseYear(int year)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Idempotency check
|
||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Load all active accounts with balances
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
||||
|
||||
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||
var expenseAccounts = accounts.Where(a =>
|
||||
a.AccountType == AccountType.Expense ||
|
||||
a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList();
|
||||
|
||||
// Find or locate the Retained Earnings account
|
||||
var retainedEarnings = accounts.FirstOrDefault(a =>
|
||||
a.AccountSubType == AccountSubType.RetainedEarnings);
|
||||
|
||||
if (retainedEarnings == null)
|
||||
{
|
||||
TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Net income = total revenue credits − total expense debits
|
||||
var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance);
|
||||
var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance);
|
||||
var netIncome = totalRevenue - totalExpenses;
|
||||
|
||||
if (totalRevenue == 0 && totalExpenses == 0)
|
||||
{
|
||||
TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
int newJeId = 0;
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var lines = new List<JournalEntryLine>();
|
||||
|
||||
// Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0)
|
||||
foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0)
|
||||
foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Plug the net into Retained Earnings: CR if profit, DR if loss
|
||||
if (netIncome > 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
CreditAmount = netIncome,
|
||||
Description = $"Net income {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome);
|
||||
}
|
||||
else if (netIncome < 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
DebitAmount = Math.Abs(netIncome),
|
||||
Description = $"Net loss {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome));
|
||||
}
|
||||
|
||||
// Post the JE
|
||||
var prefix = $"JE-{year % 100:D2}12-";
|
||||
var existing2 = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
int next = existing2.Any()
|
||||
? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||
: 1;
|
||||
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = $"{prefix}{next:D4}",
|
||||
EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
Description = $"Year-end close — {year}",
|
||||
Reference = $"CLOSE-{year}",
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = User.Identity?.Name,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = lines
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Record the close
|
||||
var close = new YearEndClose
|
||||
{
|
||||
ClosedYear = year,
|
||||
ClosedAt = DateTime.UtcNow,
|
||||
ClosedBy = User.Identity?.Name,
|
||||
JournalEntryId = je.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.YearEndCloses.AddAsync(close);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
newJeId = je.Id;
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " +
|
||||
$"See Journal Entry for details.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.AI;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly IAccountingAiService _accountingAi;
|
||||
private readonly IAiUsageLogger _usageLogger;
|
||||
|
||||
public BankReconciliationsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext)
|
||||
ITenantContext tenantContext,
|
||||
IAccountingAiService accountingAi,
|
||||
IAiUsageLogger usageLogger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_accountingAi = accountingAi;
|
||||
_usageLogger = usageLogger;
|
||||
}
|
||||
|
||||
private bool AllowAccounting() =>
|
||||
@@ -49,7 +56,7 @@ public class BankReconciliationsController : Controller
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||
@@ -58,7 +65,7 @@ public class BankReconciliationsController : Controller
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BankReconciliation model)
|
||||
{
|
||||
@@ -164,7 +171,7 @@ public class BankReconciliationsController : Controller
|
||||
/// Returns updated running totals as JSON.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleCleared(
|
||||
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>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Complete(int id, decimal difference)
|
||||
{
|
||||
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
|
||||
return View(recon);
|
||||
}
|
||||
|
||||
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
|
||||
/// to mark as cleared. The controller assembles all three transaction types (deposits,
|
||||
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
|
||||
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
|
||||
/// suggestions client-side by auto-checking the corresponding table rows.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AiSuggestMatches(int reconId)
|
||||
{
|
||||
if (!AllowAccounting()) return Forbid();
|
||||
|
||||
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.Id == reconId, false, br => br.Account))
|
||||
.FirstOrDefault();
|
||||
if (recon == null) return NotFound();
|
||||
|
||||
var accountId = recon.AccountId;
|
||||
var statementDate = recon.StatementDate;
|
||||
|
||||
var items = new List<BankRecMatchItem>();
|
||||
|
||||
(await _unitOfWork.Payments.FindAsync(
|
||||
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(p => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "Payment",
|
||||
EntityId = p.Id,
|
||||
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
|
||||
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||
Description = $"Payment #{p.InvoiceId}",
|
||||
Amount = p.Amount,
|
||||
Direction = "deposit"
|
||||
}));
|
||||
|
||||
(await _unitOfWork.BillPayments.FindAsync(
|
||||
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(bp => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "BillPayment",
|
||||
EntityId = bp.Id,
|
||||
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
|
||||
Reference = bp.PaymentNumber,
|
||||
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||
Amount = bp.Amount,
|
||||
Direction = "payment"
|
||||
}));
|
||||
|
||||
(await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
|
||||
.ToList()
|
||||
.ForEach(e => items.Add(new BankRecMatchItem
|
||||
{
|
||||
EntityType = "Expense",
|
||||
EntityId = e.Id,
|
||||
Date = e.Date.ToString("yyyy-MM-dd"),
|
||||
Reference = e.ExpenseNumber,
|
||||
Description = e.Memo ?? string.Empty,
|
||||
Amount = e.Amount,
|
||||
Direction = "payment"
|
||||
}));
|
||||
|
||||
if (!items.Any())
|
||||
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
|
||||
|
||||
var request = new AutoMatchRequest
|
||||
{
|
||||
UnclearedItems = items,
|
||||
BeginningBalance = recon.BeginningBalance,
|
||||
StatementEndingBalance = recon.EndingBalance
|
||||
};
|
||||
|
||||
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task PopulateAccountDropdownAsync()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -58,13 +58,13 @@ public class BillsController : Controller
|
||||
_usageLogger = usageLogger;
|
||||
}
|
||||
|
||||
// ── Index ────────────────────────────────────────────────────────────────
|
||||
// -- Index ----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
||||
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
||||
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
||||
@@ -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")
|
||||
{
|
||||
var expSearch = search;
|
||||
@@ -160,13 +160,13 @@ public class BillsController : Controller
|
||||
return View(pagedEntries);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
// -- Create ---------------------------------------------------------------
|
||||
|
||||
// ── Create from Purchase Order ────────────────────────────────────────────
|
||||
// -- Create from Purchase Order --------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
||||
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
||||
/// Line items are copied from PO items (using inventory item names where available), and
|
||||
@@ -174,7 +174,7 @@ public class BillsController : Controller
|
||||
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
||||
/// first active Expense/COGS account when the vendor has no default configured.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
@@ -248,7 +248,7 @@ public class BillsController : Controller
|
||||
return View("Create", dto);
|
||||
}
|
||||
|
||||
// ── Create ───────────────────────────────────────────────────────────────
|
||||
// -- Create ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
||||
@@ -257,7 +257,7 @@ public class BillsController : Controller
|
||||
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
||||
/// so the double-entry pair is ready without manual lookup.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Create(int? vendorId)
|
||||
{
|
||||
var dto = new CreateBillDto
|
||||
@@ -291,14 +291,14 @@ public class BillsController : Controller
|
||||
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
||||
/// before validation to avoid spurious errors when the browser submits blank rows.
|
||||
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||
/// useful for entering historical bills that were already settled. Account balance side
|
||||
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
||||
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
||||
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
||||
bool payNow = false,
|
||||
DateTime? paymentDate = null,
|
||||
@@ -321,6 +321,19 @@ public class BillsController : Controller
|
||||
try
|
||||
{
|
||||
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 entity, PO back-reference, and optional immediate payment all commit
|
||||
@@ -386,7 +399,7 @@ public class BillsController : Controller
|
||||
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.
|
||||
if (receiptFile != null && receiptFile.Length > 0)
|
||||
{
|
||||
@@ -415,7 +428,7 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── Details ──────────────────────────────────────────────────────────────
|
||||
// -- Details --------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Displays full bill detail including line items, payments, and the payment entry form.
|
||||
@@ -441,7 +454,7 @@ public class BillsController : Controller
|
||||
.ToList();
|
||||
|
||||
ViewBag.BankAccounts = bankAccounts
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
@@ -451,7 +464,7 @@ public class BillsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// ── Edit ─────────────────────────────────────────────────────────────────
|
||||
// -- Edit -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
||||
@@ -459,7 +472,7 @@ public class BillsController : Controller
|
||||
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
||||
/// audit trail.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Edit(int? id)
|
||||
{
|
||||
if (id == null) return NotFound();
|
||||
@@ -510,7 +523,7 @@ public class BillsController : Controller
|
||||
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
||||
{
|
||||
if (id != dto.Id) return NotFound();
|
||||
@@ -607,7 +620,7 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark Open (Draft → Open) ─────────────────────────────────────────────
|
||||
// -- Mark Open (Draft ? Open) ---------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
||||
@@ -618,7 +631,7 @@ public class BillsController : Controller
|
||||
/// deferred from bill creation to give users a review window without polluting the ledger.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> MarkOpen(int id)
|
||||
{
|
||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
||||
@@ -656,7 +669,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── Record Payment ───────────────────────────────────────────────────────
|
||||
// -- Record Payment -------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
||||
@@ -668,7 +681,7 @@ public class BillsController : Controller
|
||||
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
@@ -739,7 +752,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// ── Delete Payment ───────────────────────────────────────────────────────
|
||||
// -- Delete Payment -------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reverses a previously recorded payment. All double-entry effects of
|
||||
@@ -749,7 +762,7 @@ public class BillsController : Controller
|
||||
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
||||
{
|
||||
try
|
||||
@@ -796,7 +809,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = billId });
|
||||
}
|
||||
|
||||
// ── Edit Payment ─────────────────────────────────────────────────────────
|
||||
// -- Edit Payment ---------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
||||
@@ -805,7 +818,7 @@ public class BillsController : Controller
|
||||
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
@@ -850,11 +863,11 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// ── Void ─────────────────────────────────────────────────────────────────
|
||||
// -- Void -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
||||
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
||||
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
||||
@@ -909,7 +922,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── AJAX: Vendor default expense account ────────────────────────────────
|
||||
// -- AJAX: Vendor default expense account --------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
||||
@@ -927,7 +940,7 @@ public class BillsController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
// -- Helpers --------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
||||
@@ -966,7 +979,7 @@ public class BillsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||
/// records are included in the scan so payment numbers are never reused.
|
||||
/// </summary>
|
||||
private async Task<string> GeneratePaymentNumberAsync()
|
||||
@@ -981,7 +994,7 @@ public class BillsController : Controller
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
// ── Receipt File: Download / Remove ─────────────────────────────────────
|
||||
// -- Receipt File: Download / Remove -------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
||||
@@ -1009,7 +1022,7 @@ public class BillsController : Controller
|
||||
/// window where the UI shows a broken attachment link.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
public async Task<IActionResult> RemoveReceipt(int id)
|
||||
{
|
||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||||
@@ -1026,7 +1039,7 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
|
||||
// -- AI: Receipt Scanning -------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
||||
@@ -1038,7 +1051,7 @@ public class BillsController : Controller
|
||||
/// model can match categories to the company's specific chart of accounts.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
||||
{
|
||||
@@ -1079,7 +1092,7 @@ public class BillsController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── AI: Account Suggestion ────────────────────────────────────────────────
|
||||
// -- AI: Account Suggestion ------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
||||
@@ -1090,7 +1103,7 @@ public class BillsController : Controller
|
||||
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
||||
{
|
||||
@@ -1123,7 +1136,69 @@ public class BillsController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// ── Receipt File Helpers ──────────────────────────────────────────────────
|
||||
// -- AI: Recurring Bill Detection ------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
|
||||
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
||||
/// </summary>
|
||||
public IActionResult RecurringDetection() => View();
|
||||
|
||||
/// <summary>
|
||||
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
|
||||
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
||||
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
||||
/// Results are returned as JSON for client-side rendering in the view.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RunRecurringDetection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var cutoff = DateTime.Today.AddMonths(-12);
|
||||
|
||||
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||||
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||
.ToList();
|
||||
|
||||
if (!bills.Any())
|
||||
return Json(new RecurringBillDetectionResult
|
||||
{
|
||||
Success = true,
|
||||
Insights = new List<string> { "No bill history found in the last 12 months." }
|
||||
});
|
||||
|
||||
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
|
||||
|
||||
var request = new RecurringBillDetectionRequest
|
||||
{
|
||||
CompanyName = companyName,
|
||||
Bills = bills.Select(b => new RecurringBillHistoryItem
|
||||
{
|
||||
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
|
||||
BillNumber = b.BillNumber,
|
||||
Amount = b.Total,
|
||||
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
|
||||
Memo = b.Memo
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var result = await _accountingAi.DetectRecurringBillsAsync(request);
|
||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
|
||||
return Json(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error running recurring bill detection");
|
||||
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
|
||||
}
|
||||
}
|
||||
|
||||
// -- Receipt File Helpers --------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a receipt file to Azure Blob Storage under the path
|
||||
|
||||
@@ -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 sortDirection = "asc",
|
||||
int pageNumber = 1,
|
||||
int pageSize = 25)
|
||||
int pageSize = 25,
|
||||
bool showChurned = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
||||
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||
|
||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||
|
||||
@@ -82,6 +83,8 @@ public class CompaniesController : Controller
|
||||
{
|
||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||
var companyById = companies.ToDictionary(c => c.Id);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
foreach (var dto in companyDtos)
|
||||
{
|
||||
@@ -95,6 +98,23 @@ public class CompaniesController : Controller
|
||||
dto.WizardCompletedAt = w.CompletedAt;
|
||||
dto.WizardCompletedByName = w.CompletedByName;
|
||||
}
|
||||
|
||||
// Health badge
|
||||
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
|
||||
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
|
||||
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
|
||||
|
||||
if (companyById.TryGetValue(dto.Id, out var co))
|
||||
{
|
||||
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
|
||||
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
|
||||
&& dto.CreatedAt < now.AddDays(-7);
|
||||
dto.HealthScore = score;
|
||||
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
|
||||
}
|
||||
|
||||
dto.LastLoginDate = lastLogin;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +129,8 @@ public class CompaniesController : Controller
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||
ViewBag.ShowChurned = showChurned;
|
||||
ViewBag.ChurnedCount = churnedCount;
|
||||
|
||||
return View(companyDtos);
|
||||
}
|
||||
@@ -183,7 +205,8 @@ public class CompaniesController : Controller
|
||||
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||
c => c.Users,
|
||||
c => c.Customers,
|
||||
c => c.Jobs);
|
||||
c => c.Jobs,
|
||||
c => c.Preferences!);
|
||||
|
||||
if (company == null)
|
||||
{
|
||||
@@ -196,6 +219,51 @@ public class CompaniesController : Controller
|
||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
||||
|
||||
// Health data
|
||||
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
|
||||
var now = DateTime.UtcNow;
|
||||
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
|
||||
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
|
||||
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
|
||||
var totalJobs = companyDto.JobCount;
|
||||
var totalCust = companyDto.CustomerCount;
|
||||
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
|
||||
|
||||
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
|
||||
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
|
||||
&& company.CreatedAt < now.AddDays(-7);
|
||||
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
|
||||
|
||||
ViewBag.HealthScore = healthScore;
|
||||
ViewBag.HealthRisk = riskLevel.ToString();
|
||||
ViewBag.HealthSignals = healthSignals;
|
||||
ViewBag.Jobs30 = j30;
|
||||
ViewBag.Jobs90 = j90;
|
||||
ViewBag.LastLoginDate = lastLogin;
|
||||
|
||||
// Onboarding data (from Preferences)
|
||||
var prefs = company.Preferences;
|
||||
int steps = 0;
|
||||
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||
|
||||
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
|
||||
{
|
||||
CompanyId = company.Id,
|
||||
CompanyName = company.CompanyName ?? "",
|
||||
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
|
||||
OnboardingPath = prefs?.OnboardingPath,
|
||||
StepsCompleted = steps,
|
||||
TotalSteps = 3,
|
||||
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||
};
|
||||
|
||||
return View(companyDto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||
/// </para>
|
||||
/// </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 d30 = now.AddDays(-30);
|
||||
var d90 = now.AddDays(-90);
|
||||
var churnedCutoff = now.AddDays(-14);
|
||||
|
||||
// One query per signal — all keyed by CompanyId
|
||||
var companies = await _db.Companies
|
||||
var allCompanies = await _db.Companies
|
||||
.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted)
|
||||
.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
|
||||
.AsNoTracking().IgnoreQueryFilters()
|
||||
.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 planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
||||
|
||||
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||
|
||||
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||
&& c.CreatedAt < now.AddDays(-7);
|
||||
|
||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
||||
: score >= 75 ? ChurnRisk.Healthy
|
||||
: score >= 45 ? ChurnRisk.AtRisk
|
||||
: ChurnRisk.Critical;
|
||||
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||
|
||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||
@@ -166,6 +175,8 @@ public class CompanyHealthController : Controller
|
||||
ViewBag.Risk = risk;
|
||||
ViewBag.Search = search;
|
||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||
ViewBag.ShowChurned = showChurned;
|
||||
ViewBag.ChurnedCount = churnedCount;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
all = all.Where(h =>
|
||||
@@ -187,112 +198,10 @@ public class CompanyHealthController : Controller
|
||||
return View(all);
|
||||
}
|
||||
|
||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a
|
||||
/// single company based on its subscription status, login recency, and job activity.
|
||||
/// <para>
|
||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
||||
/// <list type="bullet">
|
||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
||||
/// <item>Subscription within grace period: −30 pts.</item>
|
||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
||||
/// </list>
|
||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static (int score, List<string> signals) ComputeHealth(
|
||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
||||
int j30, int j90, int totalJobs, DateTime now)
|
||||
{
|
||||
var score = 100;
|
||||
var signals = new List<string>();
|
||||
|
||||
if (!c.IsActive)
|
||||
{
|
||||
signals.Add("Account disabled");
|
||||
return (0, signals);
|
||||
}
|
||||
|
||||
// Subscription health (skip for comped)
|
||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||
{
|
||||
score -= 50;
|
||||
signals.Add("Subscription expired");
|
||||
}
|
||||
else if (daysUntil < 0)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("In grace period");
|
||||
}
|
||||
else if (daysUntil <= 7)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
else if (daysUntil <= 14)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
}
|
||||
|
||||
// Login activity
|
||||
if (daysSinceLogin == -1)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("Never logged in");
|
||||
}
|
||||
else if (daysSinceLogin >= 90)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 60)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 30)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
|
||||
// Job activity
|
||||
if (totalJobs == 0)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add("No jobs ever");
|
||||
}
|
||||
else if (j90 == 0)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add("No jobs in 90d");
|
||||
}
|
||||
else if (j30 == 0)
|
||||
{
|
||||
score -= 5;
|
||||
signals.Add("No jobs in 30d");
|
||||
}
|
||||
|
||||
return (Math.Max(0, score), signals);
|
||||
}
|
||||
}
|
||||
|
||||
// ── View models ────────────────────────────────────────────────────────────────
|
||||
|
||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||
|
||||
public class CompanyHealthDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||
|
||||
/// <summary>
|
||||
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||
/// </summary>
|
||||
public static class CompanyHealthHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a single
|
||||
/// company based on its subscription status, login recency, and job activity.
|
||||
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||
/// </summary>
|
||||
public static (int Score, List<string> Signals) ComputeHealth(
|
||||
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||
{
|
||||
var score = 100;
|
||||
var signals = new List<string>();
|
||||
|
||||
if (!c.IsActive)
|
||||
{
|
||||
signals.Add("Account disabled");
|
||||
return (0, signals);
|
||||
}
|
||||
|
||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||
{
|
||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||
{
|
||||
score -= 50;
|
||||
signals.Add("Subscription expired");
|
||||
}
|
||||
else if (daysUntil < 0)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("In grace period");
|
||||
}
|
||||
else if (daysUntil <= 7)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
else if (daysUntil <= 14)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"Expires in {daysUntil}d");
|
||||
}
|
||||
}
|
||||
|
||||
if (daysSinceLogin == -1)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add("Never logged in");
|
||||
}
|
||||
else if (daysSinceLogin >= 90)
|
||||
{
|
||||
score -= 30;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 60)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
else if (daysSinceLogin >= 30)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add($"No login {daysSinceLogin}d");
|
||||
}
|
||||
|
||||
if (totalJobs == 0)
|
||||
{
|
||||
score -= 20;
|
||||
signals.Add("No jobs ever");
|
||||
}
|
||||
else if (j90 == 0)
|
||||
{
|
||||
score -= 10;
|
||||
signals.Add("No jobs in 90d");
|
||||
}
|
||||
else if (j30 == 0)
|
||||
{
|
||||
score -= 5;
|
||||
signals.Add("No jobs in 30d");
|
||||
}
|
||||
|
||||
return (Math.Max(0, score), signals);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||
/// </summary>
|
||||
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||
neverActivated ? ChurnRisk.NeverActivated
|
||||
: score >= 75 ? ChurnRisk.Healthy
|
||||
: score >= 45 ? ChurnRisk.AtRisk
|
||||
: ChurnRisk.Critical;
|
||||
}
|
||||
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
|
||||
UpdatedAt = t.UpdatedAt
|
||||
}).ToList();
|
||||
|
||||
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
|
||||
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
|
||||
: null;
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (FormatException fex)
|
||||
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the books through the given date, preventing new or edited accounting entries
|
||||
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return BadRequest();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
company.BookLockedThrough = lockThrough.HasValue
|
||||
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
|
||||
: null;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = lockThrough.HasValue
|
||||
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
|
||||
: "Period lock cleared — all periods are now open.";
|
||||
|
||||
return RedirectToAction(nameof(Index), null, "company-info");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
||||
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
||||
@@ -511,6 +543,15 @@ public class CompanySettingsController : Controller
|
||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||
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>
|
||||
/// 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 —
|
||||
@@ -2653,6 +2694,7 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
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(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
|
||||
}
|
||||
|
||||
if (type == NotificationType.PaymentReceived)
|
||||
|
||||
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin,
|
||||
AppConstants.CompanyRoles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant,
|
||||
AppConstants.CompanyRoles.Worker,
|
||||
AppConstants.CompanyRoles.Viewer
|
||||
};
|
||||
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
|
||||
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
||||
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
||||
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
||||
CanViewReports = forceAllPermissions || model.CanViewReports
|
||||
CanViewReports = forceAllPermissions || model.CanViewReports,
|
||||
CanManageBills = forceAllPermissions || model.CanManageBills,
|
||||
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, model.Password);
|
||||
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
|
||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||
_ => AppConstants.Roles.ReadOnly
|
||||
};
|
||||
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
|
||||
CanManageVendors = user.CanManageVendors,
|
||||
CanManageMaintenance = user.CanManageMaintenance,
|
||||
CanManageInvoices = user.CanManageInvoices,
|
||||
CanViewReports = user.CanViewReports
|
||||
CanViewReports = user.CanViewReports,
|
||||
CanManageBills = user.CanManageBills,
|
||||
CanManageAccounting = user.CanManageAccounting
|
||||
};
|
||||
|
||||
ViewBag.ReturnUrl = returnUrl;
|
||||
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
|
||||
{
|
||||
AppConstants.CompanyRoles.CompanyAdmin,
|
||||
AppConstants.CompanyRoles.Manager,
|
||||
AppConstants.CompanyRoles.Accountant,
|
||||
AppConstants.CompanyRoles.Worker,
|
||||
AppConstants.CompanyRoles.Viewer
|
||||
};
|
||||
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
|
||||
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
||||
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
||||
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
||||
user.CanManageBills = forceAllPermissions || model.CanManageBills;
|
||||
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
|
||||
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
||||
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||
/// customer.CreditBalance atomically inside a transaction.
|
||||
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
|
||||
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
|
||||
/// a revenue deduction and an AR reduction.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class CreditMemosController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<CreditMemosController> _logger;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public CreditMemosController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<CreditMemosController> logger,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(string? status, string? search)
|
||||
{
|
||||
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => true, false,
|
||||
m => m.Customer);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
memos = memos.Where(m =>
|
||||
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
|
||||
memos = memos.Where(m => m.Status == parsed).ToList();
|
||||
|
||||
ViewBag.Status = status ?? "";
|
||||
ViewBag.Search = search ?? "";
|
||||
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
|
||||
ViewBag.OutstandingBalance = memos
|
||||
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
|
||||
.Sum(m => m.RemainingBalance);
|
||||
|
||||
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
|
||||
id, false,
|
||||
m => m.Customer,
|
||||
m => m.OriginalInvoice,
|
||||
m => m.IssuedBy);
|
||||
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
|
||||
a => a.CreditMemoId == id, false,
|
||||
a => a.Invoice,
|
||||
a => a.AppliedBy);
|
||||
|
||||
var openInvoices = await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.CustomerId == memo.CustomerId
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff);
|
||||
|
||||
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
|
||||
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
|
||||
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
|
||||
&& memo.RemainingBalance > 0;
|
||||
|
||||
return View(memo);
|
||||
}
|
||||
|
||||
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
|
||||
{
|
||||
string? linkedInvoiceNumber = null;
|
||||
if (invoiceId.HasValue)
|
||||
{
|
||||
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
|
||||
if (inv != null)
|
||||
{
|
||||
linkedInvoiceNumber = inv.InvoiceNumber;
|
||||
customerId ??= inv.CustomerId;
|
||||
}
|
||||
}
|
||||
|
||||
await PopulateCustomersAsync(customerId);
|
||||
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
|
||||
|
||||
return View(new CreditMemoCreateVm
|
||||
{
|
||||
CustomerId = customerId ?? 0,
|
||||
OriginalInvoiceId = invoiceId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
|
||||
/// credit is visible on the customer account before it is applied to any specific invoice.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCustomersAsync(vm.CustomerId);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
|
||||
if (customer == null)
|
||||
{
|
||||
ModelState.AddModelError("CustomerId", "Customer not found.");
|
||||
await PopulateCustomersAsync(null);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var memoNumber = await GenerateMemoNumberAsync(companyId);
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var memo = new CreditMemo
|
||||
{
|
||||
MemoNumber = memoNumber,
|
||||
CustomerId = vm.CustomerId,
|
||||
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
|
||||
Amount = vm.Amount,
|
||||
AmountApplied = 0,
|
||||
IssueDate = DateTime.UtcNow,
|
||||
ExpiryDate = vm.ExpiryDate.HasValue
|
||||
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
|
||||
: null,
|
||||
Reason = vm.Reason,
|
||||
Notes = vm.Notes,
|
||||
Status = CreditMemoStatus.Active,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.CreditMemos.AddAsync(memo);
|
||||
|
||||
customer.CreditBalance += vm.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
|
||||
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
|
||||
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
|
||||
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
|
||||
if (invoice == null)
|
||||
{
|
||||
TempData["Error"] = "Invoice not found.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is not available to apply.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
|
||||
if (applyAmount <= 0)
|
||||
{
|
||||
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
|
||||
{
|
||||
CreditMemoId = id,
|
||||
InvoiceId = invoiceId,
|
||||
AmountApplied = applyAmount,
|
||||
AppliedDate = DateTime.UtcNow,
|
||||
AppliedById = currentUser?.Id,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
invoice.CreditApplied += applyAmount;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
|
||||
memo.AmountApplied += applyAmount;
|
||||
memo.Status = memo.AmountApplied >= memo.Amount
|
||||
? CreditMemoStatus.FullyApplied
|
||||
: CreditMemoStatus.PartiallyApplied;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (invoice.Customer != null)
|
||||
{
|
||||
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
|
||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||
}
|
||||
|
||||
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
|
||||
{
|
||||
invoice.Status = InvoiceStatus.Paid;
|
||||
invoice.PaidDate = DateTime.UtcNow;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
||||
// The dynamic report computation attributes credit memo applications to both
|
||||
// accounts already; this call keeps Account.CurrentBalance in sync for
|
||||
// RecalculateAllAsync and any tools that read it directly.
|
||||
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "4950" && a.IsActive)
|
||||
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive
|
||||
&& a.Name.ToLower().Contains("discount"));
|
||||
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
|
||||
TempData["Error"] = "An error occurred applying the credit.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
|
||||
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
|
||||
/// settled and form part of the immutable audit trail.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Void(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
if (memo.Status == CreditMemoStatus.Voided)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is already voided.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var remaining = memo.Amount - memo.AmountApplied;
|
||||
memo.Status = CreditMemoStatus.Voided;
|
||||
memo.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (remaining > 0 && memo.Customer != null)
|
||||
{
|
||||
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
|
||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
private async Task PopulateCustomersAsync(int? selectedId)
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
ViewBag.Customers = customers
|
||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||
.Select(c => new SelectListItem
|
||||
{
|
||||
Value = c.Id.ToString(),
|
||||
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
|
||||
Selected = c.Id == selectedId
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential memo number in CM-YYMM-#### format.
|
||||
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateMemoNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
var existing = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
|
||||
.Select(m => m.MemoNumber)
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
{
|
||||
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
|
||||
if (int.TryParse(suffix, out int n) && n > maxNum)
|
||||
maxNum = n;
|
||||
}
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Customer? c) =>
|
||||
c == null ? string.Empty :
|
||||
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
}
|
||||
|
||||
public class CreditMemoCreateVm
|
||||
{
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
|
||||
public int? OriginalInvoiceId { get; set; }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class CustomersController : Controller
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
|
||||
public CustomersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -34,7 +35,8 @@ public class CustomersController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IFinancialReportService financialReports)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -43,6 +45,7 @@ public class CustomersController : Controller
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_financialReports = financialReports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -935,6 +938,30 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||
|
||||
@@ -368,6 +368,9 @@ public class DashboardController : Controller
|
||||
|
||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
||||
|
||||
var companyForKiosk = await _unitOfWork.Companies.GetByIdAsync(currentCompanyId.Value);
|
||||
ViewBag.KioskActivated = !string.IsNullOrEmpty(companyForKiosk?.KioskActivationToken);
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
|
||||
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -22,17 +23,20 @@ public class DepositsController : Controller
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<DepositsController> _logger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public DepositsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<DepositsController> logger,
|
||||
ICompanyLogoService logoService)
|
||||
ICompanyLogoService logoService,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_logoService = logoService;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -76,6 +80,7 @@ public class DepositsController : Controller
|
||||
if (currentUser == null) return Unauthorized();
|
||||
|
||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||
|
||||
var deposit = new Deposit
|
||||
{
|
||||
@@ -88,6 +93,7 @@ public class DepositsController : Controller
|
||||
ReceivedDate = receivedDate,
|
||||
Reference = reference,
|
||||
Notes = notes,
|
||||
DepositAccountId = checkingAcctId,
|
||||
RecordedById = currentUser.Id,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -97,6 +103,11 @@ public class DepositsController : Controller
|
||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
|
||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
@@ -137,6 +148,11 @@ public class DepositsController : Controller
|
||||
if (deposit.AppliedToInvoiceId != null)
|
||||
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
|
||||
|
||||
// Reverse the GL entry made at recording time: CR Checking / DR Customer Deposits 2300.
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(deposit.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(deposit.DepositAccountId, deposit.Amount);
|
||||
await _accountBalanceService.DebitAsync(custDepositsAcctId, deposit.Amount);
|
||||
|
||||
await _unitOfWork.Deposits.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -419,6 +435,24 @@ public class DepositsController : Controller
|
||||
return hex.StartsWith("#") ? hex : fallback;
|
||||
}
|
||||
|
||||
/// <summary>Returns the first active Checking or Cash account for the company, or null.</summary>
|
||||
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive
|
||||
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns account 2300 "Customer Deposits" liability for the company, or null.</summary>
|
||||
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Reflection;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Roles = "SuperAdmin,Administrator")]
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class DiagnosticsController : Controller
|
||||
{
|
||||
private readonly ILogger<DiagnosticsController> _logger;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
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 fixed asset register. Tracks depreciable assets (ovens, blast cabinets,
|
||||
/// vehicles, etc.) using straight-line depreciation. PostDepreciation auto-generates
|
||||
/// Journal Entries for a selected month, crediting Accumulated Depreciation and debiting
|
||||
/// Depreciation Expense.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class FixedAssetsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
private readonly ILogger<FixedAssetsController> _logger;
|
||||
|
||||
public FixedAssetsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
ILogger<FixedAssetsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all fixed assets for the current company with depreciation summary.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => true, false,
|
||||
fa => fa.AssetAccount,
|
||||
fa => fa.DepreciationExpenseAccount,
|
||||
fa => fa.AccumDepreciationAccount);
|
||||
|
||||
ViewBag.TotalCost = assets.Sum(a => a.PurchaseCost);
|
||||
ViewBag.TotalAccumDeprec = assets.Sum(a => a.AccumulatedDepreciation);
|
||||
ViewBag.TotalBookValue = assets.Sum(a => a.BookValue);
|
||||
ViewBag.ActiveCount = assets.Count(a => !a.IsDisposed);
|
||||
|
||||
return View(assets.OrderBy(a => a.PurchaseDate).ToList());
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(
|
||||
id, false,
|
||||
fa => fa.AssetAccount,
|
||||
fa => fa.DepreciationExpenseAccount,
|
||||
fa => fa.AccumDepreciationAccount);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
var entries = await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(
|
||||
e => e.FixedAssetId == id, false,
|
||||
e => e.JournalEntry);
|
||||
|
||||
ViewBag.Entries = entries.OrderByDescending(e => e.PeriodYear).ThenByDescending(e => e.PeriodMonth).ToList();
|
||||
ViewBag.MonthsRemaining = Math.Max(0, asset.UsefulLifeMonths - entries.Count(e => e.Amount > 0));
|
||||
ViewBag.FullyDepreciated = asset.AccumulatedDepreciation >= (asset.PurchaseCost - asset.SalvageValue);
|
||||
|
||||
return View(asset);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(new FixedAssetVm { PurchaseDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(FixedAssetVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var asset = new FixedAsset
|
||||
{
|
||||
Name = vm.Name,
|
||||
Description = vm.Description,
|
||||
PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc),
|
||||
PurchaseCost = vm.PurchaseCost,
|
||||
SalvageValue = vm.SalvageValue,
|
||||
UsefulLifeMonths = vm.UsefulLifeMonths,
|
||||
AccumulatedDepreciation = vm.AccumulatedDepreciation,
|
||||
AssetAccountId = vm.AssetAccountId,
|
||||
DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId,
|
||||
AccumDepreciationAccountId = vm.AccumDepreciationAccountId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.FixedAssets.AddAsync(asset);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" added.";
|
||||
return RedirectToAction(nameof(Details), new { id = asset.Id });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
await PopulateAccountsAsync();
|
||||
return View(new FixedAssetVm
|
||||
{
|
||||
Id = asset.Id,
|
||||
Name = asset.Name,
|
||||
Description = asset.Description,
|
||||
PurchaseDate = asset.PurchaseDate.ToLocalTime(),
|
||||
PurchaseCost = asset.PurchaseCost,
|
||||
SalvageValue = asset.SalvageValue,
|
||||
UsefulLifeMonths = asset.UsefulLifeMonths,
|
||||
AccumulatedDepreciation = asset.AccumulatedDepreciation,
|
||||
AssetAccountId = asset.AssetAccountId,
|
||||
DepreciationExpenseAccountId = asset.DepreciationExpenseAccountId,
|
||||
AccumDepreciationAccountId = asset.AccumDepreciationAccountId,
|
||||
IsDisposed = asset.IsDisposed,
|
||||
DisposalDate = asset.DisposalDate?.ToLocalTime()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, FixedAssetVm vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
asset.Name = vm.Name;
|
||||
asset.Description = vm.Description;
|
||||
asset.PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc);
|
||||
asset.PurchaseCost = vm.PurchaseCost;
|
||||
asset.SalvageValue = vm.SalvageValue;
|
||||
asset.UsefulLifeMonths = vm.UsefulLifeMonths;
|
||||
asset.AccumulatedDepreciation = vm.AccumulatedDepreciation;
|
||||
asset.AssetAccountId = vm.AssetAccountId;
|
||||
asset.DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId;
|
||||
asset.AccumDepreciationAccountId = vm.AccumDepreciationAccountId;
|
||||
asset.IsDisposed = vm.IsDisposed;
|
||||
asset.DisposalDate = vm.IsDisposed && vm.DisposalDate.HasValue
|
||||
? DateTime.SpecifyKind(vm.DisposalDate.Value, DateTimeKind.Utc)
|
||||
: null;
|
||||
asset.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" updated.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts straight-line depreciation for all active assets for the specified year/month.
|
||||
/// Skips assets that have already been depreciated for the period, assets without GL accounts,
|
||||
/// and fully-depreciated assets (BookValue ≤ SalvageValue). Creates one JE per asset.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostDepreciation(int year, int month)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => !fa.IsDisposed, false,
|
||||
fa => fa.DepreciationEntries);
|
||||
|
||||
int posted = 0, skipped = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
// Skip assets missing required GL accounts
|
||||
if (!asset.DepreciationExpenseAccountId.HasValue || !asset.AccumDepreciationAccountId.HasValue)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip already posted for this period
|
||||
if (asset.DepreciationEntries.Any(e => e.PeriodYear == year && e.PeriodMonth == month && !e.IsDeleted))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var depreciableBase = asset.PurchaseCost - asset.SalvageValue;
|
||||
var remaining = depreciableBase - asset.AccumulatedDepreciation;
|
||||
|
||||
// Skip fully depreciated assets
|
||||
if (remaining <= 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't over-depreciate in the final period
|
||||
var amount = Math.Min(asset.MonthlyDepreciation, remaining);
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// GL: DR Depreciation Expense / CR Accumulated Depreciation
|
||||
await _accountBalanceService.DebitAsync(asset.DepreciationExpenseAccountId, amount);
|
||||
await _accountBalanceService.CreditAsync(asset.AccumDepreciationAccountId, amount);
|
||||
|
||||
// Post JE
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = await GenerateJeNumberAsync(companyId),
|
||||
EntryDate = new DateTime(year, month, DateTime.DaysInMonth(year, month), 0, 0, 0, DateTimeKind.Utc),
|
||||
Description = $"Depreciation — {asset.Name} ({month:D2}/{year})",
|
||||
Reference = asset.Name,
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = currentUser?.Email,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = new List<JournalEntryLine>
|
||||
{
|
||||
new() { AccountId = asset.DepreciationExpenseAccountId!.Value, DebitAmount = amount, CreditAmount = 0, Description = $"Depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow },
|
||||
new() { AccountId = asset.AccumDepreciationAccountId!.Value, DebitAmount = 0, CreditAmount = amount, Description = $"Accum. depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }
|
||||
}
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Record the depreciation entry
|
||||
var entry = new FixedAssetDepreciationEntry
|
||||
{
|
||||
FixedAssetId = asset.Id,
|
||||
PeriodYear = year,
|
||||
PeriodMonth = month,
|
||||
Amount = amount,
|
||||
JournalEntryId = je.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.FixedAssetDepreciationEntries.AddAsync(entry);
|
||||
|
||||
asset.AccumulatedDepreciation += amount;
|
||||
asset.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
posted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error posting depreciation for asset {AssetId}", asset.Id);
|
||||
errors.Add($"{asset.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Any())
|
||||
TempData["Error"] = $"Posted {posted}, skipped {skipped}. Errors: {string.Join("; ", errors)}";
|
||||
else
|
||||
TempData["Success"] = $"Depreciation posted: {posted} asset(s) for {new DateTime(year, month, 1):MMMM yyyy}. {skipped} skipped (already posted, no GL accounts, or fully depreciated).";
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
var hasEntries = (await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(e => e.FixedAssetId == id)).Any();
|
||||
if (hasEntries)
|
||||
{
|
||||
TempData["Error"] = "Cannot delete an asset with depreciation history. Mark it as disposed instead.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.FixedAssets.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
private async Task PopulateAccountsAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
|
||||
|
||||
ViewBag.AssetAccounts = list
|
||||
.Where(a => a.AccountType == AccountType.Asset)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.ExpenseAccounts = list
|
||||
.Where(a => a.AccountType == AccountType.Expense)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
// Accumulated depreciation typically lives as a negative Asset (contra-asset)
|
||||
ViewBag.AccumDeprecAccounts = list
|
||||
.Where(a => a.AccountType is AccountType.Asset or AccountType.Expense)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Generates next JE number in JE-YYMM-#### format, ignoring soft-deleted entries.</summary>
|
||||
private async Task<string> GenerateJeNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"JE-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
int next = all.Any()
|
||||
? all.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||
: 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
}
|
||||
|
||||
public class FixedAssetVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime PurchaseDate { get; set; } = DateTime.Today;
|
||||
|
||||
[Required, Range(0.01, double.MaxValue, ErrorMessage = "Purchase cost must be greater than zero.")]
|
||||
public decimal PurchaseCost { get; set; }
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
|
||||
[Required, Range(1, 600, ErrorMessage = "Useful life must be between 1 and 600 months.")]
|
||||
public int UsefulLifeMonths { get; set; } = 60;
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
|
||||
public int? AssetAccountId { get; set; }
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IPdfService _pdfService;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public GiftCertificatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
|
||||
ILogger<GiftCertificatesController> logger,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IPdfService pdfService,
|
||||
ICompanyLogoService logoService)
|
||||
ICompanyLogoService logoService,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
|
||||
_userManager = userManager;
|
||||
_pdfService = pdfService;
|
||||
_logoService = logoService;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
|
||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: CR Gift Certificate Liability (2500) for the face value.
|
||||
// Debit side varies by reason:
|
||||
// Sold → DR Checking (received cash outside invoice flow)
|
||||
// Others → DR Sales Discounts 4950 (promotional/goodwill cost)
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
{
|
||||
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "4950");
|
||||
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
|
||||
return RedirectToAction(nameof(Details), new { id = cert.Id });
|
||||
}
|
||||
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var remaining = cert.RemainingBalance;
|
||||
cert.Status = GiftCertificateStatus.Voided;
|
||||
cert.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR GC Liability / CR Other Income (breakage — the company keeps the unredeemed amount)
|
||||
if (remaining > 0)
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||||
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
|
||||
ViewBag.Customers = list;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
|
||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "2500");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
|
||||
@@ -125,5 +125,13 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Customer Intake Kiosk help article explaining the tablet kiosk setup, the staff-triggered intake flow, and the Intakes review page.
|
||||
/// </summary>
|
||||
public IActionResult CustomerIntakeKiosk()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,32 @@ public class InventoryController : Controller
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Contribute/sync to the platform powder catalog if we have enough identity data.
|
||||
// Runs silently — a failure here never blocks the inventory save.
|
||||
if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber))
|
||||
{
|
||||
var catalogResult = new InventoryAiLookupResult
|
||||
{
|
||||
Manufacturer = dto.Manufacturer,
|
||||
ManufacturerPartNumber = dto.ManufacturerPartNumber,
|
||||
ColorName = dto.ColorName ?? item.Name,
|
||||
Finish = dto.Finish,
|
||||
CureTemperatureF = dto.CureTemperatureF,
|
||||
CureTimeMinutes = dto.CureTimeMinutes,
|
||||
ColorFamilies = dto.ColorFamilies,
|
||||
RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null,
|
||||
CoverageSqFtPerLb = dto.CoverageSqFtPerLb,
|
||||
SpecificGravity = dto.SpecificGravity,
|
||||
TransferEfficiency = dto.TransferEfficiency,
|
||||
UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null,
|
||||
SpecPageUrl = dto.SpecPageUrl,
|
||||
ImageUrl = dto.ImageUrl,
|
||||
SdsUrl = dto.SdsUrl,
|
||||
TdsUrl = dto.TdsUrl,
|
||||
};
|
||||
await EnrichFromCatalogAsync(catalogResult, autoContribute: true);
|
||||
}
|
||||
|
||||
TempData["Success"] = "Inventory item created successfully.";
|
||||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
||||
}
|
||||
@@ -704,6 +730,8 @@ public class InventoryController : Controller
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
if (result.Success)
|
||||
await EnrichFromCatalogAsync(result, autoContribute: true);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
@@ -750,6 +778,39 @@ public class InventoryController : Controller
|
||||
result.SdsUrl ??= match.SdsUrl;
|
||||
result.TdsUrl ??= match.TdsUrl;
|
||||
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
|
||||
|
||||
// Back-sync: fill NULL catalog fields from the incoming result so the catalog
|
||||
// gets richer over time without overwriting anything already stored.
|
||||
bool catalogDirty = false;
|
||||
if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; }
|
||||
if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; }
|
||||
if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; }
|
||||
if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; }
|
||||
if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; }
|
||||
if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; }
|
||||
if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; }
|
||||
if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; }
|
||||
if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; }
|
||||
if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; }
|
||||
|
||||
if (catalogDirty)
|
||||
{
|
||||
match.UpdatedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(match);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return (true, false);
|
||||
}
|
||||
|
||||
@@ -767,6 +828,7 @@ public class InventoryController : Controller
|
||||
VendorName = manufacturer,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
UnitPrice = result.UnitCostPerLb ?? 0m,
|
||||
CureTemperatureF = result.CureTemperatureF,
|
||||
CureTimeMinutes = result.CureTimeMinutes,
|
||||
Finish = result.Finish,
|
||||
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
|
||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||
.ToHashSet();
|
||||
|
||||
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
|
||||
// if the scoped search returns nothing — prevents a cross-vendor color match from
|
||||
// being returned as the only result when the user clearly intended a specific manufacturer.
|
||||
IEnumerable<PowderCatalogItem> matches;
|
||||
if (!string.IsNullOrEmpty(vendorTerm))
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.VendorName.ToLower().Contains(vendorTerm) && (
|
||||
p.Sku.ToLower() == term ||
|
||||
// Single query — all partial color/SKU matches across all vendors.
|
||||
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
|
||||
// triggers auto-fill in the JS. Everything else goes to the picker modal.
|
||||
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
|
||||
// only when that exact product is in the catalog; otherwise they see a ranked modal
|
||||
// with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
|
||||
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term)));
|
||||
|
||||
// Fall back to all vendors only when the scoped search finds nothing
|
||||
if (!matches.Any())
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
}
|
||||
|
||||
var results = matches
|
||||
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
||||
.ThenBy(p => p.ColorName)
|
||||
.Select(p => new
|
||||
.Select(p =>
|
||||
{
|
||||
id = p.Id,
|
||||
vendorName = p.VendorName,
|
||||
sku = p.Sku,
|
||||
colorName = p.ColorName,
|
||||
description = p.Description,
|
||||
unitPrice = p.UnitPrice,
|
||||
imageUrl = p.ImageUrl,
|
||||
sdsUrl = p.SdsUrl,
|
||||
tdsUrl = p.TdsUrl,
|
||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
||||
productUrl = p.ProductUrl,
|
||||
isDiscontinued = p.IsDiscontinued,
|
||||
cureTemperatureF = p.CureTemperatureF,
|
||||
cureTimeMinutes = p.CureTimeMinutes,
|
||||
finish = p.Finish,
|
||||
colorFamilies = p.ColorFamilies,
|
||||
requiresClearCoat = p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||||
specificGravity = p.SpecificGravity,
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
||||
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
|
||||
var colorExact = p.ColorName.ToLower() == term;
|
||||
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
|
||||
})
|
||||
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
|
||||
.ThenBy(x => x.p.ColorName)
|
||||
.Select(x => new
|
||||
{
|
||||
id = x.p.Id,
|
||||
vendorName = x.p.VendorName,
|
||||
sku = x.p.Sku,
|
||||
colorName = x.p.ColorName,
|
||||
description = x.p.Description,
|
||||
unitPrice = x.p.UnitPrice,
|
||||
imageUrl = x.p.ImageUrl,
|
||||
sdsUrl = x.p.SdsUrl,
|
||||
tdsUrl = x.p.TdsUrl,
|
||||
applicationGuideUrl = x.p.ApplicationGuideUrl,
|
||||
productUrl = x.p.ProductUrl,
|
||||
isDiscontinued = x.p.IsDiscontinued,
|
||||
isExact = x.isExact,
|
||||
cureTemperatureF = x.p.CureTemperatureF,
|
||||
cureTimeMinutes = x.p.CureTimeMinutes,
|
||||
finish = x.p.Finish,
|
||||
colorFamilies = x.p.ColorFamilies,
|
||||
requiresClearCoat = x.p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
|
||||
specificGravity = x.p.SpecificGravity,
|
||||
transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -276,6 +276,14 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
// Expense accounts for the write-off bad-debt modal
|
||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
ViewBag.ExpenseAccounts = expenseAccounts
|
||||
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
@@ -450,21 +458,36 @@ public class InvoicesController : Controller
|
||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
||||
}
|
||||
else if (hadJobItems && costs?.ShopSuppliesRate > 0)
|
||||
else if (hadJobItems)
|
||||
{
|
||||
// Direct job — no source quote. Derive shop supplies from the items subtotal
|
||||
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
|
||||
// from the quote snapshot instead; this path only fires when there is no quote.)
|
||||
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
|
||||
if (shopSuppliesAmount > 0.01m)
|
||||
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||
// recalculating, so the invoice always matches the total shown on the job page.
|
||||
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||
// OvenCostId) when job items are created or updated.
|
||||
if (job.OvenBatchCost > 0.01m)
|
||||
{
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
|
||||
Description = $"Oven Processing Fee",
|
||||
Quantity = 1,
|
||||
UnitPrice = shopSuppliesAmount,
|
||||
TotalPrice = shopSuppliesAmount,
|
||||
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
TotalPrice = Math.Round(job.OvenBatchCost, 2),
|
||||
DisplayOrder = order++,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
}
|
||||
|
||||
if (job.ShopSuppliesAmount > 0.01m)
|
||||
{
|
||||
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||
? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)"
|
||||
: "Shop Supplies";
|
||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||
{
|
||||
Description = suppliesDesc,
|
||||
Quantity = 1,
|
||||
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||
DisplayOrder = order,
|
||||
RevenueAccountId = defaultRevenueAccount?.Id
|
||||
});
|
||||
@@ -654,7 +677,9 @@ public class InvoicesController : Controller
|
||||
|
||||
foreach (var deposit in pendingDeposits)
|
||||
{
|
||||
// Create a Payment record for each deposit
|
||||
// DepositAccountId is intentionally null: the bank account was already debited
|
||||
// when the deposit was recorded (DR Checking / CR Customer Deposits 2300).
|
||||
// Setting it here would double-count the bank debit in the Trial Balance.
|
||||
var payment = new Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
@@ -663,6 +688,7 @@ public class InvoicesController : Controller
|
||||
PaymentMethod = deposit.PaymentMethod,
|
||||
Reference = $"Deposit {deposit.ReceiptNumber}",
|
||||
Notes = deposit.Notes,
|
||||
DepositAccountId = null,
|
||||
RecordedById = currentUser.Id,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -696,13 +722,31 @@ public class InvoicesController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Update account balances: debit AR, credit revenue accounts + sales tax
|
||||
// Update account balances: debit AR, credit revenue accounts + sales tax.
|
||||
// Discount contra-entry: DR Sales Discounts so the GL balances.
|
||||
// Without it, credits (revenue + tax) exceed the AR debit by the discount amount.
|
||||
var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(currentUser.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
// GL for auto-applied deposits: DR Customer Deposits 2300 (clears the liability) / CR AR.
|
||||
// The bank was already debited when the deposit was recorded, so Checking is not touched here.
|
||||
if (pendingDeposits.Any())
|
||||
{
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||
foreach (var dep in pendingDeposits)
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(custDepositsAcctId, dep.Amount);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, dep.Amount);
|
||||
}
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Auto-generate gift certificates for any GC line items
|
||||
@@ -850,8 +894,17 @@ public class InvoicesController : Controller
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// Recalculate totals (tax is applied after discount, consistent with quotes)
|
||||
// Capture GL state before any mutations so the reversal is exact.
|
||||
var oldTotal = invoice.Total;
|
||||
var oldTaxAmount = invoice.TaxAmount;
|
||||
var oldTaxAcctId = invoice.SalesTaxAccountId;
|
||||
var oldDiscountAmt = invoice.DiscountAmount;
|
||||
var oldItems = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted)
|
||||
.Select(i => (RevAcctId: i.RevenueAccountId, Price: i.TotalPrice))
|
||||
.ToList();
|
||||
|
||||
// Recalculate totals (tax is applied after discount, consistent with quotes)
|
||||
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||
var taxableAmount = subTotal - dto.DiscountAmount;
|
||||
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
||||
@@ -917,6 +970,31 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Reverse old GL entries then re-post new ones so account balances stay accurate.
|
||||
// Reversal is the mirror of the original Create double-entry: swap every Debit↔Credit.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
int? discAcctId = null;
|
||||
if (oldDiscountAmt > 0 || invoice.DiscountAmount > 0)
|
||||
discAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
|
||||
await _accountBalanceService.CreditAsync(arAccountId, oldTotal);
|
||||
foreach (var (revAcctId, price) in oldItems)
|
||||
await _accountBalanceService.DebitAsync(revAcctId, price);
|
||||
if (oldTaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(oldTaxAcctId, oldTaxAmount);
|
||||
if (oldDiscountAmt > 0)
|
||||
await _accountBalanceService.CreditAsync(discAcctId, oldDiscountAmt);
|
||||
|
||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(discAcctId, invoice.DiscountAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Invoice updated successfully.";
|
||||
|
||||
// Optionally re-send the updated invoice PDF to the customer
|
||||
@@ -925,11 +1003,18 @@ public class InvoicesController : Controller
|
||||
try
|
||||
{
|
||||
var currentUserForPdf = await _userManager.GetUserAsync(User);
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
{
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
|
||||
string? paymentUrl = null;
|
||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl);
|
||||
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
|
||||
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
this.SetNotificationResultToast(notifLog);
|
||||
}
|
||||
@@ -955,13 +1040,13 @@ public class InvoicesController : Controller
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
|
||||
/// fires the customer notification with a PDF attachment. Notification failure is caught
|
||||
/// separately and logged as a warning — a failed email must not roll back the status change.
|
||||
/// The payment URL is assembled from the generated token and the current request host so it
|
||||
/// works identically in dev (localhost) and production without config changes.
|
||||
/// fires the customer notification. Staff can choose email, SMS, or both via the modal.
|
||||
/// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
|
||||
/// only generated when Stripe Connect is active (expiring pay link for email/view page).
|
||||
/// Notification failure is caught separately — a failed send must not roll back the status change.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Send(int id, string? overrideEmail = null)
|
||||
public async Task<IActionResult> Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -980,27 +1065,39 @@ public class InvoicesController : Controller
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
|
||||
// Permanent view token — always generate so SMS always has a link
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
|
||||
await TryGeneratePaymentTokenAsync(invoice);
|
||||
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Generate PDF and send notification
|
||||
string? paymentUrl = null;
|
||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
|
||||
|
||||
bool pdfAndNotifSucceeded = false;
|
||||
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
|
||||
bool notifSucceeded = false;
|
||||
try
|
||||
{
|
||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
|
||||
pdfAndNotifSucceeded = true;
|
||||
byte[]? pdfBytes = null;
|
||||
if (sendEmail)
|
||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
|
||||
await _notificationService.NotifyInvoiceSentAsync(
|
||||
invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf",
|
||||
paymentUrl, overrideEmail: overrideEmail?.Trim(),
|
||||
sendSms: sendSms, viewUrl: viewUrl);
|
||||
|
||||
notifSucceeded = true;
|
||||
}
|
||||
catch (Exception notifyEx)
|
||||
{
|
||||
_logger.LogError(notifyEx,
|
||||
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " +
|
||||
"Invoice {InvoiceId} ({InvoiceNumber}): notification failed. " +
|
||||
"Inner: {InnerMessage}. Invoice status was already saved as Sent.",
|
||||
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
||||
}
|
||||
@@ -1009,8 +1106,8 @@ public class InvoicesController : Controller
|
||||
this.SetNotificationResultToast(notifLog);
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
||||
if (!pdfAndNotifSucceeded)
|
||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
|
||||
if (!notifSucceeded)
|
||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your configuration.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1324,29 +1421,49 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||
}
|
||||
|
||||
// Void any gift certificates that were generated from this invoice
|
||||
var gcItemIds = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)
|
||||
.Select(i => i.GeneratedGiftCertificateId!.Value)
|
||||
.ToList();
|
||||
foreach (var gcId in gcItemIds)
|
||||
// Void any gift certificates that were generated from this invoice.
|
||||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
|
||||
foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
|
||||
{
|
||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId);
|
||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
|
||||
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
||||
{
|
||||
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
|
||||
gc.Status = GiftCertificateStatus.Voided;
|
||||
gc.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.GiftCertificates.UpdateAsync(gc);
|
||||
}
|
||||
// FullyRedeemed GCs: not voided, nothing to reverse (GC Liability already at 0).
|
||||
}
|
||||
|
||||
// Reverse account balances: credit AR (open balance), debit revenue + sales tax
|
||||
// Reverse account balances: credit AR (open balance), debit revenue + sales tax.
|
||||
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
|
||||
// GC line items: instead of debiting revenue (which was already reclassified to GC Liability
|
||||
// at creation), debit GC Liability for the unredeemed portion, netting the obligation to 0.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
{
|
||||
if (item.IsGiftCertificate)
|
||||
{
|
||||
// GC item: debit GC Liability for unredeemed portion; skip fully-redeemed items.
|
||||
if (gcLiabilityAcctId.HasValue && gcRemainingByItemId.TryGetValue(item.Id, out var remaining) && remaining > 0)
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
|
||||
invoice.Status = InvoiceStatus.Voided;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -1366,6 +1483,113 @@ public class InvoicesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST: /Invoices/WriteOff/5
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Writes off an uncollectible invoice. Posts a GL journal entry:
|
||||
/// DR Bad Debt Expense (user-selected account) for the remaining BalanceDue
|
||||
/// CR Accounts Receivable for the same amount
|
||||
/// Then marks the invoice WrittenOff and reduces customer.CurrentBalance.
|
||||
/// Only the outstanding BalanceDue is written off; amounts already collected are unaffected.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> WriteOff(int id, int? expenseAccountId, string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await LoadInvoiceForViewAsync(id);
|
||||
if (invoice == null) return NotFound();
|
||||
|
||||
if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Voided or InvoiceStatus.WrittenOff)
|
||||
{
|
||||
TempData["Error"] = "Invoice cannot be written off in its current status.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var balanceDue = invoice.BalanceDue;
|
||||
if (balanceDue <= 0)
|
||||
{
|
||||
TempData["Error"] = "Invoice has no outstanding balance to write off.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
var badDebtAccountId = expenseAccountId > 0
|
||||
? expenseAccountId
|
||||
: await GetBadDebtAccountIdAsync(invoice.CompanyId);
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// GL: DR Bad Debt Expense / CR AR
|
||||
await _accountBalanceService.DebitAsync(badDebtAccountId, balanceDue);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||
|
||||
// Post a supporting JE for the audit trail
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = await GenerateJournalEntryNumberAsync(invoice.CompanyId),
|
||||
EntryDate = DateTime.UtcNow,
|
||||
Description = $"Write-off of invoice {invoice.InvoiceNumber}{(string.IsNullOrWhiteSpace(notes) ? "" : $" — {notes}")}",
|
||||
Reference = invoice.InvoiceNumber,
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = currentUser?.Email,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = new List<JournalEntryLine>
|
||||
{
|
||||
new JournalEntryLine
|
||||
{
|
||||
AccountId = badDebtAccountId ?? 0,
|
||||
Description = $"Bad debt — invoice {invoice.InvoiceNumber}",
|
||||
DebitAmount = balanceDue,
|
||||
CreditAmount = 0,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new JournalEntryLine
|
||||
{
|
||||
AccountId = arAccountId ?? 0,
|
||||
Description = $"Write-off AR — invoice {invoice.InvoiceNumber}",
|
||||
DebitAmount = 0,
|
||||
CreditAmount = balanceDue,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
}
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
|
||||
// Reduce customer running balance
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
|
||||
if (customer != null)
|
||||
{
|
||||
customer.CurrentBalance = Math.Max(0, customer.CurrentBalance - balanceDue);
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
invoice.Status = InvoiceStatus.WrittenOff;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} written off ({balanceDue:C} posted to Bad Debt Expense).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error writing off invoice {Id}", id);
|
||||
TempData["Error"] = "An error occurred while writing off the invoice.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET: /Invoices/DownloadPdf/5
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1591,13 +1815,30 @@ public class InvoicesController : Controller
|
||||
deposit.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax
|
||||
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax.
|
||||
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
// Reverse deposit-apply GL: DR AR / CR Customer Deposits 2300 for each previously applied
|
||||
// deposit. The deposits are now unapplied and the liability is restored.
|
||||
if (appliedDeposits.Any())
|
||||
{
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||
foreach (var dep in appliedDeposits)
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dep.Amount);
|
||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, dep.Amount);
|
||||
}
|
||||
}
|
||||
await _accountBalanceService.CreditAsync(arAccountId, invoice.Total);
|
||||
foreach (var item in invoiceItems)
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
|
||||
// Clear the JobId FK before soft-deleting so the unique index slot is freed
|
||||
// and a new invoice can be created for the same job if needed.
|
||||
@@ -1790,6 +2031,12 @@ public class InvoicesController : Controller
|
||||
|
||||
item.GeneratedGiftCertificateId = cert.Id;
|
||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||
|
||||
// GL: DR Revenue (line item account) / CR Gift Certificate Liability (2500).
|
||||
// Reclassifies the GC item's revenue as a deferred obligation until the cert is redeemed.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, item.TotalPrice);
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -1851,6 +2098,54 @@ public class InvoicesController : Controller
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the customer's payment terms, derived due date, and early-payment discount info
|
||||
/// for the Invoice Create form so JavaScript can auto-populate those fields on customer selection.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomerPaymentTerms(int customerId)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var invoiceDate = DateTime.Today;
|
||||
var dueDate = PaymentTermsParser.CalculateDueDate(customer.PaymentTerms, invoiceDate);
|
||||
var (discountPercent, discountDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(customer.PaymentTerms);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
paymentTerms = customer.PaymentTerms,
|
||||
dueDate = dueDate?.ToString("yyyy-MM-dd"),
|
||||
earlyPaymentDiscountPercent = discountPercent,
|
||||
earlyPaymentDiscountDays = discountDays,
|
||||
isTaxExempt = customer.IsTaxExempt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the default active tax rate for the current company, or zero for tax-exempt customers.
|
||||
/// Called by the Invoice Create form when the customer selection changes.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTaxRateForCustomer(int customerId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
if (customer.IsTaxExempt)
|
||||
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
||||
|
||||
var defaultRate = await _unitOfWork.TaxRates
|
||||
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
taxPercent = defaultRate?.Rate ?? 0m,
|
||||
taxRateName = defaultRate?.Name
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates ViewBag data used by both Create GET and Create POST (on validation failure re-display):
|
||||
/// — Active customer list for the customer dropdown.
|
||||
@@ -1905,6 +2200,24 @@ public class InvoicesController : Controller
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Returns the primary Checking or Cash account ID for the company, used as the
|
||||
/// deposit account when auto-applying deposits that were recorded without an explicit account.</summary>
|
||||
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns account 2300 "Customer Deposits" liability ID for the company, or null.</summary>
|
||||
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "2300");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
|
||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||
{
|
||||
@@ -1913,6 +2226,40 @@ public class InvoicesController : Controller
|
||||
return accounts.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Bad Debt Expense account for write-offs — prefers an account whose name
|
||||
/// contains "bad debt", falls back to the first active Expense-type account.
|
||||
/// </summary>
|
||||
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
|
||||
{
|
||||
var expenses = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
|
||||
?? expenses.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential JE number in JE-YYMM-#### format.
|
||||
/// Queries across soft-deleted entries to prevent reuse after deletion.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateJournalEntryNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"JE-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
int next = 1;
|
||||
if (all.Any())
|
||||
{
|
||||
var nums = all.Select(je => je.EntryNumber[prefix.Length..])
|
||||
.Select(s => int.TryParse(s, out int n) ? n : 0);
|
||||
next = nums.Max() + 1;
|
||||
}
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
|
||||
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
||||
{
|
||||
@@ -1923,6 +2270,28 @@ public class InvoicesController : Controller
|
||||
return taxAccount?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the "4950 Sales Discounts" contra-revenue account for this company, falling back
|
||||
/// to any active Revenue account whose name contains "discount". Returns null only when no
|
||||
/// such account exists (e.g. for companies whose chart of accounts predates the 4950 seed).
|
||||
/// </summary>
|
||||
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
|
||||
{
|
||||
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "4950" && a.IsActive);
|
||||
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
||||
return discountAccount?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
|
||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "2500");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
public static string GetStatusColorClass(InvoiceStatus status) => status switch
|
||||
{
|
||||
InvoiceStatus.Draft => "secondary",
|
||||
@@ -1979,6 +2348,8 @@ public class InvoicesController : Controller
|
||||
Amount = dto.Amount,
|
||||
RefundDate = dto.RefundDate,
|
||||
RefundMethod = dto.RefundMethod,
|
||||
// DepositAccountId only applies to cash/card refunds; store-credit refunds have no bank movement.
|
||||
DepositAccountId = isStoreCredit ? null : dto.DepositAccountId,
|
||||
Reason = dto.Reason,
|
||||
Reference = dto.Reference,
|
||||
Notes = dto.Notes,
|
||||
@@ -2037,6 +2408,14 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
|
||||
// Mirrors how FinancialReportService accounts for refunds:
|
||||
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
|
||||
var arAccountId = await GetArAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
|
||||
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||
}
|
||||
}
|
||||
@@ -2111,6 +2490,11 @@ public class InvoicesController : Controller
|
||||
customer.CurrentBalance += refund.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
|
||||
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
|
||||
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
||||
}
|
||||
|
||||
refund.Status = RefundStatus.Cancelled;
|
||||
@@ -2257,6 +2641,12 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
}); // end ExecuteInTransactionAsync
|
||||
@@ -2417,6 +2807,13 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
// GL: DR Gift Certificate Liability (2500) / CR AR.
|
||||
// Discharges the deferred obligation and reduces the invoice's outstanding AR balance.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
var arAcctId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAcctId, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
|
||||
}
|
||||
|
||||
@@ -1170,9 +1170,10 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
createCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -1628,8 +1629,9 @@ public class JobsController : Controller
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
dto.JobItems, companyId, dto.CustomerId,
|
||||
editCosts?.TaxPercent ?? 0m,
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
}
|
||||
@@ -3038,9 +3040,10 @@ public class JobsController : Controller
|
||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
||||
model.TaxPercent, "None", 0, false, null, 1, null);
|
||||
model.TaxPercent, "None", 0, false, job.OvenCostId, 1, null);
|
||||
|
||||
job.FinalPrice = totals.Total;
|
||||
job.OvenBatchCost = totals.OvenBatchCost;
|
||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||
job.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
@@ -167,6 +167,14 @@ public class JournalEntriesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// Period lock check — block posting if the entry date falls in a locked period
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(entry.CompanyId);
|
||||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(entry.EntryDate, company?.BookLockedThrough))
|
||||
{
|
||||
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
entry.Status = JournalEntryStatus.Posted;
|
||||
|
||||
@@ -0,0 +1,923 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Kiosk;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Application.Services;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles the customer self-service intake kiosk — both the in-person tablet flow
|
||||
/// (SignalR-triggered, activation-cookie-authenticated) and the remote email-link flow.
|
||||
///
|
||||
/// Anonymous intake routes use ignoreQueryFilters:true to load KioskSession by token
|
||||
/// because the anonymous HTTP context has no CompanyId claim, so the global tenant
|
||||
/// filter would return nothing without that flag.
|
||||
///
|
||||
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
|
||||
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
|
||||
/// </summary>
|
||||
public class KioskController : Controller
|
||||
{
|
||||
private const string CookieName = "KioskDevice";
|
||||
private const int InPersonExpireHours = 2;
|
||||
private const int RemoteExpireHours = 48;
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILookupCacheService _lookupCache;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IHubContext<KioskHub> _kioskHub;
|
||||
private readonly ILogger<KioskController> _logger;
|
||||
private readonly ICompanyLogoService _logoService;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
|
||||
|
||||
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
||||
public KioskController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ILookupCacheService lookupCache,
|
||||
IInAppNotificationService inApp,
|
||||
IEmailService emailService,
|
||||
IHubContext<KioskHub> kioskHub,
|
||||
ILogger<KioskController> logger,
|
||||
ICompanyLogoService logoService,
|
||||
IMemoryCache cache)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_lookupCache = lookupCache;
|
||||
_inApp = inApp;
|
||||
_emailService = emailService;
|
||||
_kioskHub = kioskHub;
|
||||
_logger = logger;
|
||||
_logoService = logoService;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WELCOME SCREEN (in-person tablet idle screen)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Idle branded screen displayed on the front-desk tablet.
|
||||
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
|
||||
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
|
||||
/// triggers a session via the Dashboard "Start Intake" button.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Welcome()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null)
|
||||
return View("KioskError", "This device is not activated as a kiosk. Ask a staff member to activate it at Settings → Kiosk.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||
return View("KioskError", "Kiosk activation token is invalid or has been revoked. Ask a staff member to re-activate this device.");
|
||||
|
||||
await PopulateKioskViewBag(company);
|
||||
ViewBag.ShowInactivityTimer = false; // Welcome screen stays on indefinitely
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
|
||||
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
|
||||
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
|
||||
/// for anonymous WebSocket/SSE connections through its ingress proxy).
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpGet]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public async Task<IActionResult> PollSession()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Json(new { hasSession = false });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||
return Json(new { hasSession = false });
|
||||
|
||||
// Check for a staff-pushed SMS consent request before checking for intake sessions.
|
||||
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
|
||||
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
|
||||
|
||||
var window = DateTime.UtcNow.AddSeconds(-60);
|
||||
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||
s => s.CompanyId == cookie.Value.companyId
|
||||
&& s.SessionType == KioskSessionType.InPerson
|
||||
&& s.Status == KioskSessionStatus.Active
|
||||
&& s.CreatedAt >= window,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
if (session == null) return Json(new { hasSession = false });
|
||||
return Json(new { hasSession = true, sessionToken = session.SessionToken });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
|
||||
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
|
||||
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
|
||||
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
|
||||
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PushSmsConsent(int customerId)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
if (customer.NotifyBySms)
|
||||
return Json(new { success = false, message = "Customer has already given SMS consent." });
|
||||
|
||||
var companyId = customer.CompanyId;
|
||||
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: customer.CompanyName ?? "Customer";
|
||||
|
||||
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
|
||||
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
|
||||
|
||||
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
|
||||
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public IActionResult CancelSmsConsent()
|
||||
{
|
||||
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
|
||||
if (int.TryParse(companyId, out var cid))
|
||||
_cache.Remove(SmsConsentCacheKey(cid));
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
|
||||
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> SmsConsent(int id)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
// Clear the pending entry immediately — the kiosk is now showing the form,
|
||||
// so Welcome must not redirect again if the customer cancels or navigates back.
|
||||
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
ViewBag.CompanyName = company?.CompanyName;
|
||||
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
|
||||
ViewBag.ShowInactivityTimer = false;
|
||||
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
|
||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||
: customer.CompanyName ?? "Customer";
|
||||
|
||||
return View(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the customer's SMS consent from the kiosk tablet.
|
||||
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
|
||||
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpPost]
|
||||
public async Task<IActionResult> SmsConsent(int id, bool agreed)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
if (agreed)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||
if (customer != null)
|
||||
{
|
||||
customer.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = "KioskInPerson";
|
||||
customer.SmsOptedOutAt = null;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
|
||||
|
||||
await _inApp.CreateAsync(
|
||||
customer.CompanyId,
|
||||
"SMS Consent Recorded",
|
||||
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
|
||||
"KioskConsent",
|
||||
link: $"/Customers/Details/{id}",
|
||||
customerId: id);
|
||||
}
|
||||
}
|
||||
|
||||
return Redirect("/Kiosk/Welcome");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
|
||||
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet, ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
|
||||
public async Task<IActionResult> Logo()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return NotFound();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || string.IsNullOrEmpty(company.LogoFilePath)) return NotFound();
|
||||
|
||||
var (success, fileContent, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
|
||||
if (!success || fileContent.Length == 0) return NotFound();
|
||||
|
||||
return File(fileContent, contentType);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DEVICE ACTIVATION (CompanyAdmin-only)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Shows the kiosk activation page with the current activation status.</summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> Activate()
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken);
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new activation token, saves it to the Company record,
|
||||
/// and writes the KioskDevice cookie so the current browser session becomes the active tablet.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> Activate(string action)
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
if (action == "deactivate")
|
||||
{
|
||||
company.KioskActivationToken = null;
|
||||
DeleteKioskCookie();
|
||||
TempData["Success"] = "Kiosk deactivated. The tablet will no longer accept intake sessions.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
company.KioskActivationToken = token;
|
||||
WriteKioskCookie(companyId, token);
|
||||
TempData["Success"] = "Kiosk activated. Open /Kiosk/Welcome on the tablet and bookmark it.";
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Activate));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// START IN-PERSON SESSION (any authenticated staff member)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Creates an InPerson KioskSession and pushes a SignalR StartIntake event
|
||||
/// to all connections in the company's kiosk group so the tablet navigates automatically.
|
||||
/// Called via fetch from the Dashboard "Start Intake" button.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> StartSession()
|
||||
{
|
||||
var companyId = GetCurrentCompanyId();
|
||||
|
||||
var session = new KioskSession
|
||||
{
|
||||
SessionType = KioskSessionType.InPerson,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(InPersonExpireHours),
|
||||
CompanyId = companyId
|
||||
};
|
||||
|
||||
await _unitOfWork.KioskSessions.AddAsync(session);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _kioskHub.Clients
|
||||
.Group($"kiosk-{companyId}")
|
||||
.SendAsync("StartIntake", session.SessionToken.ToString());
|
||||
|
||||
return Json(new { success = true, sessionToken = session.SessionToken });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SEND REMOTE LINK (any authenticated staff member)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Form for staff to enter a customer's email address and send an intake link.</summary>
|
||||
[Authorize]
|
||||
public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto());
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Remote KioskSession, sends the intake link by email, and redirects back
|
||||
/// with a success message. The link contains the session token (GUID) — not guessable.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SendRemoteLink(SendRemoteLinkDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(dto);
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
|
||||
var session = new KioskSession
|
||||
{
|
||||
SessionType = KioskSessionType.Remote,
|
||||
ExpiresAt = DateTime.UtcNow.AddHours(RemoteExpireHours),
|
||||
RemoteLinkEmail = dto.Email,
|
||||
RemoteLinkSentAt = DateTime.UtcNow,
|
||||
CompanyId = companyId
|
||||
};
|
||||
|
||||
await _unitOfWork.KioskSessions.AddAsync(session);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var link = $"{Request.Scheme}://{Request.Host}/Kiosk/Intake/{session.SessionToken}/Contact";
|
||||
var recipientName = string.IsNullOrWhiteSpace(dto.CustomerName) ? "Valued Customer" : dto.CustomerName;
|
||||
var companyName = company?.CompanyName ?? "Us";
|
||||
|
||||
var html = $@"
|
||||
<div style='font-family:sans-serif;max-width:560px;margin:0 auto;padding:2rem;'>
|
||||
<h2 style='color:#1e293b;'>Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},</h2>
|
||||
<p style='color:#475569;font-size:1rem;'>
|
||||
{System.Web.HttpUtility.HtmlEncode(companyName)} has sent you a quick intake form to fill out before your visit.
|
||||
It only takes a couple of minutes.
|
||||
</p>
|
||||
<a href='{link}' style='display:inline-block;margin:1.5rem 0;padding:1rem 2rem;background:#2563eb;
|
||||
color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:1.1rem;'>
|
||||
Start My Intake Form
|
||||
</a>
|
||||
<p style='color:#94a3b8;font-size:0.85rem;'>
|
||||
This link expires in 48 hours. If you did not expect this email, you can ignore it.
|
||||
</p>
|
||||
</div>";
|
||||
|
||||
await _emailService.SendEmailAsync(
|
||||
dto.Email, recipientName,
|
||||
$"Your intake form from {companyName}",
|
||||
$"Please visit this link to complete your intake form: {link}",
|
||||
htmlBody: html);
|
||||
|
||||
TempData["Success"] = $"Intake link sent to {dto.Email}.";
|
||||
return RedirectToAction(nameof(SendRemoteLink));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INTAKE STEPS (anonymous — both InPerson and Remote)
|
||||
// =========================================================================
|
||||
|
||||
// ── Step 1: Contact Info ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the contact-info form for the given session token.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Contact(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "This intake session could not be found. Please ask a staff member to start a new one.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View("Intake/Contact", new SubmitKioskContactDto
|
||||
{
|
||||
FirstName = session.CustomerFirstName,
|
||||
LastName = session.CustomerLastName,
|
||||
Phone = session.CustomerPhone,
|
||||
Email = session.CustomerEmail,
|
||||
IsReturningCustomer = session.IsReturningCustomer
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Saves contact info to the session and advances to Step 2.</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Contact(Guid token, SubmitKioskContactDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View("Intake/Contact", dto);
|
||||
}
|
||||
|
||||
session.CustomerFirstName = dto.FirstName.Trim();
|
||||
session.CustomerLastName = dto.LastName.Trim();
|
||||
session.CustomerPhone = dto.Phone.Trim();
|
||||
session.CustomerEmail = dto.Email.Trim().ToLowerInvariant();
|
||||
session.IsReturningCustomer = dto.IsReturningCustomer;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Job), new { token });
|
||||
}
|
||||
|
||||
// ── Step 2: Job Description ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the job-description form.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Job(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View("Intake/Job", new SubmitKioskJobDto
|
||||
{
|
||||
JobDescription = session.JobDescription,
|
||||
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Saves the job description and advances to Step 3.</summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Job(Guid token, SubmitKioskJobDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View("Intake/Job", dto);
|
||||
}
|
||||
|
||||
session.JobDescription = dto.JobDescription.Trim();
|
||||
session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Terms), new { token });
|
||||
}
|
||||
|
||||
// ── Step 3: Terms & Consent ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Terms(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View("Intake/Terms", new SubmitKioskTermsDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves terms agreement, triggers customer/job auto-creation, fires staff notification,
|
||||
/// and redirects to the Confirmation screen.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Terms(Guid token, SubmitKioskTermsDto dto)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
// Expired/already-submitted sessions go straight to Confirmation
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
// Require signature for in-person sessions
|
||||
if (session.SessionType == KioskSessionType.InPerson &&
|
||||
string.IsNullOrEmpty(dto.SignatureDataBase64))
|
||||
{
|
||||
ModelState.AddModelError("SignatureDataBase64", "Please sign above before continuing.");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View("Intake/Terms", dto);
|
||||
}
|
||||
|
||||
session.AgreedToTerms = true;
|
||||
session.AgreedToTermsAt = DateTime.UtcNow;
|
||||
session.SmsOptIn = dto.SmsOptIn;
|
||||
session.SignatureDataBase64 = dto.SignatureDataBase64;
|
||||
session.Status = KioskSessionStatus.Submitted;
|
||||
session.SubmittedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessSubmissionAsync(session);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
|
||||
// Customer-facing page always succeeds — staff can convert the session manually.
|
||||
// Persist the session's agreed/submitted state even if job creation failed.
|
||||
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Confirmation), new { token });
|
||||
}
|
||||
|
||||
// ── Confirmation ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Thank-you screen shown after a successful submission.</summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Confirmation(Guid token)
|
||||
{
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
ViewBag.FirstName = session.CustomerFirstName;
|
||||
return View("Intake/Confirmation");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STAFF REVIEW (authenticated)
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Lists all kiosk intake sessions for the current company — submitted, active, and expired.
|
||||
/// Manager or higher access required.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Intakes(string? filter)
|
||||
{
|
||||
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
|
||||
s => s.LinkedCustomer,
|
||||
s => s.LinkedJob);
|
||||
|
||||
var dtos = sessions
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.Select(s => new KioskSessionListDto
|
||||
{
|
||||
Id = s.Id,
|
||||
SessionToken = s.SessionToken,
|
||||
SessionType = s.SessionType,
|
||||
Status = s.Status,
|
||||
CustomerFirstName = s.CustomerFirstName,
|
||||
CustomerLastName = s.CustomerLastName,
|
||||
CustomerEmail = s.CustomerEmail,
|
||||
CustomerPhone = s.CustomerPhone,
|
||||
JobDescription = s.JobDescription,
|
||||
SmsOptIn = s.SmsOptIn,
|
||||
SubmittedAt = s.SubmittedAt,
|
||||
ExpiresAt = s.ExpiresAt,
|
||||
LinkedCustomerId = s.LinkedCustomerId,
|
||||
LinkedJobId = s.LinkedJobId,
|
||||
LinkedQuoteId = s.LinkedQuoteId,
|
||||
RemoteLinkEmail = s.RemoteLinkEmail
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Apply filter tab
|
||||
dtos = filter switch
|
||||
{
|
||||
"submitted" => dtos.Where(d => d.Status == KioskSessionStatus.Submitted).ToList(),
|
||||
"active" => dtos.Where(d => d.Status == KioskSessionStatus.Active && !d.IsExpired).ToList(),
|
||||
"expired" => dtos.Where(d => d.IsExpired || d.Status == KioskSessionStatus.Expired).ToList(),
|
||||
_ => dtos
|
||||
};
|
||||
|
||||
ViewBag.ActiveFilter = filter ?? "all";
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRIVATE HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests
|
||||
/// have no CompanyId claim, so the global tenant filter would return nothing without it.
|
||||
/// </summary>
|
||||
private async Task<KioskSession?> LoadSessionAsync(Guid token)
|
||||
{
|
||||
return await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||
s => s.SessionToken == token && !s.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the session is still in a usable state.
|
||||
/// Returns false (and optionally updates status to Expired) if the session should not proceed.
|
||||
/// </summary>
|
||||
private async Task<bool> ValidateSessionState(KioskSession session)
|
||||
{
|
||||
if (session.Status == KioskSessionStatus.Submitted)
|
||||
return false; // Already done — redirect to Confirmation (idempotent)
|
||||
|
||||
if (session.Status == KioskSessionStatus.Cancelled)
|
||||
return false;
|
||||
|
||||
if (DateTime.UtcNow > session.ExpiresAt && session.Status == KioskSessionStatus.Active)
|
||||
{
|
||||
session.Status = KioskSessionStatus.Expired;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
return session.Status == KioskSessionStatus.Active;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core submission logic: matches or creates a Customer, creates a Pending Job,
|
||||
/// applies SMS consent, and fires a staff in-app notification.
|
||||
/// CompanyId is set explicitly on new entities from session.CompanyId so the EF
|
||||
/// SaveChanges interceptor does not override it with 0 (the anonymous tenant context).
|
||||
/// </summary>
|
||||
private async Task ProcessSubmissionAsync(KioskSession session)
|
||||
{
|
||||
var companyId = session.CompanyId;
|
||||
|
||||
// 1. Match or create Customer
|
||||
Customer? customer = null;
|
||||
if (!string.IsNullOrEmpty(session.CustomerEmail))
|
||||
{
|
||||
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.CompanyId == companyId && c.Email == session.CustomerEmail && !c.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
if (customer == null && !string.IsNullOrEmpty(session.CustomerPhone))
|
||||
{
|
||||
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
|
||||
c => c.CompanyId == companyId && (c.Phone == session.CustomerPhone || c.MobilePhone == session.CustomerPhone) && !c.IsDeleted,
|
||||
ignoreQueryFilters: true);
|
||||
}
|
||||
|
||||
bool isNewCustomer = customer == null;
|
||||
if (isNewCustomer)
|
||||
{
|
||||
customer = new Customer
|
||||
{
|
||||
CompanyId = companyId,
|
||||
ContactFirstName = session.CustomerFirstName,
|
||||
ContactLastName = session.CustomerLastName,
|
||||
Phone = session.CustomerPhone,
|
||||
Email = session.CustomerEmail,
|
||||
IsActive = true,
|
||||
IsCommercial = false
|
||||
};
|
||||
await _unitOfWork.Customers.AddAsync(customer);
|
||||
await _unitOfWork.CompleteAsync(); // get Customer.Id
|
||||
}
|
||||
|
||||
// 2. Apply SMS consent
|
||||
if (session.SmsOptIn)
|
||||
{
|
||||
customer!.NotifyBySms = true;
|
||||
customer.SmsConsentedAt = session.SubmittedAt ?? DateTime.UtcNow;
|
||||
customer.SmsConsentMethod = session.SessionType == KioskSessionType.InPerson
|
||||
? "KioskIntake"
|
||||
: "RemoteIntake";
|
||||
}
|
||||
|
||||
// 3. Resolve company preference: create a Quote (default) or a Job
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
session.LinkedCustomerId = customer!.Id;
|
||||
|
||||
if (createQuote)
|
||||
{
|
||||
// 3a. Create a Draft Quote so staff can price and send for approval
|
||||
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
if (draftStatus == null)
|
||||
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var quote = new Quote
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
QuoteNumber = quoteNumber,
|
||||
QuoteStatusId = draftStatus.Id,
|
||||
Description = session.JobDescription,
|
||||
Notes = $"Source: {session.SessionType} kiosk intake",
|
||||
QuoteDate = DateTime.UtcNow,
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
|
||||
};
|
||||
|
||||
await _unitOfWork.Quotes.AddAsync(quote);
|
||||
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
||||
|
||||
session.LinkedQuoteId = quote.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3b. Create a Pending Job directly (for shops that price on the spot)
|
||||
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
if (pendingStatus == null)
|
||||
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
|
||||
?? priorities.FirstOrDefault();
|
||||
if (normalPriority == null)
|
||||
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
Description = session.JobDescription,
|
||||
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
await _unitOfWork.CompleteAsync(); // job.Id now valid
|
||||
|
||||
session.LinkedJobId = job.Id;
|
||||
}
|
||||
|
||||
// 4. Persist session links
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// 5. Fire staff notification
|
||||
var jobDesc = session.JobDescription ?? "";
|
||||
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
$"{intakeLabel} Submitted",
|
||||
$"{fullName} completed their intake form — {snippet}",
|
||||
"KioskIntake",
|
||||
link: $"/Kiosk/Intakes",
|
||||
customerId: customer.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential quote number using the company's configured prefix.
|
||||
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
|
||||
/// Implemented here because KioskController processes anonymous requests and cannot
|
||||
/// rely on ITenantContext to resolve the company ID.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateQuoteNumberAsync(int companyId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastQuoteNumber != null)
|
||||
{
|
||||
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
|
||||
if (int.TryParse(lastNumberStr, out int lastNumber))
|
||||
return $"{prefix}-{(lastNumber + 1):D4}";
|
||||
}
|
||||
|
||||
return $"{prefix}-0001";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential job number using the company's configured prefix.
|
||||
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateJobNumberAsync(int companyId)
|
||||
{
|
||||
var year = DateTime.Now.Year.ToString()[2..];
|
||||
var month = DateTime.Now.Month.ToString("D2");
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
|
||||
var prefix = $"{jobPrefix}-{year}{month}";
|
||||
|
||||
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastJobNumber != null)
|
||||
{
|
||||
var lastNumberStr = lastJobNumber[(prefix.Length + 1)..];
|
||||
if (int.TryParse(lastNumberStr, out int lastNumber))
|
||||
return $"{prefix}-{(lastNumber + 1):D4}";
|
||||
}
|
||||
|
||||
return $"{prefix}-0001";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value.
|
||||
/// Returns null if the cookie is absent or malformed.
|
||||
/// </summary>
|
||||
private (int companyId, string token)? ReadKioskCookie()
|
||||
{
|
||||
if (!Request.Cookies.TryGetValue(CookieName, out var raw) || string.IsNullOrEmpty(raw))
|
||||
return null;
|
||||
|
||||
var parts = raw.Split(':', 2);
|
||||
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
|
||||
return null;
|
||||
|
||||
return (id, parts[1]);
|
||||
}
|
||||
|
||||
/// <summary>Writes a long-lived HttpOnly kiosk device cookie.</summary>
|
||||
private void WriteKioskCookie(int companyId, string token)
|
||||
{
|
||||
Response.Cookies.Append(CookieName, $"{companyId}:{token}", new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromDays(365)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Removes the kiosk device cookie (deactivation).</summary>
|
||||
private void DeleteKioskCookie()
|
||||
{
|
||||
Response.Cookies.Delete(CookieName);
|
||||
}
|
||||
|
||||
/// <summary>Returns the current authenticated user's CompanyId claim.</summary>
|
||||
private int GetCurrentCompanyId()
|
||||
{
|
||||
var claim = User.FindFirst("CompanyId")?.Value;
|
||||
return int.TryParse(claim, out int id) ? id : 0;
|
||||
}
|
||||
|
||||
/// <summary>Sets ViewBag properties needed by _KioskLayout from a Company entity.</summary>
|
||||
private async Task PopulateKioskViewBag(Company company)
|
||||
{
|
||||
ViewBag.CompanyId = company.Id;
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
|
||||
? Url.Action("Logo", "Kiosk")
|
||||
: null;
|
||||
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
||||
|
||||
// Pass the intake output setting so Terms.cshtml can show matching wording
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
}
|
||||
|
||||
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
||||
private async Task PopulateKioskViewBagFromSession(KioskSession session)
|
||||
{
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(session.CompanyId, ignoreQueryFilters: true);
|
||||
if (company != null)
|
||||
await PopulateKioskViewBag(company);
|
||||
|
||||
ViewBag.SessionToken = session.SessionToken;
|
||||
ViewBag.SessionType = session.SessionType;
|
||||
|
||||
// Reset to Welcome screen after 45 s of inactivity on any intake step.
|
||||
// The Welcome screen itself stays on indefinitely (no timeout override there).
|
||||
ViewBag.InactivityTimeoutMs = 45_000;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Stripe;
|
||||
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -26,6 +27,7 @@ public class PaymentController : Controller
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<PaymentController> _logger;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public PaymentController(
|
||||
ApplicationDbContext context,
|
||||
@@ -33,7 +35,8 @@ public class PaymentController : Controller
|
||||
INotificationService notificationService,
|
||||
IInAppNotificationService inApp,
|
||||
IConfiguration configuration,
|
||||
ILogger<PaymentController> logger)
|
||||
ILogger<PaymentController> logger,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_context = context;
|
||||
_stripeConnect = stripeConnect;
|
||||
@@ -41,6 +44,7 @@ public class PaymentController : Controller
|
||||
_inApp = inApp;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
||||
@@ -149,6 +153,86 @@ public class PaymentController : Controller
|
||||
return Ok(new { clientSecret, surchargeAmount = surcharge });
|
||||
}
|
||||
|
||||
// ─── GET /invoice/{token} ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
|
||||
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
|
||||
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
|
||||
/// since SMS cannot attach a PDF.
|
||||
/// </summary>
|
||||
[HttpGet("/invoice/{token}")]
|
||||
public async Task<IActionResult> InvoiceView(string token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await _context.Invoices
|
||||
.AsNoTracking()
|
||||
.Include(i => i.InvoiceItems)
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
|
||||
|
||||
if (invoice == null)
|
||||
return View("PaymentError", "This invoice link is invalid or has been removed.");
|
||||
|
||||
var company = await _context.Companies.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
|
||||
|
||||
if (company == null)
|
||||
return View("PaymentError", "Unable to load invoice details.");
|
||||
|
||||
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
|
||||
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
|
||||
&& invoice.BalanceDue > 0)
|
||||
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
|
||||
: null;
|
||||
|
||||
var vm = new InvoiceViewViewModel
|
||||
{
|
||||
InvoiceNumber = invoice.InvoiceNumber,
|
||||
InvoiceDate = invoice.InvoiceDate,
|
||||
DueDate = invoice.DueDate,
|
||||
CustomerName = invoice.Customer != null
|
||||
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
|
||||
: "Valued Customer",
|
||||
CompanyName = company.CompanyName,
|
||||
CompanyPhone = company.Phone,
|
||||
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))),
|
||||
LogoFilePath = company.LogoFilePath,
|
||||
SubTotal = invoice.SubTotal,
|
||||
TaxPercent = invoice.TaxPercent,
|
||||
TaxAmount = invoice.TaxAmount,
|
||||
DiscountAmount = invoice.DiscountAmount,
|
||||
Total = invoice.Total,
|
||||
AmountPaid = invoice.AmountPaid,
|
||||
BalanceDue = invoice.BalanceDue,
|
||||
Status = invoice.Status,
|
||||
Notes = invoice.Notes,
|
||||
Terms = invoice.Terms,
|
||||
JobNumber = invoice.Job?.JobNumber,
|
||||
PaymentUrl = paymentUrl,
|
||||
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
|
||||
{
|
||||
Description = i.Description,
|
||||
Quantity = i.Quantity,
|
||||
UnitPrice = i.UnitPrice,
|
||||
TotalPrice = i.TotalPrice
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
|
||||
return View("PaymentError", "An error occurred loading this invoice.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -378,8 +462,30 @@ public class PaymentController : Controller
|
||||
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Update(invoice);
|
||||
|
||||
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
||||
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
||||
// this makes Stripe payments consistent with that path.
|
||||
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
var stripePayment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = netPayment,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reference = intent.Id,
|
||||
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||||
DepositAccountId = checkingAcctId,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Payments.Add(stripePayment);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
|
||||
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
|
||||
|
||||
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
|
||||
|
||||
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
||||
@@ -553,6 +659,8 @@ public class PaymentController : Controller
|
||||
|
||||
var refundAmountDollars = latestRefund.Amount / 100m;
|
||||
|
||||
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
|
||||
var refund = new Core.Entities.Refund
|
||||
{
|
||||
CompanyId = invoice.CompanyId,
|
||||
@@ -565,6 +673,7 @@ public class PaymentController : Controller
|
||||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
DepositAccountId = checkingAcctIdR,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Refunds.Add(refund);
|
||||
@@ -588,6 +697,10 @@ public class PaymentController : Controller
|
||||
_context.Update(invoice);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
|
||||
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
|
||||
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
|
||||
|
||||
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
|
||||
refundAmountDollars, invoice.Id, latestRefund.Id);
|
||||
}
|
||||
@@ -652,6 +765,8 @@ public class PaymentController : Controller
|
||||
if (alreadyRecorded) return;
|
||||
|
||||
var amount = dispute.Amount / 100m;
|
||||
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
|
||||
var refund = new Core.Entities.Refund
|
||||
{
|
||||
CompanyId = invoice.CompanyId,
|
||||
@@ -664,6 +779,7 @@ public class PaymentController : Controller
|
||||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
DepositAccountId = checkingAcctIdD,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Refunds.Add(refund);
|
||||
@@ -687,6 +803,9 @@ public class PaymentController : Controller
|
||||
_context.Update(invoice);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
|
||||
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
|
||||
|
||||
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
|
||||
}
|
||||
}
|
||||
@@ -696,6 +815,27 @@ public class PaymentController : Controller
|
||||
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
|
||||
/// because there is no authenticated tenant context in webhook handlers.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
|
||||
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
|
||||
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
|
||||
/// </summary>
|
||||
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
|
||||
{
|
||||
var ar = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||||
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
var checking = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||||
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash))
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
return (ar, checking);
|
||||
}
|
||||
|
||||
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
||||
@@ -837,6 +977,39 @@ public class DepositPaymentPageViewModel
|
||||
public string StripeAccountId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class InvoiceViewViewModel
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public DateTime InvoiceDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CompanyPhone { get; set; }
|
||||
public string? CompanyAddress { get; set; }
|
||||
public string? LogoFilePath { get; set; }
|
||||
public decimal SubTotal { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
public decimal AmountPaid { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? JobNumber { get; set; }
|
||||
public string? PaymentUrl { get; set; }
|
||||
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
|
||||
}
|
||||
|
||||
public class InvoiceViewLineItem
|
||||
{
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TotalPrice { get; set; }
|
||||
}
|
||||
|
||||
public class CreateIntentRequest
|
||||
{
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user