Compare commits
47 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 |
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
|||||||
public string? RecommendedAction { get; set; }
|
public string? RecommendedAction { get; set; }
|
||||||
public string? BillNumber { get; set; }
|
public string? BillNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public class BankRecMatchItem
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||||
|
public string Reference { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchRequest
|
||||||
|
{
|
||||||
|
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||||
|
public decimal BeginningBalance { get; set; }
|
||||||
|
public decimal StatementEndingBalance { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchSuggestion
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public double Confidence { get; set; } // 0.0–1.0
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||||
|
public class ClaudeAutoMatchResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeAutoMatchSuggestion
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||||
|
|
||||||
|
public class OpenInvoiceSummary
|
||||||
|
{
|
||||||
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
public decimal BalanceDue { get; set; }
|
||||||
|
public string? DueDateIso { get; set; }
|
||||||
|
public int DaysOverdue { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentCustomerData
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public decimal TotalOwed { get; set; }
|
||||||
|
public double AvgDaysToPay { get; set; } // historical average
|
||||||
|
public int TotalInvoicesAllTime { get; set; }
|
||||||
|
public int LateInvoicesAllTime { get; set; }
|
||||||
|
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPredictionRequest
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPrediction
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
/// <summary>"high", "medium", or "low"</summary>
|
||||||
|
public string RiskLevel { get; set; } = "medium";
|
||||||
|
public int EstimatedDaysToPayment { get; set; }
|
||||||
|
public string Reasoning { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPredictionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||||
|
public class ClaudeLatePaymentResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeLatePaymentPrediction
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string RiskLevel { get; set; } = "medium";
|
||||||
|
public int EstimatedDaysToPayment { get; set; }
|
||||||
|
public string Reasoning { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||||
|
|
||||||
|
public class MonthlyFinancialSummary
|
||||||
|
{
|
||||||
|
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||||
|
public decimal Revenue { get; set; }
|
||||||
|
public decimal Expenses { get; set; }
|
||||||
|
public decimal NetIncome { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryContext
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public string AsOfDate { get; set; } = string.Empty;
|
||||||
|
public decimal TotalRevenueYtd { get; set; }
|
||||||
|
public decimal TotalExpensesYtd { get; set; }
|
||||||
|
public decimal NetIncomeYtd { get; set; }
|
||||||
|
public decimal ArOutstanding { get; set; }
|
||||||
|
public decimal ApOutstanding { get; set; }
|
||||||
|
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||||
|
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryRequest
|
||||||
|
{
|
||||||
|
public string Question { get; set; } = string.Empty;
|
||||||
|
public FinancialQueryContext Context { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string Answer { get; set; } = string.Empty;
|
||||||
|
public string? FollowUpSuggestion { get; set; }
|
||||||
|
public List<string> RelevantFacts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||||
|
public class ClaudeFinancialQueryResponse
|
||||||
|
{
|
||||||
|
public string Answer { get; set; } = string.Empty;
|
||||||
|
public string? FollowUpSuggestion { get; set; }
|
||||||
|
public List<string> RelevantFacts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||||
|
|
||||||
|
public class RecurringBillHistoryItem
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
public string BillNumber { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string DateIso { get; set; } = string.Empty;
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillDetectionRequest
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillPattern
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||||
|
public string Frequency { get; set; } = string.Empty;
|
||||||
|
public decimal TypicalAmount { get; set; }
|
||||||
|
public string? NextExpectedDateIso { get; set; }
|
||||||
|
/// <summary>"high", "medium", or "low"</summary>
|
||||||
|
public string Confidence { get; set; } = "medium";
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? SuggestedAction { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillDetectionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||||
|
public class ClaudeRecurringBillResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeRecurringPattern
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
public string Frequency { get; set; } = string.Empty;
|
||||||
|
public decimal TypicalAmount { get; set; }
|
||||||
|
public string? NextExpectedDateIso { get; set; }
|
||||||
|
public string Confidence { get; set; } = "medium";
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? SuggestedAction { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
|||||||
public bool WizardCompleted { get; set; }
|
public bool WizardCompleted { get; set; }
|
||||||
public DateTime? WizardCompletedAt { get; set; }
|
public DateTime? WizardCompletedAt { get; set; }
|
||||||
public string? WizardCompletedByName { get; set; }
|
public string? WizardCompletedByName { get; set; }
|
||||||
|
|
||||||
|
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||||
|
public int HealthScore { get; set; }
|
||||||
|
public string HealthRisk { get; set; } = "Healthy";
|
||||||
|
public DateTime? LastLoginDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
|||||||
// Blank Work Order PDF Template
|
// Blank Work Order PDF Template
|
||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
public string? WoTerms { get; set; }
|
public string? WoTerms { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateAppDefaultsDto
|
public class UpdateAppDefaultsDto
|
||||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
|||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class UpdateKioskSettingsDto
|
||||||
|
{
|
||||||
|
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||||
|
[Required]
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
|
|||||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one contact method is required (Email OR Phone)
|
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||||
{
|
{
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
"Please provide at least one contact method (Email or Phone)",
|
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||||
new[] { nameof(Email), nameof(Phone) });
|
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each address in comma-separated email fields
|
// Validate each address in comma-separated email fields
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
|||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public string? CustomerPhone { get; set; }
|
public string? CustomerPhone { get; set; }
|
||||||
|
public string? CustomerMobilePhone { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; }
|
public bool CustomerNotifyByEmail { get; set; }
|
||||||
|
public bool CustomerNotifyBySms { get; set; }
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
public string? PreparedByName { get; set; }
|
public string? PreparedByName { get; set; }
|
||||||
public InvoiceStatus Status { get; set; }
|
public InvoiceStatus Status { get; set; }
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
|||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||||
public PaymentMethod RefundMethod { get; set; }
|
public PaymentMethod RefundMethod { get; set; }
|
||||||
|
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
public string Reason { get; set; } = string.Empty;
|
public string Reason { get; set; } = string.Empty;
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||||
|
|
||||||
|
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||||
|
public class SendRemoteLinkDto
|
||||||
|
{
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskContactDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, Phone]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskJobDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(2000)]
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||||
|
public class SubmitKioskTermsDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||||
|
public class KioskSessionListDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public Guid SessionToken { get; set; }
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; }
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
|
||||||
|
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||||
|
public string JobDescriptionSnippet =>
|
||||||
|
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||||
|
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||||
|
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||||
|
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
|||||||
public bool CanManageMaintenance { get; set; }
|
public bool CanManageMaintenance { get; set; }
|
||||||
public bool CanManageInvoices { get; set; }
|
public bool CanManageInvoices { get; set; }
|
||||||
public bool CanViewReports { get; set; }
|
public bool CanViewReports { get; set; }
|
||||||
|
public bool CanManageBills { get; set; }
|
||||||
|
public bool CanManageAccounting { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
|||||||
[Display(Name = "Can View Reports")]
|
[Display(Name = "Can View Reports")]
|
||||||
public bool CanViewReports { get; set; }
|
public bool CanViewReports { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Can Manage Bills & AP")]
|
||||||
|
public bool CanManageBills { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Can Manage Accounting")]
|
||||||
|
public bool CanManageAccounting { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Send Welcome Email")]
|
[Display(Name = "Send Welcome Email")]
|
||||||
public bool SendWelcomeEmail { get; set; } = true;
|
public bool SendWelcomeEmail { get; set; } = true;
|
||||||
}
|
}
|
||||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
|||||||
|
|
||||||
[Display(Name = "Can View Reports")]
|
[Display(Name = "Can View Reports")]
|
||||||
public bool CanViewReports { get; set; }
|
public bool CanViewReports { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Can Manage Bills & AP")]
|
||||||
|
public bool CanManageBills { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Can Manage Accounting")]
|
||||||
|
public bool CanManageAccounting { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
|||||||
/// Returns a ranked list of flagged items with recommended actions.
|
/// Returns a ranked list of flagged items with recommended actions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
|
||||||
|
/// a statement. Returns a ranked list of suggestions with confidence scores based on
|
||||||
|
/// amount/date patterns and the gap between the current cleared balance and the
|
||||||
|
/// statement ending balance.
|
||||||
|
/// </summary>
|
||||||
|
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Predicts likelihood of late payment for each open AR customer using their historical
|
||||||
|
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
|
||||||
|
/// Returns risk levels (high/medium/low) and estimated days to collection.
|
||||||
|
/// </summary>
|
||||||
|
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
|
||||||
|
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
|
||||||
|
/// and an optional follow-up question suggestion.
|
||||||
|
/// </summary>
|
||||||
|
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes 6–12 months of bill history to detect recurring payment patterns per vendor.
|
||||||
|
/// Returns detected patterns with frequency, typical amount, next expected date, and
|
||||||
|
/// suggested actions (e.g. set a reminder, create a template).
|
||||||
|
/// </summary>
|
||||||
|
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
|||||||
/// Notify customer when an invoice has been sent.
|
/// Notify customer when an invoice has been sent.
|
||||||
/// Optionally includes an online payment link in the email body.
|
/// Optionally includes an online payment link in the email body.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||||
|
|||||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
|||||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||||
|
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
|||||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||||
: null))
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||||
|
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||||
|
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||||
: null))
|
: null))
|
||||||
|
|||||||
@@ -590,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
QuoteItemPricingResult itemResult;
|
QuoteItemPricingResult itemResult;
|
||||||
|
|
||||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||||
if (item.CatalogItemId.HasValue)
|
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||||
{
|
|
||||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
|
||||||
if (catalogItem != null)
|
|
||||||
{
|
|
||||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
|
||||||
// (which already includes the catalog base price + coat costs)
|
|
||||||
if (item.Coats != null && item.Coats.Any())
|
|
||||||
{
|
|
||||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
|
||||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No coats - use simple catalog default price
|
|
||||||
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
|
|
||||||
itemResult = new QuoteItemPricingResult
|
|
||||||
{
|
|
||||||
MaterialCost = 0,
|
|
||||||
LaborCost = 0,
|
|
||||||
EquipmentCost = 0,
|
|
||||||
ItemSubtotal = catalogItemTotal,
|
|
||||||
UnitPrice = catalogItem.DefaultPrice,
|
|
||||||
TotalPrice = catalogItemTotal
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Catalog item not found, create zero result
|
|
||||||
itemResult = new QuoteItemPricingResult
|
|
||||||
{
|
|
||||||
MaterialCost = 0,
|
|
||||||
LaborCost = 0,
|
|
||||||
EquipmentCost = 0,
|
|
||||||
ItemSubtotal = 0,
|
|
||||||
UnitPrice = 0,
|
|
||||||
TotalPrice = 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Calculated items use the full pricing calculation
|
|
||||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
|
||||||
}
|
|
||||||
|
|
||||||
itemResults.Add(itemResult);
|
itemResults.Add(itemResult);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,8 +104,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||||
if (catalogItem != null)
|
if (catalogItem != null)
|
||||||
{
|
{
|
||||||
item.UnitPrice = catalogItem.DefaultPrice;
|
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||||
item.TotalPrice = catalogItem.DefaultPrice * itemDto.Quantity;
|
? itemDto.PowderCostOverride.Value
|
||||||
|
: catalogItem.DefaultPrice;
|
||||||
|
item.UnitPrice = unitPrice;
|
||||||
|
item.TotalPrice = unitPrice * itemDto.Quantity;
|
||||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
|
|||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
public decimal RemainingAmount { get; set; }
|
public decimal RemainingAmount { get; set; }
|
||||||
public string? Memo { get; set; }
|
public string? Memo { get; set; }
|
||||||
|
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||||
|
public DateTime? PostedDate { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Vendor Vendor { get; set; } = null!;
|
public virtual Vendor Vendor { get; set; } = null!;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
|
|||||||
public bool CanManageMaintenance { get; set; } = false;
|
public bool CanManageMaintenance { get; set; } = false;
|
||||||
public bool CanManageInvoices { get; set; } = false;
|
public bool CanManageInvoices { get; set; } = false;
|
||||||
public bool CanViewReports { get; set; } = false;
|
public bool CanViewReports { get; set; } = false;
|
||||||
|
public bool CanManageBills { get; set; } = false;
|
||||||
|
public bool CanManageAccounting { get; set; } = false;
|
||||||
|
|
||||||
// Profile Photo (filesystem storage)
|
// Profile Photo (filesystem storage)
|
||||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ public class Company : BaseEntity
|
|||||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||||
|
|
||||||
|
// Kiosk
|
||||||
|
/// <summary>
|
||||||
|
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||||
|
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||||
|
/// tablet can serve the intake form without requiring a logged-in user.
|
||||||
|
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||||
|
/// </summary>
|
||||||
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
|||||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||||
public string? QbMigrationStateJson { get; set; }
|
public string? QbMigrationStateJson { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
/// <summary>
|
||||||
|
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||||
|
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||||
|
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||||
|
/// </summary>
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
|
||||||
// Guided activation / first-workflow onboarding
|
// Guided activation / first-workflow onboarding
|
||||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||||
public string? OnboardingPath { get; set; }
|
public string? OnboardingPath { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? RecordedById { get; set; }
|
public string? RecordedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||||
|
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
|
|
||||||
// Applied to invoice when invoice is created
|
// Applied to invoice when invoice is created
|
||||||
public int? AppliedToInvoiceId { get; set; }
|
public int? AppliedToInvoiceId { get; set; }
|
||||||
public DateTime? AppliedDate { get; set; }
|
public DateTime? AppliedDate { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
|||||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||||
|
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||||
|
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicViewToken { get; set; }
|
||||||
|
|
||||||
// Online payments (Stripe Connect)
|
// Online payments (Stripe Connect)
|
||||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class Job : BaseEntity
|
|||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal OvenBatchCost { get; set; }
|
||||||
public decimal ShopSuppliesAmount { get; set; }
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
public decimal ShopSuppliesPercent { get; set; }
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||||
|
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||||
|
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskSession : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||||
|
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||||
|
|
||||||
|
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
|
||||||
|
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
|
||||||
|
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
public DateTime? AgreedToTermsAt { get; set; }
|
||||||
|
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
|
||||||
|
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
public DateTime? RemoteLinkSentAt { get; set; }
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
public virtual Customer? LinkedCustomer { get; set; }
|
||||||
|
public virtual Job? LinkedJob { get; set; }
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
|||||||
public DateTime? IssuedDate { get; set; }
|
public DateTime? IssuedDate { get; set; }
|
||||||
public string? IssuedById { get; set; }
|
public string? IssuedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||||
|
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
|
|
||||||
// For store-credit refunds: the CreditMemo created on their behalf
|
// For store-credit refunds: the CreditMemo created on their behalf
|
||||||
public int? CreditMemoId { get; set; }
|
public int? CreditMemoId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -154,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||||
|
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||||
|
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CompanyCountSummary(
|
public record CompanyCountSummary(
|
||||||
IReadOnlyDictionary<int, int> JobCounts,
|
IReadOnlyDictionary<int, int> JobCounts,
|
||||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||||
|
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||||
/// total unfiltered count for pagination.
|
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||||
|
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||||
|
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||||
|
|||||||
@@ -367,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -746,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||||
|
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||||
|
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasIndex(e => e.SessionToken)
|
||||||
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedCustomer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedCustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedJob)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
|
|||||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
},
|
},
|
||||||
new NotificationTemplate
|
new NotificationTemplate
|
||||||
|
{
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
DisplayName = "Invoice Sent (SMS)",
|
||||||
|
Subject = null,
|
||||||
|
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||||
|
IsActive = true,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new NotificationTemplate
|
||||||
{
|
{
|
||||||
NotificationType = NotificationType.PaymentReceived,
|
NotificationType = NotificationType.PaymentReceived,
|
||||||
Channel = NotificationChannel.Email,
|
Channel = NotificationChannel.Email,
|
||||||
|
|||||||
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")
|
b.Property<bool>("CanCreateQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageAccounting")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("CanManageBills")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("CanManageCalendar")
|
b.Property<bool>("CanManageCalendar")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -1806,6 +1812,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskActivationToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("LogoContentType")
|
b.Property<string>("LogoContentType")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2244,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobRetentionYears")
|
b.Property<int>("JobRetentionYears")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("KioskIntakeOutput")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("LogRetentionDays")
|
b.Property<int>("LogRetentionDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -2886,6 +2899,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -3910,6 +3926,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("PublicViewToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("SalesTaxAccountId")
|
b.Property<int?>("SalesTaxAccountId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4183,6 +4202,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("OriginalJobId")
|
b.Property<int?>("OriginalJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("OvenBatchCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5552,6 +5574,118 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("JournalEntryLines");
|
b.ToTable("JournalEntryLines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AgreedToTerms")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AgreedToTermsAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerFirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerLastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerPhone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("HowDidYouHearAboutUs")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReturningCustomer")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("JobDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedCustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedJobId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedQuoteId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteLinkEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RemoteLinkSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid>("SessionToken")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("SessionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SignatureDataBase64")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("SmsOptIn")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SubmittedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedCustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedJobId");
|
||||||
|
|
||||||
|
b.HasIndex("SessionToken")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("KioskSessions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -6565,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6576,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6587,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976),
|
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7645,6 +7779,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("InvoiceId")
|
b.Property<int>("InvoiceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8375,6 +8512,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal>("RemainingAmount")
|
b.Property<decimal>("RemainingAmount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -9703,6 +9843,23 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedCustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedJobId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("LinkedCustomer");
|
||||||
|
|
||||||
|
b.Navigation("LinkedJob");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<GiftCertificate>? _giftCertificates;
|
private IRepository<GiftCertificate>? _giftCertificates;
|
||||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -460,6 +463,10 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
||||||
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
// Job Templates
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
|
|||||||
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
|
|||||||
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
|
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
|
||||||
|
/// between the current running balance and the statement ending balance. The items list
|
||||||
|
/// includes both deposits and payments with their direction tag so Claude can reason about
|
||||||
|
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
|
||||||
|
/// target ending balance — items that together sum close to the required difference score
|
||||||
|
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
|
||||||
|
/// compact because we only need entity-type/id pairs plus a short reason per item.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
|
||||||
|
{
|
||||||
|
var apiKey = GetApiKey();
|
||||||
|
if (apiKey == null)
|
||||||
|
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
|
||||||
|
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
|
||||||
|
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
|
||||||
|
|
||||||
|
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
""suggestedCleared"": [
|
||||||
|
{
|
||||||
|
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
|
||||||
|
""entityId"": number,
|
||||||
|
""confidence"": number (0.0 to 1.0),
|
||||||
|
""reason"": ""string — one sentence why this item should be cleared""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
""insights"": [""string"", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
|
||||||
|
- Difference needed = statementEndingBalance - beginningBalance
|
||||||
|
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
|
||||||
|
- confidence 0.6-0.89: likely but not certain
|
||||||
|
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
|
||||||
|
- insights: 2-4 observations about patterns or items that need manual review
|
||||||
|
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
|
||||||
|
|
||||||
|
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
|
||||||
|
var needed = request.StatementEndingBalance - request.BeginningBalance;
|
||||||
|
|
||||||
|
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
|
||||||
|
|
||||||
|
Beginning Balance: {request.BeginningBalance:F2}
|
||||||
|
Statement Ending Balance: {request.StatementEndingBalance:F2}
|
||||||
|
Difference needed (deposits - payments): {needed:F2}
|
||||||
|
|
||||||
|
Uncleared transactions:
|
||||||
|
{itemsJson}";
|
||||||
|
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
var messageParams = new MessageParameters
|
||||||
|
{
|
||||||
|
Model = Model,
|
||||||
|
MaxTokens = 1024,
|
||||||
|
SystemMessage = systemPrompt,
|
||||||
|
Messages = new List<Message>
|
||||||
|
{
|
||||||
|
new Message
|
||||||
|
{
|
||||||
|
Role = RoleType.User,
|
||||||
|
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendAsync(client, messageParams);
|
||||||
|
var rawText = response.FirstMessage?.Text
|
||||||
|
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||||
|
?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||||
|
|
||||||
|
var raw = StripJsonFences(rawText);
|
||||||
|
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
|
||||||
|
if (parsed == null)
|
||||||
|
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||||
|
|
||||||
|
return new AutoMatchResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
|
||||||
|
{
|
||||||
|
EntityType = s.EntityType,
|
||||||
|
EntityId = s.EntityId,
|
||||||
|
Confidence = s.Confidence,
|
||||||
|
Reason = s.Reason
|
||||||
|
}).ToList(),
|
||||||
|
Insights = parsed.Insights ?? new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
|
||||||
|
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error running bank rec auto-match with AI");
|
||||||
|
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Predicts payment risk per open AR customer by combining current overdue status with
|
||||||
|
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
|
||||||
|
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 0–1 ratio rather than
|
||||||
|
/// raw counts, which produces more consistent confidence scoring across customers with very
|
||||||
|
/// different invoice volumes. Risk levels are validated against the three allowed values and
|
||||||
|
/// default to "medium" when Claude returns anything outside the expected set.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
|
||||||
|
{
|
||||||
|
var apiKey = GetApiKey();
|
||||||
|
if (apiKey == null)
|
||||||
|
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
|
||||||
|
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
|
||||||
|
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
""predictions"": [
|
||||||
|
{
|
||||||
|
""customerName"": ""string"",
|
||||||
|
""riskLevel"": ""high"" | ""medium"" | ""low"",
|
||||||
|
""estimatedDaysToPayment"": number,
|
||||||
|
""reasoning"": ""string — one sentence explaining the prediction""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
""insights"": [""string"", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
|
||||||
|
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
|
||||||
|
- riskLevel ""low"": customer typically pays on time and is not severely overdue
|
||||||
|
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
|
||||||
|
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
|
||||||
|
- Only include predictions for customers with open invoices";
|
||||||
|
|
||||||
|
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
|
||||||
|
{
|
||||||
|
c.CustomerName,
|
||||||
|
c.TotalOwed,
|
||||||
|
c.AvgDaysToPay,
|
||||||
|
LatePaymentRate = c.TotalInvoicesAllTime > 0
|
||||||
|
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
|
||||||
|
: 0,
|
||||||
|
c.OpenInvoices
|
||||||
|
}));
|
||||||
|
|
||||||
|
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
|
||||||
|
|
||||||
|
Customer data (includes historical payment behavior):
|
||||||
|
{customersJson}";
|
||||||
|
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
var messageParams = new MessageParameters
|
||||||
|
{
|
||||||
|
Model = Model,
|
||||||
|
MaxTokens = 1024,
|
||||||
|
SystemMessage = systemPrompt,
|
||||||
|
Messages = new List<Message>
|
||||||
|
{
|
||||||
|
new Message
|
||||||
|
{
|
||||||
|
Role = RoleType.User,
|
||||||
|
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendAsync(client, messageParams);
|
||||||
|
var rawText = response.FirstMessage?.Text
|
||||||
|
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||||
|
?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||||
|
|
||||||
|
var raw = StripJsonFences(rawText);
|
||||||
|
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
|
||||||
|
if (parsed == null)
|
||||||
|
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||||
|
|
||||||
|
var validRiskLevels = new[] { "high", "medium", "low" };
|
||||||
|
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
|
||||||
|
{
|
||||||
|
CustomerName = p.CustomerName,
|
||||||
|
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
|
||||||
|
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
|
||||||
|
Reasoning = p.Reasoning
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new LatePaymentPredictionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Predictions = predictions,
|
||||||
|
Insights = parsed.Insights ?? new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
|
||||||
|
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error predicting late payments with AI");
|
||||||
|
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
|
||||||
|
/// financial data. The context object is serialized to JSON and embedded in the user prompt
|
||||||
|
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
|
||||||
|
/// system prompt explicitly constrains Claude to the data provided and forbids it from
|
||||||
|
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
|
||||||
|
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
|
||||||
|
/// to justify the answer, displayed below the answer in the UI so users can verify.
|
||||||
|
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
|
||||||
|
{
|
||||||
|
var apiKey = GetApiKey();
|
||||||
|
if (apiKey == null)
|
||||||
|
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
|
||||||
|
Answer plain-English financial questions using ONLY the data provided in the context.
|
||||||
|
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
""answer"": ""string — direct, plain-English answer to the question"",
|
||||||
|
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
|
||||||
|
""relevantFacts"": [""string"", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- answer: be direct and specific with dollar amounts and percentages from the data
|
||||||
|
- If the data does not contain enough information to answer the question, say so clearly in the answer
|
||||||
|
- Do NOT invent or estimate figures that are not in the provided data
|
||||||
|
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
|
||||||
|
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
|
||||||
|
- Keep the answer under 100 words — be concise";
|
||||||
|
|
||||||
|
var contextJson = JsonSerializer.Serialize(request.Context);
|
||||||
|
var userPrompt = $@"Question: {request.Question}
|
||||||
|
|
||||||
|
Financial context:
|
||||||
|
{contextJson}";
|
||||||
|
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
var messageParams = new MessageParameters
|
||||||
|
{
|
||||||
|
Model = Model,
|
||||||
|
MaxTokens = 1500,
|
||||||
|
SystemMessage = systemPrompt,
|
||||||
|
Messages = new List<Message>
|
||||||
|
{
|
||||||
|
new Message
|
||||||
|
{
|
||||||
|
Role = RoleType.User,
|
||||||
|
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendAsync(client, messageParams);
|
||||||
|
var rawText = response.FirstMessage?.Text
|
||||||
|
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||||
|
?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||||
|
|
||||||
|
var raw = StripJsonFences(rawText);
|
||||||
|
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
|
||||||
|
if (parsed == null)
|
||||||
|
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||||
|
|
||||||
|
return new FinancialQueryResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Answer = parsed.Answer ?? string.Empty,
|
||||||
|
FollowUpSuggestion = parsed.FollowUpSuggestion,
|
||||||
|
RelevantFacts = parsed.RelevantFacts ?? new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
|
||||||
|
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error answering financial query with AI");
|
||||||
|
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes 6–12 months of historical bills to detect recurring payment patterns per vendor.
|
||||||
|
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
|
||||||
|
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
|
||||||
|
/// regular the cadence is — a bill appearing every 28–32 days for 6 consecutive months is
|
||||||
|
/// high confidence; 2–3 occurrences at similar amounts is medium. NextExpectedDateIso is
|
||||||
|
/// calculated by Claude from the pattern's most recent date plus the detected period length.
|
||||||
|
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
|
||||||
|
{
|
||||||
|
var apiKey = GetApiKey();
|
||||||
|
if (apiKey == null)
|
||||||
|
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
|
||||||
|
Analyze the provided bill history to detect recurring payment patterns per vendor.
|
||||||
|
Respond ONLY with a valid JSON object — no markdown, no explanation.
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{
|
||||||
|
""patterns"": [
|
||||||
|
{
|
||||||
|
""vendorName"": ""string"",
|
||||||
|
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
|
||||||
|
""typicalAmount"": number,
|
||||||
|
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
|
||||||
|
""confidence"": ""high"" | ""medium"" | ""low"",
|
||||||
|
""description"": ""string — one sentence describing the pattern"",
|
||||||
|
""suggestedAction"": ""string — one specific action to take, or null""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
""insights"": [""string"", ...]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only report patterns with at least 2 occurrences
|
||||||
|
- monthly: bills occurring every 25–35 days
|
||||||
|
- quarterly: bills occurring every 80–100 days
|
||||||
|
- biannual: bills occurring every 170–195 days
|
||||||
|
- annual: bills occurring roughly once per year
|
||||||
|
- irregular: a vendor bills regularly but the cadence is inconsistent
|
||||||
|
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
|
||||||
|
- confidence ""medium"": 2–3 occurrences with consistent timing, or 4+ with variable timing
|
||||||
|
- confidence ""low"": pattern is weak but worth monitoring
|
||||||
|
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
|
||||||
|
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
|
||||||
|
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
|
||||||
|
- If no recurring patterns are found, return an empty patterns array";
|
||||||
|
|
||||||
|
// Group bills by vendor for clarity in the prompt
|
||||||
|
var grouped = request.Bills
|
||||||
|
.GroupBy(b => b.VendorName)
|
||||||
|
.Select(g => new
|
||||||
|
{
|
||||||
|
VendorName = g.Key,
|
||||||
|
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
|
||||||
|
});
|
||||||
|
|
||||||
|
var billsJson = JsonSerializer.Serialize(grouped);
|
||||||
|
|
||||||
|
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
|
||||||
|
Data covers the last 6–12 months of bills, grouped by vendor.
|
||||||
|
|
||||||
|
Bill history by vendor:
|
||||||
|
{billsJson}";
|
||||||
|
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
var messageParams = new MessageParameters
|
||||||
|
{
|
||||||
|
Model = Model,
|
||||||
|
MaxTokens = 1500,
|
||||||
|
SystemMessage = systemPrompt,
|
||||||
|
Messages = new List<Message>
|
||||||
|
{
|
||||||
|
new Message
|
||||||
|
{
|
||||||
|
Role = RoleType.User,
|
||||||
|
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await SendAsync(client, messageParams);
|
||||||
|
var rawText = response.FirstMessage?.Text
|
||||||
|
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
|
||||||
|
?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(rawText))
|
||||||
|
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
|
||||||
|
|
||||||
|
var raw = StripJsonFences(rawText);
|
||||||
|
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
|
||||||
|
if (parsed == null)
|
||||||
|
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
|
||||||
|
|
||||||
|
var validConfidence = new[] { "high", "medium", "low" };
|
||||||
|
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
|
||||||
|
|
||||||
|
return new RecurringBillDetectionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
|
||||||
|
{
|
||||||
|
VendorName = p.VendorName,
|
||||||
|
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
|
||||||
|
TypicalAmount = p.TypicalAmount,
|
||||||
|
NextExpectedDateIso = p.NextExpectedDateIso,
|
||||||
|
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
|
||||||
|
Description = p.Description,
|
||||||
|
SuggestedAction = p.SuggestedAction
|
||||||
|
}).ToList(),
|
||||||
|
Insights = parsed.Insights ?? new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
|
||||||
|
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error detecting recurring bills with AI");
|
||||||
|
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,7 +435,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum
|
|||||||
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
||||||
}
|
}
|
||||||
|
|
||||||
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
|
string coatingSpeedLine;
|
||||||
|
if (coatingRate > 0)
|
||||||
|
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
|
||||||
|
else
|
||||||
|
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
|
||||||
|
|
||||||
|
var rateInstruction = (blastRate > 0 || coatingRate > 0)
|
||||||
|
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
|
||||||
|
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
|
||||||
|
|
||||||
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
||||||
|
|
||||||
@@ -453,7 +461,7 @@ Company operating costs for your reference:
|
|||||||
{shopSpeedLine}
|
{shopSpeedLine}
|
||||||
{coatingSpeedLine}
|
{coatingSpeedLine}
|
||||||
|
|
||||||
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
|
{rateInstruction}
|
||||||
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
||||||
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
||||||
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
||||||
@@ -547,9 +555,9 @@ Respond with the JSON object only.";
|
|||||||
_ => 0
|
_ => 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
|
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
|
||||||
// The unit price × quantity must equal the total batch labor cost.
|
// Unit price is per item; the caller multiplies by quantity for the line total.
|
||||||
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
|
var rawPerItemMinutes = aiResult.EstimatedMinutes;
|
||||||
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
||||||
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
||||||
var laborHours = perItemMinutes / 60m;
|
var laborHours = perItemMinutes / 60m;
|
||||||
@@ -611,7 +619,7 @@ Respond with the JSON object only.";
|
|||||||
CoatCount = request.CoatCount,
|
CoatCount = request.CoatCount,
|
||||||
MaterialCost = Math.Round(materialCost, 2),
|
MaterialCost = Math.Round(materialCost, 2),
|
||||||
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
||||||
EstimatedMinutes = (int)Math.Round(perItemMinutes),
|
EstimatedMinutes = perItemMinutes,
|
||||||
MaterialMinMinutes = materialMinMinutes,
|
MaterialMinMinutes = materialMinMinutes,
|
||||||
MinFloorApplied = minFloorApplied,
|
MinFloorApplied = minFloorApplied,
|
||||||
LaborCost = Math.Round(laborCost, 2),
|
LaborCost = Math.Round(laborCost, 2),
|
||||||
|
|||||||
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
|
|||||||
identity.AddClaim(new Claim("Permission", "ViewReports"));
|
identity.AddClaim(new Claim("Permission", "ViewReports"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.CanManageBills)
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim("Permission", "ManageBills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.CanManageAccounting)
|
||||||
|
{
|
||||||
|
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
|
||||||
|
}
|
||||||
|
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true)
|
||||||
{
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
// Always count churned regardless of hideChurned so the banner can show a number.
|
||||||
|
var churnedCount = await _context.Companies
|
||||||
|
.AsNoTracking()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(c => !c.IsDeleted
|
||||||
|
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
var query = _context.Companies
|
var query = _context.Companies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (hideChurned)
|
||||||
|
query = query.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var s = searchTerm.ToLower();
|
var s = searchTerm.ToLower();
|
||||||
@@ -61,12 +81,16 @@ public class CompanyListService : ICompanyListService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return (companies, totalCount);
|
return (companies, totalCount, churnedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||||
{
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var d30 = now.AddDays(-30);
|
||||||
|
var d90 = now.AddDays(-90);
|
||||||
|
|
||||||
var jobCounts = await _context.Jobs
|
var jobCounts = await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||||
@@ -98,6 +122,32 @@ public class CompanyListService : ICompanyListService
|
|||||||
x => x.CompanyId,
|
x => x.CompanyId,
|
||||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
||||||
|
|
||||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
|
var jobs30 = await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d30)
|
||||||
|
.GroupBy(j => j.CompanyId)
|
||||||
|
.Select(g => new { g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||||
|
|
||||||
|
var jobs90 = await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d90)
|
||||||
|
.GroupBy(j => j.CompanyId)
|
||||||
|
.Select(g => new { g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||||
|
|
||||||
|
var lastLoginRaw = await _context.Users
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null)
|
||||||
|
.GroupBy(u => u.CompanyId)
|
||||||
|
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lastLogins = lastLoginRaw.ToDictionary(
|
||||||
|
x => x.CompanyId,
|
||||||
|
x => x.Last);
|
||||||
|
|
||||||
|
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo,
|
||||||
|
jobs30, jobs90, lastLogins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
if (unlinkedRevenue > 0)
|
if (unlinkedRevenue > 0)
|
||||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||||
|
|
||||||
|
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||||
|
var periodDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
var periodCredits = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
var totalDeductions = periodDiscounts + periodCredits;
|
||||||
|
if (totalDeductions > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "4950",
|
||||||
|
AccountName = "Less: Sales Discounts & Credits",
|
||||||
|
Amount = -totalDeductions
|
||||||
|
});
|
||||||
|
|
||||||
|
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||||
|
var periodGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
if (periodGcReclassified > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "2500",
|
||||||
|
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
|
||||||
|
Amount = -periodGcReclassified
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||||
|
var periodGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||||
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
if (periodGcBreakage > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "—",
|
||||||
|
AccountName = "Gift Certificate Breakage Income",
|
||||||
|
Amount = periodGcBreakage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
||||||
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||||
|
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var taxByAcct = await _context.Invoices
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||||
|
arCredits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
||||||
|
arCredits -= await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
|
||||||
// Retained earnings = net P&L from inception through asOf
|
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||||
|
var refundsByAcctBs = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
var depositsByAcctDepBs = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// Retained earnings = net P&L from inception through asOf, covering four sources:
|
||||||
|
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
|
||||||
|
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||||
|
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||||
var lifetimeRevenue = await _context.InvoiceItems
|
var lifetimeRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
var lifetimeCogs = await _context.Expenses
|
var lifetimeDiscounts = isCash ? 0m
|
||||||
|
: (await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||||
|
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
||||||
|
var lifetimeCreditMemos = isCash ? 0m
|
||||||
|
: (await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
||||||
|
var lifetimeDirectExp = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.Date <= asOfEnd)
|
||||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||||
var lifetimeBillCosts = await _context.BillLineItems
|
var lifetimeBillCosts = await _context.BillLineItems
|
||||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
|
||||||
|
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||||
|
var revenueAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
var expCogsAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId
|
||||||
|
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
|
||||||
|
&& !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
|
||||||
|
var jeRevNet = revenueAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => revenueAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
|
||||||
|
var jeExpNet = expCogsAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => expCogsAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||||
|
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||||
|
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
|
||||||
|
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||||
|
- lifetimeDiscounts
|
||||||
|
- lifetimeCreditMemos
|
||||||
|
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||||
|
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||||
|
- lifetimeDirectExp
|
||||||
|
- lifetimeBillCosts
|
||||||
|
- jeExpNet;
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.IsActive)
|
.Where(a => a.IsActive)
|
||||||
@@ -248,6 +415,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
{
|
{
|
||||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebitsBs; // deposits applied → DR liability
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Balances are computed dynamically from transaction tables using the same pre-computed
|
||||||
|
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
|
||||||
|
/// date is respected. This replaces the previous implementation that read the denormalised
|
||||||
|
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
|
||||||
|
/// what date was selected.
|
||||||
|
/// </remarks>
|
||||||
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||||
{
|
{
|
||||||
|
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||||
var companyName = await GetCompanyNameAsync(companyId);
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
|
||||||
|
|
||||||
|
// Bank/cash: customer payments deposited here (DR)
|
||||||
|
var depositsByAcct = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||||
|
// issues a credit note and it is matched against a specific bill.
|
||||||
|
var vcByApAcct = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: expenses paid from here (CR)
|
||||||
|
var expFromByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.PaymentAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: bill payments made from here (CR)
|
||||||
|
var bpFromByAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.BankAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bills increase AP (CR)
|
||||||
|
var billsByApAcct = await _context.Bills
|
||||||
|
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(b => b.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bill payments reduce AP (DR)
|
||||||
|
var bpByApAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.Bill.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Tax liability: sales tax collected (CR)
|
||||||
|
var taxByAcct = await _context.Invoices
|
||||||
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Revenue accounts: invoice line items (CR)
|
||||||
|
var revenueByAcct = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.RevenueAccountId != null
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense accounts: direct expenses (DR)
|
||||||
|
var expenseByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense/COGS accounts: vendor bill line items (DR)
|
||||||
|
var billLinesByAcct = await _context.BillLineItems
|
||||||
|
.Where(bli => bli.AccountId != null
|
||||||
|
&& bli.Bill.Status != BillStatus.Draft
|
||||||
|
&& bli.Bill.Status != BillStatus.Voided
|
||||||
|
&& bli.Bill.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(bli => bli.AccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||||
|
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||||
|
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||||
|
var discountAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
discountAcctId ??= await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
|
||||||
|
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
var cmApplied = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
|
||||||
|
var discountsByAcct = new Dictionary<int, decimal>();
|
||||||
|
if (discountAcctId.HasValue)
|
||||||
|
{
|
||||||
|
var totalDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.DiscountAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft
|
||||||
|
&& i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
if (totalDiscounts + cmApplied > 0)
|
||||||
|
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JE lines: posted entries debit/credit all account types
|
||||||
|
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AR totals (single AR account assumed per standard small-business chart of accounts).
|
||||||
|
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||||
|
// when a customer credit is applied against a specific invoice).
|
||||||
|
var arTotalDebits = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||||
|
var arTotalCredits = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||||
|
|
||||||
|
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
||||||
|
var refundTotal = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
arTotalCredits -= refundTotal;
|
||||||
|
|
||||||
|
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||||
|
var refundsByAcct = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||||
|
var depositsByAcctDep = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// ── Per-account balance computation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var lines = new List<TrialBalanceLine>();
|
decimal ComputeAsOfBalance(Account a)
|
||||||
|
{
|
||||||
|
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
|
||||||
|
decimal debits = 0m, credits = 0m;
|
||||||
|
|
||||||
|
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||||
|
{
|
||||||
|
debits = arTotalDebits;
|
||||||
|
credits = arTotalCredits;
|
||||||
|
}
|
||||||
|
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
|
{
|
||||||
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// All other accounts: sum contributions from each transaction source that can
|
||||||
|
// post to this account. Dictionaries only contain entries for relevant account IDs,
|
||||||
|
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
|
||||||
|
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += revenueByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebits; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCredits; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebits; // deposits applied → DR liability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||||
|
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
|
||||||
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
|
? a.OpeningBalance : 0m;
|
||||||
|
decimal net = isDebitNormal ? debits - credits : credits - debits;
|
||||||
|
return opening + net;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<TrialBalanceLine>();
|
||||||
foreach (var acct in accounts)
|
foreach (var acct in accounts)
|
||||||
{
|
{
|
||||||
if (acct.CurrentBalance == 0) continue;
|
var balance = ComputeAsOfBalance(acct);
|
||||||
|
if (balance == 0m) continue;
|
||||||
|
|
||||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||||
var line = new TrialBalanceLine
|
var line = new TrialBalanceLine
|
||||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
if (isDebitNormal)
|
if (isDebitNormal)
|
||||||
{
|
{
|
||||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.DebitBalance = balance;
|
||||||
else line.CreditBalance = -acct.CurrentBalance;
|
else line.CreditBalance = -balance;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.CreditBalance = balance;
|
||||||
else line.DebitBalance = -acct.CurrentBalance;
|
else line.DebitBalance = -balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.Add(line);
|
lines.Add(line);
|
||||||
|
|||||||
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
|||||||
LinkId = p.InvoiceId
|
LinkId = p.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||||
|
var depositedDeposits = await _context.Deposits
|
||||||
|
.Where(d => d.DepositAccountId == accountId
|
||||||
|
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var d in depositedDeposits)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.ReceivedDate,
|
||||||
|
Reference = d.ReceiptNumber,
|
||||||
|
Source = "Customer Deposit",
|
||||||
|
Description = d.Notes ?? d.Reference,
|
||||||
|
Debit = d.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "Jobs",
|
||||||
|
LinkId = d.JobId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||||
|
var refundsPaidFrom = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => r.DepositAccountId == accountId
|
||||||
|
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in refundsPaidFrom)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate,
|
||||||
|
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||||
|
Source = "Refund",
|
||||||
|
Description = r.Reason,
|
||||||
|
Debit = 0,
|
||||||
|
Credit = r.Amount,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
|
|
||||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||||
// e.g. Checking account used to pay an expense
|
// e.g. Checking account used to pay an expense
|
||||||
var expensesPaidFrom = await _context.Expenses
|
var expensesPaidFrom = await _context.Expenses
|
||||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Invoices",
|
LinkController = "Invoices",
|
||||||
LinkId = p.InvoiceId
|
LinkId = p.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Credit memo applications reduce open AR (CREDIT)
|
||||||
|
var arCreditMemos = await _context.CreditMemoApplications
|
||||||
|
.Include(a => a.Invoice)
|
||||||
|
.Include(a => a.CreditMemo)
|
||||||
|
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cm in arCreditMemos)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = cm.AppliedDate,
|
||||||
|
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
|
||||||
|
Source = "Credit Memo",
|
||||||
|
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
|
||||||
|
Debit = 0,
|
||||||
|
Credit = cm.AmountApplied,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = cm.InvoiceId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||||
|
var arRefunds = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in arRefunds)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate,
|
||||||
|
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||||
|
Source = "Refund",
|
||||||
|
Description = r.Reason,
|
||||||
|
Debit = r.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||||
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Bills",
|
LinkController = "Bills",
|
||||||
LinkId = bp.BillId
|
LinkId = bp.BillId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
|
||||||
|
var apVendorCredits = await _context.VendorCreditApplications
|
||||||
|
.Include(vca => vca.VendorCredit)
|
||||||
|
.Include(vca => vca.Bill)
|
||||||
|
.Where(vca => vca.VendorCredit.APAccountId == accountId
|
||||||
|
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var vca in apVendorCredits)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = vca.AppliedDate,
|
||||||
|
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
|
||||||
|
Source = "Vendor Credit",
|
||||||
|
Description = $"Credit applied to {vca.Bill?.BillNumber}",
|
||||||
|
Debit = vca.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "VendorCredits",
|
||||||
|
LinkId = vca.VendorCreditId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
|
||||||
|
// CR when GC is issued; DR when redeemed or voided with remaining balance.
|
||||||
|
if (account.AccountNumber == "2500")
|
||||||
|
{
|
||||||
|
var gcIssued = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var gc in gcIssued)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = gc.IssueDate, Reference = gc.CertificateCode,
|
||||||
|
Source = "Gift Certificate", Description = "GC issued",
|
||||||
|
Debit = 0, Credit = gc.OriginalAmount,
|
||||||
|
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var gcRedemptions = await _context.GiftCertificateRedemptions
|
||||||
|
.Include(r => r.GiftCertificate)
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var r in gcRedemptions)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||||
|
Source = "GC Redemption", Description = "GC applied to invoice",
|
||||||
|
Debit = r.AmountRedeemed, Credit = 0,
|
||||||
|
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
|
||||||
|
});
|
||||||
|
|
||||||
|
var gcVoided = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
|
||||||
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var gc in gcVoided)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
|
||||||
|
Source = "GC Voided", Description = "Breakage income",
|
||||||
|
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
|
||||||
|
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12. Customer Deposits liability (account 2300) ────────────────────
|
||||||
|
// CR when deposit is recorded; DR when deposit is applied to an invoice.
|
||||||
|
if (account.AccountNumber == "2300")
|
||||||
|
{
|
||||||
|
var depositsRecorded = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var d in depositsRecorded)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
|
||||||
|
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
|
||||||
|
Debit = 0, Credit = d.Amount,
|
||||||
|
LinkController = "Jobs", LinkId = d.JobId
|
||||||
|
});
|
||||||
|
|
||||||
|
var depositsApplied = await _context.Deposits
|
||||||
|
.Include(d => d.AppliedToInvoice)
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
|
||||||
|
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var d in depositsApplied)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
|
||||||
|
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
|
||||||
|
Debit = d.Amount, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
|
|||||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
|
||||||
|
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||||
|
debits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
|
|
||||||
|
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||||
|
credits += await _context.Refunds
|
||||||
|
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||||
|
|
||||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||||
credits += await _context.Expenses
|
credits += await _context.Expenses
|
||||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||||
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
|
|||||||
credits += await _context.Payments
|
credits += await _context.Payments
|
||||||
.Where(p => p.PaymentDate < beforeDate)
|
.Where(p => p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
|
||||||
|
credits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
|
||||||
|
debits += await _context.Refunds
|
||||||
|
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Accounts Payable
|
// 9. Accounts Payable
|
||||||
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
|
|||||||
debits += await _context.BillPayments
|
debits += await _context.BillPayments
|
||||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||||
|
|
||||||
|
debits += await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
|
||||||
|
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. GC Liability (account 2500)
|
||||||
|
if (account.AccountNumber == "2500")
|
||||||
|
{
|
||||||
|
credits += await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
|
||||||
|
debits += await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||||
|
debits += await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Customer Deposits liability (account 2300)
|
||||||
|
if (account.AccountNumber == "2300")
|
||||||
|
{
|
||||||
|
credits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
|
debits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Posted journal entry lines touching this account (prior to period)
|
// 10. Posted journal entry lines touching this account (prior to period)
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
|
|||||||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||||||
/// standard "here is your invoice" message with no payment CTA.
|
/// standard "here is your invoice" message with no payment CTA.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
|
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
|
|||||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||||||
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
|
||||||
|
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
|
||||||
|
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
|
||||||
|
if (sendSms)
|
||||||
|
{
|
||||||
|
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||||
|
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||||
|
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["companyName"] = companyName,
|
||||||
|
["invoiceNumber"] = invoice.InvoiceNumber,
|
||||||
|
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||||||
|
["viewUrl"] = urlForSms
|
||||||
|
};
|
||||||
|
|
||||||
|
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
|
||||||
|
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
|
||||||
|
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||||
|
|
||||||
|
await WriteLog(new NotificationLog
|
||||||
|
{
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||||
|
RecipientName = customerName,
|
||||||
|
Recipient = smsPhone,
|
||||||
|
Message = message,
|
||||||
|
ErrorMessage = smsError,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
CompanyId = invoice.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
|
||||||
|
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
|
|||||||
"Invoice {{invoiceNumber}} from {{companyName}}",
|
"Invoice {{invoiceNumber}} from {{companyName}}",
|
||||||
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
),
|
),
|
||||||
|
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
|
||||||
|
null,
|
||||||
|
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
|
||||||
|
),
|
||||||
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
||||||
"Payment Received — Invoice {{invoiceNumber}}",
|
"Payment Received — Invoice {{invoiceNumber}}",
|
||||||
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public partial class SeedDataService
|
|||||||
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
|
||||||
|
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||||
|
// reducing net revenue to match the discounted AR amount that was posted.
|
||||||
|
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||||
@@ -96,4 +100,44 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
return accounts.Count;
|
return accounts.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
|
||||||
|
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
|
||||||
|
/// call repeatedly from the "Seed Lookup Tables" flow.
|
||||||
|
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
|
||||||
|
/// companies get all accounts in one pass while existing companies receive only the missing ones.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
|
||||||
|
private async Task<int> EnsureSystemAccountsAsync(Company company)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
int added = 0;
|
||||||
|
|
||||||
|
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
|
||||||
|
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
|
||||||
|
var has4950 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has4950)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "4950",
|
||||||
|
Name = "Sales Discounts",
|
||||||
|
AccountType = AccountType.Revenue,
|
||||||
|
AccountSubType = AccountSubType.OtherIncome,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Contra-revenue for invoice discounts granted to customers",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
|
|||||||
result.ItemsSeeded += accountsSeeded;
|
result.ItemsSeeded += accountsSeeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill any system accounts added after the initial seed (idempotent).
|
||||||
|
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||||
|
if (systemAccountsAdded > 0)
|
||||||
|
{
|
||||||
|
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||||
|
result.ItemsSeeded += systemAccountsAdded;
|
||||||
|
}
|
||||||
|
|
||||||
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||||
result.Details = details;
|
result.Details = details;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public static class AppConstants
|
|||||||
{
|
{
|
||||||
public const string CompanyAdmin = "CompanyAdmin";
|
public const string CompanyAdmin = "CompanyAdmin";
|
||||||
public const string Manager = "Manager";
|
public const string Manager = "Manager";
|
||||||
|
public const string Accountant = "Accountant";
|
||||||
public const string Worker = "Worker";
|
public const string Worker = "Worker";
|
||||||
public const string Viewer = "Viewer";
|
public const string Viewer = "Viewer";
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,8 @@ public static class AppConstants
|
|||||||
public const string CanManageMaintenance = "CanManageMaintenance";
|
public const string CanManageMaintenance = "CanManageMaintenance";
|
||||||
public const string CanManageInvoices = "CanManageInvoices";
|
public const string CanManageInvoices = "CanManageInvoices";
|
||||||
public const string CanViewReports = "CanViewReports";
|
public const string CanViewReports = "CanViewReports";
|
||||||
|
public const string CanManageBills = "CanManageBills";
|
||||||
|
public const string CanManageAccounting = "CanManageAccounting";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FileUpload
|
public static class FileUpload
|
||||||
@@ -103,6 +106,10 @@ public static class AppConstants
|
|||||||
public const string FinancialSummary = "FinancialSummary";
|
public const string FinancialSummary = "FinancialSummary";
|
||||||
public const string CashFlowForecast = "CashFlowForecast";
|
public const string CashFlowForecast = "CashFlowForecast";
|
||||||
public const string AnomalyDetection = "AnomalyDetection";
|
public const string AnomalyDetection = "AnomalyDetection";
|
||||||
|
public const string BankRecAutoMatch = "BankRecAutoMatch";
|
||||||
|
public const string LatePaymentPrediction = "LatePaymentPrediction";
|
||||||
|
public const string FinancialQuery = "FinancialQuery";
|
||||||
|
public const string RecurringBillDetection = "RecurringBillDetection";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Legal
|
public static class Legal
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.DTOs.AI;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IAccountingAiService _accountingAi;
|
||||||
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
|
||||||
public BankReconciliationsController(
|
public BankReconciliationsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext)
|
ITenantContext tenantContext,
|
||||||
|
IAccountingAiService accountingAi,
|
||||||
|
IAiUsageLogger usageLogger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
|
_accountingAi = accountingAi;
|
||||||
|
_usageLogger = usageLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool AllowAccounting() =>
|
private bool AllowAccounting() =>
|
||||||
@@ -49,7 +56,7 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
public async Task<IActionResult> Create()
|
public async Task<IActionResult> Create()
|
||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||||
@@ -58,7 +65,7 @@ public class BankReconciliationsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Create(BankReconciliation model)
|
public async Task<IActionResult> Create(BankReconciliation model)
|
||||||
{
|
{
|
||||||
@@ -164,7 +171,7 @@ public class BankReconciliationsController : Controller
|
|||||||
/// Returns updated running totals as JSON.
|
/// Returns updated running totals as JSON.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> ToggleCleared(
|
public async Task<IActionResult> ToggleCleared(
|
||||||
int reconId, string entityType, int entityId, bool isCleared)
|
int reconId, string entityType, int entityId, bool isCleared)
|
||||||
@@ -200,7 +207,7 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Complete(int id, decimal difference)
|
public async Task<IActionResult> Complete(int id, decimal difference)
|
||||||
{
|
{
|
||||||
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
|
|||||||
return View(recon);
|
return View(recon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
|
||||||
|
/// to mark as cleared. The controller assembles all three transaction types (deposits,
|
||||||
|
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
|
||||||
|
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
|
||||||
|
/// suggestions client-side by auto-checking the corresponding table rows.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> AiSuggestMatches(int reconId)
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return Forbid();
|
||||||
|
|
||||||
|
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||||
|
br => br.Id == reconId, false, br => br.Account))
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (recon == null) return NotFound();
|
||||||
|
|
||||||
|
var accountId = recon.AccountId;
|
||||||
|
var statementDate = recon.StatementDate;
|
||||||
|
|
||||||
|
var items = new List<BankRecMatchItem>();
|
||||||
|
|
||||||
|
(await _unitOfWork.Payments.FindAsync(
|
||||||
|
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(p => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "Payment",
|
||||||
|
EntityId = p.Id,
|
||||||
|
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = p.Reference ?? $"PMT-{p.Id}",
|
||||||
|
Description = $"Payment #{p.InvoiceId}",
|
||||||
|
Amount = p.Amount,
|
||||||
|
Direction = "deposit"
|
||||||
|
}));
|
||||||
|
|
||||||
|
(await _unitOfWork.BillPayments.FindAsync(
|
||||||
|
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(bp => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "BillPayment",
|
||||||
|
EntityId = bp.Id,
|
||||||
|
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = bp.PaymentNumber,
|
||||||
|
Description = bp.Memo ?? bp.BillId.ToString(),
|
||||||
|
Amount = bp.Amount,
|
||||||
|
Direction = "payment"
|
||||||
|
}));
|
||||||
|
|
||||||
|
(await _unitOfWork.Expenses.FindAsync(
|
||||||
|
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
|
||||||
|
.ToList()
|
||||||
|
.ForEach(e => items.Add(new BankRecMatchItem
|
||||||
|
{
|
||||||
|
EntityType = "Expense",
|
||||||
|
EntityId = e.Id,
|
||||||
|
Date = e.Date.ToString("yyyy-MM-dd"),
|
||||||
|
Reference = e.ExpenseNumber,
|
||||||
|
Description = e.Memo ?? string.Empty,
|
||||||
|
Amount = e.Amount,
|
||||||
|
Direction = "payment"
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!items.Any())
|
||||||
|
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
|
||||||
|
|
||||||
|
var request = new AutoMatchRequest
|
||||||
|
{
|
||||||
|
UnclearedItems = items,
|
||||||
|
BeginningBalance = recon.BeginningBalance,
|
||||||
|
StatementEndingBalance = recon.EndingBalance
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
|
||||||
|
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task PopulateAccountDropdownAsync()
|
private async Task PopulateAccountDropdownAsync()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
@@ -58,13 +58,13 @@ public class BillsController : Controller
|
|||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Index ────────────────────────────────────────────────────────────────
|
// -- Index ----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
|
||||||
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
|
||||||
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
|
||||||
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
|
||||||
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
|
||||||
@@ -112,7 +112,7 @@ public class BillsController : Controller
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
|
||||||
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
|
||||||
{
|
{
|
||||||
var expSearch = search;
|
var expSearch = search;
|
||||||
@@ -160,13 +160,13 @@ public class BillsController : Controller
|
|||||||
return View(pagedEntries);
|
return View(pagedEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// -- Create ---------------------------------------------------------------
|
||||||
|
|
||||||
// ── Create from Purchase Order ────────────────────────────────────────────
|
// -- Create from Purchase Order --------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
|
||||||
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
|
||||||
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
|
||||||
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
|
||||||
/// Line items are copied from PO items (using inventory item names where available), and
|
/// Line items are copied from PO items (using inventory item names where available), and
|
||||||
@@ -174,7 +174,7 @@ public class BillsController : Controller
|
|||||||
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
|
||||||
/// first active Expense/COGS account when the vendor has no default configured.
|
/// first active Expense/COGS account when the vendor has no default configured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
|
||||||
{
|
{
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
@@ -248,7 +248,7 @@ public class BillsController : Controller
|
|||||||
return View("Create", dto);
|
return View("Create", dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Create ───────────────────────────────────────────────────────────────
|
// -- Create ---------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
|
||||||
@@ -257,7 +257,7 @@ public class BillsController : Controller
|
|||||||
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
|
||||||
/// so the double-entry pair is ready without manual lookup.
|
/// so the double-entry pair is ready without manual lookup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Create(int? vendorId)
|
public async Task<IActionResult> Create(int? vendorId)
|
||||||
{
|
{
|
||||||
var dto = new CreateBillDto
|
var dto = new CreateBillDto
|
||||||
@@ -291,14 +291,14 @@ public class BillsController : Controller
|
|||||||
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
|
||||||
/// before validation to avoid spurious errors when the browser submits blank rows.
|
/// before validation to avoid spurious errors when the browser submits blank rows.
|
||||||
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
|
||||||
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
|
||||||
/// useful for entering historical bills that were already settled. Account balance side
|
/// useful for entering historical bills that were already settled. Account balance side
|
||||||
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
|
||||||
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
/// affect the AP ledger until they are approved. If the bill was created from a PO the
|
||||||
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
|
||||||
bool payNow = false,
|
bool payNow = false,
|
||||||
DateTime? paymentDate = null,
|
DateTime? paymentDate = null,
|
||||||
@@ -322,7 +322,7 @@ public class BillsController : Controller
|
|||||||
{
|
{
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
// Period lock check — block if the bill date is in a locked period
|
// Period lock check — block if the bill date is in a locked period
|
||||||
if (currentUser != null)
|
if (currentUser != null)
|
||||||
{
|
{
|
||||||
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||||
@@ -399,7 +399,7 @@ public class BillsController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Receipt upload after the transaction commits — bill.Id is set and core data
|
// Receipt upload after the transaction commits — bill.Id is set and core data
|
||||||
// is secure. A blob failure here leaves the bill intact without an attachment.
|
// is secure. A blob failure here leaves the bill intact without an attachment.
|
||||||
if (receiptFile != null && receiptFile.Length > 0)
|
if (receiptFile != null && receiptFile.Length > 0)
|
||||||
{
|
{
|
||||||
@@ -428,7 +428,7 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Details ──────────────────────────────────────────────────────────────
|
// -- Details --------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays full bill detail including line items, payments, and the payment entry form.
|
/// Displays full bill detail including line items, payments, and the payment entry form.
|
||||||
@@ -454,7 +454,7 @@ public class BillsController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.BankAccounts = bankAccounts
|
ViewBag.BankAccounts = bankAccounts
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||||
@@ -464,7 +464,7 @@ public class BillsController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit ─────────────────────────────────────────────────────────────────
|
// -- Edit -----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
|
||||||
@@ -472,7 +472,7 @@ public class BillsController : Controller
|
|||||||
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
|
||||||
/// audit trail.
|
/// audit trail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Edit(int? id)
|
public async Task<IActionResult> Edit(int? id)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
@@ -523,7 +523,7 @@ public class BillsController : Controller
|
|||||||
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
|
||||||
{
|
{
|
||||||
if (id != dto.Id) return NotFound();
|
if (id != dto.Id) return NotFound();
|
||||||
@@ -620,7 +620,7 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mark Open (Draft → Open) ─────────────────────────────────────────────
|
// -- Mark Open (Draft ? Open) ---------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
|
||||||
@@ -631,7 +631,7 @@ public class BillsController : Controller
|
|||||||
/// deferred from bill creation to give users a review window without polluting the ledger.
|
/// deferred from bill creation to give users a review window without polluting the ledger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> MarkOpen(int id)
|
public async Task<IActionResult> MarkOpen(int id)
|
||||||
{
|
{
|
||||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
|
||||||
@@ -669,7 +669,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Record Payment ───────────────────────────────────────────────────────
|
// -- Record Payment -------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
/// Records a full or partial payment against an open bill. Overpayment is blocked because
|
||||||
@@ -681,7 +681,7 @@ public class BillsController : Controller
|
|||||||
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@@ -752,7 +752,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Delete Payment ───────────────────────────────────────────────────────
|
// -- Delete Payment -------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses a previously recorded payment. All double-entry effects of
|
/// Reverses a previously recorded payment. All double-entry effects of
|
||||||
@@ -762,7 +762,7 @@ public class BillsController : Controller
|
|||||||
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -809,7 +809,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = billId });
|
return RedirectToAction(nameof(Details), new { id = billId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit Payment ─────────────────────────────────────────────────────────
|
// -- Edit Payment ---------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
|
||||||
@@ -818,7 +818,7 @@ public class BillsController : Controller
|
|||||||
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
/// amount on the AP side does not change so no AP balance adjustment is needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
|
||||||
{
|
{
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
@@ -863,11 +863,11 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Void ─────────────────────────────────────────────────────────────────
|
// -- Void -----------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
|
||||||
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
|
||||||
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
/// already recorded remain as historical cash transactions. The vendor balance is likewise
|
||||||
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
|
||||||
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
|
||||||
@@ -922,7 +922,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AJAX: Vendor default expense account ────────────────────────────────
|
// -- AJAX: Vendor default expense account --------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
|
||||||
@@ -940,7 +940,7 @@ public class BillsController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// -- Helpers --------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
|
||||||
@@ -979,7 +979,7 @@ public class BillsController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
|
||||||
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
|
||||||
/// records are included in the scan so payment numbers are never reused.
|
/// records are included in the scan so payment numbers are never reused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string> GeneratePaymentNumberAsync()
|
private async Task<string> GeneratePaymentNumberAsync()
|
||||||
@@ -994,7 +994,7 @@ public class BillsController : Controller
|
|||||||
return $"{prefix}{next:D4}";
|
return $"{prefix}{next:D4}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receipt File: Download / Remove ─────────────────────────────────────
|
// -- Receipt File: Download / Remove -------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
|
||||||
@@ -1022,7 +1022,7 @@ public class BillsController : Controller
|
|||||||
/// window where the UI shows a broken attachment link.
|
/// window where the UI shows a broken attachment link.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
public async Task<IActionResult> RemoveReceipt(int id)
|
public async Task<IActionResult> RemoveReceipt(int id)
|
||||||
{
|
{
|
||||||
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
|
||||||
@@ -1039,7 +1039,7 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
|
// -- AI: Receipt Scanning -------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
|
||||||
@@ -1051,7 +1051,7 @@ public class BillsController : Controller
|
|||||||
/// model can match categories to the company's specific chart of accounts.
|
/// model can match categories to the company's specific chart of accounts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
|
||||||
{
|
{
|
||||||
@@ -1092,7 +1092,7 @@ public class BillsController : Controller
|
|||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI: Account Suggestion ────────────────────────────────────────────────
|
// -- AI: Account Suggestion ------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
/// AI-powered account categorisation for a single bill line item. When the caller does not
|
||||||
@@ -1103,7 +1103,7 @@ public class BillsController : Controller
|
|||||||
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
|
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
|
||||||
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
|
||||||
{
|
{
|
||||||
@@ -1136,7 +1136,69 @@ public class BillsController : Controller
|
|||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Receipt File Helpers ──────────────────────────────────────────────────
|
// -- AI: Recurring Bill Detection ------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
|
||||||
|
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult RecurringDetection() => View();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
|
||||||
|
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
|
||||||
|
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
|
||||||
|
/// Results are returned as JSON for client-side rendering in the view.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> RunRecurringDetection()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||||
|
var cutoff = DateTime.Today.AddMonths(-12);
|
||||||
|
|
||||||
|
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
||||||
|
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (!bills.Any())
|
||||||
|
return Json(new RecurringBillDetectionResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Insights = new List<string> { "No bill history found in the last 12 months." }
|
||||||
|
});
|
||||||
|
|
||||||
|
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
|
||||||
|
|
||||||
|
var request = new RecurringBillDetectionRequest
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
Bills = bills.Select(b => new RecurringBillHistoryItem
|
||||||
|
{
|
||||||
|
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
|
||||||
|
BillNumber = b.BillNumber,
|
||||||
|
Amount = b.Total,
|
||||||
|
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
|
||||||
|
Memo = b.Memo
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _accountingAi.DetectRecurringBillsAsync(request);
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error running recurring bill detection");
|
||||||
|
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Receipt File Helpers --------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Uploads a receipt file to Azure Blob Storage under the path
|
/// Uploads a receipt file to Azure Blob Storage under the path
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
|||||||
string sortColumn = "CompanyName",
|
string sortColumn = "CompanyName",
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 25)
|
int pageSize = 25,
|
||||||
|
bool showChurned = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||||
|
|
||||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||||
|
|
||||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||||
|
|
||||||
@@ -82,6 +83,8 @@ public class CompaniesController : Controller
|
|||||||
{
|
{
|
||||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||||
|
var companyById = companies.ToDictionary(c => c.Id);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var dto in companyDtos)
|
foreach (var dto in companyDtos)
|
||||||
{
|
{
|
||||||
@@ -95,6 +98,23 @@ public class CompaniesController : Controller
|
|||||||
dto.WizardCompletedAt = w.CompletedAt;
|
dto.WizardCompletedAt = w.CompletedAt;
|
||||||
dto.WizardCompletedByName = w.CompletedByName;
|
dto.WizardCompletedByName = w.CompletedByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health badge
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
|
||||||
|
if (companyById.TryGetValue(dto.Id, out var co))
|
||||||
|
{
|
||||||
|
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
|
||||||
|
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
|
||||||
|
&& dto.CreatedAt < now.AddDays(-7);
|
||||||
|
dto.HealthScore = score;
|
||||||
|
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.LastLoginDate = lastLogin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +129,8 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
return View(companyDtos);
|
return View(companyDtos);
|
||||||
}
|
}
|
||||||
@@ -183,7 +205,8 @@ public class CompaniesController : Controller
|
|||||||
.GetByIdAsync(id, ignoreQueryFilters: true,
|
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||||
c => c.Users,
|
c => c.Users,
|
||||||
c => c.Customers,
|
c => c.Customers,
|
||||||
c => c.Jobs);
|
c => c.Jobs,
|
||||||
|
c => c.Preferences!);
|
||||||
|
|
||||||
if (company == null)
|
if (company == null)
|
||||||
{
|
{
|
||||||
@@ -196,6 +219,51 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
||||||
|
|
||||||
|
// Health data
|
||||||
|
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
|
||||||
|
var totalJobs = companyDto.JobCount;
|
||||||
|
var totalCust = companyDto.CustomerCount;
|
||||||
|
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
|
||||||
|
|
||||||
|
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
|
||||||
|
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
|
||||||
|
&& company.CreatedAt < now.AddDays(-7);
|
||||||
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
|
||||||
|
|
||||||
|
ViewBag.HealthScore = healthScore;
|
||||||
|
ViewBag.HealthRisk = riskLevel.ToString();
|
||||||
|
ViewBag.HealthSignals = healthSignals;
|
||||||
|
ViewBag.Jobs30 = j30;
|
||||||
|
ViewBag.Jobs90 = j90;
|
||||||
|
ViewBag.LastLoginDate = lastLogin;
|
||||||
|
|
||||||
|
// Onboarding data (from Preferences)
|
||||||
|
var prefs = company.Preferences;
|
||||||
|
int steps = 0;
|
||||||
|
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||||
|
|
||||||
|
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
|
||||||
|
{
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyName = company.CompanyName ?? "",
|
||||||
|
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
|
||||||
|
OnboardingPath = prefs?.OnboardingPath,
|
||||||
|
StepsCompleted = steps,
|
||||||
|
TotalSteps = 3,
|
||||||
|
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||||
|
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||||
|
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||||
|
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||||
|
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||||
|
};
|
||||||
|
|
||||||
return View(companyDto);
|
return View(companyDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
|||||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
|
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var d30 = now.AddDays(-30);
|
var d30 = now.AddDays(-30);
|
||||||
var d90 = now.AddDays(-90);
|
var d90 = now.AddDays(-90);
|
||||||
|
var churnedCutoff = now.AddDays(-14);
|
||||||
|
|
||||||
// One query per signal — all keyed by CompanyId
|
// One query per signal — all keyed by CompanyId
|
||||||
var companies = await _db.Companies
|
var allCompanies = await _db.Companies
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var churnedCount = allCompanies.Count(c =>
|
||||||
|
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
|
||||||
|
|
||||||
|
var companies = showChurned
|
||||||
|
? allCompanies
|
||||||
|
: allCompanies.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var lastLogins = await _db.Users
|
var lastLogins = await _db.Users
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(u => u.LastLoginDate != null)
|
.Where(u => u.LastLoginDate != null)
|
||||||
@@ -118,15 +130,12 @@ public class CompanyHealthController : Controller
|
|||||||
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
||||||
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
||||||
|
|
||||||
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||||
|
|
||||||
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||||
&& c.CreatedAt < now.AddDays(-7);
|
&& c.CreatedAt < now.AddDays(-7);
|
||||||
|
|
||||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||||
: score >= 75 ? ChurnRisk.Healthy
|
|
||||||
: score >= 45 ? ChurnRisk.AtRisk
|
|
||||||
: ChurnRisk.Critical;
|
|
||||||
|
|
||||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||||
@@ -166,6 +175,8 @@ public class CompanyHealthController : Controller
|
|||||||
ViewBag.Risk = risk;
|
ViewBag.Risk = risk;
|
||||||
ViewBag.Search = search;
|
ViewBag.Search = search;
|
||||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
all = all.Where(h =>
|
all = all.Where(h =>
|
||||||
@@ -187,112 +198,10 @@ public class CompanyHealthController : Controller
|
|||||||
return View(all);
|
return View(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a
|
|
||||||
/// single company based on its subscription status, login recency, and job activity.
|
|
||||||
/// <para>
|
|
||||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
|
||||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
|
||||||
/// <item>Subscription within grace period: −30 pts.</item>
|
|
||||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
|
||||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
|
||||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
|
||||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
|
||||||
/// </list>
|
|
||||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
|
||||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private static (int score, List<string> signals) ComputeHealth(
|
|
||||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
|
||||||
int j30, int j90, int totalJobs, DateTime now)
|
|
||||||
{
|
|
||||||
var score = 100;
|
|
||||||
var signals = new List<string>();
|
|
||||||
|
|
||||||
if (!c.IsActive)
|
|
||||||
{
|
|
||||||
signals.Add("Account disabled");
|
|
||||||
return (0, signals);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription health (skip for comped)
|
|
||||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
|
||||||
{
|
|
||||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
|
||||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
|
||||||
{
|
|
||||||
score -= 50;
|
|
||||||
signals.Add("Subscription expired");
|
|
||||||
}
|
|
||||||
else if (daysUntil < 0)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("In grace period");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 7)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 14)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login activity
|
|
||||||
if (daysSinceLogin == -1)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("Never logged in");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 90)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 60)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 30)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job activity
|
|
||||||
if (totalJobs == 0)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add("No jobs ever");
|
|
||||||
}
|
|
||||||
else if (j90 == 0)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add("No jobs in 90d");
|
|
||||||
}
|
|
||||||
else if (j30 == 0)
|
|
||||||
{
|
|
||||||
score -= 5;
|
|
||||||
signals.Add("No jobs in 30d");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (Math.Max(0, score), signals);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View models ────────────────────────────────────────────────────────────────
|
// ── View models ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
|
||||||
|
|
||||||
public class CompanyHealthDto
|
public class CompanyHealthDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||||
|
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||||
|
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||||
|
/// </summary>
|
||||||
|
public static class CompanyHealthHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a 0–100 health score and a list of human-readable risk signals for a single
|
||||||
|
/// company based on its subscription status, login recency, and job activity.
|
||||||
|
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||||
|
/// </summary>
|
||||||
|
public static (int Score, List<string> Signals) ComputeHealth(
|
||||||
|
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||||
|
{
|
||||||
|
var score = 100;
|
||||||
|
var signals = new List<string>();
|
||||||
|
|
||||||
|
if (!c.IsActive)
|
||||||
|
{
|
||||||
|
signals.Add("Account disabled");
|
||||||
|
return (0, signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||||
|
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||||
|
{
|
||||||
|
score -= 50;
|
||||||
|
signals.Add("Subscription expired");
|
||||||
|
}
|
||||||
|
else if (daysUntil < 0)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("In grace period");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 7)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 14)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysSinceLogin == -1)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("Never logged in");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 90)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 60)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 30)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalJobs == 0)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add("No jobs ever");
|
||||||
|
}
|
||||||
|
else if (j90 == 0)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add("No jobs in 90d");
|
||||||
|
}
|
||||||
|
else if (j30 == 0)
|
||||||
|
{
|
||||||
|
score -= 5;
|
||||||
|
signals.Add("No jobs in 30d");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Math.Max(0, score), signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||||
|
/// </summary>
|
||||||
|
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||||
|
neverActivated ? ChurnRisk.NeverActivated
|
||||||
|
: score >= 75 ? ChurnRisk.Healthy
|
||||||
|
: score >= 45 ? ChurnRisk.AtRisk
|
||||||
|
: ChurnRisk.Critical;
|
||||||
|
}
|
||||||
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
|
|||||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
UpdatePreferences(dto, "Work order settings saved successfully.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
|
||||||
|
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
||||||
|
/// </summary>
|
||||||
|
// POST: CompanySettings/UpdateKioskSettings
|
||||||
|
[HttpPost]
|
||||||
|
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
|
||||||
|
UpdatePreferences(dto, "Kiosk settings saved successfully.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
||||||
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||||
@@ -2685,6 +2694,7 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
||||||
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
||||||
|
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == NotificationType.PaymentReceived)
|
if (type == NotificationType.PaymentReceived)
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin,
|
AppConstants.CompanyRoles.CompanyAdmin,
|
||||||
AppConstants.CompanyRoles.Manager,
|
AppConstants.CompanyRoles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant,
|
||||||
AppConstants.CompanyRoles.Worker,
|
AppConstants.CompanyRoles.Worker,
|
||||||
AppConstants.CompanyRoles.Viewer
|
AppConstants.CompanyRoles.Viewer
|
||||||
};
|
};
|
||||||
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
|
|||||||
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
CanManageVendors = forceAllPermissions || model.CanManageVendors,
|
||||||
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
|
||||||
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
|
||||||
CanViewReports = forceAllPermissions || model.CanViewReports
|
CanViewReports = forceAllPermissions || model.CanViewReports,
|
||||||
|
CanManageBills = forceAllPermissions || model.CanManageBills,
|
||||||
|
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(user, model.Password);
|
var result = await _userManager.CreateAsync(user, model.Password);
|
||||||
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
|
||||||
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
|
||||||
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
|
||||||
_ => AppConstants.Roles.ReadOnly
|
_ => AppConstants.Roles.ReadOnly
|
||||||
};
|
};
|
||||||
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
|
|||||||
CanManageVendors = user.CanManageVendors,
|
CanManageVendors = user.CanManageVendors,
|
||||||
CanManageMaintenance = user.CanManageMaintenance,
|
CanManageMaintenance = user.CanManageMaintenance,
|
||||||
CanManageInvoices = user.CanManageInvoices,
|
CanManageInvoices = user.CanManageInvoices,
|
||||||
CanViewReports = user.CanViewReports
|
CanViewReports = user.CanViewReports,
|
||||||
|
CanManageBills = user.CanManageBills,
|
||||||
|
CanManageAccounting = user.CanManageAccounting
|
||||||
};
|
};
|
||||||
|
|
||||||
ViewBag.ReturnUrl = returnUrl;
|
ViewBag.ReturnUrl = returnUrl;
|
||||||
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
|
|||||||
{
|
{
|
||||||
AppConstants.CompanyRoles.CompanyAdmin,
|
AppConstants.CompanyRoles.CompanyAdmin,
|
||||||
AppConstants.CompanyRoles.Manager,
|
AppConstants.CompanyRoles.Manager,
|
||||||
|
AppConstants.CompanyRoles.Accountant,
|
||||||
AppConstants.CompanyRoles.Worker,
|
AppConstants.CompanyRoles.Worker,
|
||||||
AppConstants.CompanyRoles.Viewer
|
AppConstants.CompanyRoles.Viewer
|
||||||
};
|
};
|
||||||
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
|
|||||||
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
|
||||||
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
|
||||||
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
user.CanViewReports = forceAllPermissions || model.CanViewReports;
|
||||||
|
user.CanManageBills = forceAllPermissions || model.CanManageBills;
|
||||||
|
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
var result = await _userManager.UpdateAsync(user);
|
var result = await _userManager.UpdateAsync(user);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -15,6 +16,9 @@ namespace PowderCoating.Web.Controllers;
|
|||||||
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
/// 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
|
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||||
/// customer.CreditBalance atomically inside a transaction.
|
/// 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>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
public class CreditMemosController : Controller
|
public class CreditMemosController : Controller
|
||||||
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
|
|||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<CreditMemosController> _logger;
|
private readonly ILogger<CreditMemosController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public CreditMemosController(
|
public CreditMemosController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<CreditMemosController> logger)
|
ILogger<CreditMemosController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||||
@@ -245,6 +252,20 @@ public class CreditMemosController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ public class DashboardController : Controller
|
|||||||
|
|
||||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, 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);
|
return View(vm);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
|
|||||||
using QuestPDF.Fluent;
|
using QuestPDF.Fluent;
|
||||||
using QuestPDF.Helpers;
|
using QuestPDF.Helpers;
|
||||||
using QuestPDF.Infrastructure;
|
using QuestPDF.Infrastructure;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -22,17 +23,20 @@ public class DepositsController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<DepositsController> _logger;
|
private readonly ILogger<DepositsController> _logger;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public DepositsController(
|
public DepositsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<DepositsController> logger,
|
ILogger<DepositsController> logger,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -76,6 +80,7 @@ public class DepositsController : Controller
|
|||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||||
|
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||||
|
|
||||||
var deposit = new Deposit
|
var deposit = new Deposit
|
||||||
{
|
{
|
||||||
@@ -88,6 +93,7 @@ public class DepositsController : Controller
|
|||||||
ReceivedDate = receivedDate,
|
ReceivedDate = receivedDate,
|
||||||
Reference = reference,
|
Reference = reference,
|
||||||
Notes = notes,
|
Notes = notes,
|
||||||
|
DepositAccountId = checkingAcctId,
|
||||||
RecordedById = currentUser.Id,
|
RecordedById = currentUser.Id,
|
||||||
CompanyId = currentUser.CompanyId,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -97,6 +103,11 @@ public class DepositsController : Controller
|
|||||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||||
await _unitOfWork.CompleteAsync();
|
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
|
return Json(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -137,6 +148,11 @@ public class DepositsController : Controller
|
|||||||
if (deposit.AppliedToInvoiceId != null)
|
if (deposit.AppliedToInvoiceId != null)
|
||||||
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
|
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.Deposits.SoftDeleteAsync(id);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -419,6 +435,24 @@ public class DepositsController : Controller
|
|||||||
return hex.StartsWith("#") ? hex : fallback;
|
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)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
[Authorize(Roles = "SuperAdmin,Administrator")]
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||||
public class DiagnosticsController : Controller
|
public class DiagnosticsController : Controller
|
||||||
{
|
{
|
||||||
private readonly ILogger<DiagnosticsController> _logger;
|
private readonly ILogger<DiagnosticsController> _logger;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
|
|||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using PowderCoating.Web.Helpers;
|
using PowderCoating.Web.Helpers;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public GiftCertificatesController(
|
public GiftCertificatesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
|
|||||||
ILogger<GiftCertificatesController> logger,
|
ILogger<GiftCertificatesController> logger,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPdfService pdfService,
|
IPdfService pdfService,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
|
|||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
|
|||||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
|
||||||
return RedirectToAction(nameof(Details), new { id = cert.Id });
|
return RedirectToAction(nameof(Details), new { id = cert.Id });
|
||||||
}
|
}
|
||||||
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remaining = cert.RemainingBalance;
|
||||||
cert.Status = GiftCertificateStatus.Voided;
|
cert.Status = GiftCertificateStatus.Voided;
|
||||||
cert.UpdatedAt = DateTime.UtcNow;
|
cert.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
|
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
|
|||||||
ViewBag.Customers = list;
|
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)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -125,5 +125,13 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
return View();
|
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();
|
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.";
|
TempData["Success"] = "Inventory item created successfully.";
|
||||||
return RedirectToAction(nameof(Details), new { id = item.Id });
|
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." });
|
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||||
|
|
||||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||||
|
if (result.Success)
|
||||||
|
await EnrichFromCatalogAsync(result, autoContribute: true);
|
||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +778,39 @@ public class InventoryController : Controller
|
|||||||
result.SdsUrl ??= match.SdsUrl;
|
result.SdsUrl ??= match.SdsUrl;
|
||||||
result.TdsUrl ??= match.TdsUrl;
|
result.TdsUrl ??= match.TdsUrl;
|
||||||
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
|
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);
|
return (true, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,6 +828,7 @@ public class InventoryController : Controller
|
|||||||
VendorName = manufacturer,
|
VendorName = manufacturer,
|
||||||
Sku = sku,
|
Sku = sku,
|
||||||
ColorName = colorName,
|
ColorName = colorName,
|
||||||
|
UnitPrice = result.UnitCostPerLb ?? 0m,
|
||||||
CureTemperatureF = result.CureTemperatureF,
|
CureTemperatureF = result.CureTemperatureF,
|
||||||
CureTimeMinutes = result.CureTimeMinutes,
|
CureTimeMinutes = result.CureTimeMinutes,
|
||||||
Finish = result.Finish,
|
Finish = result.Finish,
|
||||||
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
|
|||||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
|
// Single query — all partial color/SKU matches across all vendors.
|
||||||
// if the scoped search returns nothing — prevents a cross-vendor color match from
|
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
|
||||||
// being returned as the only result when the user clearly intended a specific manufacturer.
|
// triggers auto-fill in the JS. Everything else goes to the picker modal.
|
||||||
IEnumerable<PowderCatalogItem> matches;
|
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
|
||||||
if (!string.IsNullOrEmpty(vendorTerm))
|
// 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.
|
||||||
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||||
p.VendorName.ToLower().Contains(vendorTerm) && (
|
|
||||||
p.Sku.ToLower() == term ||
|
|
||||||
p.ColorName.ToLower().Contains(term) ||
|
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.Sku.ToLower() == term ||
|
||||||
p.ColorName.ToLower().Contains(term) ||
|
|
||||||
p.Sku.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
|
var results = matches
|
||||||
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
|
||||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
.Select(p =>
|
||||||
.ThenBy(p => p.ColorName)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
{
|
||||||
id = p.Id,
|
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
|
||||||
vendorName = p.VendorName,
|
var colorExact = p.ColorName.ToLower() == term;
|
||||||
sku = p.Sku,
|
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
|
||||||
colorName = p.ColorName,
|
})
|
||||||
description = p.Description,
|
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
|
||||||
unitPrice = p.UnitPrice,
|
.ThenBy(x => x.p.ColorName)
|
||||||
imageUrl = p.ImageUrl,
|
.Select(x => new
|
||||||
sdsUrl = p.SdsUrl,
|
{
|
||||||
tdsUrl = p.TdsUrl,
|
id = x.p.Id,
|
||||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
vendorName = x.p.VendorName,
|
||||||
productUrl = p.ProductUrl,
|
sku = x.p.Sku,
|
||||||
isDiscontinued = p.IsDiscontinued,
|
colorName = x.p.ColorName,
|
||||||
cureTemperatureF = p.CureTemperatureF,
|
description = x.p.Description,
|
||||||
cureTimeMinutes = p.CureTimeMinutes,
|
unitPrice = x.p.UnitPrice,
|
||||||
finish = p.Finish,
|
imageUrl = x.p.ImageUrl,
|
||||||
colorFamilies = p.ColorFamilies,
|
sdsUrl = x.p.SdsUrl,
|
||||||
requiresClearCoat = p.RequiresClearCoat,
|
tdsUrl = x.p.TdsUrl,
|
||||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
applicationGuideUrl = x.p.ApplicationGuideUrl,
|
||||||
specificGravity = p.SpecificGravity,
|
productUrl = x.p.ProductUrl,
|
||||||
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
|
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();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -458,21 +458,36 @@ public class InvoicesController : Controller
|
|||||||
dto.TaxPercent = sourceQuote.TaxPercent;
|
dto.TaxPercent = sourceQuote.TaxPercent;
|
||||||
dto.DiscountAmount = sourceQuote.DiscountAmount;
|
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
|
// Direct job — no source quote. Use the stored job-level fees rather than
|
||||||
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
|
// recalculating, so the invoice always matches the total shown on the job page.
|
||||||
// from the quote snapshot instead; this path only fires when there is no quote.)
|
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
|
||||||
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
// OvenCostId) when job items are created or updated.
|
||||||
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
|
if (job.OvenBatchCost > 0.01m)
|
||||||
if (shopSuppliesAmount > 0.01m)
|
|
||||||
{
|
{
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
Description = $"Shop Supplies ({costs.ShopSuppliesRate:0.##}%)",
|
Description = $"Oven Processing Fee",
|
||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
UnitPrice = shopSuppliesAmount,
|
UnitPrice = Math.Round(job.OvenBatchCost, 2),
|
||||||
TotalPrice = shopSuppliesAmount,
|
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,
|
DisplayOrder = order,
|
||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
@@ -662,7 +677,9 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
foreach (var deposit in pendingDeposits)
|
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
|
var payment = new Payment
|
||||||
{
|
{
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
@@ -671,6 +688,7 @@ public class InvoicesController : Controller
|
|||||||
PaymentMethod = deposit.PaymentMethod,
|
PaymentMethod = deposit.PaymentMethod,
|
||||||
Reference = $"Deposit {deposit.ReceiptNumber}",
|
Reference = $"Deposit {deposit.ReceiptNumber}",
|
||||||
Notes = deposit.Notes,
|
Notes = deposit.Notes,
|
||||||
|
DepositAccountId = null,
|
||||||
RecordedById = currentUser.Id,
|
RecordedById = currentUser.Id,
|
||||||
CompanyId = currentUser.CompanyId,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -704,13 +722,31 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
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);
|
var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId);
|
||||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Auto-generate gift certificates for any GC line items
|
// Auto-generate gift certificates for any GC line items
|
||||||
@@ -858,8 +894,17 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
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 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 subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||||
var taxableAmount = subTotal - dto.DiscountAmount;
|
var taxableAmount = subTotal - dto.DiscountAmount;
|
||||||
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
||||||
@@ -925,6 +970,31 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = "Invoice updated successfully.";
|
||||||
|
|
||||||
// Optionally re-send the updated invoice PDF to the customer
|
// Optionally re-send the updated invoice PDF to the customer
|
||||||
@@ -933,11 +1003,18 @@ public class InvoicesController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var currentUserForPdf = await _userManager.GetUserAsync(User);
|
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);
|
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
|
||||||
string? paymentUrl = null;
|
string? paymentUrl = null;
|
||||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{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);
|
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||||
this.SetNotificationResultToast(notifLog);
|
this.SetNotificationResultToast(notifLog);
|
||||||
}
|
}
|
||||||
@@ -963,13 +1040,13 @@ public class InvoicesController : Controller
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
|
/// 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
|
/// fires the customer notification. Staff can choose email, SMS, or both via the modal.
|
||||||
/// separately and logged as a warning — a failed email must not roll back the status change.
|
/// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
|
||||||
/// The payment URL is assembled from the generated token and the current request host so it
|
/// only generated when Stripe Connect is active (expiring pay link for email/view page).
|
||||||
/// works identically in dev (localhost) and production without config changes.
|
/// Notification failure is caught separately — a failed send must not roll back the status change.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[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
|
try
|
||||||
{
|
{
|
||||||
@@ -988,27 +1065,39 @@ public class InvoicesController : Controller
|
|||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
invoice.UpdatedBy = currentUser?.Email;
|
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 TryGeneratePaymentTokenAsync(invoice);
|
||||||
|
|
||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Generate PDF and send notification
|
|
||||||
string? paymentUrl = null;
|
string? paymentUrl = null;
|
||||||
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
|
||||||
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{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
|
try
|
||||||
{
|
{
|
||||||
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
byte[]? pdfBytes = null;
|
||||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim());
|
if (sendEmail)
|
||||||
pdfAndNotifSucceeded = true;
|
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)
|
catch (Exception notifyEx)
|
||||||
{
|
{
|
||||||
_logger.LogError(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.",
|
"Inner: {InnerMessage}. Invoice status was already saved as Sent.",
|
||||||
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
|
||||||
}
|
}
|
||||||
@@ -1017,8 +1106,8 @@ public class InvoicesController : Controller
|
|||||||
this.SetNotificationResultToast(notifLog);
|
this.SetNotificationResultToast(notifLog);
|
||||||
|
|
||||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
|
||||||
if (!pdfAndNotifSucceeded)
|
if (!notifSucceeded)
|
||||||
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration.";
|
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 });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1332,29 +1421,49 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Void any gift certificates that were generated from this invoice
|
// Void any gift certificates that were generated from this invoice.
|
||||||
var gcItemIds = invoice.InvoiceItems
|
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||||
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||||
.Select(i => i.GeneratedGiftCertificateId!.Value)
|
var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
|
||||||
.ToList();
|
foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
|
||||||
foreach (var gcId in gcItemIds)
|
|
||||||
{
|
{
|
||||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId);
|
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
|
||||||
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
||||||
{
|
{
|
||||||
|
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
|
||||||
gc.Status = GiftCertificateStatus.Voided;
|
gc.Status = GiftCertificateStatus.Voided;
|
||||||
gc.UpdatedAt = DateTime.UtcNow;
|
gc.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.GiftCertificates.UpdateAsync(gc);
|
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);
|
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
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);
|
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
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.Status = InvoiceStatus.Voided;
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -1706,13 +1815,30 @@ public class InvoicesController : Controller
|
|||||||
deposit.UpdatedAt = DateTime.UtcNow;
|
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);
|
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);
|
await _accountBalanceService.CreditAsync(arAccountId, invoice.Total);
|
||||||
foreach (var item in invoiceItems)
|
foreach (var item in invoiceItems)
|
||||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
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
|
// 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.
|
// and a new invoice can be created for the same job if needed.
|
||||||
@@ -1905,6 +2031,12 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
item.GeneratedGiftCertificateId = cert.Id;
|
item.GeneratedGiftCertificateId = cert.Id;
|
||||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -2068,6 +2200,24 @@ public class InvoicesController : Controller
|
|||||||
.ToList();
|
.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>
|
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
|
||||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
@@ -2120,6 +2270,28 @@ public class InvoicesController : Controller
|
|||||||
return taxAccount?.Id;
|
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
|
public static string GetStatusColorClass(InvoiceStatus status) => status switch
|
||||||
{
|
{
|
||||||
InvoiceStatus.Draft => "secondary",
|
InvoiceStatus.Draft => "secondary",
|
||||||
@@ -2176,6 +2348,8 @@ public class InvoicesController : Controller
|
|||||||
Amount = dto.Amount,
|
Amount = dto.Amount,
|
||||||
RefundDate = dto.RefundDate,
|
RefundDate = dto.RefundDate,
|
||||||
RefundMethod = dto.RefundMethod,
|
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,
|
Reason = dto.Reason,
|
||||||
Reference = dto.Reference,
|
Reference = dto.Reference,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
@@ -2234,6 +2408,14 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2308,6 +2490,11 @@ public class InvoicesController : Controller
|
|||||||
customer.CurrentBalance += refund.Amount;
|
customer.CurrentBalance += refund.Amount;
|
||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
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;
|
refund.Status = RefundStatus.Cancelled;
|
||||||
@@ -2454,6 +2641,12 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // end ExecuteInTransactionAsync
|
||||||
@@ -2614,6 +2807,13 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
|
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1170,9 +1170,10 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
createCosts?.TaxPercent ?? 0m,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -1628,8 +1629,9 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
editCosts?.TaxPercent ?? 0m,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
}
|
}
|
||||||
@@ -3038,9 +3040,10 @@ public class JobsController : Controller
|
|||||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
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.FinalPrice = totals.Total;
|
||||||
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -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.Infrastructure.Data;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ public class PaymentController : Controller
|
|||||||
private readonly IInAppNotificationService _inApp;
|
private readonly IInAppNotificationService _inApp;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<PaymentController> _logger;
|
private readonly ILogger<PaymentController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public PaymentController(
|
public PaymentController(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
@@ -33,7 +35,8 @@ public class PaymentController : Controller
|
|||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IInAppNotificationService inApp,
|
IInAppNotificationService inApp,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<PaymentController> logger)
|
ILogger<PaymentController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_stripeConnect = stripeConnect;
|
_stripeConnect = stripeConnect;
|
||||||
@@ -41,6 +44,7 @@ public class PaymentController : Controller
|
|||||||
_inApp = inApp;
|
_inApp = inApp;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
||||||
@@ -149,6 +153,86 @@ public class PaymentController : Controller
|
|||||||
return Ok(new { clientSecret, surchargeAmount = surcharge });
|
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} ────────────────────────────────────────────
|
// ─── GET /pay/deposit/{token} ────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -378,8 +462,30 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
_context.Update(invoice);
|
_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 _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);
|
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
|
||||||
|
|
||||||
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
||||||
@@ -553,6 +659,8 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var refundAmountDollars = latestRefund.Amount / 100m;
|
var refundAmountDollars = latestRefund.Amount / 100m;
|
||||||
|
|
||||||
|
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||||
|
|
||||||
var refund = new Core.Entities.Refund
|
var refund = new Core.Entities.Refund
|
||||||
{
|
{
|
||||||
CompanyId = invoice.CompanyId,
|
CompanyId = invoice.CompanyId,
|
||||||
@@ -565,6 +673,7 @@ public class PaymentController : Controller
|
|||||||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||||||
Status = Core.Enums.RefundStatus.Issued,
|
Status = Core.Enums.RefundStatus.Issued,
|
||||||
IssuedDate = DateTime.UtcNow,
|
IssuedDate = DateTime.UtcNow,
|
||||||
|
DepositAccountId = checkingAcctIdR,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_context.Refunds.Add(refund);
|
_context.Refunds.Add(refund);
|
||||||
@@ -588,6 +697,10 @@ public class PaymentController : Controller
|
|||||||
_context.Update(invoice);
|
_context.Update(invoice);
|
||||||
await _context.SaveChangesAsync();
|
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})",
|
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
|
||||||
refundAmountDollars, invoice.Id, latestRefund.Id);
|
refundAmountDollars, invoice.Id, latestRefund.Id);
|
||||||
}
|
}
|
||||||
@@ -652,6 +765,8 @@ public class PaymentController : Controller
|
|||||||
if (alreadyRecorded) return;
|
if (alreadyRecorded) return;
|
||||||
|
|
||||||
var amount = dispute.Amount / 100m;
|
var amount = dispute.Amount / 100m;
|
||||||
|
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||||
|
|
||||||
var refund = new Core.Entities.Refund
|
var refund = new Core.Entities.Refund
|
||||||
{
|
{
|
||||||
CompanyId = invoice.CompanyId,
|
CompanyId = invoice.CompanyId,
|
||||||
@@ -664,6 +779,7 @@ public class PaymentController : Controller
|
|||||||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||||||
Status = Core.Enums.RefundStatus.Issued,
|
Status = Core.Enums.RefundStatus.Issued,
|
||||||
IssuedDate = DateTime.UtcNow,
|
IssuedDate = DateTime.UtcNow,
|
||||||
|
DepositAccountId = checkingAcctIdD,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_context.Refunds.Add(refund);
|
_context.Refunds.Add(refund);
|
||||||
@@ -687,6 +803,9 @@ public class PaymentController : Controller
|
|||||||
_context.Update(invoice);
|
_context.Update(invoice);
|
||||||
await _context.SaveChangesAsync();
|
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);
|
_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
|
/// 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.
|
/// because there is no authenticated tenant context in webhook handlers.
|
||||||
/// </summary>
|
/// </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)
|
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
||||||
@@ -837,6 +977,39 @@ public class DepositPaymentPageViewModel
|
|||||||
public string StripeAccountId { get; set; } = string.Empty;
|
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 class CreateIntentRequest
|
||||||
{
|
{
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
using PowderCoating.Web.ViewModels.PlatformAdmin;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||||
|
public class PlatformAdminController : Controller
|
||||||
|
{
|
||||||
|
private static readonly bool ShowRawLogFiles = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
|
||||||
|
private static readonly bool ShowStorageMigration = string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"));
|
||||||
|
|
||||||
|
public IActionResult TenantsBilling() => View(BuildTenantsBillingHub());
|
||||||
|
|
||||||
|
public IActionResult PeopleActivity() => View(BuildPeopleActivityHub());
|
||||||
|
|
||||||
|
public IActionResult ContentMessaging() => View(BuildContentMessagingHub());
|
||||||
|
|
||||||
|
public IActionResult Observability() => View(BuildObservabilityHub());
|
||||||
|
|
||||||
|
public IActionResult Maintenance() => View(BuildMaintenanceHub());
|
||||||
|
|
||||||
|
private static PlatformAdminHubViewModel BuildTenantsBillingHub() => new()
|
||||||
|
{
|
||||||
|
Title = "Tenants & Billing",
|
||||||
|
PageIcon = "bi-building-gear",
|
||||||
|
Intro = "Manage tenant accounts, subscription health, pricing plans, and payment-system signals from one place.",
|
||||||
|
Cards = new List<PlatformAdminLinkCardViewModel>
|
||||||
|
{
|
||||||
|
Card("Companies", "Browse all tenant companies, create new accounts, and jump into company-level details.", "Companies", "Index", "bi-building", "Daily", SubtleBadge("primary")),
|
||||||
|
Card("Company Health", "Review readiness, setup gaps, and health signals across every company.", "CompanyHealth", "Index", "bi-heart-pulse", "Review", SubtleBadge("success")),
|
||||||
|
Card("Subscriptions", "Manage tenant subscriptions, grace periods, expirations, and billing status.", "SubscriptionManagement", "Index", "bi-credit-card", "Daily", SubtleBadge("warning")),
|
||||||
|
Card("Subscription Plans", "Adjust platform plan definitions, limits, pricing, and feature packaging.", "PlatformSubscription", "Index", "bi-layers", "Config", SubtleBadge("info")),
|
||||||
|
Card("Revenue Dashboard", "Track platform-level revenue, plan mix, and commercial performance over time.", "Revenue", "Index", "bi-graph-up-arrow", "Monitor", SubtleBadge("secondary")),
|
||||||
|
Card("Stripe Events", "Inspect incoming Stripe webhook activity and payment-provider events.", "StripeEvents", "Index", "bi-lightning-charge", "Advanced", SubtleBadge("secondary")),
|
||||||
|
Card("SMS Agreements", "Audit company SMS agreement status and identify accounts blocked by compliance gating.", "SmsAgreements", "Index", "bi-file-earmark-check", "Compliance", SubtleBadge("danger"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PlatformAdminHubViewModel BuildPeopleActivityHub() => new()
|
||||||
|
{
|
||||||
|
Title = "People & Activity",
|
||||||
|
PageIcon = "bi-people",
|
||||||
|
Intro = "See who is using the platform, what they are doing, and which tenants are still onboarding.",
|
||||||
|
Cards = new List<PlatformAdminLinkCardViewModel>
|
||||||
|
{
|
||||||
|
Card("Platform Users", "Manage SuperAdmin accounts and review platform-user access details.", "PlatformUsers", "Index", "bi-people-fill", "Daily", SubtleBadge("primary")),
|
||||||
|
Card("User Activity", "Review cross-tenant usage history, filters, and behavioral trends.", "UserActivity", "Index", "bi-person-lines-fill", "Review", SubtleBadge("success")),
|
||||||
|
Card("Online Now", "Check who is currently active in the application right now.", "UserActivity", "Online", "bi-broadcast-pin", "Live", SubtleBadge("success")),
|
||||||
|
Card("Platform Notifications", "Review platform-level in-app notifications and operational follow-ups.", "PlatformNotifications", "Index", "bi-bell", "Monitor", SubtleBadge("info"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PlatformAdminHubViewModel BuildContentMessagingHub() => new()
|
||||||
|
{
|
||||||
|
Title = "Content & Messaging",
|
||||||
|
PageIcon = "bi-megaphone",
|
||||||
|
Intro = "Manage the platform-facing announcements, release communication, admin outreach, and inbound support signals.",
|
||||||
|
Cards = new List<PlatformAdminLinkCardViewModel>
|
||||||
|
{
|
||||||
|
Card("Announcements", "Publish or retire platform-wide announcements shown to tenant users.", "Announcements", "Index", "bi-megaphone", "Publish", SubtleBadge("primary")),
|
||||||
|
Card("Dashboard Tips", "Curate the rotating tips and educational nudges shown in the product dashboard.", "DashboardTips", "Index", "bi-lightbulb", "Content", SubtleBadge("warning")),
|
||||||
|
Card("Email Broadcast", "Send platform-admin broadcast emails to selected tenant companies.", "EmailBroadcast", "Index", "bi-broadcast", "Support", SubtleBadge("info")),
|
||||||
|
Card("Release Notes", "Publish and maintain customer-facing release notes and change summaries.", "ReleaseNotes", "Manage", "bi-journal-text", "Publish", SubtleBadge("success")),
|
||||||
|
Card("Contact Submissions", "Review inbound contact requests, questions, and support follow-up items.", "Contact", "Submissions", "bi-envelope", "Inbox", SubtleBadge("secondary")),
|
||||||
|
Card("Bug Reports", "Triage submitted product bugs and inspect the supporting report detail.", "BugReport", "Index", "bi-bug", "Triage", SubtleBadge("danger"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static PlatformAdminHubViewModel BuildObservabilityHub()
|
||||||
|
{
|
||||||
|
var cards = new List<PlatformAdminLinkCardViewModel>
|
||||||
|
{
|
||||||
|
Card("Audit Log", "Review the platform audit trail for sensitive actions and administrative changes.", "AuditLog", "Index", "bi-shield-check", "Investigate", SubtleBadge("primary")),
|
||||||
|
Card("System Logs", "Inspect application warning and error logs from the structured logging pipeline.", "SystemLogs", "Index", "bi-database-exclamation", "Investigate", SubtleBadge("danger")),
|
||||||
|
Card("System Info", "Review environment, infrastructure, and runtime diagnostics for the platform.", "SystemInfo", "Index", "bi-cpu", "Advanced", SubtleBadge("secondary")),
|
||||||
|
Card("AI Usage", "Monitor AI feature usage, cross-tenant consumption, and feature mix.", "AiUsageReport", "Index", "bi-robot", "Monitor", SubtleBadge("info")),
|
||||||
|
Card("Usage & Quota", "Review platform-wide usage ceilings, quota trends, and consumption pressure.", "UsageQuota", "Index", "bi-speedometer2", "Monitor", SubtleBadge("warning")),
|
||||||
|
Card("Banned IPs", "Manage blocked IP addresses and platform-level abuse controls.", "BannedIps", "Index", "bi-slash-circle", "Security", SubtleBadge("danger"))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ShowRawLogFiles)
|
||||||
|
{
|
||||||
|
cards.Add(Card("Raw Log Files", "Open raw log-file views for deeper troubleshooting in environments where file logs are available.", "Diagnostics", "ViewLogs", "bi-file-text", "Advanced", SubtleBadge("secondary")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PlatformAdminHubViewModel
|
||||||
|
{
|
||||||
|
Title = "Observability",
|
||||||
|
PageIcon = "bi-binoculars",
|
||||||
|
Intro = "Investigate platform behavior across audit trails, system logs, AI usage, quotas, and security-oriented diagnostics.",
|
||||||
|
Cards = cards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlatformAdminHubViewModel BuildMaintenanceHub()
|
||||||
|
{
|
||||||
|
var cards = new List<PlatformAdminLinkCardViewModel>
|
||||||
|
{
|
||||||
|
Card("Data Export", "Export a tenant company's data set for audits, offboarding, support, or migration work.", "DataExport", "Index", "bi-file-earmark-arrow-down", "Maintenance", SubtleBadge("warning")),
|
||||||
|
Card("Data Purge", "Permanently delete soft-deleted records after previewing impact and cutoff windows.", "DataPurge", "Index", "bi-trash3", "Dangerous", SubtleBadge("danger")),
|
||||||
|
Card("Seed Data", "Seed or remove system and demo data for setup, QA, or controlled test scenarios.", "SeedData", "Index", "bi-database-fill-gear", "Restricted", SubtleBadge("danger"))
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ShowStorageMigration)
|
||||||
|
cards.Insert(2, Card("Storage Migration", "Run one-off migration of local media files into cloud storage.", "StorageMigration", "Index", "bi-cloud-upload", "One-off", SubtleBadge("info")));
|
||||||
|
|
||||||
|
return new PlatformAdminHubViewModel
|
||||||
|
{
|
||||||
|
Title = "Maintenance",
|
||||||
|
PageIcon = "bi-wrench-adjustable-circle",
|
||||||
|
Intro = "Use these tools for exceptional maintenance work, migration tasks, and destructive admin operations. They are not routine day-to-day workflows.",
|
||||||
|
WarningTitle = "Use With Care",
|
||||||
|
WarningMessage = "These tools can expose bulk data, change platform state, or permanently remove records. Use them deliberately and preferably with a written reason or ticket.",
|
||||||
|
Cards = cards
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlatformAdminLinkCardViewModel Card(
|
||||||
|
string title,
|
||||||
|
string description,
|
||||||
|
string controller,
|
||||||
|
string action,
|
||||||
|
string icon,
|
||||||
|
string badgeText,
|
||||||
|
string badgeStyle) => new()
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
Description = description,
|
||||||
|
Controller = controller,
|
||||||
|
Action = action,
|
||||||
|
Icon = icon,
|
||||||
|
BadgeText = badgeText,
|
||||||
|
BadgeStyle = badgeStyle
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string SubtleBadge(string tone) =>
|
||||||
|
$"bg-{tone}-subtle text-{tone}-emphasis border border-{tone}-subtle";
|
||||||
|
}
|
||||||
@@ -112,12 +112,14 @@ public class QuotesController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Load statuses once up front — needed for statusCode resolution and default filter
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||||
|
|
||||||
// Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links)
|
// Resolve statusCode → statusFilter ID if provided (e.g. from dashboard links)
|
||||||
if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue)
|
if (!string.IsNullOrWhiteSpace(statusCode) && !statusFilter.HasValue)
|
||||||
{
|
{
|
||||||
var companyIdForLookup = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var match = quoteStatuses.FirstOrDefault(s =>
|
||||||
var allStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyIdForLookup);
|
|
||||||
var match = allStatuses.FirstOrDefault(s =>
|
|
||||||
s.StatusCode.Equals(statusCode.Trim().ToUpper(), StringComparison.OrdinalIgnoreCase));
|
s.StatusCode.Equals(statusCode.Trim().ToUpper(), StringComparison.OrdinalIgnoreCase));
|
||||||
if (match != null)
|
if (match != null)
|
||||||
statusFilter = match.Id;
|
statusFilter = match.Id;
|
||||||
@@ -169,6 +171,18 @@ public class QuotesController : Controller
|
|||||||
var statusId = statusFilter.Value;
|
var statusId = statusFilter.Value;
|
||||||
filter = q => q.QuoteStatusId == statusId;
|
filter = q => q.QuoteStatusId == statusId;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Default (no filter): hide Converted to keep the list clean.
|
||||||
|
// Users can view converted quotes by selecting Converted from the status filter.
|
||||||
|
var convertedId = quoteStatuses
|
||||||
|
.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted)?.Id;
|
||||||
|
if (convertedId.HasValue)
|
||||||
|
{
|
||||||
|
var cId = convertedId.Value;
|
||||||
|
filter = q => q.QuoteStatusId != cId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<Quote>, IOrderedQueryable<Quote>> orderBy = gridRequest.SortColumn switch
|
Func<IQueryable<Quote>, IOrderedQueryable<Quote>> orderBy = gridRequest.SortColumn switch
|
||||||
@@ -216,9 +230,6 @@ public class QuotesController : Controller
|
|||||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
|
|
||||||
// Use cached quote statuses
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
|
||||||
ViewBag.QuoteStatuses = quoteStatuses
|
ViewBag.QuoteStatuses = quoteStatuses
|
||||||
.OrderBy(s => s.DisplayOrder)
|
.OrderBy(s => s.DisplayOrder)
|
||||||
.Select(s => new SelectListItem
|
.Select(s => new SelectListItem
|
||||||
@@ -3059,6 +3070,18 @@ public class QuotesController : Controller
|
|||||||
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
|
quote.ApprovalTokenExpiresAt = DateTime.UtcNow.AddDays(
|
||||||
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
|
int.TryParse(await _platformSettings.GetAsync(PlatformSettingKeys.QuoteApprovalTokenDays), out var tokenDays) ? tokenDays : 30);
|
||||||
quote.ApprovalTokenUsedAt = null;
|
quote.ApprovalTokenUsedAt = null;
|
||||||
|
|
||||||
|
// Advance from Draft → Sent (mirrors the Create and SendSms paths)
|
||||||
|
var resendCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var resendStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(resendCompanyId);
|
||||||
|
var resendSentStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Sent);
|
||||||
|
var resendDraftStatus = resendStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||||
|
if (resendSentStatus != null && quote.QuoteStatusId == (resendDraftStatus?.Id ?? 0))
|
||||||
|
{
|
||||||
|
quote.QuoteStatusId = resendSentStatus.Id;
|
||||||
|
quote.SentDate ??= DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -2118,6 +2118,195 @@ public class ReportsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI: Late Payment Prediction ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX POST — loads all open AR invoices with customer payment history, then asks Claude
|
||||||
|
/// to score each customer's payment risk. Avg days to pay and late rate are pre-computed
|
||||||
|
/// from the full invoice history rather than open invoices only, so customers with only
|
||||||
|
/// one open invoice still get meaningful risk scoring based on prior behavior.
|
||||||
|
/// Gated behind <see cref="AllowAccounting"/>.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
|
public async Task<IActionResult> PredictLatePayments()
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyName = await GetCompanyNameAsync();
|
||||||
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
|
var activeInvoices = allInvoices.Where(i =>
|
||||||
|
i.Status != InvoiceStatus.Voided &&
|
||||||
|
i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
|
|
||||||
|
static string CustomerDisplayName(Invoice i) =>
|
||||||
|
i.Customer?.CompanyName ?? (i.Customer != null
|
||||||
|
? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
|
||||||
|
: $"Customer #{i.CustomerId}");
|
||||||
|
|
||||||
|
var outstandingByCustomer = activeInvoices
|
||||||
|
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid)
|
||||||
|
.GroupBy(i => CustomerDisplayName(i))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Pre-compute per-customer historical behavior from all paid invoices
|
||||||
|
var historyByCustomer = activeInvoices
|
||||||
|
.Where(i => i.Status == InvoiceStatus.Paid && i.PaidDate.HasValue && i.SentDate.HasValue)
|
||||||
|
.GroupBy(i => CustomerDisplayName(i))
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => new
|
||||||
|
{
|
||||||
|
AvgDaysToPay = g.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays),
|
||||||
|
TotalInvoices = g.Count(),
|
||||||
|
LateInvoices = g.Count(i => i.DueDate.HasValue && i.PaidDate!.Value > i.DueDate.Value)
|
||||||
|
});
|
||||||
|
|
||||||
|
var customerData = outstandingByCustomer.Select(g =>
|
||||||
|
{
|
||||||
|
var history = historyByCustomer.GetValueOrDefault(g.Key);
|
||||||
|
return new LatePaymentCustomerData
|
||||||
|
{
|
||||||
|
CustomerName = g.Key,
|
||||||
|
TotalOwed = g.Sum(i => i.BalanceDue),
|
||||||
|
AvgDaysToPay = history?.AvgDaysToPay ?? 30,
|
||||||
|
TotalInvoicesAllTime = history?.TotalInvoices ?? 0,
|
||||||
|
LateInvoicesAllTime = history?.LateInvoices ?? 0,
|
||||||
|
OpenInvoices = g.Select(i => new OpenInvoiceSummary
|
||||||
|
{
|
||||||
|
InvoiceNumber = i.InvoiceNumber,
|
||||||
|
BalanceDue = i.BalanceDue,
|
||||||
|
DueDateIso = i.DueDate?.ToString("yyyy-MM-dd"),
|
||||||
|
DaysOverdue = i.DueDate.HasValue && i.DueDate.Value < today
|
||||||
|
? (today - i.DueDate.Value).Days : 0
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
if (!customerData.Any())
|
||||||
|
return Json(new LatePaymentPredictionResult { Success = true, Insights = new() { "No outstanding invoices to analyze." } });
|
||||||
|
|
||||||
|
var result = await _accountingAi.PredictLatePaymentsAsync(new LatePaymentPredictionRequest
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
Customers = customerData
|
||||||
|
});
|
||||||
|
|
||||||
|
var lpCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _lpC) ? _lpC : 0;
|
||||||
|
await _usageLogger.LogAsync(lpCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.LatePaymentPrediction, result.Success);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error predicting late payments");
|
||||||
|
return Json(new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while analyzing payment risk." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI: Natural Language Financial Queries ────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET page for the natural language financial query tool. Pre-loads the financial context
|
||||||
|
/// snapshot so the first query does not have a visible data-fetch delay — the context is
|
||||||
|
/// serialized into a hidden field and passed back on the POST.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> FinancialQuery()
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
|
ViewBag.Context = await BuildFinancialQueryContextAsync();
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AJAX POST — receives the user's plain-English question and the pre-built context object,
|
||||||
|
/// then sends both to Claude. The context is passed from client to server (rather than
|
||||||
|
/// re-fetched on every request) so that rapid follow-up questions do not trigger additional
|
||||||
|
/// database round-trips. The context JSON is validated server-side before passing to the AI
|
||||||
|
/// service so a corrupted hidden field cannot cause a crash.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
|
||||||
|
public async Task<IActionResult> RunFinancialQuery([FromBody] FinancialQueryRequest? request)
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
|
||||||
|
if (request == null || string.IsNullOrWhiteSpace(request.Question))
|
||||||
|
return Json(new FinancialQueryResult { Success = false, ErrorMessage = "Please enter a question." });
|
||||||
|
|
||||||
|
// If context is empty (e.g. client didn't pass it), rebuild from DB
|
||||||
|
if (request.Context == null || string.IsNullOrWhiteSpace(request.Context.CompanyName))
|
||||||
|
request.Context = await BuildFinancialQueryContextAsync();
|
||||||
|
|
||||||
|
var result = await _accountingAi.AnswerFinancialQueryAsync(request);
|
||||||
|
var fqCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fqC) ? _fqC : 0;
|
||||||
|
await _usageLogger.LogAsync(fqCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialQuery, result.Success);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="FinancialQueryContext"/> snapshot from live DB data covering
|
||||||
|
/// YTD totals, last 12 months of monthly revenue/expense summaries, and current
|
||||||
|
/// AR/AP outstanding. This is factored out so both the GET page load and the fallback
|
||||||
|
/// POST path share identical context-building logic.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<FinancialQueryContext> BuildFinancialQueryContextAsync()
|
||||||
|
{
|
||||||
|
var companyName = await GetCompanyNameAsync();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var startOfYear = new DateTime(now.Year, 1, 1);
|
||||||
|
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var ytdRevenue = allInvoices.Where(i => i.InvoiceDate >= startOfYear).Sum(i => i.Total);
|
||||||
|
var arOutstanding = allInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue);
|
||||||
|
|
||||||
|
var allBills = await _operationalReports.GetActiveBillsAsync();
|
||||||
|
var ytdExpenses = allBills.Where(b => b.BillDate >= startOfYear).Sum(b => b.Total);
|
||||||
|
var apOutstanding = allBills.Where(b => b.BalanceDue > 0).Sum(b => b.BalanceDue);
|
||||||
|
|
||||||
|
// Monthly summaries for last 12 months
|
||||||
|
var monthly = new List<MonthlyFinancialSummary>();
|
||||||
|
for (var i = 11; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
|
||||||
|
var monthEnd = monthStart.AddMonths(1);
|
||||||
|
var rev = allInvoices.Where(inv => inv.InvoiceDate >= monthStart && inv.InvoiceDate < monthEnd).Sum(inv => inv.Total);
|
||||||
|
var exp = allBills.Where(b => b.BillDate >= monthStart && b.BillDate < monthEnd).Sum(b => b.Total);
|
||||||
|
monthly.Add(new MonthlyFinancialSummary
|
||||||
|
{
|
||||||
|
Month = monthStart.ToString("yyyy-MM"),
|
||||||
|
Revenue = rev,
|
||||||
|
Expenses = exp,
|
||||||
|
NetIncome = rev - exp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense breakdown from bills by account
|
||||||
|
var expensesByAccount = allBills
|
||||||
|
.GroupBy(b => b.Memo ?? "Uncategorized")
|
||||||
|
.Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(b => b.Total) })
|
||||||
|
.OrderByDescending(e => e.Amount)
|
||||||
|
.Take(10)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new FinancialQueryContext
|
||||||
|
{
|
||||||
|
CompanyName = companyName,
|
||||||
|
AsOfDate = now.ToString("yyyy-MM-dd"),
|
||||||
|
TotalRevenueYtd = ytdRevenue,
|
||||||
|
TotalExpensesYtd = ytdExpenses,
|
||||||
|
NetIncomeYtd = ytdRevenue - ytdExpenses,
|
||||||
|
ArOutstanding = arOutstanding,
|
||||||
|
ApOutstanding = apOutstanding,
|
||||||
|
Last12Months = monthly,
|
||||||
|
ExpensesByCategory = expensesByAccount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// GET: /Reports/BudgetVsActual
|
// GET: /Reports/BudgetVsActual
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ public class VendorCreditsController : Controller
|
|||||||
foreach (var line in vc.LineItems)
|
foreach (var line in vc.LineItems)
|
||||||
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
|
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
|
||||||
|
|
||||||
|
// Record posting date so Void() can reverse only if GL entries were actually made.
|
||||||
|
vc.PostedDate = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.VendorCredits.UpdateAsync(vc);
|
||||||
|
|
||||||
// Status stays Open — the credit is now in the GL but not yet applied to a bill
|
// Status stays Open — the credit is now in the GL but not yet applied to a bill
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
@@ -260,6 +264,12 @@ public class VendorCreditsController : Controller
|
|||||||
|
|
||||||
// ── Void ─────────────────────────────────────────────────────────────────
|
// ── Void ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Voids a vendor credit. If the credit was previously posted (PostedDate is set), reverses the
|
||||||
|
/// original GL entries: CR Accounts Payable / DR each expense line item, restoring both balances.
|
||||||
|
/// Only the unapplied RemainingAmount of AP is reversed — applied portions reduced bill balances
|
||||||
|
/// that are already settled and remain part of the immutable audit trail.
|
||||||
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
@@ -267,7 +277,10 @@ public class VendorCreditsController : Controller
|
|||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||||
|
|
||||||
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
|
var vc = (await _unitOfWork.VendorCredits.FindAsync(
|
||||||
|
v => v.Id == id, false,
|
||||||
|
v => v.LineItems))
|
||||||
|
.FirstOrDefault();
|
||||||
if (vc == null) return NotFound();
|
if (vc == null) return NotFound();
|
||||||
|
|
||||||
if (vc.Status == VendorCreditStatus.Applied)
|
if (vc.Status == VendorCreditStatus.Applied)
|
||||||
@@ -276,9 +289,25 @@ public class VendorCreditsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
|
{
|
||||||
|
// Reverse GL only if Post() was previously called; unposted credits have no GL entries.
|
||||||
|
if (vc.PostedDate.HasValue && vc.RemainingAmount > 0)
|
||||||
|
{
|
||||||
|
// CR AP for the unapplied amount (undoes the debit made at Post time)
|
||||||
|
await _accountBalanceService.CreditAsync(vc.APAccountId, vc.RemainingAmount);
|
||||||
|
|
||||||
|
// DR each expense line proportionally (unapplied fraction of each line)
|
||||||
|
var applyRatio = vc.Total > 0 ? vc.RemainingAmount / vc.Total : 1m;
|
||||||
|
foreach (var line in vc.LineItems)
|
||||||
|
await _accountBalanceService.DebitAsync(line.AccountId, line.Amount * applyRatio);
|
||||||
|
}
|
||||||
|
|
||||||
vc.Status = VendorCreditStatus.Voided;
|
vc.Status = VendorCreditStatus.Voided;
|
||||||
vc.RemainingAmount = 0;
|
vc.RemainingAmount = 0;
|
||||||
|
await _unitOfWork.VendorCredits.UpdateAsync(vc);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
});
|
||||||
|
|
||||||
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
|
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
|
|||||||
@@ -82,7 +82,8 @@ public static class HelpKnowledgeBase
|
|||||||
ROLE AWARENESS:
|
ROLE AWARENESS:
|
||||||
- SuperAdmin: Full access to everything including Platform Management tools
|
- SuperAdmin: Full access to everything including Platform Management tools
|
||||||
- CompanyAdmin: Full access to all company features including Settings, Users, Billing
|
- CompanyAdmin: Full access to all company features including Settings, Users, Billing
|
||||||
- Manager: Access to jobs, quotes, invoices, customers, inventory, reports — no platform tools
|
- Manager: Access to jobs, quotes, invoices, customers, inventory, vendors, reports — no platform tools
|
||||||
|
- Accountant: Financial focus — bills & AP, invoices, bank reconciliations, chart of accounts, vendors, purchase orders, reports; no jobs, settings, or user management
|
||||||
- Worker: Can create/edit jobs and quotes; no settings, billing, or user management
|
- Worker: Can create/edit jobs and quotes; no settings, billing, or user management
|
||||||
- Viewer: Read-only access to most data; no create/edit capabilities
|
- Viewer: Read-only access to most data; no create/edit capabilities
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ public static class HelpKnowledgeBase
|
|||||||
- Job Priority Board → /JobsPriority
|
- Job Priority Board → /JobsPriority
|
||||||
- Online Payments → /Invoices/OnlinePayments
|
- Online Payments → /Invoices/OnlinePayments
|
||||||
- Gift Certificates → /GiftCertificates
|
- Gift Certificates → /GiftCertificates
|
||||||
|
- Intake Sessions → /Kiosk/Intakes (walk-in and remote intake sessions submitted via the kiosk tablet)
|
||||||
|
|
||||||
**Inventory section:**
|
**Inventory section:**
|
||||||
- Catalog Items → /CatalogItems
|
- Catalog Items → /CatalogItems
|
||||||
@@ -511,6 +513,8 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**AI Receipt Scanning:** Bills → "Scan Receipt" — upload a photo or PDF of a vendor receipt/invoice and AI will pre-fill the vendor, date, amount, and line items.
|
**AI Receipt Scanning:** Bills → "Scan Receipt" — upload a photo or PDF of a vendor receipt/invoice and AI will pre-fill the vendor, date, amount, and line items.
|
||||||
|
|
||||||
|
**Recurring Bill Detection:** [Bills](/Bills) → "Detect Recurring Bills" button (top-left of the bills list, or navigate to /Bills/RecurringDetection). AI scans the last 12 months of bills to identify vendors you pay on a regular schedule. Each pattern card shows the vendor name, frequency (Monthly / Bi-Monthly / Quarterly / Annual / Irregular), typical amount, confidence level (High / Medium / Low), estimated next expected date, and a suggested action (e.g., "Set up auto-pay" or "Budget monthly"). Useful for cash flow planning — knowing what's coming reduces surprises. At least 2 occurrences are needed before the AI can establish a pattern; one-time bills are filtered out automatically.
|
||||||
|
|
||||||
**Accounts:** [/Accounts](/Accounts) — The chart of accounts (Assets, Liabilities, Equity, Revenue, COGS, Expenses). Accounts are assigned to bill line items for financial reporting.
|
**Accounts:** [/Accounts](/Accounts) — The chart of accounts (Assets, Liabilities, Equity, Revenue, COGS, Expenses). Accounts are assigned to bill line items for financial reporting.
|
||||||
|
|
||||||
**Accounting Export:** [/AccountingExport](/AccountingExport) — Export financial data to accounting software.
|
**Accounting Export:** [/AccountingExport](/AccountingExport) — Export financial data to accounting software.
|
||||||
@@ -577,6 +581,8 @@ public static class HelpKnowledgeBase
|
|||||||
- *Cash Flow Forecast* — 30/60/90-day projection based on open AR, AP, and job pipeline
|
- *Cash Flow Forecast* — 30/60/90-day projection based on open AR, AP, and job pipeline
|
||||||
- *Anomaly Detection* — AI scans for duplicate bills, amount spikes, unusual vendors
|
- *Anomaly Detection* — AI scans for duplicate bills, amount spikes, unusual vendors
|
||||||
- *AR Follow-Up Emails* — AI drafts collection emails for overdue invoices (from AR Aging report)
|
- *AR Follow-Up Emails* — AI drafts collection emails for overdue invoices (from AR Aging report)
|
||||||
|
- *AI Payment Risk Prediction* — on the AR Aging report, click "Predict Payment Risk" to get an AI assessment for each outstanding customer: High / Medium / Low risk with reasoning and recommended action (call now, send reminder, standard follow-up). Powered by each customer's payment history, current balance, and days outstanding.
|
||||||
|
- *Ask Your Financials* — [/Reports/FinancialQuery](/Reports/FinancialQuery) — natural language query interface. Type any financial question ("What were my top expenses last quarter?", "Which customers owe the most?") and the AI answers using your real data. Includes suggestion chips, follow-up prompts, supporting facts, and session history. The right panel shows a YTD financial snapshot (revenue, expenses, net income, open AR, open AP).
|
||||||
- *Powder Usage Report* — powder consumption by item/job
|
- *Powder Usage Report* — powder consumption by item/job
|
||||||
- *Job Cycle Time Report* — how long jobs spend in each status
|
- *Job Cycle Time Report* — how long jobs spend in each status
|
||||||
|
|
||||||
@@ -891,8 +897,9 @@ public static class HelpKnowledgeBase
|
|||||||
**Where:** [Company Users](/CompanyUsers) — via Settings menu → Users
|
**Where:** [Company Users](/CompanyUsers) — via Settings menu → Users
|
||||||
|
|
||||||
**Roles:**
|
**Roles:**
|
||||||
- *CompanyAdmin* — full company access including settings, users, billing
|
- *CompanyAdmin* — full company access including settings, users, billing. All permissions granted automatically.
|
||||||
- *Manager* — jobs, quotes, invoices, customers, inventory, reports — no settings or user management
|
- *Manager* — jobs, quotes, invoices, customers, inventory, vendors, reports — no settings or user management
|
||||||
|
- *Accountant* — financial focus: bills & AP, invoices, bank reconciliations, chart of accounts, vendors, purchase orders, and reports. No job management, settings, or user management. When selected, the system auto-checks the five relevant permissions (Invoices, Reports, Vendors, Bills & AP, Accounting).
|
||||||
- *Worker* — create/edit jobs and quotes; no settings, billing, or user management
|
- *Worker* — create/edit jobs and quotes; no settings, billing, or user management
|
||||||
- *Viewer* — read-only access
|
- *Viewer* — read-only access
|
||||||
|
|
||||||
@@ -902,6 +909,12 @@ public static class HelpKnowledgeBase
|
|||||||
3. System sends an invitation email
|
3. System sends an invitation email
|
||||||
4. Save
|
4. Save
|
||||||
|
|
||||||
|
**Fine-grained permissions:** Below the role dropdown on the Create/Edit user form, individual permission checkboxes let you grant specific capabilities beyond what the role provides. Notable permissions:
|
||||||
|
- *Can Manage Bills & AP* — access to vendor bills, expenses, bill payments, and recurring bill detection. The Bills controller requires this permission for all write actions.
|
||||||
|
- *Can Manage Accounting* — access to chart of accounts, bank reconciliations, and journal entries.
|
||||||
|
- *Can View Reports* — access to all financial reports and AI analytics features (cash flow, anomaly detection, financial queries, late payment prediction).
|
||||||
|
CompanyAdmin users always have all permissions (checkboxes are locked). Accountant role auto-checks: Can Manage Invoices, Can View Reports, Can Manage Vendors, Can Manage Bills & AP, and Can Manage Accounting.
|
||||||
|
|
||||||
**Resetting a password (sending a reset link):** On the Company Users list or the user's Details page, click the envelope-arrow button (<i class="bi bi-envelope-arrow-up"></i>) next to the user. This sends the user an email with a secure password reset link — they click it and choose a new password themselves. This is the recommended way to help a user who is locked out or who fat-fingered their email at signup.
|
**Resetting a password (sending a reset link):** On the Company Users list or the user's Details page, click the envelope-arrow button (<i class="bi bi-envelope-arrow-up"></i>) next to the user. This sends the user an email with a secure password reset link — they click it and choose a new password themselves. This is the recommended way to help a user who is locked out or who fat-fingered their email at signup.
|
||||||
|
|
||||||
**Deactivating a user:** Use the toggle on the user list or the edit form.
|
**Deactivating a user:** Use the toggle on the user list or the edit form.
|
||||||
@@ -1239,12 +1252,74 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
11. **AI Quick Quote** — A floating button (visible on every page) that lets you get an instant rough estimate from a verbal description — ideal for phone calls and walk-in customers. Type a description such as "4 wheels, gloss black, need sandblasting", enter quantity and coat count, and the AI returns a price estimate with a confidence score. Detected color names are matched against your inventory so you can see at a glance whether you have the powder in stock. You can then save the quote under a "Walk-In / Phone" customer with one click and reassign it to the real customer record later. Access via the **dark-blue floating button** in the bottom-right corner, just above the AI Help button.
|
11. **AI Quick Quote** — A floating button (visible on every page) that lets you get an instant rough estimate from a verbal description — ideal for phone calls and walk-in customers. Type a description such as "4 wheels, gloss black, need sandblasting", enter quantity and coat count, and the AI returns a price estimate with a confidence score. Detected color names are matched against your inventory so you can see at a glance whether you have the powder in stock. You can then save the quote under a "Walk-In / Phone" customer with one click and reassign it to the real customer record later. Access via the **dark-blue floating button** in the bottom-right corner, just above the AI Help button.
|
||||||
|
|
||||||
|
12. **Recurring Bill Detection** — AI scans the last 12 months of vendor bills to identify recurring payment patterns. Access via [Bills](/Bills) → "Detect Recurring Bills." See the BILLS section above for full details.
|
||||||
|
|
||||||
|
13. **AI Payment Risk Prediction** — On the [AR Aging](/Reports/ArAging) report, click "Predict Payment Risk" to get a risk assessment (High / Medium / Low) for each outstanding customer with reasoning and a recommended action. Powered by payment history, current balance, and days outstanding.
|
||||||
|
|
||||||
|
14. **Ask Your Financials** — Natural language financial queries at [/Reports/FinancialQuery](/Reports/FinancialQuery). Ask questions in plain English and get answers drawn from your real financial data, with supporting facts and follow-up prompts. See the REPORTS section above for full details.
|
||||||
|
|
||||||
|
15. **Bank Rec Auto-Match** — On the [Bank Reconciliations](/BankReconciliations) Reconcile page, click "AI Suggest Matches" to have AI review your uncleared transactions and suggest which ones to mark as cleared to reach your target balance. Each suggestion includes a confidence percentage and a reason. Click "Apply All Suggestions" to mark all recommended items cleared in one step. You still control the final reconciliation — the AI suggestions are a starting point, not a commitment.
|
||||||
|
|
||||||
**Plan availability:** AI Photo Quotes and AI Inventory Assist are enabled at the subscription plan level. If you do not see the AI Photo Quote option in the quote wizard or the AI lookup button on inventory items, the feature may not be included in your current plan. Contact your administrator or check [Billing](/Billing) to see your plan details.
|
**Plan availability:** AI Photo Quotes and AI Inventory Assist are enabled at the subscription plan level. If you do not see the AI Photo Quote option in the quote wizard or the AI lookup button on inventory items, the feature may not be included in your current plan. Contact your administrator or check [Billing](/Billing) to see your plan details.
|
||||||
|
|
||||||
The AI Profile (in Company Settings) lets you describe your shop's specialties to improve AI quote estimates. This tab only appears when AI Photo Quotes are enabled for your account.
|
The AI Profile (in Company Settings) lets you describe your shop's specialties to improve AI quote estimates. This tab only appears when AI Photo Quotes are enabled for your account.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CUSTOMER INTAKE KIOSK
|
||||||
|
|
||||||
|
**Where:** Kiosk Setup → [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions → [/Kiosk/Intakes](/Kiosk/Intakes)
|
||||||
|
|
||||||
|
**What it does:** Lets walk-in customers fill out their own intake form on a front-desk tablet. On submission, a Customer record and either a Draft Quote or a Pending Job are auto-created (controlled by the Kiosk Output Setting), and staff receive an in-app notification. Also supports remote intake via email link so customers fill out the form on their own phone before arriving.
|
||||||
|
|
||||||
|
**Kiosk Output Setting (Company Settings → Kiosk tab):**
|
||||||
|
- "Create a Quote" (default) — creates a Draft quote on submission; terms shown to customer say "subject to a formal quote." Best for shops that price after seeing the parts.
|
||||||
|
- "Create a Job" — creates a Pending job on submission; terms say "team member will reach out about pricing." Best for shops that price on the spot.
|
||||||
|
|
||||||
|
**Setup (one-time per device):**
|
||||||
|
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
||||||
|
2. Click Activate Kiosk — generates a secure activation token and sets a device cookie (365-day lifespan)
|
||||||
|
3. On the tablet browser, navigate to /Kiosk/Welcome — the tablet is now in kiosk mode
|
||||||
|
4. Add to Home Screen on iOS/Android for a full-screen PWA experience that preserves camera permissions
|
||||||
|
|
||||||
|
**Starting an in-person intake:**
|
||||||
|
1. Customer approaches the tablet — it shows the Welcome screen with company logo and a green "Ready" dot
|
||||||
|
2. Staff member clicks "Start Intake" on the Dashboard (Kiosk card)
|
||||||
|
3. Tablet picks up the new session within 3 seconds and auto-navigates to the intake form
|
||||||
|
4. Customer completes 3 steps: Contact info → Job description → Terms & drawn signature
|
||||||
|
5. On submit: thank-you screen shown, kiosk returns to Welcome after 30 seconds
|
||||||
|
6. If idle for 45 seconds during any intake step, the form resets to the Welcome screen automatically
|
||||||
|
|
||||||
|
**Sending a remote intake link:**
|
||||||
|
- Click "Send Intake Link" on the Dashboard Kiosk card OR from /Kiosk/Intakes → Send Intake Link
|
||||||
|
- Enter the customer's email → they receive a link to complete the form on their own device
|
||||||
|
- Remote sessions use a checkbox agreement instead of a drawn signature
|
||||||
|
|
||||||
|
**What happens on submission:**
|
||||||
|
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
|
||||||
|
- A Draft Quote or Pending Job is created depending on the Kiosk Output Setting (see above)
|
||||||
|
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
|
||||||
|
- In-app notification fires: "Walk-in Intake Submitted" (in-person) or "Remote Intake Submitted" (remote link) with a link to /Kiosk/Intakes
|
||||||
|
|
||||||
|
**Reviewing submissions (Intake Sessions page):**
|
||||||
|
- Filter tabs: All / Submitted / Pending / Expired
|
||||||
|
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
|
||||||
|
- "View Quote" button → appears in Quote mode; opens the auto-created Draft quote for pricing and review
|
||||||
|
- "View Job" button → appears in Job mode; opens the auto-created Pending job so staff can assign and progress it
|
||||||
|
- "Customer" button → opens the matched/created customer record
|
||||||
|
- If submission failed (e.g. seed data not run), the session is still marked Submitted but buttons won't appear — raw intake data is still visible so staff can create manually
|
||||||
|
|
||||||
|
**Dashboard Kiosk card:** Shows whether the kiosk is activated. Contains "Start Intake" (triggers in-person session) and "Send Intake Link" (opens email dialog) buttons. Both are disabled if the kiosk is not activated.
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
|
||||||
|
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
|
||||||
|
- No View Quote/Job button after submission: Seed Data not run — Platform Admin must run it from Platform Management → Seed Data
|
||||||
|
- Signature pad not working: requires capacitive touch (finger or stylus); ensure "Request Desktop Site" is off in browser settings
|
||||||
|
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## COMMON WORKFLOWS
|
## COMMON WORKFLOWS
|
||||||
|
|
||||||
**New company first-time setup:**
|
**New company first-time setup:**
|
||||||
@@ -1259,6 +1334,15 @@ public static class HelpKnowledgeBase
|
|||||||
**Prospect to customer:**
|
**Prospect to customer:**
|
||||||
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
||||||
|
|
||||||
|
**Walk-in customer intake (kiosk — Quote mode):**
|
||||||
|
Staff clicks "Start Intake" on Dashboard → tablet navigates to intake form within 3 s → customer fills out 3 steps (contact, job description, terms + signature) → system creates Customer + Draft Quote → "Walk-in Intake Submitted" notification fires → staff reviews at /Kiosk/Intakes → clicks "View Quote" to price and send the quote
|
||||||
|
|
||||||
|
**Walk-in customer intake (kiosk — Job mode):**
|
||||||
|
Same flow as above, but system creates a Pending Job instead of a Quote → staff clicks "View Job" to assign a worker and progress the job through the workflow
|
||||||
|
|
||||||
|
**Remote intake (customer fills out before arriving):**
|
||||||
|
Staff clicks "Send Intake Link" on Dashboard or Intakes page → enters customer email → customer receives link and completes form on their own device → same auto-create flow as in-person; notification reads "Remote Intake Submitted"
|
||||||
|
|
||||||
**Walk-in / phone quote (quick estimate):**
|
**Walk-in / phone quote (quick estimate):**
|
||||||
Click the AI Quick Quote button (dark-blue floating button, bottom-right) → type description → AI returns price estimate → Save as draft under "Walk-In / Phone" → open the quote → reassign the Customer dropdown on Quote Details to the real customer record once you have their info
|
Click the AI Quick Quote button (dark-blue floating button, bottom-right) → type description → AI returns price estimate → Save as draft under "Walk-In / Phone" → open the quote → reassign the Customer dropdown on Quote Details to the real customer record once you have their info
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub that delivers "StartIntake" push events to the front-desk tablet.
|
||||||
|
/// Deliberately [AllowAnonymous] — the tablet runs without a logged-in user.
|
||||||
|
/// Security is enforced at the kiosk route level via the KioskActivationToken cookie.
|
||||||
|
///
|
||||||
|
/// On connect the tablet passes ?companyId=N in the hub URL query string; this hub
|
||||||
|
/// places that connection in the company-scoped group "kiosk-{companyId}" so that
|
||||||
|
/// KioskController.StartSession can push to exactly that company's tablet.
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class KioskHub : Hub
|
||||||
|
{
|
||||||
|
private readonly ILogger<KioskHub> _logger;
|
||||||
|
|
||||||
|
/// <summary>Initialises the hub with the required logger.</summary>
|
||||||
|
public KioskHub(ILogger<KioskHub> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Joins the connection to the company-scoped kiosk group on connect.
|
||||||
|
/// companyId is read from the ?companyId query param embedded in the hub URL by the Welcome view.
|
||||||
|
/// </summary>
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = Context.GetHttpContext()?.Request.Query["companyId"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(companyId))
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, $"kiosk-{companyId}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error in KioskHub.OnConnectedAsync for connection {ConnectionId}", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Logs unexpected disconnects (e.g. tablet going to sleep).</summary>
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
if (exception != null)
|
||||||
|
_logger.LogWarning(exception, "KioskHub client disconnected with error: {ConnectionId}", Context.ConnectionId);
|
||||||
|
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ public class SubscriptionMiddleware
|
|||||||
"/Billing",
|
"/Billing",
|
||||||
"/api/",
|
"/api/",
|
||||||
"/stripe/",
|
"/stripe/",
|
||||||
|
"/hubs/",
|
||||||
|
"/Kiosk/",
|
||||||
"/Profile/Photo",
|
"/Profile/Photo",
|
||||||
"/CompanyLogo",
|
"/CompanyLogo",
|
||||||
"/AccountDataExport"
|
"/AccountDataExport"
|
||||||
|
|||||||
@@ -414,6 +414,9 @@ builder.Services.AddAuthorization(options =>
|
|||||||
var user = context.User;
|
var user = context.User;
|
||||||
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
|
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||||
return true;
|
return true;
|
||||||
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
|
if (companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
|
return true;
|
||||||
return user.HasClaim("Permission", "ManageVendors");
|
return user.HasClaim("Permission", "ManageVendors");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -425,7 +428,8 @@ builder.Services.AddAuthorization(options =>
|
|||||||
return true;
|
return true;
|
||||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||||
companyRole == AppConstants.CompanyRoles.Manager)
|
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
return true;
|
return true;
|
||||||
return user.HasClaim("Permission", "ManageInventory") ||
|
return user.HasClaim("Permission", "ManageInventory") ||
|
||||||
user.HasClaim("Permission", "ManageVendors");
|
user.HasClaim("Permission", "ManageVendors");
|
||||||
@@ -448,7 +452,8 @@ builder.Services.AddAuthorization(options =>
|
|||||||
return true;
|
return true;
|
||||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||||
companyRole == AppConstants.CompanyRoles.Manager)
|
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
return true;
|
return true;
|
||||||
return user.HasClaim("Permission", "ManageInvoices") ||
|
return user.HasClaim("Permission", "ManageInvoices") ||
|
||||||
user.HasClaim("Permission", "ManageJobs");
|
user.HasClaim("Permission", "ManageJobs");
|
||||||
@@ -462,11 +467,40 @@ builder.Services.AddAuthorization(options =>
|
|||||||
return true;
|
return true;
|
||||||
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||||
companyRole == AppConstants.CompanyRoles.Manager)
|
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
return true;
|
return true;
|
||||||
return user.HasClaim("Permission", "ViewReports");
|
return user.HasClaim("Permission", "ViewReports");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy("CanManageBills", policy =>
|
||||||
|
policy.RequireAssertion(context =>
|
||||||
|
{
|
||||||
|
var user = context.User;
|
||||||
|
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||||
|
return true;
|
||||||
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
|
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
|
return true;
|
||||||
|
return user.HasClaim("Permission", "ManageBills");
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy("CanManageAccounting", policy =>
|
||||||
|
policy.RequireAssertion(context =>
|
||||||
|
{
|
||||||
|
var user = context.User;
|
||||||
|
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||||
|
return true;
|
||||||
|
var companyRole = user.FindFirst("CompanyRole")?.Value;
|
||||||
|
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Manager ||
|
||||||
|
companyRole == AppConstants.CompanyRoles.Accountant)
|
||||||
|
return true;
|
||||||
|
return user.HasClaim("Permission", "ManageAccounting");
|
||||||
|
}));
|
||||||
|
|
||||||
options.AddPolicy("CanManageUsers", policy =>
|
options.AddPolicy("CanManageUsers", policy =>
|
||||||
policy.RequireAssertion(context =>
|
policy.RequireAssertion(context =>
|
||||||
{
|
{
|
||||||
@@ -693,6 +727,12 @@ app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
|
|||||||
// Track authenticated user presence (throttled, in-memory)
|
// Track authenticated user presence (throttled, in-memory)
|
||||||
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
|
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
|
||||||
|
|
||||||
|
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
|
||||||
|
app.MapControllerRoute(
|
||||||
|
name: "kiosk_intake",
|
||||||
|
pattern: "Kiosk/Intake/{token}/{action}",
|
||||||
|
defaults: new { controller = "Kiosk" });
|
||||||
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||||
@@ -702,6 +742,7 @@ app.MapRazorPages();
|
|||||||
// Map SignalR hubs
|
// Map SignalR hubs
|
||||||
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
|
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
|
||||||
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
|
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
|
||||||
|
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
|
|||||||
message = notification.Message,
|
message = notification.Message,
|
||||||
link = notification.Link,
|
link = notification.Link,
|
||||||
notificationType = notification.NotificationType,
|
notificationType = notification.NotificationType,
|
||||||
|
customerId = notification.CustomerId,
|
||||||
createdAt = now.ToString("o")
|
createdAt = now.ToString("o")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace PowderCoating.Web.ViewModels.PlatformAdmin;
|
||||||
|
|
||||||
|
public class PlatformAdminHubViewModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string PageIcon { get; set; } = string.Empty;
|
||||||
|
public string Intro { get; set; } = string.Empty;
|
||||||
|
public string? WarningTitle { get; set; }
|
||||||
|
public string? WarningMessage { get; set; }
|
||||||
|
public IReadOnlyList<PlatformAdminLinkCardViewModel> Cards { get; set; } = Array.Empty<PlatformAdminLinkCardViewModel>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace PowderCoating.Web.ViewModels.PlatformAdmin;
|
||||||
|
|
||||||
|
public class PlatformAdminLinkCardViewModel
|
||||||
|
{
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string Controller { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public string BadgeText { get; set; } = string.Empty;
|
||||||
|
public string BadgeStyle { get; set; } = string.Empty;
|
||||||
|
public Dictionary<string, string>? RouteValues { get; set; }
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4>
|
<h4 class="mb-0"><i class="bi bi-robot me-2 text-primary"></i>AI Usage Report</h4>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid py-3" style="max-width:700px">
|
<div class="container-fluid py-3" style="max-width:700px">
|
||||||
<div class="d-flex align-items-center gap-3 mb-3">
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
|
<a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back</a>
|
||||||
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>New Announcement</h4>
|
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>New Announcement</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid py-3" style="max-width:700px">
|
<div class="container-fluid py-3" style="max-width:700px">
|
||||||
<div class="d-flex align-items-center gap-3 mb-3">
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
|
<a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back</a>
|
||||||
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Edit Announcement</h4>
|
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Edit Announcement</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Announcements</h4>
|
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Announcements</h4>
|
||||||
<a asp-action="Create" class="btn btn-primary btn-sm">
|
<a asp-action="Create" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Announcement
|
<i class="bi bi-plus-lg me-1"></i>New Announcement
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary" id="quickCreateSubmit">
|
<button type="submit" class="btn btn-primary" id="quickCreateSubmit">
|
||||||
<i class="bi bi-check-circle"></i> Create Appointment
|
<i class="bi bi-check-circle"></i> Create Appointment
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="container-fluid py-3" style="max-width:900px">
|
<div class="container-fluid py-3" style="max-width:900px">
|
||||||
<div class="d-flex align-items-center gap-3 mb-3">
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>Back
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
</a>
|
</a>
|
||||||
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Entry #@Model.Id</h4>
|
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Entry #@Model.Id</h4>
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid py-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4>
|
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Log</h4>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<div class="d-flex align-items-center mb-3 gap-2">
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
<h4 class="mb-0 fw-semibold">Bank Reconciliation</h4>
|
<h4 class="mb-0 fw-semibold">Bank Reconciliation</h4>
|
||||||
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
|
<a asp-action="Create" class="btn btn-primary ms-auto">
|
||||||
<i class="bi bi-plus-lg me-1"></i>Start New Reconciliation
|
<i class="bi bi-plus-lg me-1"></i>Start New Reconciliation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,6 +115,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Auto-Match panel -->
|
||||||
|
<div class="card shadow-sm mb-3 border-0 bg-light">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Auto-Match</span>
|
||||||
|
<span class="text-muted small ms-2">Let Claude suggest which transactions to clear based on amounts and dates.</span>
|
||||||
|
</div>
|
||||||
|
<button id="aiMatchBtn" class="btn btn-outline-primary btn-sm ms-auto" type="button">
|
||||||
|
<i class="bi bi-magic me-1"></i>Suggest Matches
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="aiMatchResult" class="d-none px-3 pb-3">
|
||||||
|
<div id="aiMatchInsights" class="mb-2 text-muted small"></div>
|
||||||
|
<div id="aiMatchActions" class="d-flex gap-2 flex-wrap">
|
||||||
|
<button id="aiMatchAccept" class="btn btn-sm btn-success d-none">
|
||||||
|
<i class="bi bi-check-all me-1"></i>Apply All Suggestions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form asp-action="Complete" method="post" id="completeForm">
|
<form asp-action="Complete" method="post" id="completeForm">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="id" value="@recon?.Id" />
|
<input type="hidden" name="id" value="@recon?.Id" />
|
||||||
@@ -184,6 +205,88 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
recalculate();
|
recalculate();
|
||||||
|
|
||||||
|
// ── AI Auto-Match ──────────────────────────────────────────────────────────
|
||||||
|
let aiSuggestions = [];
|
||||||
|
|
||||||
|
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
|
||||||
|
const btn = this;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'RequestVerificationToken': token
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({ reconId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const resultEl = document.getElementById('aiMatchResult');
|
||||||
|
const insightsEl = document.getElementById('aiMatchInsights');
|
||||||
|
resultEl.classList.remove('d-none');
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
insightsEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${data.errorMessage || 'AI unavailable.'}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
aiSuggestions = data.suggestedCleared || [];
|
||||||
|
|
||||||
|
// Highlight suggested rows
|
||||||
|
aiSuggestions.forEach(s => {
|
||||||
|
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.classList.add('table-info');
|
||||||
|
const td = row.querySelector('td:last-child');
|
||||||
|
if (td) {
|
||||||
|
const pct = Math.round(s.confidence * 100);
|
||||||
|
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insights
|
||||||
|
const insights = data.insights || [];
|
||||||
|
insightsEl.innerHTML = insights.map(i => `<i class="bi bi-lightbulb me-1 text-warning"></i>${i}`).join('<br>');
|
||||||
|
|
||||||
|
if (aiSuggestions.length > 0) {
|
||||||
|
document.getElementById('aiMatchAccept').classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
|
||||||
|
document.getElementById('aiMatchResult').classList.remove('d-none');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Suggest Matches';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('aiMatchAccept')?.addEventListener('click', async function() {
|
||||||
|
for (const s of aiSuggestions) {
|
||||||
|
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
|
||||||
|
if (!row) continue;
|
||||||
|
const cb = row.querySelector('.cleared-checkbox');
|
||||||
|
if (!cb || cb.checked) continue;
|
||||||
|
cb.checked = true;
|
||||||
|
// Persist via the existing toggle endpoint
|
||||||
|
try {
|
||||||
|
await fetch('/BankReconciliations/ToggleCleared', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
|
||||||
|
body: new URLSearchParams({ reconId, entityType: s.entityType, entityId: s.entityId, isCleared: true })
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
recalculate();
|
||||||
|
this.textContent = 'Applied';
|
||||||
|
this.disabled = true;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="container-fluid py-4">
|
<div class="container-fluid py-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<a asp-controller="PlatformAdmin" asp-action="Observability" class="text-muted small text-decoration-none">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Observability
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
@* Add new ban form *@
|
@* Add new ban form *@
|
||||||
<div class="card shadow-sm mb-4">
|
<div class="card shadow-sm mb-4">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@model PowderCoating.Application.DTOs.Accounting.CreateBillDto
|
@model PowderCoating.Application.DTOs.Accounting.CreateBillDto
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "New Bill";
|
ViewData["Title"] = "New Bill";
|
||||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||||
ViewData["PageHelpTitle"] = "New Bill";
|
ViewData["PageHelpTitle"] = "New Bill";
|
||||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||||
string? fromPoNumber = ViewBag.FromPoNumber as string;
|
string? fromPoNumber = ViewBag.FromPoNumber as string;
|
||||||
int? fromPoId = ViewBag.FromPoId as int?;
|
int? fromPoId = ViewBag.FromPoId as int?;
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<div>
|
<div>
|
||||||
@if (!string.IsNullOrEmpty(fromPoNumber))
|
@if (!string.IsNullOrEmpty(fromPoNumber))
|
||||||
{
|
{
|
||||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (fromPoId.HasValue)
|
@if (fromPoId.HasValue)
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Bill Details"
|
data-bs-title="Bill Details"
|
||||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
|
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
|
||||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||||
<option value="">— Select Vendor —</option>
|
<option value="">— Select Vendor —</option>
|
||||||
<option value="__new__">+ Add New Vendor…</option>
|
<option value="__new__">+ Add New Vendor…</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="VendorId" class="text-danger small"></span>
|
<span asp-validation-for="VendorId" class="text-danger small"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
||||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Line Items"
|
data-bs-title="Line Items"
|
||||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||||
data-bs-title="Bill Summary"
|
data-bs-title="Bill Summary"
|
||||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
|
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
|
||||||
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
|
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
|
||||||
<option value="">— Select Account —</option>
|
<option value="">— Select Account —</option>
|
||||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||||
{
|
{
|
||||||
<option value="@item.Value">@item.Text</option>
|
<option value="@item.Value">@item.Text</option>
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
<tr class="line-item-row">
|
<tr class="line-item-row">
|
||||||
<td>
|
<td>
|
||||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||||
<option value="">— Account —</option>
|
<option value="">— Account —</option>
|
||||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||||
{
|
{
|
||||||
<option value="@item.Value">@item.Text</option>
|
<option value="@item.Value">@item.Text</option>
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||||
<td>
|
<td>
|
||||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||||
{
|
{
|
||||||
<option value="@item.Value">@item.Text</option>
|
<option value="@item.Value">@item.Text</option>
|
||||||
@@ -273,12 +273,12 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
|
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
|
||||||
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
|
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" id="scanReceiptUploadBtn">
|
<button type="button" class="btn btn-primary" id="scanReceiptUploadBtn">
|
||||||
<i class="bi bi-camera me-1"></i>Scan & Fill
|
<i class="bi bi-camera me-1"></i>Scan & Fill
|
||||||
</button>
|
</button>
|
||||||
@@ -393,8 +393,8 @@
|
|||||||
}
|
}
|
||||||
if (lineCount === 0) addLineItem();
|
if (lineCount === 0) addLineItem();
|
||||||
|
|
||||||
// ── AI Auto-suggest Account on description blur ───────────────────────
|
// ── AI Auto-suggest Account on description blur ───────────────────────
|
||||||
// Keyword shortcuts — handle common cases with zero API cost
|
// Keyword shortcuts — handle common cases with zero API cost
|
||||||
const _keywordMap = [
|
const _keywordMap = [
|
||||||
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
|
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
|
||||||
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
|
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
|
||||||
@@ -407,7 +407,7 @@
|
|||||||
{ words: ['advertising','marketing','promo'], hint: 'advertising' },
|
{ words: ['advertising','marketing','promo'], hint: 'advertising' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Session cache: description (lowercased) → { accountId, accountName }
|
// Session cache: description (lowercased) → { accountId, accountName }
|
||||||
const _suggestCache = new Map();
|
const _suggestCache = new Map();
|
||||||
|
|
||||||
function _keywordGuess(description) {
|
function _keywordGuess(description) {
|
||||||
@@ -480,7 +480,7 @@
|
|||||||
hint2.className = 'ai-account-hint text-muted small mt-1';
|
hint2.className = 'ai-account-hint text-muted small mt-1';
|
||||||
accountSel.parentNode.appendChild(hint2);
|
accountSel.parentNode.appendChild(hint2);
|
||||||
}
|
}
|
||||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||||
@@ -501,14 +501,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event delegation — works for dynamically added rows
|
// Event delegation — works for dynamically added rows
|
||||||
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
|
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
|
||||||
if (e.target.matches('[name$=".Description"]')) {
|
if (e.target.matches('[name$=".Description"]')) {
|
||||||
_suggestAccountForRow(e.target.closest('tr'));
|
_suggestAccountForRow(e.target.closest('tr'));
|
||||||
}
|
}
|
||||||
}, true); // capture phase so blur bubbles
|
}, true); // capture phase so blur bubbles
|
||||||
|
|
||||||
// ── Scan Receipt ─────────────────────────────────────────────────────
|
// ── Scan Receipt ─────────────────────────────────────────────────────
|
||||||
document.getElementById('scanReceiptUploadBtn').addEventListener('click', async function () {
|
document.getElementById('scanReceiptUploadBtn').addEventListener('click', async function () {
|
||||||
const fileInput = document.getElementById('scanReceiptFile');
|
const fileInput = document.getElementById('scanReceiptFile');
|
||||||
if (!fileInput.files.length) { alert('Please select a file.'); return; }
|
if (!fileInput.files.length) { alert('Please select a file.'); return; }
|
||||||
@@ -535,7 +535,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fill bill header — try to match vendor name to dropdown
|
// Auto-fill bill header — try to match vendor name to dropdown
|
||||||
if (data.vendorName) {
|
if (data.vendorName) {
|
||||||
const vendorSel = document.getElementById('vendorSelect');
|
const vendorSel = document.getElementById('vendorSelect');
|
||||||
if (vendorSel && !vendorSel.value) {
|
if (vendorSel && !vendorSel.value) {
|
||||||
@@ -553,7 +553,7 @@
|
|||||||
vendorSel.value = bestOption.value;
|
vendorSel.value = bestOption.value;
|
||||||
vendorSel.dispatchEvent(new Event('change'));
|
vendorSel.dispatchEvent(new Event('change'));
|
||||||
} else {
|
} else {
|
||||||
// No match — put the name in Memo so user knows what the AI saw
|
// No match — put the name in Memo so user knows what the AI saw
|
||||||
const memo = document.querySelector('[name="Memo"]');
|
const memo = document.querySelector('[name="Memo"]');
|
||||||
if (memo && !memo.value) memo.value = data.vendorName;
|
if (memo && !memo.value) memo.value = data.vendorName;
|
||||||
}
|
}
|
||||||
@@ -598,7 +598,7 @@
|
|||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
||||||
if (modal) modal.hide();
|
if (modal) modal.hide();
|
||||||
|
|
||||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = 'Error connecting to AI service.';
|
statusEl.textContent = 'Error connecting to AI service.';
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<a asp-action="RecurringDetection" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-robot me-1"></i>Detect Recurring Bills
|
||||||
|
</a>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
|
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
|
||||||
<i class="bi bi-plus-lg me-1"></i>New Bill
|
<i class="bi bi-plus-lg me-1"></i>New Bill
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Recurring Bill Detection";
|
||||||
|
ViewData["PageIcon"] = "bi-arrow-repeat";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-semibold mb-1"><i class="bi bi-arrow-repeat text-primary me-2"></i>Recurring Bill Detection</h4>
|
||||||
|
<p class="text-muted small mb-0">Claude analyzes your last 12 months of bills to find recurring payment patterns and help you anticipate upcoming expenses.</p>
|
||||||
|
</div>
|
||||||
|
<a asp-action="Index" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Bills
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="scanForm" method="post" asp-action="RunRecurringDetection">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="card shadow-sm mb-4 border-0 bg-light">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Analysis</span>
|
||||||
|
<span class="text-muted small ms-2">Scans up to 12 months of bills grouped by vendor to detect patterns.</span>
|
||||||
|
</div>
|
||||||
|
<button id="scanBtn" type="submit" class="btn btn-primary ms-auto">
|
||||||
|
<i class="bi bi-magic me-1"></i>Detect Recurring Bills
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="resultArea" class="d-none">
|
||||||
|
<div id="spinnerArea" class="text-center py-5 d-none">
|
||||||
|
<div class="spinner-border text-primary" style="width:2.5rem;height:2.5rem;" role="status"></div>
|
||||||
|
<p class="text-muted mt-3">Claude is reviewing your bill history…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorArea" class="alert alert-danger alert-permanent d-none"></div>
|
||||||
|
|
||||||
|
<div id="insightsArea" class="alert alert-info alert-permanent d-none mb-3">
|
||||||
|
<i class="bi bi-lightbulb me-2"></i><span id="insightsList"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="noPatterns" class="card shadow-sm d-none">
|
||||||
|
<div class="card-body text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-search fs-1 d-block mb-2"></i>
|
||||||
|
<p class="mb-0 fw-semibold">No recurring patterns detected</p>
|
||||||
|
<p class="small">Need at least 2 occurrences of a vendor bill at a similar cadence. Add more bill history and try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="patternsArea" class="d-none">
|
||||||
|
<div class="row g-3" id="patternCards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="/js/recurring-detection.js"></script>
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user