Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7020797a25 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd | |||
| 4ca90f561e | |||
| f95397204c | |||
| 31d305b66a | |||
| 42a8c089d5 | |||
| 2c353f2e7f | |||
| c02a5584b4 | |||
| 17da692dce | |||
| 656f830898 | |||
| dde66c807f | |||
| feff0fa73d | |||
| 59beba2e15 | |||
| 959e323f3a | |||
| e2f9e9ae4f |
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
|||||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||||
|
|
||||||
|
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||||
|
|
||||||
|
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||||
|
|
||||||
|
| Flag | Effect if missing on JobItem |
|
||||||
|
|------|------------------------------|
|
||||||
|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||||
|
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||||
|
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||||
|
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||||
|
|
||||||
|
**Checklist when adding a new pricing routing flag:**
|
||||||
|
1. Add the property to `QuoteItem` (Core/Entities)
|
||||||
|
2. Add the property to `JobItem` (Core/Entities)
|
||||||
|
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||||
|
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||||
|
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||||
|
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||||
|
7. Add a migration if the field is new on a persisted entity
|
||||||
|
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||||
|
|
||||||
### Branding
|
### Branding
|
||||||
- Application name: **Powder Coating Logix**
|
- Application name: **Powder Coating Logix**
|
||||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||||
|
|||||||
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
|||||||
public string? RecommendedAction { get; set; }
|
public string? RecommendedAction { get; set; }
|
||||||
public string? BillNumber { get; set; }
|
public string? BillNumber { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public class BankRecMatchItem
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||||
|
public string Reference { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchRequest
|
||||||
|
{
|
||||||
|
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||||
|
public decimal BeginningBalance { get; set; }
|
||||||
|
public decimal StatementEndingBalance { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchSuggestion
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public double Confidence { get; set; } // 0.0–1.0
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoMatchResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||||
|
public class ClaudeAutoMatchResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeAutoMatchSuggestion
|
||||||
|
{
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
public string Reason { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||||
|
|
||||||
|
public class OpenInvoiceSummary
|
||||||
|
{
|
||||||
|
public string InvoiceNumber { get; set; } = string.Empty;
|
||||||
|
public decimal BalanceDue { get; set; }
|
||||||
|
public string? DueDateIso { get; set; }
|
||||||
|
public int DaysOverdue { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentCustomerData
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public decimal TotalOwed { get; set; }
|
||||||
|
public double AvgDaysToPay { get; set; } // historical average
|
||||||
|
public int TotalInvoicesAllTime { get; set; }
|
||||||
|
public int LateInvoicesAllTime { get; set; }
|
||||||
|
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPredictionRequest
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPrediction
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
/// <summary>"high", "medium", or "low"</summary>
|
||||||
|
public string RiskLevel { get; set; } = "medium";
|
||||||
|
public int EstimatedDaysToPayment { get; set; }
|
||||||
|
public string Reasoning { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LatePaymentPredictionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||||
|
public class ClaudeLatePaymentResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeLatePaymentPrediction
|
||||||
|
{
|
||||||
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
|
public string RiskLevel { get; set; } = "medium";
|
||||||
|
public int EstimatedDaysToPayment { get; set; }
|
||||||
|
public string Reasoning { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||||
|
|
||||||
|
public class MonthlyFinancialSummary
|
||||||
|
{
|
||||||
|
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||||
|
public decimal Revenue { get; set; }
|
||||||
|
public decimal Expenses { get; set; }
|
||||||
|
public decimal NetIncome { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryContext
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public string AsOfDate { get; set; } = string.Empty;
|
||||||
|
public decimal TotalRevenueYtd { get; set; }
|
||||||
|
public decimal TotalExpensesYtd { get; set; }
|
||||||
|
public decimal NetIncomeYtd { get; set; }
|
||||||
|
public decimal ArOutstanding { get; set; }
|
||||||
|
public decimal ApOutstanding { get; set; }
|
||||||
|
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||||
|
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryRequest
|
||||||
|
{
|
||||||
|
public string Question { get; set; } = string.Empty;
|
||||||
|
public FinancialQueryContext Context { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FinancialQueryResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public string Answer { get; set; } = string.Empty;
|
||||||
|
public string? FollowUpSuggestion { get; set; }
|
||||||
|
public List<string> RelevantFacts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||||
|
public class ClaudeFinancialQueryResponse
|
||||||
|
{
|
||||||
|
public string Answer { get; set; } = string.Empty;
|
||||||
|
public string? FollowUpSuggestion { get; set; }
|
||||||
|
public List<string> RelevantFacts { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||||
|
|
||||||
|
public class RecurringBillHistoryItem
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
public string BillNumber { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string DateIso { get; set; } = string.Empty;
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillDetectionRequest
|
||||||
|
{
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillPattern
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||||
|
public string Frequency { get; set; } = string.Empty;
|
||||||
|
public decimal TypicalAmount { get; set; }
|
||||||
|
public string? NextExpectedDateIso { get; set; }
|
||||||
|
/// <summary>"high", "medium", or "low"</summary>
|
||||||
|
public string Confidence { get; set; } = "medium";
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? SuggestedAction { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecurringBillDetectionResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||||
|
public class ClaudeRecurringBillResponse
|
||||||
|
{
|
||||||
|
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||||
|
public List<string> Insights { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClaudeRecurringPattern
|
||||||
|
{
|
||||||
|
public string VendorName { get; set; } = string.Empty;
|
||||||
|
public string Frequency { get; set; } = string.Empty;
|
||||||
|
public decimal TypicalAmount { get; set; }
|
||||||
|
public string? NextExpectedDateIso { get; set; }
|
||||||
|
public string Confidence { get; set; } = "medium";
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string? SuggestedAction { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
|||||||
public bool WizardCompleted { get; set; }
|
public bool WizardCompleted { get; set; }
|
||||||
public DateTime? WizardCompletedAt { get; set; }
|
public DateTime? WizardCompletedAt { get; set; }
|
||||||
public string? WizardCompletedByName { get; set; }
|
public string? WizardCompletedByName { get; set; }
|
||||||
|
|
||||||
|
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||||
|
public int HealthScore { get; set; }
|
||||||
|
public string HealthRisk { get; set; } = "Healthy";
|
||||||
|
public DateTime? LastLoginDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
|||||||
// Blank Work Order PDF Template
|
// Blank Work Order PDF Template
|
||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
public string? WoTerms { get; set; }
|
public string? WoTerms { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateAppDefaultsDto
|
public class UpdateAppDefaultsDto
|
||||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
|||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class UpdateKioskSettingsDto
|
||||||
|
{
|
||||||
|
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||||
|
[Required]
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
|
|||||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// At least one contact method is required (Email OR Phone)
|
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||||
{
|
{
|
||||||
yield return new ValidationResult(
|
yield return new ValidationResult(
|
||||||
"Please provide at least one contact method (Email or Phone)",
|
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||||
new[] { nameof(Email), nameof(Phone) });
|
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate each address in comma-separated email fields
|
// Validate each address in comma-separated email fields
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
|||||||
public GiftCertificateStatus Status { get; set; }
|
public GiftCertificateStatus Status { get; set; }
|
||||||
public DateTime IssueDate { get; set; }
|
public DateTime IssueDate { get; set; }
|
||||||
public DateTime? ExpiryDate { get; set; }
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GiftCertificateDto : GiftCertificateListDto
|
public class GiftCertificateDto : GiftCertificateListDto
|
||||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
|||||||
[Range(0.01, 9999.99)]
|
[Range(0.01, 9999.99)]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BulkCreateGiftCertificateDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||||
|
[Display(Name = "Number of Certificates")]
|
||||||
|
public int Quantity { get; set; } = 25;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||||
|
[Display(Name = "Face Value (each)")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Issued Reason")]
|
||||||
|
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||||
|
|
||||||
|
[Display(Name = "Expiry Date (optional)")]
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
|||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public string? CustomerPhone { get; set; }
|
public string? CustomerPhone { get; set; }
|
||||||
|
public string? CustomerMobilePhone { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; }
|
public bool CustomerNotifyByEmail { get; set; }
|
||||||
|
public bool CustomerNotifyBySms { get; set; }
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
public string? PreparedByName { get; set; }
|
public string? PreparedByName { get; set; }
|
||||||
public InvoiceStatus Status { get; set; }
|
public InvoiceStatus Status { get; set; }
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
|||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||||
public PaymentMethod RefundMethod { get; set; }
|
public PaymentMethod RefundMethod { get; set; }
|
||||||
|
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
public string Reason { get; set; } = string.Empty;
|
public string Reason { get; set; } = string.Empty;
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|||||||
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
|
public int? OvenCostId { get; set; }
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||||
|
|
||||||
|
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||||
|
public class SendRemoteLinkDto
|
||||||
|
{
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskContactDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, Phone]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskJobDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(2000)]
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||||
|
public class SubmitKioskTermsDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||||
|
public class KioskSessionListDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public Guid SessionToken { get; set; }
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; }
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
|
||||||
|
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||||
|
public string JobDescriptionSnippet =>
|
||||||
|
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||||
|
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||||
|
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||||
|
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||||
|
}
|
||||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
|||||||
|
|
||||||
public decimal SubtotalBeforeDiscount { get; set; }
|
public decimal SubtotalBeforeDiscount { get; set; }
|
||||||
|
|
||||||
|
public decimal PricingTierDiscountAmount { get; set; }
|
||||||
|
public decimal PricingTierDiscountPercent { get; set; }
|
||||||
|
public decimal QuoteDiscountAmount { get; set; }
|
||||||
|
public decimal QuoteDiscountPercent { get; set; }
|
||||||
|
|
||||||
public decimal DiscountAmount { get; set; }
|
public decimal DiscountAmount { get; set; }
|
||||||
public decimal DiscountPercent { get; set; }
|
public decimal DiscountPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -51,4 +51,10 @@ public interface IPdfService
|
|||||||
byte[]? companyLogo,
|
byte[]? companyLogo,
|
||||||
string? companyLogoContentType,
|
string? companyLogoContentType,
|
||||||
CompanyInfoDto companyInfo);
|
CompanyInfoDto companyInfo);
|
||||||
|
|
||||||
|
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
|||||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||||
|
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
|||||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||||
: null))
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||||
|
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||||
|
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||||
: null))
|
: null))
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = pricing.UnitPrice,
|
UnitPrice = pricing.UnitPrice,
|
||||||
TotalPrice = pricing.TotalPrice,
|
TotalPrice = pricing.TotalPrice,
|
||||||
LaborCost = pricing.TotalPrice * 0.4m,
|
LaborCost = pricing.LaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = source.UnitPrice,
|
UnitPrice = source.UnitPrice,
|
||||||
TotalPrice = source.TotalPrice,
|
TotalPrice = source.TotalPrice,
|
||||||
LaborCost = source.TotalPrice * 0.4m,
|
LaborCost = source.ItemLaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = seed.IsGenericItem,
|
IsGenericItem = seed.IsGenericItem,
|
||||||
IsLaborItem = seed.IsLaborItem,
|
IsLaborItem = seed.IsLaborItem,
|
||||||
IsSalesItem = seed.IsSalesItem,
|
IsSalesItem = seed.IsSalesItem,
|
||||||
|
IsAiItem = seed.IsAiItem,
|
||||||
Sku = seed.Sku,
|
Sku = seed.Sku,
|
||||||
ManualUnitPrice = seed.ManualUnitPrice,
|
ManualUnitPrice = seed.ManualUnitPrice,
|
||||||
PowderCostOverride = seed.PowderCostOverride,
|
PowderCostOverride = seed.PowderCostOverride,
|
||||||
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public bool IsGenericItem { get; init; }
|
public bool IsGenericItem { get; init; }
|
||||||
public bool IsLaborItem { get; init; }
|
public bool IsLaborItem { get; init; }
|
||||||
public bool IsSalesItem { get; init; }
|
public bool IsSalesItem { get; init; }
|
||||||
|
public bool IsAiItem { get; init; }
|
||||||
public string? Sku { get; init; }
|
public string? Sku { get; init; }
|
||||||
public decimal? ManualUnitPrice { get; init; }
|
public decimal? ManualUnitPrice { get; init; }
|
||||||
public decimal? PowderCostOverride { get; init; }
|
public decimal? PowderCostOverride { get; init; }
|
||||||
|
|||||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||||
|
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||||
|
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo)
|
||||||
|
{
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
const string accent = "#7c3aed";
|
||||||
|
const string gold = "#b45309";
|
||||||
|
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var doc = Document.Create(container =>
|
||||||
|
{
|
||||||
|
foreach (var cert in certs)
|
||||||
|
{
|
||||||
|
container.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.Letter);
|
||||||
|
page.Margin(0.75f, Unit.Inch);
|
||||||
|
page.PageColor(Colors.White);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
|
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(text =>
|
||||||
|
{
|
||||||
|
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
|
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.GeneratePdf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||||
|
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||||
|
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||||
@@ -42,8 +44,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||||
|
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||||
|
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||||
|
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||||
|
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||||
|
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||||
quote.RushFee = pricingResult.RushFee;
|
quote.RushFee = pricingResult.RushFee;
|
||||||
quote.TaxAmount = pricingResult.TaxAmount;
|
quote.TaxAmount = pricingResult.TaxAmount;
|
||||||
quote.Total = pricingResult.Total;
|
quote.Total = pricingResult.Total;
|
||||||
@@ -104,8 +111,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; }
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
|||||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||||
public int? SourceInvoiceItemId { get; set; }
|
public int? SourceInvoiceItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Customer? RecipientCustomer { get; set; }
|
public virtual Customer? RecipientCustomer { get; set; }
|
||||||
public virtual Customer? PurchasingCustomer { get; set; }
|
public virtual Customer? PurchasingCustomer { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
|||||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||||
|
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||||
|
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicViewToken { get; set; }
|
||||||
|
|
||||||
// Online payments (Stripe Connect)
|
// Online payments (Stripe Connect)
|
||||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ public class Job : BaseEntity
|
|||||||
// Selected oven (carried over from quote; null = company default rate)
|
// Selected oven (carried over from quote; null = company default rate)
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
|
|
||||||
|
// Oven scheduling (carried over from quote)
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
|
public decimal OvenBatchCost { get; set; }
|
||||||
public decimal ShopSuppliesAmount { get; set; }
|
public decimal ShopSuppliesAmount { get; set; }
|
||||||
public decimal ShopSuppliesPercent { get; set; }
|
public decimal ShopSuppliesPercent { get; set; }
|
||||||
|
|
||||||
@@ -61,6 +66,10 @@ public class Job : BaseEntity
|
|||||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||||
|
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||||
|
public string? PricingBreakdownJson { get; set; }
|
||||||
|
|
||||||
// Rework tracking
|
// Rework tracking
|
||||||
public bool IsReworkJob { get; set; }
|
public bool IsReworkJob { get; set; }
|
||||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
|||||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||||
public string? Complexity { get; set; }
|
public string? Complexity { get; set; }
|
||||||
|
|
||||||
|
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||||
|
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||||
public string? AiTags { get; set; }
|
public string? AiTags { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||||
|
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||||
|
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskSession : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||||
|
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||||
|
|
||||||
|
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
|
||||||
|
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
|
||||||
|
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
public DateTime? AgreedToTermsAt { get; set; }
|
||||||
|
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
|
||||||
|
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
public DateTime? RemoteLinkSentAt { get; set; }
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
public virtual Customer? LinkedCustomer { get; set; }
|
||||||
|
public virtual Job? LinkedJob { get; set; }
|
||||||
|
}
|
||||||
@@ -45,21 +45,28 @@ public class Quote : BaseEntity
|
|||||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||||
|
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||||
|
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||||
|
|
||||||
// Discount Information
|
// Discount Information
|
||||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||||
|
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||||
|
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||||
|
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||||
|
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||||
public string? DiscountReason { get; set; } // Why discount was applied
|
public string? DiscountReason { get; set; } // Why discount was applied
|
||||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||||
|
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||||
|
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
public decimal TaxAmount { get; set; }
|
public decimal TaxAmount { 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobOvenBatchFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobItemIsAiItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10754
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGiftCertificateBatchId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobPricingSnapshot : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddQuotePricingSnapshotFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -3274,6 +3290,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid?>("BatchId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("CertificateCode")
|
b.Property<string>("CertificateCode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -3910,6 +3929,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("PublicViewToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("SalesTaxAccountId")
|
b.Property<int?>("SalesTaxAccountId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4183,9 +4205,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("OriginalJobId")
|
b.Property<int?>("OriginalJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("OvenBatchCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("OvenBatches")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("OvenCycleMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PricingBreakdownJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4454,6 +4488,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAiItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -5552,6 +5589,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 +6714,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, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6576,7 +6725,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, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6587,7 +6736,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, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6834,6 +6983,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("ExpirationDate")
|
b.Property<DateTime?>("ExpirationDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<bool>("HideDiscountFromCustomer")
|
b.Property<bool>("HideDiscountFromCustomer")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -6879,6 +7034,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal>("ProfitMargin")
|
b.Property<decimal>("ProfitMargin")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -6918,6 +7079,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("QuoteDate")
|
b.Property<DateTime>("QuoteDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("QuoteNumber")
|
b.Property<string>("QuoteNumber")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -6943,6 +7110,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("SubTotal")
|
b.Property<decimal>("SubTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SubtotalAfterDiscount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("Tags")
|
b.Property<string>("Tags")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7645,6 +7815,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 +8548,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 +9879,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>
|
||||||
@@ -103,7 +107,8 @@ public class GiftCertificatesController : Controller
|
|||||||
IssuedReason = gc.IssuedReason,
|
IssuedReason = gc.IssuedReason,
|
||||||
Status = gc.Status,
|
Status = gc.Status,
|
||||||
IssueDate = gc.IssueDate,
|
IssueDate = gc.IssueDate,
|
||||||
ExpiryDate = gc.ExpiryDate
|
ExpiryDate = gc.ExpiryDate,
|
||||||
|
BatchId = gc.BatchId
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -240,6 +245,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 +297,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 +433,191 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
|
||||||
|
/// since the primary use case is car shows and events where a batch of same-value certificates
|
||||||
|
/// is distributed to attendees.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult BulkCreate()
|
||||||
|
{
|
||||||
|
return View(new BulkCreateGiftCertificateDto());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
|
||||||
|
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
|
||||||
|
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
|
||||||
|
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
|
||||||
|
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
|
||||||
|
/// Sales Discounts (4950) and credit GC Liability (2500).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View(dto);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var companyId = currentUser?.CompanyId ?? 0;
|
||||||
|
|
||||||
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||||
|
int? checkingAcctId = null;
|
||||||
|
int? discountAcctId = null;
|
||||||
|
|
||||||
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||||
|
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||||
|
checkingAcctId = acct?.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.IsActive && a.AccountNumber == "4950");
|
||||||
|
discountAcctId = acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchId = Guid.NewGuid();
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
for (int i = 0; i < dto.Quantity; i++)
|
||||||
|
{
|
||||||
|
var code = await GenerateCertificateCodeAsync(companyId);
|
||||||
|
|
||||||
|
var cert = new GiftCertificate
|
||||||
|
{
|
||||||
|
CertificateCode = code,
|
||||||
|
OriginalAmount = dto.Amount,
|
||||||
|
RedeemedAmount = 0,
|
||||||
|
IssuedReason = dto.IssuedReason,
|
||||||
|
Status = GiftCertificateStatus.Active,
|
||||||
|
IssueDate = now,
|
||||||
|
ExpiryDate = dto.ExpiryDate,
|
||||||
|
Notes = dto.Notes,
|
||||||
|
IssuedById = currentUser?.Id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = currentUser?.Email,
|
||||||
|
BatchId = batchId
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||||
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
|
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
|
||||||
|
else
|
||||||
|
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating bulk gift certificates");
|
||||||
|
this.ToastError("An error occurred creating the certificates.");
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
|
||||||
|
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> BulkResult(Guid batchId)
|
||||||
|
{
|
||||||
|
if (batchId == Guid.Empty)
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||||
|
gc => gc.BatchId == batchId, false);
|
||||||
|
|
||||||
|
if (!certs.Any())
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
return View(certs.OrderBy(c => c.CertificateCode).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
|
||||||
|
/// user can bookmark or re-open it at any time after the batch was originally created.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
|
||||||
|
{
|
||||||
|
if (batchId == Guid.Empty)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var companyId = currentUser?.CompanyId ?? 0;
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
|
||||||
|
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||||
|
{
|
||||||
|
CompanyName = company?.CompanyName ?? string.Empty,
|
||||||
|
Phone = company?.Phone,
|
||||||
|
Address = company?.Address,
|
||||||
|
City = company?.City,
|
||||||
|
State = company?.State,
|
||||||
|
ZipCode = company?.ZipCode,
|
||||||
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||||
|
gc => gc.BatchId == batchId, false,
|
||||||
|
gc => gc.RecipientCustomer);
|
||||||
|
|
||||||
|
if (!certs.Any())
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
|
||||||
|
{
|
||||||
|
Id = cert.Id,
|
||||||
|
CertificateCode = cert.CertificateCode,
|
||||||
|
OriginalAmount = cert.OriginalAmount,
|
||||||
|
RedeemedAmount = cert.RedeemedAmount,
|
||||||
|
RemainingBalance = cert.RemainingBalance,
|
||||||
|
RecipientName = cert.RecipientCustomer != null
|
||||||
|
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
|
||||||
|
: cert.RecipientName,
|
||||||
|
RecipientEmail = cert.RecipientEmail,
|
||||||
|
IssuedReason = cert.IssuedReason,
|
||||||
|
Status = cert.Status,
|
||||||
|
IssueDate = cert.IssueDate,
|
||||||
|
ExpiryDate = cert.ExpiryDate,
|
||||||
|
Notes = cert.Notes
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
|
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
|
||||||
|
var first = dtos.First().CertificateCode;
|
||||||
|
var last = dtos.Last().CertificateCode;
|
||||||
|
var fileName = dtos.Count == 1
|
||||||
|
? $"GiftCertificate-{first}.pdf"
|
||||||
|
: $"GiftCertificates-{first}-to-{last}.pdf";
|
||||||
|
return File(pdfBytes, "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
|
||||||
|
TempData["Error"] = "Could not generate PDF.";
|
||||||
|
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PowderCoating.Application.DTOs.Common;
|
using PowderCoating.Application.DTOs.Common;
|
||||||
using PowderCoating.Application.DTOs.Invoice;
|
using PowderCoating.Application.DTOs.Invoice;
|
||||||
|
using PowderCoating.Application.DTOs.Quote;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
|
|||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
SourceJobItemId = item.Id,
|
SourceJobItemId = item.Id,
|
||||||
|
CatalogItemId = item.CatalogItemId,
|
||||||
Description = item.Description ?? "Powder Coating",
|
Description = item.Description ?? "Powder Coating",
|
||||||
Quantity = 1,
|
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||||
UnitPrice = item.TotalPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
TotalPrice = item.TotalPrice,
|
TotalPrice = item.TotalPrice,
|
||||||
ColorName = item.ColorName,
|
ColorName = item.ColorName,
|
||||||
|
Notes = item.Notes,
|
||||||
DisplayOrder = order++,
|
DisplayOrder = order++,
|
||||||
RevenueAccountId = revenueAccountId
|
RevenueAccountId = revenueAccountId
|
||||||
});
|
});
|
||||||
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
|
|||||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
||||||
if (sourceQuote != null)
|
if (sourceQuote != null)
|
||||||
{
|
{
|
||||||
|
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
||||||
|
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
||||||
var processingFees = sourceQuote.OvenBatchCost
|
var processingFees = sourceQuote.OvenBatchCost
|
||||||
|
+ sourceQuote.FacilityOverheadCost
|
||||||
+ sourceQuote.ShopSuppliesAmount
|
+ sourceQuote.ShopSuppliesAmount
|
||||||
+ sourceQuote.RushFee;
|
+ sourceQuote.RushFee;
|
||||||
|
|
||||||
@@ -458,21 +465,66 @@ 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. Read all charges from the pricing snapshot so the
|
||||||
// using the current company rate. (Quote-sourced jobs read the pre-agreed amount
|
// invoice always matches the total shown on the job's Pricing Summary card.
|
||||||
// from the quote snapshot instead; this path only fires when there is no quote.)
|
QuotePricingBreakdownDto? jobBreakdown = null;
|
||||||
var itemsSubtotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
var shopSuppliesAmount = Math.Round(itemsSubtotal * (costs.ShopSuppliesRate / 100m), 2);
|
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
if (shopSuppliesAmount > 0.01m)
|
|
||||||
|
if (job.OvenBatchCost > 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
|
||||||
|
if (facilityOverhead > 0.01m)
|
||||||
|
{
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = "Facility Overhead",
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = Math.Round(facilityOverhead, 2),
|
||||||
|
TotalPrice = Math.Round(facilityOverhead, 2),
|
||||||
|
DisplayOrder = order++,
|
||||||
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.ShopSuppliesAmount > 0.01m)
|
||||||
|
{
|
||||||
|
var suppliesDesc = job.ShopSuppliesPercent > 0
|
||||||
|
? $"Shop Supplies ({job.ShopSuppliesPercent:0.##}%)"
|
||||||
|
: "Shop Supplies";
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = suppliesDesc,
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||||
|
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
|
||||||
|
DisplayOrder = order++,
|
||||||
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var rushFee = jobBreakdown?.RushFee ?? 0m;
|
||||||
|
if (rushFee > 0.01m)
|
||||||
|
{
|
||||||
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
|
{
|
||||||
|
Description = "Rush Fee",
|
||||||
|
Quantity = 1,
|
||||||
|
UnitPrice = Math.Round(rushFee, 2),
|
||||||
|
TotalPrice = Math.Round(rushFee, 2),
|
||||||
DisplayOrder = order,
|
DisplayOrder = order,
|
||||||
RevenueAccountId = defaultRevenueAccount?.Id
|
RevenueAccountId = defaultRevenueAccount?.Id
|
||||||
});
|
});
|
||||||
@@ -662,7 +714,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 +725,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 +759,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 +931,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 +1007,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 +1040,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 +1077,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 +1102,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 +1143,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 +1458,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 +1852,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 +2068,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 +2237,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 +2307,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 +2385,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 +2445,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 +2527,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 +2678,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 +2844,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.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,72 +422,24 @@ public class JobsController : Controller
|
|||||||
// Populate Edit Items wizard data (inline modal on Details page)
|
// Populate Edit Items wizard data (inline modal on Details page)
|
||||||
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
|
||||||
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m;
|
ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
|
||||||
|
|
||||||
// Internal pricing breakdown (not printed — mirrors quote details breakdown)
|
// Display the pricing snapshot stored when items were last saved.
|
||||||
var breakdownItems = job.JobItems
|
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
|
||||||
.Where(ji => !ji.IsDeleted)
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
.Select(ji => new CreateQuoteItemDto
|
|
||||||
{
|
{
|
||||||
Description = ji.Description,
|
ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
|
||||||
Quantity = ji.Quantity,
|
}
|
||||||
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
|
else if (job.FinalPrice > 0)
|
||||||
EstimatedMinutes = ji.EstimatedMinutes,
|
|
||||||
CatalogItemId = ji.CatalogItemId,
|
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
|
||||||
IsLaborItem = ji.IsLaborItem,
|
|
||||||
IsSalesItem = ji.IsSalesItem,
|
|
||||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
|
||||||
PowderCostOverride = ji.PowderCostOverride,
|
|
||||||
IncludePrepCost = ji.IncludePrepCost,
|
|
||||||
Complexity = ji.Complexity,
|
|
||||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
|
||||||
{
|
{
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
// Legacy job created before snapshot was introduced — show what we have stored
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
|
||||||
PowderToOrder = c.PowderToOrder
|
|
||||||
}).ToList(),
|
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
|
||||||
{
|
|
||||||
PrepServiceId = ps.PrepServiceId,
|
|
||||||
EstimatedMinutes = ps.EstimatedMinutes
|
|
||||||
}).ToList()
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
if (breakdownItems.Any())
|
|
||||||
{
|
|
||||||
var pr = await _pricingService.CalculateQuoteTotalsAsync(
|
|
||||||
breakdownItems, job.CompanyId, job.CustomerId,
|
|
||||||
wizardCosts?.TaxPercent ?? 0m,
|
|
||||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
|
||||||
job.OvenCostId, 1, null);
|
|
||||||
|
|
||||||
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
|
||||||
{
|
{
|
||||||
MaterialCosts = pr.MaterialCosts,
|
OvenBatchCost = job.OvenBatchCost,
|
||||||
LaborCosts = pr.LaborCosts,
|
OvenBatches = job.OvenBatches,
|
||||||
EquipmentCosts = pr.EquipmentCosts,
|
ShopSuppliesAmount = job.ShopSuppliesAmount,
|
||||||
ItemsSubtotal = pr.ItemsSubtotal,
|
ShopSuppliesPercent = job.ShopSuppliesPercent,
|
||||||
OvenBatchCost = pr.OvenBatchCost,
|
Total = job.FinalPrice
|
||||||
OvenBatches = pr.OvenBatches,
|
|
||||||
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
|
|
||||||
FacilityOverheadCost = pr.FacilityOverheadCost,
|
|
||||||
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
|
||||||
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
|
||||||
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
|
||||||
OverheadCosts = pr.OverheadCosts,
|
|
||||||
OverheadPercent = pr.OverheadPercent,
|
|
||||||
ProfitMargin = pr.ProfitMargin,
|
|
||||||
ProfitPercent = pr.ProfitPercent,
|
|
||||||
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
|
||||||
DiscountAmount = pr.DiscountAmount,
|
|
||||||
DiscountPercent = pr.DiscountPercent,
|
|
||||||
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
|
||||||
RushFee = pr.RushFee,
|
|
||||||
TaxAmount = pr.TaxAmount,
|
|
||||||
TaxPercent = pr.TaxPercent,
|
|
||||||
Total = pr.Total
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
|
||||||
@@ -506,6 +458,7 @@ public class JobsController : Controller
|
|||||||
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
|
||||||
isLaborItem = ji.IsLaborItem,
|
isLaborItem = ji.IsLaborItem,
|
||||||
isSalesItem = ji.IsSalesItem,
|
isSalesItem = ji.IsSalesItem,
|
||||||
|
isAiItem = ji.IsAiItem,
|
||||||
sku = ji.Sku,
|
sku = ji.Sku,
|
||||||
requiresSandblasting = ji.RequiresSandblasting,
|
requiresSandblasting = ji.RequiresSandblasting,
|
||||||
requiresMasking = ji.RequiresMasking,
|
requiresMasking = ji.RequiresMasking,
|
||||||
@@ -1106,6 +1059,7 @@ public class JobsController : Controller
|
|||||||
CustomerId = dto.CustomerId,
|
CustomerId = dto.CustomerId,
|
||||||
QuoteId = dto.QuoteId,
|
QuoteId = dto.QuoteId,
|
||||||
AssignedUserId = dto.AssignedUserId,
|
AssignedUserId = dto.AssignedUserId,
|
||||||
|
OvenCostId = dto.OvenCostId,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
JobPriorityId = dto.JobPriorityId,
|
JobPriorityId = dto.JobPriorityId,
|
||||||
JobStatusId = pendingStatus?.Id ?? 1,
|
JobStatusId = pendingStatus?.Id ?? 1,
|
||||||
@@ -1167,14 +1121,23 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Recalculate total from wizard items
|
// Recalculate total from wizard items
|
||||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
|
decimal? createOvenRate = null;
|
||||||
|
if (dto.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
|
||||||
|
if (createOven != null && createOven.CompanyId == companyId)
|
||||||
|
createOvenRate = createOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
createCosts?.TaxPercent ?? 0m,
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
|
|
||||||
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.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
@@ -1261,6 +1224,7 @@ public class JobsController : Controller
|
|||||||
PowderCostOverride = ji.PowderCostOverride,
|
PowderCostOverride = ji.PowderCostOverride,
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
|
IsAiItem = ji.IsAiItem,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -1625,13 +1589,22 @@ public class JobsController : Controller
|
|||||||
if (dto.JobItems.Any())
|
if (dto.JobItems.Any())
|
||||||
{
|
{
|
||||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
|
decimal? editOvenRate = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (editOven != null && editOven.CompanyId == companyId)
|
||||||
|
editOvenRate = editOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
editCosts?.TaxPercent ?? 0m,
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, null, 1, null);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
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.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save change history records
|
// Save change history records
|
||||||
@@ -2924,6 +2897,7 @@ public class JobsController : Controller
|
|||||||
PowderCostOverride = ji.PowderCostOverride,
|
PowderCostOverride = ji.PowderCostOverride,
|
||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
|
IsAiItem = ji.IsAiItem,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -2956,7 +2930,10 @@ public class JobsController : Controller
|
|||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
JobNumber = job.JobNumber,
|
JobNumber = job.JobNumber,
|
||||||
CustomerId = job.CustomerId,
|
CustomerId = job.CustomerId,
|
||||||
TaxPercent = costs?.TaxPercent ?? 0m,
|
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||||
|
OvenCostId = job.OvenCostId,
|
||||||
|
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||||
JobItems = existingItems
|
JobItems = existingItems
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2990,7 +2967,7 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError("", "Please add at least one job item.");
|
ModelState.AddModelError("", "Please add at least one job item.");
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
|
||||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||||
@@ -3035,14 +3012,26 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate full total (overhead, margins, tax) to match what the wizard displays
|
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||||
|
decimal? ovenRateOverride = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (oven != null && oven.CompanyId == currentUser.CompanyId)
|
||||||
|
ovenRateOverride = oven.CostPerHour;
|
||||||
|
}
|
||||||
|
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
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);
|
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||||
|
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||||
|
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
|
|
||||||
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.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
job.UpdatedBy = currentUser.UserName;
|
job.UpdatedBy = currentUser.UserName;
|
||||||
await _unitOfWork.Jobs.UpdateAsync(job);
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
@@ -3056,7 +3045,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
|
||||||
TempData["Error"] = "An error occurred while saving job items.";
|
TempData["Error"] = "An error occurred while saving job items.";
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
model.TaxPercent = costs?.TaxPercent ?? 0m;
|
model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
|
||||||
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
return View("EditItems", model);
|
return View("EditItems", model);
|
||||||
}
|
}
|
||||||
@@ -3098,30 +3087,47 @@ public class JobsController : Controller
|
|||||||
CatalogItemId = ji.CatalogItemId,
|
CatalogItemId = ji.CatalogItemId,
|
||||||
IsGenericItem = ji.IsGenericItem,
|
IsGenericItem = ji.IsGenericItem,
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
ManualUnitPrice = ji.ManualUnitPrice,
|
IsSalesItem = ji.IsSalesItem,
|
||||||
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
|
IsAiItem = ji.IsAiItem,
|
||||||
|
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||||
|
IncludePrepCost = ji.IncludePrepCost,
|
||||||
|
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||||
{
|
{
|
||||||
|
InventoryItemId = c.InventoryItemId,
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb
|
PowderCostPerLb = c.PowderCostPerLb
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
if (remainingDtos.Any())
|
if (remainingDtos.Any())
|
||||||
{
|
{
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
decimal? deleteOvenRate = null;
|
||||||
|
if (job.OvenCostId.HasValue)
|
||||||
|
{
|
||||||
|
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
|
||||||
|
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
|
||||||
|
deleteOvenRate = deleteOven.CostPerHour;
|
||||||
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
remainingDtos, currentUser.CompanyId, job.CustomerId,
|
||||||
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
|
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
|
||||||
|
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||||
|
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
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.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
job.FinalPrice = 0;
|
job.FinalPrice = 0;
|
||||||
|
job.OvenBatchCost = 0;
|
||||||
job.ShopSuppliesAmount = 0;
|
job.ShopSuppliesAmount = 0;
|
||||||
job.ShopSuppliesPercent = 0;
|
job.ShopSuppliesPercent = 0;
|
||||||
|
job.PricingBreakdownJson = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
job.UpdatedAt = DateTime.UtcNow;
|
job.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -3231,6 +3237,57 @@ public class JobsController : Controller
|
|||||||
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
|
||||||
|
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
|
||||||
|
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
|
||||||
|
/// are never charged tax when a job is saved or recalculated.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
|
||||||
|
{
|
||||||
|
if (customerId is > 0)
|
||||||
|
{
|
||||||
|
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
|
||||||
|
if (customer?.IsTaxExempt == true) return 0m;
|
||||||
|
}
|
||||||
|
return companyDefaultRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||||
|
new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
MaterialCosts = pr.MaterialCosts,
|
||||||
|
LaborCosts = pr.LaborCosts,
|
||||||
|
EquipmentCosts = pr.EquipmentCosts,
|
||||||
|
ItemsSubtotal = pr.ItemsSubtotal,
|
||||||
|
OvenBatchCost = pr.OvenBatchCost,
|
||||||
|
OvenBatches = pr.OvenBatches,
|
||||||
|
OvenCycleMinutes = pr.OvenCycleMinutes,
|
||||||
|
FacilityOverheadCost = pr.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
|
||||||
|
ShopSuppliesAmount = pr.ShopSuppliesAmount,
|
||||||
|
ShopSuppliesPercent = pr.ShopSuppliesPercent,
|
||||||
|
OverheadCosts = pr.OverheadCosts,
|
||||||
|
OverheadPercent = pr.OverheadPercent,
|
||||||
|
ProfitMargin = pr.ProfitMargin,
|
||||||
|
ProfitPercent = pr.ProfitPercent,
|
||||||
|
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
|
||||||
|
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
|
||||||
|
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
|
||||||
|
QuoteDiscountAmount = pr.QuoteDiscountAmount,
|
||||||
|
QuoteDiscountPercent = pr.QuoteDiscountPercent,
|
||||||
|
DiscountAmount = pr.DiscountAmount,
|
||||||
|
DiscountPercent = pr.DiscountPercent,
|
||||||
|
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
|
||||||
|
RushFee = pr.RushFee,
|
||||||
|
TaxAmount = pr.TaxAmount,
|
||||||
|
TaxPercent = pr.TaxPercent,
|
||||||
|
Total = pr.Total
|
||||||
|
};
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Item Pricing (AJAX)
|
#region Item Pricing (AJAX)
|
||||||
|
|||||||
@@ -0,0 +1,928 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
|
||||||
|
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
|
||||||
|
// Remote sessions: customer is on their own phone — never redirect; they may
|
||||||
|
// take several minutes between steps and have no KioskDevice cookie anyway.
|
||||||
|
if (session.SessionType == KioskSessionType.InPerson)
|
||||||
|
ViewBag.InactivityTimeoutMs = 45_000;
|
||||||
|
else
|
||||||
|
ViewBag.ShowInactivityTimer = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AutoMapper;
|
using System.Text.Json;
|
||||||
|
using AutoMapper;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -112,12 +113,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 +172,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 +231,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
|
||||||
@@ -2829,13 +2841,46 @@ public class QuotesController : Controller
|
|||||||
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
|
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
|
||||||
QuoteId = quote.Id,
|
QuoteId = quote.Id,
|
||||||
OvenCostId = quote.OvenCostId, // Carry oven selection from quote
|
OvenCostId = quote.OvenCostId, // Carry oven selection from quote
|
||||||
|
OvenBatches = quote.OvenBatches > 0 ? quote.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = quote.OvenCycleMinutes,
|
||||||
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
|
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
|
||||||
JobStatusId = approvedStatus?.Id ?? 1,
|
JobStatusId = approvedStatus?.Id ?? 1,
|
||||||
JobPriorityId = selectedPriority?.Id ?? 1,
|
JobPriorityId = selectedPriority?.Id ?? 1,
|
||||||
QuotedPrice = quote.Total,
|
QuotedPrice = quote.Total,
|
||||||
FinalPrice = quote.Total,
|
FinalPrice = quote.Total,
|
||||||
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
|
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
MaterialCosts = quote.MaterialCosts,
|
||||||
|
LaborCosts = quote.LaborCosts,
|
||||||
|
EquipmentCosts = quote.EquipmentCosts,
|
||||||
|
ItemsSubtotal = quote.ItemsSubtotal,
|
||||||
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
|
OvenBatches = quote.OvenBatches,
|
||||||
|
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
|
||||||
|
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||||
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
|
OverheadCosts = quote.OverheadAmount,
|
||||||
|
OverheadPercent = quote.OverheadPercent,
|
||||||
|
ProfitMargin = quote.ProfitMargin,
|
||||||
|
ProfitPercent = quote.ProfitPercent,
|
||||||
|
SubtotalBeforeDiscount = quote.SubTotal,
|
||||||
|
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
|
||||||
|
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
|
||||||
|
QuoteDiscountAmount = quote.QuoteDiscountAmount,
|
||||||
|
QuoteDiscountPercent = quote.QuoteDiscountPercent,
|
||||||
|
DiscountAmount = quote.DiscountAmount,
|
||||||
|
DiscountPercent = quote.DiscountPercent,
|
||||||
|
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
|
||||||
|
RushFee = quote.RushFee,
|
||||||
|
TaxAmount = quote.TaxAmount,
|
||||||
|
TaxPercent = quote.TaxPercent,
|
||||||
|
Total = quote.Total
|
||||||
|
}),
|
||||||
CustomerPO = quote.CustomerPO,
|
CustomerPO = quote.CustomerPO,
|
||||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||||
IsCustomerApproved = true,
|
IsCustomerApproved = true,
|
||||||
@@ -3059,6 +3104,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));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user