Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,158 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Chart of Accounts entry. Supports a flat or one-level hierarchy via ParentAccountId.
/// </summary>
public class Account : BaseEntity
{
public string AccountNumber { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public AccountSubType AccountSubType { get; set; }
public string? Description { get; set; }
/// <summary>Nullable FK for sub-accounts (one level deep).</summary>
public int? ParentAccountId { get; set; }
/// <summary>System accounts cannot be deleted (seeded defaults).</summary>
public bool IsSystem { get; set; } = false;
public bool IsActive { get; set; } = true;
/// <summary>Starting balance when the account was first set up in this system.</summary>
public decimal OpeningBalance { get; set; } = 0;
/// <summary>The date the opening balance is as-of. Null means it pre-dates all transactions.</summary>
public DateTime? OpeningBalanceDate { get; set; }
/// <summary>
/// Denormalized running balance kept in sync with each transaction. Positive = normal-balance direction
/// (debit-normal for Assets/Expenses/COGS; credit-normal for Liabilities/Equity/Revenue).
/// Use AccountsController.RecalculateBalances to rebuild from scratch if needed.
/// </summary>
public decimal CurrentBalance { get; set; } = 0;
// Navigation
public virtual Account? ParentAccount { get; set; }
public virtual ICollection<Account> SubAccounts { get; set; } = new List<Account>();
public virtual ICollection<BillLineItem> BillLineItems { get; set; } = new List<BillLineItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual ICollection<Expense> ExpensePaymentAccounts { get; set; } = new List<Expense>();
}
/// <summary>
/// Vendor bill (accounts payable). Represents money owed to a supplier.
/// </summary>
public class Bill : BaseEntity
{
public string BillNumber { get; set; } = string.Empty;
/// <summary>Vendor's own invoice/reference number.</summary>
public string? VendorInvoiceNumber { get; set; }
public int VendorId { get; set; }
/// <summary>Which AP account this bill posts to (default: Accounts Payable 2000).</summary>
public int APAccountId { get; set; }
public DateTime BillDate { get; set; } = DateTime.UtcNow;
public DateTime? DueDate { get; set; }
public BillStatus Status { get; set; } = BillStatus.Draft;
public string? Terms { get; set; }
public string? Memo { get; set; }
// Financials
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public decimal BalanceDue => Total - AmountPaid;
/// <summary>Blob path to an attached receipt/invoice document (PDF or image).</summary>
public string? ReceiptFilePath { get; set; }
// Navigation
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account APAccount { get; set; } = null!;
public virtual ICollection<BillLineItem> LineItems { get; set; } = new List<BillLineItem>();
public virtual ICollection<BillPayment> Payments { get; set; } = new List<BillPayment>();
}
/// <summary>
/// A single line on a vendor bill, posted to an expense or asset account.
/// </summary>
public class BillLineItem : BaseEntity
{
public int BillId { get; set; }
/// <summary>Expense/asset account this line item is categorized under. Nullable for QB-imported bills where account is unknown.</summary>
public int? AccountId { get; set; }
/// <summary>Optional job costing link.</summary>
public int? JobId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; } = 1;
public decimal UnitPrice { get; set; }
public decimal Amount { get; set; }
public int DisplayOrder { get; set; }
// Navigation
public virtual Bill Bill { get; set; } = null!;
public virtual Account? Account { get; set; }
public virtual Job? Job { get; set; }
}
/// <summary>
/// A payment made against a vendor bill.
/// </summary>
public class BillPayment : BaseEntity
{
public string PaymentNumber { get; set; } = string.Empty;
public int BillId { get; set; }
/// <summary>Denormalized for AP reporting without joining through Bill.</summary>
public int VendorId { get; set; }
/// <summary>Bank/cash account the payment came out of.</summary>
public int BankAccountId { get; set; }
public DateTime PaymentDate { get; set; } = DateTime.UtcNow;
public decimal Amount { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public string? CheckNumber { get; set; }
public string? Memo { get; set; }
// Navigation
public virtual Bill Bill { get; set; } = null!;
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account BankAccount { get; set; } = null!;
}
/// <summary>
/// A direct expense paid immediately (not via a bill). Covers cash/card purchases.
/// </summary>
public class Expense : BaseEntity
{
public string ExpenseNumber { get; set; } = string.Empty;
public DateTime Date { get; set; } = DateTime.UtcNow;
/// <summary>Optional vendor the expense was paid to.</summary>
public int? VendorId { get; set; }
/// <summary>Expense category account (e.g. 6200 Powder & Materials).</summary>
public int ExpenseAccountId { get; set; }
/// <summary>Account money came out of (e.g. 1000 Checking, 2100 Credit Card).</summary>
public int PaymentAccountId { get; set; }
/// <summary>Optional job costing link.</summary>
public int? JobId { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public decimal Amount { get; set; }
public string? Memo { get; set; }
public string? ReceiptFilePath { get; set; }
// Navigation
public virtual Vendor? Vendor { get; set; }
public virtual Account ExpenseAccount { get; set; } = null!;
public virtual Account PaymentAccount { get; set; } = null!;
public virtual Job? Job { get; set; }
}
@@ -0,0 +1,23 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores the raw AI prediction data captured at analysis time.
/// Both QuoteItem and JobItem carry a nullable FK to this table so the
/// same prediction record is shared when a quote converts to a job —
/// no duplication, clean lineage for reporting.
/// </summary>
public class AiItemPrediction : BaseEntity
{
// Raw AI predictions — captured once, never mutated
public decimal PredictedSurfaceAreaSqFt { get; set; }
public int PredictedMinutes { get; set; }
public string PredictedComplexity { get; set; } = "Moderate"; // Simple|Moderate|Complex|Extreme
public decimal PredictedUnitPrice { get; set; } // EstimatedUnitPrice shown to user in wizard
public string Confidence { get; set; } = "Medium"; // Low|Medium|High
public string? Reasoning { get; set; } // AI explanation text
public string? AiTags { get; set; } // Comma-separated tags from fixed taxonomy
public int ConversationRounds { get; set; } = 1; // How many follow-up rounds were needed
// Set when the item is saved: was the AI surface area or price overridden by the user?
public bool UserOverrodeEstimate { get; set; }
}
@@ -0,0 +1,26 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Immutable audit log of every Anthropic API call made on behalf of a tenant.
/// Intentionally omits BaseEntity — no soft delete, no UpdatedAt, no per-tenant query filter
/// (this is a platform-wide log read by SuperAdmins across all companies).
/// </summary>
public class AiUsageLog
{
public long Id { get; set; }
public int CompanyId { get; set; }
public string UserId { get; set; } = string.Empty;
/// <summary>Which AI feature triggered this call. Use AppConstants.AiFeatures constants.</summary>
public string Feature { get; set; } = string.Empty;
/// <summary>True when the AI service returned successfully; false on exception or upstream error.</summary>
public bool Success { get; set; } = true;
/// <summary>Approximate input size in characters/bytes — used as a rough token-cost proxy.</summary>
public int InputLength { get; set; }
public DateTime CalledAt { get; set; }
public Company? Company { get; set; }
}
@@ -0,0 +1,48 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-wide announcements shown as dismissible banners to company users.
/// Not a BaseEntity — SuperAdmin-managed, not per-tenant.
/// </summary>
public class Announcement
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>info | success | warning | danger</summary>
public string Type { get; set; } = "info";
/// <summary>All | Plan | Company</summary>
public string Target { get; set; } = "All";
/// <summary>Populated when Target == "Plan" (matches Company.SubscriptionPlan int)</summary>
public int? TargetPlan { get; set; }
/// <summary>Populated when Target == "Company"</summary>
public int? TargetCompanyId { get; set; }
public DateTime StartsAt { get; set; } = DateTime.UtcNow;
public DateTime? ExpiresAt { get; set; }
public bool IsDismissible { get; set; } = true;
public bool IsActive { get; set; } = true;
public string CreatedByUserId { get; set; } = string.Empty;
public string CreatedByUserName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public virtual ICollection<AnnouncementDismissal> Dismissals { get; set; } = new List<AnnouncementDismissal>();
}
/// <summary>
/// Records that a specific user has dismissed a specific announcement.
/// </summary>
public class AnnouncementDismissal
{
public int Id { get; set; }
public int AnnouncementId { get; set; }
public string UserId { get; set; } = string.Empty;
public DateTime DismissedAt { get; set; } = DateTime.UtcNow;
public virtual Announcement Announcement { get; set; } = null!;
}
@@ -0,0 +1,93 @@
using Microsoft.AspNetCore.Identity;
using PowderCoating.Core.Enums;
using System.Linq;
namespace PowderCoating.Core.Entities;
public class ApplicationUser : IdentityUser
{
// Multi-tenancy
public int CompanyId { get; set; }
public virtual Company? Company { get; set; } // Nullable to avoid EF query filter issues
public string? CompanyRole { get; set; } // CompanyAdmin, Manager, Worker, Viewer
// Basic Information
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string? EmployeeNumber { get; set; }
public DateTime HireDate { get; set; }
public DateTime? TerminationDate { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? Department { get; set; }
public string? Position { get; set; }
// User Preferences
public string? Theme { get; set; } = "light";
public string? DateFormat { get; set; } = "MM/dd/yyyy";
public string? TimeZone { get; set; } = "America/New_York";
public int? DashboardLayout { get; set; }
// Settings
public bool IsActive { get; set; } = true;
public bool CanViewShopFloor { get; set; } = true;
public bool CanManageJobs { get; set; } = false;
public bool CanManageInventory { get; set; } = false;
public bool CanManageCustomers { get; set; } = false;
public bool CanCreateQuotes { get; set; } = false;
public bool CanApproveQuotes { get; set; } = false;
public bool CanManageCalendar { get; set; } = false;
public bool CanViewCalendar { get; set; } = true;
public bool CanManageProducts { get; set; } = false;
public bool CanViewProducts { get; set; } = true;
public bool CanManageEquipment { get; set; } = false;
public bool CanManageVendors { get; set; } = false;
public bool CanManageMaintenance { get; set; } = false;
public bool CanManageInvoices { get; set; } = false;
public bool CanViewReports { get; set; } = false;
// Profile Photo (filesystem storage)
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
public string? SidebarColor { get; set; } = "ocean";
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
// Ban
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
public string? BanReason { get; set; }
public string? BannedByUserId { get; set; }
// Relationships
public virtual ICollection<Quote> PreparedQuotes { get; set; } = new List<Quote>();
public virtual ICollection<MaintenanceRecord> PerformedMaintenances { get; set; } = new List<MaintenanceRecord>();
// Full name helper
public string FullName => $"{FirstName} {LastName}";
/// <summary>
/// Sets every <c>Can*</c> boolean permission property to <c>true</c>.
/// Use this when creating a first-time company admin so that any permission added
/// to this class in the future is automatically granted without needing to update
/// every account-creation path individually.
/// </summary>
public void GrantAllPermissions()
{
foreach (var prop in typeof(ApplicationUser).GetProperties()
.Where(p => p.Name.StartsWith("Can") &&
p.PropertyType == typeof(bool) &&
p.CanWrite))
{
prop.SetValue(this, true);
}
}
}
@@ -0,0 +1,104 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents a scheduled appointment for customer drop-offs, pick-ups, consultations, or job work.
/// </summary>
public class Appointment : BaseEntity
{
/// <summary>
/// Auto-generated appointment number in format APT-YYMM-####
/// </summary>
public string AppointmentNumber { get; set; } = string.Empty;
/// <summary>
/// Brief title/description of the appointment
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Detailed description and notes about the appointment
/// </summary>
public string? Description { get; set; }
// Customer Information
/// <summary>
/// Optional foreign key to Customer (not required for internal appointments like employee days off)
/// </summary>
public int? CustomerId { get; set; }
/// <summary>
/// Optional foreign key to Job (required for JOB_WORK appointment type)
/// </summary>
public int? JobId { get; set; }
// Lookup Foreign Keys
/// <summary>
/// Foreign key to AppointmentStatusLookup
/// </summary>
public int AppointmentStatusId { get; set; }
/// <summary>
/// Foreign key to AppointmentTypeLookup
/// </summary>
public int AppointmentTypeId { get; set; }
/// <summary>
/// Optional foreign key to ApplicationUser assigned to handle this appointment
/// </summary>
public string? AssignedUserId { get; set; }
// Timing
/// <summary>
/// Scheduled start date and time (UTC)
/// </summary>
public DateTime ScheduledStartTime { get; set; }
/// <summary>
/// Scheduled end date and time (UTC)
/// </summary>
public DateTime ScheduledEndTime { get; set; }
/// <summary>
/// Whether this is an all-day appointment (hides specific times)
/// </summary>
public bool IsAllDay { get; set; } = false;
/// <summary>
/// Actual start time when customer arrived (UTC, nullable until appointment starts)
/// </summary>
public DateTime? ActualStartTime { get; set; }
/// <summary>
/// Actual end time when appointment finished (UTC, nullable until appointment completes)
/// </summary>
public DateTime? ActualEndTime { get; set; }
// Additional Information
/// <summary>
/// Optional location at the shop (e.g., "Main Office", "Loading Dock", "Inspection Area")
/// </summary>
public string? Location { get; set; }
/// <summary>
/// Internal notes for staff (not visible to customer)
/// </summary>
public string? Notes { get; set; }
// Reminder Settings
/// <summary>
/// Whether to send reminder notifications
/// </summary>
public bool IsReminderEnabled { get; set; } = true;
/// <summary>
/// How many minutes before appointment to send reminder (default 30 minutes)
/// </summary>
public int ReminderMinutesBefore { get; set; } = 30;
// Navigation Properties
public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; }
public virtual AppointmentStatusLookup AppointmentStatus { get; set; } = null!;
public virtual AppointmentTypeLookup AppointmentType { get; set; } = null!;
public virtual ApplicationUser? AssignedUser { get; set; }
}
@@ -0,0 +1,59 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific appointment status lookup table.
/// Enables workflow customization for appointment lifecycle management.
/// </summary>
public class AppointmentStatusLookup : BaseEntity
{
/// <summary>
/// Immutable status code used in code logic (e.g., "SCHEDULED", "CONFIRMED", "COMPLETED").
/// Acts as the identifier for status-specific business rules.
/// </summary>
public string StatusCode { get; set; } = string.Empty;
/// <summary>
/// User-customizable display name shown in UI (e.g., "Scheduled", "Confirmed", "No Show").
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Workflow sequence number for ordering statuses (1 = first, higher = later).
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Bootstrap color class for badge styling (primary, success, danger, warning, info, secondary, dark).
/// </summary>
public string ColorClass { get; set; } = "secondary";
/// <summary>
/// Optional Bootstrap icon class (e.g., "bi-calendar-check", "bi-clock", "bi-x-circle").
/// </summary>
public string? IconClass { get; set; }
/// <summary>
/// Whether this status is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// System-defined statuses cannot be deleted (SCHEDULED, COMPLETED, CANCELLED).
/// Prevents breaking core appointment workflow.
/// </summary>
public bool IsSystemDefined { get; set; } = true;
/// <summary>
/// Whether this status represents a completed appointment (COMPLETED, CANCELLED, NO_SHOW).
/// Used for reporting and filtering.
/// </summary>
public bool IsTerminalStatus { get; set; } = false;
/// <summary>
/// Optional description explaining when to use this status.
/// </summary>
public string? Description { get; set; }
// Navigation properties
public virtual ICollection<Appointment> Appointments { get; set; } = new List<Appointment>();
}
@@ -0,0 +1,59 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific appointment type lookup table.
/// Defines different categories of appointments (drop-off, pick-up, consultation, job work).
/// </summary>
public class AppointmentTypeLookup : BaseEntity
{
/// <summary>
/// Immutable type code used in code logic (e.g., "DROP_OFF", "PICK_UP", "CONSULTATION", "JOB_WORK").
/// </summary>
public string TypeCode { get; set; } = string.Empty;
/// <summary>
/// User-customizable display name shown in UI (e.g., "Customer Drop-Off", "Customer Pick-Up").
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Sort order for displaying types in dropdowns and filters.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Bootstrap color class for calendar event styling (purple, green, blue, orange, etc.).
/// Each type gets a distinct color for visual differentiation.
/// </summary>
public string ColorClass { get; set; } = "primary";
/// <summary>
/// Optional Bootstrap icon class for calendar events (e.g., "bi-box-arrow-down", "bi-box-arrow-up").
/// </summary>
public string? IconClass { get; set; }
/// <summary>
/// Whether appointments of this type require a Job to be linked.
/// True for JOB_WORK type, false for others.
/// </summary>
public bool RequiresJobLink { get; set; } = false;
/// <summary>
/// Whether this type is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// System-defined types cannot be deleted (the 4 default types).
/// Prevents breaking core appointment functionality.
/// </summary>
public bool IsSystemDefined { get; set; } = true;
/// <summary>
/// Optional description explaining when to use this appointment type.
/// </summary>
public string? Description { get; set; }
// Navigation properties
public virtual ICollection<Appointment> Appointments { get; set; } = new List<Appointment>();
}
@@ -0,0 +1,29 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-wide audit trail. Not a BaseEntity — no soft delete, no tenant filter.
/// </summary>
public class AuditLog
{
public long Id { get; set; }
// Who
public string? UserId { get; set; }
public string UserName { get; set; } = "System";
public int? CompanyId { get; set; }
public string? CompanyName { get; set; }
// What
public string Action { get; set; } = string.Empty; // Created | Updated | Deleted | Restored | Login | ManualChange
public string EntityType { get; set; } = string.Empty; // Customer | Job | Quote | …
public string? EntityId { get; set; }
public string? EntityDescription { get; set; } // Human-readable label (e.g. "JOB-2501-0042")
// Change detail (JSON)
public string? OldValues { get; set; }
public string? NewValues { get; set; }
// Context
public string? IpAddress { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,17 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// A platform-level IP address block. When an IP is active here, login attempts
/// from that address are rejected before Identity even checks credentials.
/// No BaseEntity — no company scope, no soft delete; hard-delete is fine for bans.
/// </summary>
public class BannedIp
{
public int Id { get; set; }
public string IpAddress { get; set; } = string.Empty;
public string? Reason { get; set; }
public string? BannedByUserId { get; set; }
public DateTime BannedAt { get; set; } = DateTime.UtcNow;
public DateTime? ExpiresAt { get; set; } // null = permanent
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,23 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Base entity class that all domain entities inherit from
/// </summary>
public abstract class BaseEntity
{
public int Id { get; set; }
// Multi-tenancy
public int CompanyId { get; set; }
// Audit fields
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
// Soft delete
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
@@ -0,0 +1,19 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class BugReport : BaseEntity
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string SubmittedByUserId { get; set; } = string.Empty;
public string SubmittedByUserName { get; set; } = string.Empty;
public string? CompanyName { get; set; }
public BugReportPriority Priority { get; set; } = BugReportPriority.Normal;
public BugReportStatus Status { get; set; } = BugReportStatus.New;
public string? ResolutionNotes { get; set; }
public DateTime? ResolvedAt { get; set; }
public string? ResolvedBy { get; set; }
public ICollection<BugReportAttachment> Attachments { get; set; } = new List<BugReportAttachment>();
}
@@ -0,0 +1,12 @@
namespace PowderCoating.Core.Entities;
public class BugReportAttachment : BaseEntity
{
public int BugReportId { get; set; }
public BugReport BugReport { get; set; } = null!;
public string BlobPath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long FileSizeBytes { get; set; }
}
@@ -0,0 +1,59 @@
using System.Collections.Generic;
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Represents a hierarchical category in the product catalog.
/// Supports multi-level nesting for organizing catalog items.
/// </summary>
public class CatalogCategory : BaseEntity
{
/// <summary>
/// Gets or sets the category name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional category description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the parent category ID for hierarchical organization.
/// Null indicates this is a root-level category.
/// </summary>
public int? ParentCategoryId { get; set; }
/// <summary>
/// Gets or sets the parent category navigation property.
/// </summary>
public virtual CatalogCategory? ParentCategory { get; set; }
/// <summary>
/// Gets or sets the collection of child categories.
/// </summary>
public virtual ICollection<CatalogCategory> SubCategories { get; set; } = new List<CatalogCategory>();
/// <summary>
/// Gets or sets the display order for sorting categories at the same level.
/// Lower numbers appear first.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Gets or sets whether this category is currently active and visible.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// When true, items in this category are retail merchandise available for direct sale
/// (e.g. branded apparel, cleaning products). New items inherit this flag.
/// </summary>
public bool IsMerchandise { get; set; } = false;
/// <summary>
/// Gets or sets the collection of catalog items in this category.
/// </summary>
public virtual ICollection<CatalogItem> Items { get; set; } = new List<CatalogItem>();
}
}
@@ -0,0 +1,101 @@
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Represents a reusable item in the product catalog that can be quickly added to quotes.
/// Stores default values for common powder coating jobs.
/// </summary>
public class CatalogItem : BaseEntity
{
/// <summary>
/// Gets or sets the item name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the optional item description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the optional SKU (Stock Keeping Unit) or item code.
/// </summary>
public string? SKU { get; set; }
/// <summary>
/// Gets or sets the category ID this item belongs to.
/// </summary>
public int CategoryId { get; set; }
/// <summary>
/// Gets or sets the category navigation property.
/// </summary>
public virtual CatalogCategory Category { get; set; } = null!;
/// <summary>
/// Gets or sets the default price for this item.
/// Can be overridden when added to a quote.
/// </summary>
public decimal DefaultPrice { get; set; }
/// <summary>
/// Gets or sets whether this item typically requires sandblasting.
/// </summary>
public bool DefaultRequiresSandblasting { get; set; }
/// <summary>
/// Gets or sets whether this item typically requires masking/taping.
/// </summary>
public bool DefaultRequiresMasking { get; set; }
/// <summary>
/// Gets or sets the estimated processing time in minutes.
/// </summary>
public int? DefaultEstimatedMinutes { get; set; }
/// <summary>
/// Gets or sets the approximate surface area in square feet (or square meters if using metric).
/// </summary>
public decimal? ApproximateArea { get; set; }
/// <summary>
/// Gets or sets the display order for sorting items within a category.
/// Lower numbers appear first.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Gets or sets whether this item is currently active and visible.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// When true, this item is retail merchandise available for direct sale on an invoice
/// without a job (e.g. branded apparel, cleaning products). Inherited from the category
/// by default but can be overridden per item.
/// </summary>
public bool IsMerchandise { get; set; } = false;
/// <summary>
/// Optional link to an inventory item so stock is decremented when this item is sold.
/// </summary>
public int? InventoryItemId { get; set; }
public virtual InventoryItem? InventoryItem { get; set; }
// ── Financial Account Mapping ──────────────────────────────────────────
/// <summary>
/// Gets or sets the revenue account ID for invoicing this item.
/// When null, falls back to the default revenue account.
/// </summary>
public int? RevenueAccountId { get; set; }
/// <summary>
/// Gets or sets the COGS account ID for recording material cost when this item is used.
/// When null, falls back to the default COGS account.
/// </summary>
public int? CogsAccountId { get; set; }
public virtual Account? RevenueAccount { get; set; }
public virtual Account? CogsAccount { get; set; }
}
}
+110
View File
@@ -0,0 +1,110 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents a company/tenant in the multi-tenant system
/// </summary>
public class Company : BaseEntity
{
// Basic Information
public string CompanyName { get; set; } = string.Empty;
public string? CompanyCode { get; set; } // Short code (e.g., ABC)
// Contact Information
public string PrimaryContactName { get; set; } = string.Empty;
public string PrimaryContactEmail { get; set; } = string.Empty;
public string? Phone { get; set; }
// Address
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
// Subscription/Status
public bool IsActive { get; set; } = true;
public DateTime SubscriptionStartDate { get; set; } = DateTime.UtcNow;
public DateTime? SubscriptionEndDate { get; set; }
public int SubscriptionPlan { get; set; } = 0;
public SubscriptionStatus SubscriptionStatus { get; set; } = SubscriptionStatus.Active;
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
/// <summary>
/// When true the company has complimentary/internal access.
/// All plan limits are treated as unlimited and the subscription
/// expiry banner/lockout is suppressed regardless of SubscriptionEndDate.
/// </summary>
public bool IsComped { get; set; } = false;
// Stripe Connect — online invoice payments
public string? StripeAccountId { get; set; } // acct_xxx from OAuth
public StripeConnectStatus StripeConnectStatus { get; set; } = StripeConnectStatus.NotConnected;
public OnlinePaymentSurchargeType OnlinePaymentSurchargeType { get; set; } = OnlinePaymentSurchargeType.None;
public decimal OnlinePaymentSurchargeValue { get; set; } = 0; // % or flat $ depending on type
public bool OnlineSurchargeAcknowledged { get; set; } = false; // shop accepted compliance disclaimer
/// <summary>Internal notes about manual subscription changes (not shown to the company).</summary>
public string? SubscriptionNotes { get; set; }
// Per-company limit overrides. null = use plan config default. -1 = unlimited.
public int? MaxUsersOverride { get; set; }
public int? MaxActiveJobsOverride { get; set; }
public int? MaxCustomersOverride { get; set; }
public int? MaxQuotesOverride { get; set; }
public int? MaxCatalogItemsOverride { get; set; }
public int? MaxJobPhotosOverride { get; set; }
public int? MaxQuotePhotosOverride { get; set; }
/// <summary>null = use plan config default. -1 = unlimited. 0 = disabled for this company.</summary>
public int? MaxAiPhotoQuotesPerMonthOverride { get; set; }
// AI Feature Flags (SuperAdmin-controlled per company)
/// <summary>Enables/disables AI Photo Quote analysis for this company.</summary>
public bool AiPhotoQuotesEnabled { get; set; } = true;
/// <summary>Enables/disables the AI Inventory Assist lookup for this company.</summary>
public bool AiInventoryAssistEnabled { get; set; } = true;
/// <summary>
/// Stores the billing period the customer selected at registration (or last changed on the Billing page).
/// true = annual billing, false = monthly billing.
/// Used when redirecting to Stripe Checkout at trial-end so the right price ID is used automatically.
/// </summary>
public bool IsAnnualBilling { get; set; } = false;
// Per-company feature overrides (SuperAdmin-controlled)
/// <summary>
/// null = use plan config default.
/// true = force-enable online payments for this company regardless of plan.
/// false = force-disable online payments for this company regardless of plan.
/// </summary>
public bool? OnlinePaymentsOverride { get; set; }
/// <summary>
/// null = use plan config default.
/// true = force-enable accounting module for this company regardless of plan.
/// false = force-disable accounting module for this company regardless of plan.
/// </summary>
public bool? AccountingOverride { get; set; }
// Email marketing opt-out (CAN-SPAM compliance for platform broadcast emails)
public bool MarketingEmailOptOut { get; set; } = false;
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
// Settings
public string? TimeZone { get; set; } = "America/New_York";
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
// Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
public virtual ICollection<Job> Jobs { get; set; } = new List<Job>();
public virtual ICollection<Equipment> Equipment { get; set; } = new List<Equipment>();
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
public virtual CompanyPreferences? Preferences { get; set; }
}
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// A named blast setup for a company (e.g. "Siphon Cabinet", "Pressure Pot #1", "Blast Room").
/// Companies can have multiple setups; one is flagged as the default used for AI quoting when
/// the user doesn't pick a specific one.
/// </summary>
public class CompanyBlastSetup : BaseEntity
{
/// <summary>Friendly name shown in dropdowns, e.g. "Main Cabinet" or "Outdoor Blast Pot".</summary>
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
public BlastSetupType SetupType { get; set; } = BlastSetupType.PressurePot;
/// <summary>Compressor CFM available at the blast nozzle.</summary>
[Range(0, 9999)]
public decimal CompressorCfm { get; set; }
/// <summary>Nozzle orifice number (28).</summary>
[Range(2, 8)]
public int BlastNozzleSize { get; set; } = 5;
public BlastSubstrateType PrimarySubstrate { get; set; } = BlastSubstrateType.Mixed;
/// <summary>
/// When set, bypasses the derived formula and uses this exact rate (sqft/hr).
/// Useful for shops that have measured their own throughput.
/// </summary>
[Range(0, 99999)]
public decimal? BlastRateSqFtPerHourOverride { get; set; }
/// <summary>Used as the default rate when no setup is explicitly selected in the wizard.</summary>
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
public int DisplayOrder { get; set; }
// Navigation
public virtual Company Company { get; set; } = null!;
}
@@ -0,0 +1,131 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities
{
public class CompanyOperatingCosts : BaseEntity
{
// Navigation
public new int CompanyId { get; set; }
public virtual Company Company { get; set; } = null!;
// Labor Rates (per hour, in currency)
[Range(0, 10000)]
public decimal StandardLaborRate { get; set; }
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
[Range(0, 100)]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
// Equipment Operating Costs (per hour)
[Range(0, 10000)]
public decimal OvenOperatingCostPerHour { get; set; }
[Range(0, 10000)]
public decimal SandblasterCostPerHour { get; set; }
[Range(0, 10000)]
public decimal CoatingBoothCostPerHour { get; set; }
// Material Costs
[Range(0, 1000)]
public decimal PowderCoatingCostPerSqFt { get; set; } // per square foot
// Markup / Margin
/// <summary>Whether markup is applied to material only, or margin is applied to total item cost.</summary>
public PricingMode PricingMode { get; set; } = PricingMode.MarkupOnMaterial;
/// <summary>Markup % added on top of material costs (used when PricingMode = MarkupOnMaterial).</summary>
[Range(0, 100)]
public decimal GeneralMarkupPercentage { get; set; }
/// <summary>Target gross margin % applied to total item cost (used when PricingMode = MarginOnTotalCost).</summary>
[Range(0, 99)]
public decimal TargetMarginPercent { get; set; }
// Tax Percentage
[Range(0, 100)]
public decimal TaxPercent { get; set; }
// Shop Supplies Rate (percentage applied to materials/labor)
[Range(0, 100)]
public decimal ShopSuppliesRate { get; set; }
// Oven batch defaults
[Range(1, 1440)]
public int DefaultOvenCycleMinutes { get; set; } = 45;
// Rush Charge
public string RushChargeType { get; set; } = "Percentage"; // "Percentage" or "FixedAmount"
[Range(0, 100)]
public decimal RushChargePercentage { get; set; }
[Range(0, 100000)]
public decimal RushChargeFixedAmount { get; set; }
// Shop Minimum
[Range(0, 100000)]
public decimal ShopMinimumCharge { get; set; }
// Part Complexity Multipliers (% added to calculated item price)
[Range(0, 500)]
public decimal ComplexitySimplePercent { get; set; } = 0m;
[Range(0, 500)]
public decimal ComplexityModeratePercent { get; set; } = 5m;
[Range(0, 500)]
public decimal ComplexityComplexPercent { get; set; } = 15m;
[Range(0, 500)]
public decimal ComplexityExtremePercent { get; set; } = 25m;
/// <summary>
/// Free-text description of this shop's specialties, typical item types, and pricing style.
/// Injected into the AI system prompt so the model can calibrate estimates for this company.
/// Example: "We specialize in automotive restoration parts — wheels, frames, brackets.
/// We charge premium rates and rarely work on items over 20 sqft."
/// </summary>
[StringLength(2000)]
public string? AiContextProfile { get; set; }
// ── Shop Capability / Quoting Calibration ─────────────────────────────────────
// These fields drive the derived BlastRateSqFtPerHour used in AI photo quoting
// and calculated item time suggestions. The Override field lets a shop bypass the
// formula and enter their real-world number directly.
/// <summary>Broad capability tier chosen in Setup Wizard; sets sensible defaults for all fields below.</summary>
public ShopCapabilityTier ShopCapabilityTier { get; set; } = ShopCapabilityTier.Small;
/// <summary>Type of blasting setup — biggest single factor in blast throughput.</summary>
public BlastSetupType BlastSetupType { get; set; } = BlastSetupType.SiphonCabinet;
/// <summary>Compressor CFM available for the blast setup.</summary>
[Range(0, 2000)]
public decimal CompressorCfm { get; set; } = 0m;
/// <summary>Blast nozzle size (#3 #8). Larger nozzle = more media flow.</summary>
[Range(3, 8)]
public int BlastNozzleSize { get; set; } = 4;
/// <summary>Primary substrate being removed; affects passes required per sqft.</summary>
public BlastSubstrateType PrimaryBlastSubstrate { get; set; } = BlastSubstrateType.Mixed;
/// <summary>
/// Manual blast rate override (sqft/hr). When set, the formula is bypassed entirely.
/// Useful for dialed-in shops who know their exact throughput.
/// </summary>
[Range(0, 5000)]
public decimal? BlastRateSqFtPerHourOverride { get; set; }
/// <summary>Coating gun technology — affects application speed on complex parts.</summary>
public CoatingGunType CoatingGunType { get; set; } = CoatingGunType.Corona;
/// <summary>
/// Manual coating application rate override (sqft/hr). When set, bypasses the formula.
/// </summary>
[Range(0, 5000)]
public decimal? CoatingRateSqFtPerHourOverride { get; set; }
}
}
@@ -0,0 +1,91 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific application and workflow preferences
/// </summary>
public class CompanyPreferences : BaseEntity
{
public int CompanyId { get; set; }
// Application Defaults
public string DefaultCurrency { get; set; } = "USD";
public string DefaultDateFormat { get; set; } = "MM/dd/yyyy";
public string DefaultTimeFormat { get; set; } = "12h";
public string DefaultPaymentTerms { get; set; } = "Net 30";
public int DefaultQuoteValidityDays { get; set; } = 30;
public string QuoteNumberPrefix { get; set; } = "QT";
public string JobNumberPrefix { get; set; } = "JOB";
public string InvoiceNumberPrefix { get; set; } = "INV";
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
// Job / Workflow Defaults
public string DefaultJobPriority { get; set; } = "Normal";
public bool RequireCustomerPO { get; set; } = false;
public bool AllowCustomerApproval { get; set; } = true;
public int DefaultTurnaroundDays { get; set; } = 7;
// Email Sender Identity
/// <summary>From address used in outgoing emails. Falls back to SendGrid:FromEmail in appsettings when null.</summary>
public string? EmailFromAddress { get; set; }
/// <summary>From display name used in outgoing emails. Falls back to SendGrid:FromName in appsettings when null.</summary>
public string? EmailFromName { get; set; }
// Notifications & Alerts
public bool EmailNotificationsEnabled { get; set; } = true;
public bool NotifyOnNewJob { get; set; } = true;
public bool NotifyOnNewQuote { get; set; } = true;
public bool NotifyOnJobStatusChange { get; set; } = true;
public bool NotifyOnQuoteApproval { get; set; } = true;
public bool NotifyOnPaymentReceived { get; set; } = true;
public int QuoteExpiryWarningDays { get; set; } = 3;
public int DueDateWarningDays { get; set; } = 2;
public int MaintenanceAlertDays { get; set; } = 7;
// Payment Reminders
/// <summary>When true, the background service will send overdue payment reminder emails.</summary>
public bool PaymentRemindersEnabled { get; set; } = false;
/// <summary>Comma-separated days-past-due thresholds at which reminders are sent (e.g. "7,14,30").</summary>
public string PaymentReminderDays { get; set; } = "7,14,30";
// Data Retention
public int QuoteRetentionYears { get; set; } = 7;
public int JobRetentionYears { get; set; } = 7;
public int LogRetentionDays { get; set; } = 90;
public int AutoArchiveJobsDays { get; set; } = 365;
public int DeletedRecordRetentionDays { get; set; } = 30;
// Quote PDF Template
public string QtAccentColor { get; set; } = "#374151";
public string? QtDefaultTerms { get; set; }
public string? QtFooterNote { get; set; }
// Invoice PDF Template
public string InAccentColor { get; set; } = "#374151";
public string? InDefaultTerms { get; set; }
public string? InFooterNote { get; set; }
// Blank Work Order PDF Template
public string WoAccentColor { get; set; } = "#374151";
public string? WoTerms { get; set; }
// Setup Wizard Progress
public bool SetupWizardStarted { get; set; } = false;
public bool SetupWizardCompleted { get; set; } = false;
/// <summary>Comma-separated step numbers that have been completed (e.g. "1,2,3")</summary>
public string? SetupWizardDoneSteps { get; set; }
/// <summary>Comma-separated step numbers the user chose to skip</summary>
public string? SetupWizardSkippedSteps { get; set; }
/// <summary>UTC timestamp of when the setup wizard was completed. Null if not yet completed.</summary>
public DateTime? SetupWizardCompletedAt { get; set; }
/// <summary>ASP.NET Identity user ID of the user who completed the setup wizard.</summary>
public string? SetupWizardCompletedByUserId { get; set; }
/// <summary>Display name of the user who completed the setup wizard, stored at completion time
/// to avoid a runtime JOIN to the Identity tables when listing companies.</summary>
public string? SetupWizardCompletedByName { get; set; }
/// <summary>True when the company indicated they are migrating data from QuickBooks Desktop.</summary>
public bool MigratingFromQuickBooks { get; set; } = false;
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; }
// Navigation
public virtual Company Company { get; set; } = null!;
}
@@ -0,0 +1,24 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Persisted record of a contact form submission. Inherits CompanyId from BaseEntity so
/// company users can only see their own submissions; SuperAdmin sees all via IsPlatformAdmin filter.
/// </summary>
public class ContactSubmission : BaseEntity
{
public string SenderName { get; set; } = string.Empty;
public string SenderEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Subject { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
/// <summary>True once a SuperAdmin has opened/acknowledged the submission.</summary>
public bool IsRead { get; set; } = false;
public DateTime? ReadAt { get; set; }
public string? ReadByUserId { get; set; }
public string? ReadByUserName { get; set; }
/// <summary>Optional internal note added by SuperAdmin (e.g. "Replied via email 4/19").</summary>
public string? AdminNotes { get; set; }
}
@@ -0,0 +1,51 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// A credit issued to a customer — either from a warranty resolution, billing correction,
/// or goodwill. Can be applied against a future invoice to reduce the balance due.
/// </summary>
public class CreditMemo : BaseEntity
{
public string MemoNumber { get; set; } = string.Empty; // CM-YYMM-####
public int CustomerId { get; set; }
public int? OriginalInvoiceId { get; set; } // Invoice that prompted the credit
public int? ReworkRecordId { get; set; } // If from warranty/rework resolution
public decimal Amount { get; set; }
public decimal AmountApplied { get; set; } // How much has been used so far
public decimal RemainingBalance => Amount - AmountApplied;
public DateTime IssueDate { get; set; } = DateTime.UtcNow;
public DateTime? ExpiryDate { get; set; } // Optional expiry
public string Reason { get; set; } = string.Empty;
public string? Notes { get; set; }
public CreditMemoStatus Status { get; set; } = CreditMemoStatus.Active;
public string? IssuedById { get; set; }
// Navigation
public virtual Customer Customer { get; set; } = null!;
public virtual Invoice? OriginalInvoice { get; set; }
public virtual ReworkRecord? ReworkRecord { get; set; }
public virtual ApplicationUser? IssuedBy { get; set; }
public virtual ICollection<CreditMemoApplication> Applications { get; set; } = new List<CreditMemoApplication>();
}
/// <summary>
/// Records each time a credit memo is (partially) applied to an invoice.
/// </summary>
public class CreditMemoApplication : BaseEntity
{
public int CreditMemoId { get; set; }
public int InvoiceId { get; set; }
public decimal AmountApplied { get; set; }
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
public string? AppliedById { get; set; }
// Navigation
public virtual CreditMemo CreditMemo { get; set; } = null!;
public virtual Invoice Invoice { get; set; } = null!;
public virtual ApplicationUser? AppliedBy { get; set; }
}
@@ -0,0 +1,57 @@
namespace PowderCoating.Core.Entities;
public class Customer : BaseEntity
{
public string? CompanyName { get; set; }
public string? ContactFirstName { get; set; }
public string? ContactLastName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? Country { get; set; } = "USA";
// Business Information
public bool IsCommercial { get; set; }
public string? TaxId { get; set; }
public decimal CreditLimit { get; set; }
public decimal CurrentBalance { get; set; }
public decimal CreditBalance { get; set; } // Available store credit (credit memos)
public string? PaymentTerms { get; set; }
public int? PricingTierId { get; set; }
// Tax Exemption
public bool IsTaxExempt { get; set; }
public byte[]? TaxExemptCertificateData { get; set; }
public string? TaxExemptCertificateContentType { get; set; }
public string? TaxExemptCertificateFileName { get; set; }
// Relationships
public virtual PricingTier? PricingTier { get; set; }
public virtual ICollection<Job> Jobs { get; set; } = new List<Job>();
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<CustomerNote> CustomerNotes { get; set; } = new List<CustomerNote>();
// Additional fields
public string? GeneralNotes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? LastContactDate { get; set; }
// Notification preferences
public bool NotifyByEmail { get; set; } = true;
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
public bool NotifyBySms { get; set; } = false;
// Unique token used in email unsubscribe links (no auth required)
public string UnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
// SMS consent tracking (TCPA compliance)
public DateTime? SmsConsentedAt { get; set; }
public string? SmsConsentMethod { get; set; }
/// <summary>Set when the customer replies STOP or is manually opted out. Null means they have never opted out.</summary>
public DateTime? SmsOptedOutAt { get; set; }
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
}
@@ -0,0 +1,13 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-wide tip of the day shown on the dashboard welcome section.
/// Not tenant-scoped — visible to all companies.
/// </summary>
public class DashboardTip
{
public int Id { get; set; }
public string TipText { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,28 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Deposit : BaseEntity
{
public string ReceiptNumber { get; set; } = string.Empty;
public int CustomerId { get; set; }
public int? JobId { get; set; }
public int? QuoteId { get; set; }
public decimal Amount { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public DateTime ReceivedDate { get; set; } = DateTime.UtcNow;
public string? Reference { get; set; }
public string? Notes { get; set; }
public string? RecordedById { get; set; }
// Applied to invoice when invoice is created
public int? AppliedToInvoiceId { get; set; }
public DateTime? AppliedDate { get; set; }
// Navigation
public virtual Customer Customer { get; set; } = null!;
public virtual Job? Job { get; set; }
public virtual Quote? Quote { get; set; }
public virtual Invoice? AppliedToInvoice { get; set; }
public virtual ApplicationUser? RecordedBy { get; set; }
}
@@ -0,0 +1,82 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Equipment : BaseEntity
{
public string EquipmentName { get; set; } = string.Empty;
public string? EquipmentNumber { get; set; }
public string EquipmentType { get; set; } = string.Empty; // Oven, Spray Booth, Compressor, etc.
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public DateTime? PurchaseDate { get; set; }
public decimal PurchasePrice { get; set; }
public DateTime? WarrantyExpiration { get; set; }
public EquipmentStatus Status { get; set; } = EquipmentStatus.Operational;
public string? Location { get; set; }
// Maintenance Information
public int RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
// Oven-specific capacity (relevant when EquipmentType == "Oven")
public decimal? MaxLoadSqFt { get; set; }
public int? OvenCycleMinutes { get; set; }
// User Manual
public string? ManualFilePath { get; set; } // Filesystem path: /media/{CompanyId}/equipment-manuals/{equipmentId}/{filename}.pdf
public string? ManualFileName { get; set; } // Original filename
public long? ManualFileSize { get; set; } // File size in bytes
public string? ManualContentType { get; set; } // e.g., "application/pdf"
public DateTime? ManualUploadedDate { get; set; }
// Relationships
public virtual ICollection<MaintenanceRecord> MaintenanceRecords { get; set; } = new List<MaintenanceRecord>();
public virtual ICollection<OvenBatch> OvenBatches { get; set; } = new List<OvenBatch>();
}
public class MaintenanceRecord : BaseEntity
{
public int EquipmentId { get; set; }
public string MaintenanceType { get; set; } = string.Empty; // Preventive, Repair, Inspection, etc.
public MaintenanceStatus Status { get; set; } = MaintenanceStatus.Scheduled;
public MaintenancePriority Priority { get; set; } = MaintenancePriority.Normal;
public DateTime ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; }
public string? PerformedById { get; set; } // Changed from int? to string? for Identity FK
public string? AssignedUserId { get; set; } // Assigned user
public string Description { get; set; } = string.Empty;
public string? WorkPerformed { get; set; }
public string? PartsReplaced { get; set; }
public decimal LaborCost { get; set; }
public decimal PartsCost { get; set; }
public decimal TotalCost { get; set; }
public decimal DowntimeHours { get; set; }
public string? Notes { get; set; }
public string? TechnicianNotes { get; set; }
// Recurrence
public bool IsRecurring { get; set; }
public MaintenanceRecurrenceFrequency? RecurrenceFrequency { get; set; }
public DateTime? RecurrenceEndDate { get; set; }
public string? RecurrenceGroupId { get; set; } // GUID string groups all occurrences in a series
public int? RecurrenceParentId { get; set; } // null on parent; child → parent.Id
// Relationships
public virtual Equipment Equipment { get; set; } = null!;
public virtual ApplicationUser? PerformedBy { get; set; }
public virtual ApplicationUser? AssignedUser { get; set; }
public virtual MaintenanceRecord? RecurrenceParent { get; set; }
}
@@ -0,0 +1,54 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class GiftCertificate : BaseEntity
{
/// <summary>Certificate code shown on the physical/emailed certificate. Format: GC-YYMM-####</summary>
public string CertificateCode { get; set; } = string.Empty;
public decimal OriginalAmount { get; set; }
public decimal RedeemedAmount { get; set; }
public decimal RemainingBalance => OriginalAmount - RedeemedAmount;
// Who it's for (optional — may be given to an unknown recipient)
public int? RecipientCustomerId { get; set; }
public string? RecipientName { get; set; } // Free-text name for non-customers
public string? RecipientEmail { get; set; }
// How it was issued
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Sold;
// If sold: what the buyer paid (may be less than face value for a promotional sale)
public decimal? PurchasePrice { get; set; }
public int? PurchasingCustomerId { get; set; }
public GiftCertificateStatus Status { get; set; } = GiftCertificateStatus.Active;
public DateTime IssueDate { get; set; } = DateTime.UtcNow;
public DateTime? ExpiryDate { get; set; }
public string? Notes { get; set; }
public string? IssuedById { get; set; }
/// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; }
// Navigation
public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; }
public virtual ApplicationUser? IssuedBy { get; set; }
public virtual ICollection<GiftCertificateRedemption> Redemptions { get; set; } = new List<GiftCertificateRedemption>();
}
public class GiftCertificateRedemption : BaseEntity
{
public int GiftCertificateId { get; set; }
public int InvoiceId { get; set; }
public decimal AmountRedeemed { get; set; }
public DateTime RedeemedDate { get; set; } = DateTime.UtcNow;
public string? RedeemedById { get; set; }
// Navigation
public virtual GiftCertificate GiftCertificate { get; set; } = null!;
public virtual Invoice Invoice { get; set; } = null!;
public virtual ApplicationUser? RedeemedBy { get; set; }
}
@@ -0,0 +1,21 @@
namespace PowderCoating.Core.Entities;
public class InAppNotification : BaseEntity
{
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Link { get; set; }
public string NotificationType { get; set; } = string.Empty; // QuoteApproved, QuoteDeclined, InvoicePaid
public bool IsRead { get; set; }
public DateTime? ReadAt { get; set; }
// Optional FK links for context
public int? QuoteId { get; set; }
public int? InvoiceId { get; set; }
public int? CustomerId { get; set; }
// Navigation
public virtual Quote? Quote { get; set; }
public virtual Invoice? Invoice { get; set; }
public virtual Customer? Customer { get; set; }
}
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Entities;
public class InventoryCategoryLookup : BaseEntity
{
public string CategoryCode { get; set; } = string.Empty; // Immutable: "POWDER", "PRIMER"
public string DisplayName { get; set; } = string.Empty; // Customizable: "Powder Coating"
public int DisplayOrder { get; set; } // Sort order in dropdowns
public string? Description { get; set; } // Optional description
public bool IsActive { get; set; } = true; // Can be disabled
public bool IsSystemDefined { get; set; } = true; // Protect from deletion
public bool IsCoating { get; set; } = false; // Indicates this category contains coatings that can be applied
// Relationships
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
}
@@ -0,0 +1,76 @@
namespace PowderCoating.Core.Entities;
public class InventoryItem : BaseEntity
{
public string SKU { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
// Category (lookup-based)
public int? InventoryCategoryId { get; set; } // Nullable during migration
public virtual InventoryCategoryLookup? InventoryCategory { get; set; }
// Legacy field for seed data (will be removed after migration)
public string Category { get; set; } = string.Empty;
// Powder-specific fields
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
public string? Finish { get; set; }
public string? Manufacturer { get; set; }
public string? ManufacturerPartNumber { get; set; }
public decimal? CoverageSqFtPerLb { get; set; } // Square feet coverage per pound (default 30)
public decimal? TransferEfficiency { get; set; } // Percentage of powder that sticks (default 65%)
public decimal? CureTemperatureF { get; set; } // Required cure temperature in °F (recommended for oven scheduling)
public int? CureTimeMinutes { get; set; } // Required hold time at cure temperature
public string? ColorFamilies { get; set; } // Comma-separated primary color families e.g. "Green,Blue"
public bool RequiresClearCoat { get; set; } // True if this powder requires a clear coat topcoat
public string? SpecPageUrl { get; set; } // Link to manufacturer's product/spec page
// Sample Panel Tracking (coating category items only)
public bool HasSamplePanel { get; set; } = false;
// Inventory Management
public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs"; // lbs, kg, gallons, units, etc.
public decimal ReorderPoint { get; set; }
public decimal ReorderQuantity { get; set; }
public decimal MinimumStock { get; set; }
public decimal MaximumStock { get; set; }
// Pricing
public decimal UnitCost { get; set; }
public decimal AverageCost { get; set; }
public decimal LastPurchasePrice { get; set; }
public DateTime? LastPurchaseDate { get; set; }
// Vendor Information
public int? PrimaryVendorId { get; set; }
public string? VendorPartNumber { get; set; }
// Additional fields
public string? Location { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime? DiscontinuedDate { get; set; }
// ── Financial Account Mapping ──────────────────────────────────────────
/// <summary>
/// Asset account where inventory value is tracked on the balance sheet (e.g., 1200 Inventory - Powder).
/// When null, falls back to the default inventory asset account.
/// </summary>
public int? InventoryAccountId { get; set; }
/// <summary>
/// COGS account debited when this material is consumed on a job (e.g., 5100 Powder & Materials).
/// When null, falls back to the default COGS account.
/// </summary>
public int? CogsAccountId { get; set; }
// Relationships
public virtual Vendor? PrimaryVendor { get; set; }
public virtual Account? InventoryAccount { get; set; }
public virtual Account? CogsAccount { get; set; }
public virtual ICollection<InventoryTransaction> Transactions { get; set; } = new List<InventoryTransaction>();
}
@@ -0,0 +1,67 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Invoice : BaseEntity
{
public string InvoiceNumber { get; set; } = string.Empty;
public int? JobId { get; set; }
public int CustomerId { get; set; }
public string? PreparedById { get; set; }
public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
// Dates
public DateTime InvoiceDate { get; set; } = DateTime.UtcNow;
public DateTime? DueDate { get; set; }
public DateTime? SentDate { get; set; }
public DateTime? PaidDate { get; set; }
// Financials
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 CreditApplied { get; set; } // Sum of credit memo applications
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
// Online payments (Stripe Connect)
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
public DateTime? PaymentLinkExpiresAt { get; set; } // 5 days from generation
public string? StripePaymentIntentId { get; set; } // Most recent PaymentIntent ID
public decimal OnlineAmountPaid { get; set; } = 0; // Running total of online payments received
public decimal OnlineSurchargeCollected { get; set; } = 0; // Surcharge amount collected (for records)
// Text
public string? Notes { get; set; }
public string? InternalNotes { get; set; }
public string? Terms { get; set; }
public string? CustomerPO { get; set; }
/// <summary>
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
/// Stored for searchability and traceability after import. Searchable from the invoice list.
/// </summary>
public string? ExternalReference { get; set; }
/// <summary>
/// Liability account where collected sales tax is tracked (e.g., 2200 Sales Tax Payable).
/// Populated automatically when TaxAmount > 0.
/// </summary>
public int? SalesTaxAccountId { get; set; }
// Navigation
public virtual Job? Job { get; set; }
public virtual Customer Customer { get; set; } = null!;
public virtual ApplicationUser? PreparedBy { get; set; }
public virtual Account? SalesTaxAccount { get; set; }
public virtual ICollection<InvoiceItem> InvoiceItems { get; set; } = new List<InvoiceItem>();
public virtual ICollection<Payment> Payments { get; set; } = new List<Payment>();
public virtual ICollection<Refund> Refunds { get; set; } = new List<Refund>();
public virtual ICollection<CreditMemoApplication> CreditApplications { get; set; } = new List<CreditMemoApplication>();
public virtual ICollection<GiftCertificateRedemption> GiftCertificateRedemptions { get; set; } = new List<GiftCertificateRedemption>();
}
@@ -0,0 +1,36 @@
namespace PowderCoating.Core.Entities;
public class InvoiceItem : BaseEntity
{
public int InvoiceId { get; set; }
public int? SourceJobItemId { get; set; }
public int? CatalogItemId { get; set; } // set for merchandise line items
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
public string? ColorName { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
/// <summary>
/// Revenue account this line item is posted to.
/// Pulled from the catalog item at invoice creation time; null falls back to default revenue account.
/// </summary>
public int? RevenueAccountId { get; set; }
// Gift certificate sale fields
public bool IsGiftCertificate { get; set; } = false;
public string? GcRecipientName { get; set; }
public string? GcRecipientEmail { get; set; }
public DateTime? GcExpiryDate { get; set; }
public int? GeneratedGiftCertificateId { get; set; }
// Navigation
public virtual Invoice Invoice { get; set; } = null!;
public virtual JobItem? SourceJobItem { get; set; }
public virtual CatalogItem? CatalogItem { get; set; }
public virtual Account? RevenueAccount { get; set; }
public virtual GiftCertificate? GeneratedGiftCertificate { get; set; }
}
+80
View File
@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations.Schema;
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Job : BaseEntity
{
public string JobNumber { get; set; } = string.Empty;
public int CustomerId { get; set; }
public int? QuoteId { get; set; }
public string? AssignedUserId { get; set; } // Assigned user
public string Description { get; set; } = string.Empty;
// Lookup foreign keys (replacing enums)
public int JobStatusId { get; set; }
public int JobPriorityId { get; set; }
// Dates
public DateTime? ScheduledDate { get; set; }
public DateTime? StartedDate { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DueDate { get; set; }
// Selected oven (carried over from quote; null = company default rate)
public int? OvenCostId { get; set; }
// Pricing
public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; }
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
public bool IsRushJob { get; set; }
public DiscountType DiscountType { get; set; } = DiscountType.None;
public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; }
// Job Completion Details
public decimal? ActualTimeSpentHours { get; set; }
// Additional Information
public string? CustomerPO { get; set; }
public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } // Internal notes from quote
public string? Tags { get; set; }
public bool RequiresCustomerApproval { get; set; }
public bool IsCustomerApproved { get; set; }
// Shop floor QR access token (no login required; scoped to this job only)
public Guid ShopAccessCode { get; set; } = Guid.NewGuid();
// Part intake / receiving
public DateTime? IntakeDate { get; set; }
public string? IntakeConditionNotes { get; set; }
public int? IntakePartCount { get; set; }
public string? IntakeCheckedByUserId { get; set; }
// Rework tracking
public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
// Relationships
[ForeignKey("IntakeCheckedByUserId")]
public virtual ApplicationUser? IntakeCheckedBy { get; set; }
public virtual OvenCost? OvenCost { get; set; }
public virtual Customer Customer { get; set; } = null!;
public virtual Quote? Quote { get; set; }
public virtual ApplicationUser? AssignedUser { get; set; }
public virtual JobStatusLookup JobStatus { get; set; } = null!;
public virtual JobPriorityLookup JobPriority { get; set; } = null!;
public virtual ICollection<JobItem> JobItems { get; set; } = new List<JobItem>();
public virtual ICollection<JobPhoto> Photos { get; set; } = new List<JobPhoto>();
public virtual ICollection<JobNote> Notes { get; set; } = new List<JobNote>();
public virtual ICollection<JobStatusHistory> StatusHistory { get; set; } = new List<JobStatusHistory>();
public virtual ICollection<JobPrepService> JobPrepServices { get; set; } = new List<JobPrepService>();
public virtual Invoice? Invoice { get; set; }
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
public virtual ICollection<ReworkRecord> ReworkRecords { get; set; } = new List<ReworkRecord>();
public virtual Job? OriginalJob { get; set; }
}
@@ -0,0 +1,16 @@
namespace PowderCoating.Core.Entities;
public class JobChangeHistory : BaseEntity
{
public int JobId { get; set; }
public string? ChangedByUserId { get; set; }
public DateTime ChangedAt { get; set; } = DateTime.UtcNow;
public string FieldName { get; set; } = string.Empty;
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string ChangeDescription { get; set; } = string.Empty;
// Navigation properties
public Job Job { get; set; } = null!;
public ApplicationUser? ChangedBy { get; set; }
}
@@ -0,0 +1,14 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores the daily display order for jobs on the Jobs Priority page
/// </summary>
public class JobDailyPriority : BaseEntity
{
public int JobId { get; set; }
public DateTime ScheduledDate { get; set; }
public int DisplayOrder { get; set; }
// Navigation
public virtual Job Job { get; set; } = null!;
}
@@ -0,0 +1,56 @@
namespace PowderCoating.Core.Entities;
public class JobItem : BaseEntity
{
public int JobId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
// Powder/Material Information
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
public string? Finish { get; set; } // Gloss, Matte, Satin, etc.
// Measurements
public decimal? SurfaceArea { get; set; }
public decimal SurfaceAreaSqFt { get; set; }
// Catalog item reference (optional — null for generic/labor items)
public int? CatalogItemId { get; set; }
// Pricing
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
public decimal LaborCost { get; set; }
public bool IsGenericItem { get; set; }
public decimal? ManualUnitPrice { get; set; }
public decimal? PowderCostOverride { get; set; } // Optional unit price override for catalog items
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public string? Sku { get; set; }
public bool IncludePrepCost { get; set; } = true;
// Processing Details
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
public int EstimatedMinutes { get; set; }
public string? Notes { get; set; }
// Part complexity level — applies a price multiplier for calculated items
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
public string? Complexity { get; set; }
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
public string? AiTags { get; set; }
// Link to shared AI prediction record (null for non-AI items; shared with QuoteItem when converted)
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
// Relationships
public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
public virtual ICollection<JobItemCoat> Coats { get; set; } = new List<JobItemCoat>();
public virtual ICollection<JobItemPrepService> PrepServices { get; set; } = new List<JobItemPrepService>();
}
@@ -0,0 +1,52 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents a single coating layer applied to a job item.
/// Supports multi-coat configurations (primer, base coat, top coat, clear coat, etc.)
/// </summary>
public class JobItemCoat : BaseEntity
{
// Parent relationship
public int JobItemId { get; set; }
// Coat identification (user-defined)
public string CoatName { get; set; } = string.Empty; // "Primer", "Base Coat", "Top Coat", etc.
public int Sequence { get; set; } // 1, 2, 3... for ordering
// Powder selection (from quote)
public int? InventoryItemId { get; set; } // In-stock powder
public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc.
public string? Finish { get; set; } // Gloss, Matte, Textured, etc.
// Coverage parameters (from quote)
public decimal CoverageSqFtPerLb { get; set; } = 30m;
public decimal TransferEfficiency { get; set; } = 65m;
// Cost information (from quote)
public decimal? PowderCostPerLb { get; set; } // $/lb
public decimal? PowderToOrder { get; set; } // Pounds estimated to order
// Job completion tracking
public decimal? ActualPowderUsedLbs { get; set; } // Actual powder used when job is completed
// Powder ordering tracking
public bool PowderOrdered { get; set; } = false;
public DateTime? PowderOrderedAt { get; set; }
public string? PowderOrderedByUserId { get; set; }
// Powder receiving tracking
public bool PowderReceived { get; set; } = false;
public DateTime? PowderReceivedAt { get; set; }
public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; }
// Notes
public string? Notes { get; set; }
// Navigation properties
public virtual JobItem JobItem { get; set; } = null!;
public virtual InventoryItem? InventoryItem { get; set; }
public virtual Vendor? Vendor { get; set; }
}
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Entities;
public class JobItemPrepService : BaseEntity
{
public int JobItemId { get; set; }
public int PrepServiceId { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Which blast setup was selected when this sandblasting prep service was added.</summary>
public int? BlastSetupId { get; set; }
public virtual JobItem JobItem { get; set; } = null!;
public virtual PrepService PrepService { get; set; } = null!;
public virtual CompanyBlastSetup? BlastSetup { get; set; }
}
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Join table for the many-to-many relationship between Jobs and PrepServices
/// </summary>
public class JobPrepService : BaseEntity
{
public int JobId { get; set; }
public int PrepServiceId { get; set; }
// Navigation properties
[ForeignKey(nameof(JobId))]
public virtual Job Job { get; set; } = null!;
[ForeignKey(nameof(PrepServiceId))]
public virtual PrepService PrepService { get; set; } = null!;
}
}
@@ -0,0 +1,54 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific job priority lookup table.
/// Replaces hardcoded JobPriority enum to enable priority customization.
/// </summary>
public class JobPriorityLookup : BaseEntity
{
/// <summary>
/// Immutable priority code used in code logic (e.g., "LOW", "NORMAL", "URGENT", "RUSH").
/// Acts like the old enum name.
/// </summary>
public string PriorityCode { get; set; } = string.Empty;
/// <summary>
/// User-customizable display name shown in UI (e.g., "Standard", "Expedited", "Emergency").
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Priority level for sorting (1 = lowest priority, 5 = highest priority).
/// Replaces enum integer comparisons.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Bootstrap color class for badge styling (primary, secondary, success, danger, warning, info, dark).
/// Eliminates switch statement duplication for priority colors.
/// </summary>
public string ColorClass { get; set; } = "secondary";
/// <summary>
/// Optional Bootstrap icon class (e.g., "bi-arrow-up", "bi-exclamation-triangle").
/// </summary>
public string? IconClass { get; set; }
/// <summary>
/// Whether this priority is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// System-defined priorities cannot be deleted.
/// </summary>
public bool IsSystemDefined { get; set; } = true;
/// <summary>
/// Optional description explaining when to use this priority level.
/// </summary>
public string? Description { get; set; }
// Navigation properties
public virtual ICollection<Job> Jobs { get; set; } = new List<Job>();
}
@@ -0,0 +1,74 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific job status lookup table.
/// Replaces hardcoded JobStatus enum to enable workflow customization.
/// </summary>
public class JobStatusLookup : BaseEntity
{
/// <summary>
/// Immutable status code used in code logic (e.g., "PENDING", "COATING", "COMPLETED").
/// Acts like the old enum name.
/// </summary>
public string StatusCode { get; set; } = string.Empty;
/// <summary>
/// User-customizable display name shown in UI (e.g., "In Progress", "Ready for Coating").
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Workflow sequence number for ordering statuses (1 = first, higher = later in workflow).
/// Replaces enum integer comparisons.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Bootstrap color class for badge styling (primary, secondary, success, danger, warning, info, dark).
/// Eliminates switch statement duplication for status colors.
/// </summary>
public string ColorClass { get; set; } = "secondary";
/// <summary>
/// Optional Bootstrap icon class (e.g., "bi-clock", "bi-paint-bucket", "bi-check-circle").
/// </summary>
public string? IconClass { get; set; }
/// <summary>
/// Whether this status is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// System-defined statuses cannot be deleted (PENDING, COMPLETED, CANCELLED).
/// Prevents breaking workflow dependencies.
/// </summary>
public bool IsSystemDefined { get; set; } = true;
/// <summary>
/// Terminal statuses represent completed workflows (COMPLETED, DELIVERED, CANCELLED).
/// Used for reporting and filtering.
/// </summary>
public bool IsTerminalStatus { get; set; } = false;
/// <summary>
/// Work-in-progress statuses represent active production (SANDBLASTING, COATING, CURING, etc.).
/// Used for dashboard stats and capacity planning.
/// </summary>
public bool IsWorkInProgressStatus { get; set; } = false;
/// <summary>
/// Optional description explaining when to use this status.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Optional workflow category for grouping statuses (e.g., "Pre-Production", "Production", "Post-Production").
/// </summary>
public string? WorkflowCategory { get; set; }
// Navigation properties
public virtual ICollection<Job> Jobs { get; set; } = new List<Job>();
public virtual ICollection<JobStatusHistory> FromStatusHistory { get; set; } = new List<JobStatusHistory>();
public virtual ICollection<JobStatusHistory> ToStatusHistory { get; set; } = new List<JobStatusHistory>();
}
@@ -0,0 +1,18 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// A saved job configuration that can be reused to quickly create new jobs.
/// </summary>
public class JobTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public int? CustomerId { get; set; }
public string? SpecialInstructions { get; set; }
public bool IsActive { get; set; } = true;
public int UsageCount { get; set; } = 0;
// Navigation properties
public virtual Customer? Customer { get; set; }
public virtual ICollection<JobTemplateItem> Items { get; set; } = new List<JobTemplateItem>();
}
@@ -0,0 +1,26 @@
namespace PowderCoating.Core.Entities;
public class JobTemplateItem : BaseEntity
{
public int JobTemplateId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; } = 1;
public decimal SurfaceAreaSqFt { get; set; }
public int? CatalogItemId { get; set; }
public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; }
public decimal? ManualUnitPrice { get; set; }
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
public bool IncludePrepCost { get; set; }
public int EstimatedMinutes { get; set; }
public string? Complexity { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
// Navigation properties
public virtual JobTemplate JobTemplate { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
public virtual ICollection<JobTemplateItemCoat> Coats { get; set; } = new List<JobTemplateItemCoat>();
public virtual ICollection<JobTemplateItemPrepService> PrepServices { get; set; } = new List<JobTemplateItemPrepService>();
}
@@ -0,0 +1,22 @@
namespace PowderCoating.Core.Entities;
public class JobTemplateItemCoat : BaseEntity
{
public int JobTemplateItemId { get; set; }
public string CoatName { get; set; } = string.Empty;
public int Sequence { get; set; }
public int? InventoryItemId { get; set; }
public string? ColorName { get; set; }
public int? VendorId { get; set; }
public string? ColorCode { get; set; }
public string? Finish { get; set; }
public decimal CoverageSqFtPerLb { get; set; } = 30m;
public decimal TransferEfficiency { get; set; } = 65m;
public decimal? PowderCostPerLb { get; set; }
public string? Notes { get; set; }
// Navigation properties
public virtual JobTemplateItem JobTemplateItem { get; set; } = null!;
public virtual InventoryItem? InventoryItem { get; set; }
public virtual Vendor? Vendor { get; set; }
}
@@ -0,0 +1,12 @@
namespace PowderCoating.Core.Entities;
public class JobTemplateItemPrepService : BaseEntity
{
public int JobTemplateItemId { get; set; }
public int PrepServiceId { get; set; }
public int EstimatedMinutes { get; set; }
// Navigation properties
public virtual JobTemplateItem JobTemplateItem { get; set; } = null!;
public virtual PrepService PrepService { get; set; } = null!;
}
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity
{
public int JobId { get; set; }
public int ShopWorkerId { get; set; }
public DateTime WorkDate { get; set; }
public decimal HoursWorked { get; set; }
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
public string? Notes { get; set; }
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual ShopWorker Worker { get; set; } = null!;
}
@@ -0,0 +1,34 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores per-manufacturer URL patterns so the AI lookup service can build
/// direct product page URLs rather than relying solely on search results.
/// These records are global (CompanyId = 0) and shared across all tenants.
/// </summary>
public class ManufacturerLookupPattern : BaseEntity
{
/// <summary>Display / match name (case-insensitive contains check).</summary>
public string ManufacturerName { get; set; } = string.Empty;
/// <summary>
/// URL template. Supported placeholders:
/// {partNumber} manufacturer part number (slashes normalized to hyphens)
/// {slug} color name transformed by SlugTransform
/// {colorCode} color code as-is
/// If a required placeholder is missing at runtime the template is skipped and
/// the system falls back to a Serper search URL.
/// </summary>
public string? ProductUrlTemplate { get; set; }
/// <summary>How the color name is turned into a URL slug.</summary>
public string SlugTransform { get; set; } = "LowerHyphen"; // LowerHyphen | LowerUnderscore | TitleHyphen | AsIs
/// <summary>
/// Manufacturer/retailer domain (e.g. "prismaticpowders.com").
/// Used by the search result URL picker even when no template is configured.
/// </summary>
public string? Domain { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}
@@ -0,0 +1,28 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class NotificationLog : BaseEntity
{
public NotificationChannel Channel { get; set; }
public NotificationType NotificationType { get; set; }
public NotificationStatus Status { get; set; }
public string RecipientName { get; set; } = string.Empty;
public string Recipient { get; set; } = string.Empty; // email address or phone number
public string? Subject { get; set; }
public string Message { get; set; } = string.Empty;
public string? ErrorMessage { get; set; }
public DateTime SentAt { get; set; }
// Optional FK links
public int? CustomerId { get; set; }
public int? JobId { get; set; }
public int? QuoteId { get; set; }
public int? InvoiceId { get; set; }
// Navigation properties
public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; }
public virtual Quote? Quote { get; set; }
public virtual Invoice? Invoice { get; set; }
}
@@ -0,0 +1,14 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class NotificationTemplate : BaseEntity
{
public NotificationType NotificationType { get; set; }
public NotificationChannel Channel { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? Subject { get; set; } // null for SMS
public string Body { get; set; } = string.Empty; // HTML for Email, plain text for SMS
public bool IsActive { get; set; } = true;
public virtual Company Company { get; set; } = null!;
}
@@ -0,0 +1,53 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class OvenBatch : BaseEntity
{
public string BatchNumber { get; set; } = string.Empty;
public int? EquipmentId { get; set; }
public int? OvenCostId { get; set; }
public OvenBatchStatus Status { get; set; } = OvenBatchStatus.Planned;
public DateTime ScheduledDate { get; set; }
public DateTime? ScheduledStartTime { get; set; }
public DateTime? EstimatedEndTime { get; set; }
public DateTime? ActualStartTime { get; set; }
public DateTime? ActualEndTime { get; set; }
public decimal TotalSurfaceAreaSqFt { get; set; }
public decimal? CureTemperatureF { get; set; }
public int CycleMinutes { get; set; } = 45;
public string? PrimaryColorName { get; set; }
public string? PrimaryColorCode { get; set; }
public bool AiSuggested { get; set; }
public string? AiReasoningJson { get; set; }
public string? Notes { get; set; }
// Navigation
public virtual Equipment? Equipment { get; set; }
public virtual OvenCost? OvenCost { get; set; }
public virtual ICollection<OvenBatchItem> Items { get; set; } = new List<OvenBatchItem>();
}
public class OvenBatchItem : BaseEntity
{
public int OvenBatchId { get; set; }
public int JobId { get; set; }
public int JobItemId { get; set; }
public int JobItemCoatId { get; set; }
public decimal SurfaceAreaContribution { get; set; }
public int CoatPassNumber { get; set; } = 1;
public int SortOrder { get; set; }
public OvenBatchItemStatus Status { get; set; } = OvenBatchItemStatus.Pending;
public string? Notes { get; set; }
// Navigation
public virtual OvenBatch Batch { get; set; } = null!;
public virtual Job Job { get; set; } = null!;
public virtual JobItem JobItem { get; set; } = null!;
public virtual JobItemCoat JobItemCoat { get; set; } = null!;
}
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Core.Entities;
public class OvenCost : BaseEntity
{
[Required]
[StringLength(100)]
public string Label { get; set; } = string.Empty;
[Range(0, 10000)]
public decimal CostPerHour { get; set; }
public bool IsActive { get; set; } = true;
public int DisplayOrder { get; set; } = 0;
/// <summary>Maximum load capacity in square feet for the Oven Scheduler.</summary>
[Range(0, 100000)]
public decimal? MaxLoadSqFt { get; set; }
/// <summary>Default cure cycle duration in minutes for the Oven Scheduler.</summary>
[Range(1, 1440)]
public int? DefaultCycleMinutes { get; set; }
// Navigation
public virtual Company Company { get; set; } = null!;
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<Job> Jobs { get; set; } = new List<Job>();
}
@@ -0,0 +1,25 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Payment : BaseEntity
{
public int InvoiceId { get; set; }
public decimal Amount { get; set; }
public DateTime PaymentDate { get; set; } = DateTime.UtcNow;
public PaymentMethod PaymentMethod { get; set; }
public string? Reference { get; set; }
public string? Notes { get; set; }
public string? RecordedById { get; set; }
/// <summary>
/// Bank/checking account the payment is deposited into.
/// When null, no specific deposit account is tracked.
/// </summary>
public int? DepositAccountId { get; set; }
// Navigation
public virtual Invoice Invoice { get; set; } = null!;
public virtual ApplicationUser? RecordedBy { get; set; }
public virtual Account? DepositAccount { get; set; }
}
@@ -0,0 +1,28 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Temporarily stores registration form data while the user completes a Stripe checkout.
/// Keyed by a GUID token that is embedded in the Stripe success/cancel URLs so no session
/// cookie is required to survive the cross-origin redirect.
/// Does NOT inherit BaseEntity — no CompanyId, no soft-delete, no tenant filter.
/// </summary>
public class PendingRegistrationSession
{
public int Id { get; set; }
/// <summary>GUID token embedded in the Stripe success/cancel URLs.</summary>
public string Token { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public int Plan { get; set; }
public bool IsAnnual { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Set to true once the registration is successfully completed.</summary>
public bool IsCompleted { get; set; }
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-wide key/value settings managed via the SuperAdmin UI.
/// Intentionally does NOT inherit BaseEntity — no CompanyId, no soft-delete, no tenant filter.
/// </summary>
public class PlatformSetting
{
public int Id { get; set; }
/// <summary>Unique string key, e.g. "AdminNotificationEmail"</summary>
[MaxLength(200)]
public string Key { get; set; } = string.Empty;
/// <summary>The stored value (null = unset/use default)</summary>
public string? Value { get; set; }
/// <summary>Human-readable label shown in the Platform Settings UI</summary>
public string? Label { get; set; }
/// <summary>Explanation shown below the field in the UI</summary>
public string? Description { get; set; }
/// <summary>Groups related settings together in the UI (e.g. "Notifications", "Branding")</summary>
public string? GroupName { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
@@ -0,0 +1,17 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Well-known keys for the PlatformSettings table.
/// </summary>
public static class PlatformSettingKeys
{
public const string AdminNotificationEmail = "AdminNotificationEmail";
public const string BaseUrl = "BaseUrl";
public const string TrialPeriodDays = "TrialPeriodDays";
public const string TrialsEnabled = "TrialsEnabled";
public const string QuoteApprovalTokenDays = "QuoteApprovalTokenDays";
public const string AuditLogRetentionDays = "AuditLogRetentionDays";
public const string StripeWebhookRetentionDays = "StripeWebhookRetentionDays";
public const string MaxTenants = "MaxTenants";
public const string SmsEnabled = "SmsEnabled";
}
@@ -0,0 +1,30 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Records actual powder consumption against a specific coat on a job.
/// Links the InventoryTransaction (the deduction) to the JobItemCoat (what the powder was used on).
/// This is the bridge that enables prediction vs actual reporting per powder SKU.
/// </summary>
public class PowderUsageLog : BaseEntity
{
public int JobId { get; set; }
public int JobItemId { get; set; }
public int JobItemCoatId { get; set; }
public int? InventoryItemId { get; set; } // null for custom/vendor powder
public int? InventoryTransactionId { get; set; } // FK to the inventory deduction (if auto-linked)
public decimal ActualLbsUsed { get; set; }
public decimal EstimatedLbs { get; set; } // Snapshot of PowderToOrder at time of recording
public decimal VarianceLbs { get; set; } // ActualLbsUsed - EstimatedLbs (positive = used more than estimated)
public string RecordedByUserId { get; set; } = string.Empty;
public DateTime RecordedAt { get; set; } = DateTime.UtcNow;
public string? Notes { get; set; }
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual JobItem JobItem { get; set; } = null!;
public virtual JobItemCoat JobItemCoat { get; set; } = null!;
public virtual InventoryItem? InventoryItem { get; set; }
public virtual InventoryTransaction? InventoryTransaction { get; set; }
}
@@ -0,0 +1,34 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific preparation service lookup table.
/// Stores common prep services like "Sandblasting", "Chemical Stripping", "Hand Sanding", etc.
/// </summary>
public class PrepService : BaseEntity
{
/// <summary>
/// Name of the preparation service (e.g., "Sandblasting", "Chemical Stripping").
/// </summary>
public string ServiceName { get; set; } = string.Empty;
/// <summary>
/// Optional detailed description of the service.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Display order for sorting services in lists (lower numbers appear first).
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Whether this service is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// When true, the item wizard shows a blast-setup selector for this prep service so the
/// correct throughput rate is used to calculate estimated minutes.
/// </summary>
public bool RequiresBlastSetup { get; set; }
}
@@ -0,0 +1,51 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class PurchaseOrder : BaseEntity
{
public string PoNumber { get; set; } = string.Empty;
public int VendorId { get; set; }
public virtual Vendor Vendor { get; set; } = null!;
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
public DateTime? ExpectedDeliveryDate { get; set; }
public DateTime? ReceivedDate { get; set; }
public decimal ShippingCost { get; set; } = 0;
public decimal SubTotal { get; set; } = 0;
public decimal TotalAmount { get; set; } = 0;
public string? Notes { get; set; }
public string? InternalNotes { get; set; }
// Optional link to a Bill created after receipt
public int? BillId { get; set; }
public virtual Bill? Bill { get; set; }
public virtual ICollection<PurchaseOrderItem> Items { get; set; } = new List<PurchaseOrderItem>();
}
public class PurchaseOrderItem : BaseEntity
{
public int PurchaseOrderId { get; set; }
public virtual PurchaseOrder PurchaseOrder { get; set; } = null!;
// Null for custom/non-inventory line items
public int? InventoryItemId { get; set; }
public virtual InventoryItem? InventoryItem { get; set; }
// Used when InventoryItemId is null
public string? Description { get; set; }
public string? UnitOfMeasure { get; set; }
public decimal QuantityOrdered { get; set; }
public decimal QuantityReceived { get; set; } = 0;
public decimal UnitCost { get; set; }
public decimal LineTotal { get; set; }
public string? Notes { get; set; }
}
+103
View File
@@ -0,0 +1,103 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Quote : BaseEntity
{
public string QuoteNumber { get; set; } = string.Empty;
public int? CustomerId { get; set; } // Nullable to support quotes for prospects
public string? PreparedById { get; set; } // Changed from int? to string? for Identity FK
// Prospect Contact Information (used when CustomerId is null)
public string? ProspectCompanyName { get; set; }
public string? ProspectContactName { get; set; }
public string? ProspectEmail { get; set; }
public string? ProspectPhone { get; set; }
public string? ProspectAddress { get; set; }
public string? ProspectCity { get; set; }
public string? ProspectState { get; set; }
public string? ProspectZipCode { get; set; }
// Lookup foreign key (replacing enum)
public int QuoteStatusId { get; set; }
// Selected oven for this quote (null = use company default rate)
public int? OvenCostId { get; set; }
// Oven batch pricing
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; } // null = use company DefaultOvenCycleMinutes
public bool IsCommercial { get; set; }
public bool IsRushJob { get; set; } = false;
// Dates
public DateTime QuoteDate { get; set; } = DateTime.UtcNow;
public DateTime? ExpirationDate { get; set; }
public DateTime? SentDate { get; set; }
public DateTime? ApprovedDate { get; set; }
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
public decimal LaborCosts { get; set; } // Sum of labor 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 OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount
public decimal OverheadPercent { get; set; } // Overhead percentage used
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
public decimal ProfitPercent { get; set; } // Profit margin percentage used
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
// Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None;
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 DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal RushFee { get; set; } = 0;
public decimal Total { get; set; }
// Deposit — require deposit payment before work begins
public bool RequiresDeposit { get; set; } = false;
public decimal DepositPercent { get; set; } = 0; // e.g. 50 = 50% deposit required
// Online deposit payment (Stripe Connect)
public string? DepositPaymentLinkToken { get; set; }
public DateTime? DepositPaymentLinkExpiresAt { get; set; }
public decimal DepositAmountPaid { get; set; } = 0;
public string? DepositPaymentIntentId { get; set; }
// Additional Information
public string? Description { get; set; }
public string? Terms { get; set; }
public string? Notes { get; set; }
public string? CustomerPO { get; set; }
public string? Tags { get; set; }
// Conversion tracking
public int? ConvertedToJobId { get; set; }
public DateTime? ConvertedDate { get; set; }
// Customer self-service approval
public string? ApprovalToken { get; set; }
public DateTime? ApprovalTokenExpiresAt { get; set; }
public DateTime? ApprovalTokenUsedAt { get; set; }
public string? DeclineReason { get; set; }
public string? DeclinedByIp { get; set; }
// Relationships
public virtual OvenCost? OvenCost { get; set; }
public virtual Customer? Customer { get; set; }
public virtual ApplicationUser? PreparedBy { get; set; }
public virtual QuoteStatusLookup QuoteStatus { get; set; } = null!;
public virtual ICollection<QuoteItem> QuoteItems { get; set; } = new List<QuoteItem>();
public virtual ICollection<QuotePrepService> QuotePrepServices { get; set; } = new List<QuotePrepService>();
public virtual Job? ConvertedToJob { get; set; }
public virtual ICollection<QuotePhoto> QuotePhotos { get; set; } = new List<QuotePhoto>();
}
@@ -0,0 +1,16 @@
namespace PowderCoating.Core.Entities;
public class QuoteChangeHistory : BaseEntity
{
public int QuoteId { get; set; }
public string? ChangedByUserId { get; set; }
public DateTime ChangedAt { get; set; } = DateTime.UtcNow;
public string FieldName { get; set; } = string.Empty;
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string ChangeDescription { get; set; } = string.Empty;
// Navigation properties
public Quote Quote { get; set; } = null!;
public ApplicationUser? ChangedBy { get; set; }
}
@@ -0,0 +1,59 @@
namespace PowderCoating.Core.Entities;
public class QuoteItem : BaseEntity
{
public int QuoteId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
// Measurements (optional for quoting)
public decimal? SurfaceArea { get; set; }
public decimal SurfaceAreaSqFt { get; set; } // Surface area in square feet for pricing
// Catalog item reference (optional)
public int? CatalogItemId { get; set; } // Link to catalog item (optional)
// Pricing
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
// Cost breakdown snapshot (set at save time for breakdown display)
public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; }
public decimal ItemEquipmentCost { get; set; }
public bool IsGenericItem { get; set; }
public decimal? ManualUnitPrice { get; set; }
public decimal? PowderCostOverride { get; set; } // Optional unit price override for catalog items
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public string? Sku { get; set; }
// Processing estimates
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
public int EstimatedMinutes { get; set; }
public string? Notes { get; set; }
// Part complexity level — applies a price multiplier for calculated items
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
public string? Complexity { get; set; }
// True when this item was generated via AI photo analysis.
// Must be persisted so that recalculations (Details view, Edit view) can
// honour ManualUnitPrice instead of running the regular pricing engine.
public bool IsAiItem { get; set; }
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
public string? AiTags { get; set; }
// Link to shared AI prediction record (null for non-AI items)
public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; }
// Relationships
public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
public virtual ICollection<QuoteItemCoat> Coats { get; set; } = new List<QuoteItemCoat>();
public virtual ICollection<QuoteItemPrepService> PrepServices { get; set; } = new List<QuoteItemPrepService>();
}
@@ -0,0 +1,43 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents a single coating layer applied to a quote item.
/// Supports multi-coat configurations (primer, base coat, top coat, clear coat, etc.)
/// </summary>
public class QuoteItemCoat : BaseEntity
{
// Parent relationship
public int QuoteItemId { get; set; }
// Coat identification (user-defined)
public string CoatName { get; set; } = string.Empty; // "Primer", "Base Coat", "Top Coat", etc.
public int Sequence { get; set; } // 1, 2, 3... for ordering
// Powder selection (same pattern as current QuoteItem)
public int? InventoryItemId { get; set; } // In-stock powder
public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc.
public string? Finish { get; set; } // Gloss, Matte, Textured, etc.
// Coverage parameters (defaults from inventory or user-specified)
public decimal CoverageSqFtPerLb { get; set; } = 30m;
public decimal TransferEfficiency { get; set; } = 65m;
// Cost override for custom powder
public decimal? PowderCostPerLb { get; set; } // $/lb for custom orders
public decimal? PowderToOrder { get; set; } // Pounds to order (rounded up from needed)
// Calculated costs (stored for audit trail)
public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
// Notes
public string? Notes { get; set; }
// Navigation properties
public virtual QuoteItem QuoteItem { get; set; } = null!;
public virtual InventoryItem? InventoryItem { get; set; }
public virtual Vendor? Vendor { get; set; }
}
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Entities;
public class QuoteItemPrepService : BaseEntity
{
public int QuoteItemId { get; set; }
public int PrepServiceId { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Which blast setup was selected when this sandblasting prep service was added.</summary>
public int? BlastSetupId { get; set; }
public virtual QuoteItem QuoteItem { get; set; } = null!;
public virtual PrepService PrepService { get; set; } = null!;
public virtual CompanyBlastSetup? BlastSetup { get; set; }
}
@@ -0,0 +1,18 @@
namespace PowderCoating.Core.Entities;
public class QuotePhoto : BaseEntity
{
public int? QuoteId { get; set; } // null while in temp/pending state
public string TempId { get; set; } = string.Empty; // GUID key used before quote is saved
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; } = string.Empty;
public string? Caption { get; set; }
public bool IsAiAnalysisPhoto { get; set; } = true;
public string? UploadedById { get; set; }
// Relationships
public virtual Quote? Quote { get; set; }
public virtual ApplicationUser? UploadedBy { get; set; }
}
@@ -0,0 +1,14 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Join table for many-to-many relationship between Quote and PrepService
/// </summary>
public class QuotePrepService : BaseEntity
{
public int QuoteId { get; set; }
public int PrepServiceId { get; set; }
// Navigation properties
public virtual Quote Quote { get; set; } = null!;
public virtual PrepService PrepService { get; set; } = null!;
}
@@ -0,0 +1,77 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Company-specific quote status lookup table.
/// Replaces hardcoded QuoteStatus enum to enable workflow customization.
/// </summary>
public class QuoteStatusLookup : BaseEntity
{
/// <summary>
/// Immutable status code used in code logic (e.g., "DRAFT", "APPROVED", "CONVERTED").
/// Acts like the old enum name.
/// </summary>
public string StatusCode { get; set; } = string.Empty;
/// <summary>
/// User-customizable display name shown in UI (e.g., "Pending Approval", "Accepted by Customer").
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Display order for sorting statuses in dropdowns and reports.
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// Bootstrap color class for badge styling (primary, secondary, success, danger, warning, info, dark).
/// Eliminates switch statement duplication for status colors.
/// </summary>
public string ColorClass { get; set; } = "secondary";
/// <summary>
/// Optional Bootstrap icon class (e.g., "bi-file-earmark", "bi-check-circle").
/// </summary>
public string? IconClass { get; set; }
/// <summary>
/// Whether this status is currently active and available for selection.
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// System-defined statuses cannot be deleted.
/// </summary>
public bool IsSystemDefined { get; set; } = true;
/// <summary>
/// CRITICAL BUSINESS RULE: Exactly ONE status per company must be marked as the "approved" status.
/// Only quotes with this status can be converted to jobs.
/// Enforced by validation in CompanySettingsController.
/// </summary>
public bool IsApprovedStatus { get; set; } = false;
/// <summary>
/// CRITICAL BUSINESS RULE: Exactly ONE status per company must be marked as the "converted" status.
/// This status is automatically set after a quote is successfully converted to a job.
/// </summary>
public bool IsConvertedStatus { get; set; } = false;
/// <summary>
/// Indicates the default draft status (enables expiration date logic, etc.).
/// </summary>
public bool IsDraftStatus { get; set; } = false;
/// <summary>
/// CRITICAL BUSINESS RULE: Marks the rejected/declined status.
/// Used by the customer self-service approval portal to set the quote status when a customer declines.
/// </summary>
public bool IsRejectedStatus { get; set; } = false;
/// <summary>
/// Optional description explaining when to use this status.
/// </summary>
public string? Description { get; set; }
// Navigation properties
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
}
+33
View File
@@ -0,0 +1,33 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Records a refund issued to a customer against an invoice.
/// Does not move actual money — the shop issues the refund manually.
/// </summary>
public class Refund : BaseEntity
{
public int InvoiceId { get; set; }
public int? PaymentId { get; set; } // Specific payment being refunded (optional)
public decimal Amount { get; set; }
public DateTime RefundDate { get; set; } = DateTime.UtcNow;
public PaymentMethod RefundMethod { get; set; }
public string Reason { get; set; } = string.Empty;
public string? Reference { get; set; } // Check #, transaction ID, etc.
public string? Notes { get; set; }
public RefundStatus Status { get; set; } = RefundStatus.Pending;
public DateTime? IssuedDate { get; set; }
public string? IssuedById { get; set; }
// For store-credit refunds: the CreditMemo created on their behalf
public int? CreditMemoId { get; set; }
// Navigation
public virtual Invoice Invoice { get; set; } = null!;
public virtual Payment? Payment { get; set; }
public virtual ApplicationUser? IssuedBy { get; set; }
public virtual CreditMemo? CreditMemo { get; set; }
}
@@ -0,0 +1,30 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform release notes / changelog entries published by SuperAdmins.
/// Not a BaseEntity — platform-wide, not per-tenant.
/// </summary>
public class ReleaseNote
{
public int Id { get; set; }
/// <summary>Semantic version string, e.g. "1.4.0"</summary>
public string Version { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
/// <summary>Markdown-formatted release notes body</summary>
public string Body { get; set; } = string.Empty;
/// <summary>Display category tag, e.g. "Feature", "Improvement", "Fix", "Breaking"</summary>
public string Tag { get; set; } = "Feature";
public bool IsPublished { get; set; } = false;
public DateTime ReleasedAt { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
public string? CreatedByUserId { get; set; }
public string? CreatedByUserName { get; set; }
}
@@ -0,0 +1,44 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Tracks defect, warranty, or damage rework on a job.
/// The original job owns the cost; a new linked job (ReworkJobId) handles the shop floor workflow.
/// </summary>
public class ReworkRecord : BaseEntity
{
// ── Links ─────────────────────────────────────────────────────────────────
public int JobId { get; set; }
public int? JobItemId { get; set; } // null = whole job; set = specific item
// Optional new job created to do the redo work on the shop floor
public int? ReworkJobId { get; set; }
// ── Classification ────────────────────────────────────────────────────────
public ReworkType ReworkType { get; set; }
public ReworkReason Reason { get; set; }
public string DefectDescription { get; set; } = string.Empty;
// ── Discovery ─────────────────────────────────────────────────────────────
public ReworkDiscoveredBy DiscoveredBy { get; set; }
public DateTime DiscoveredDate { get; set; } = DateTime.UtcNow;
public string? ReportedByName { get; set; } // Customer name if external report
// ── Cost & Billing ────────────────────────────────────────────────────────
public decimal EstimatedReworkCost { get; set; }
public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
// ── Resolution ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; }
public DateTime? ResolvedDate { get; set; }
public string? ResolutionNotes { get; set; }
// ── Navigation ────────────────────────────────────────────────────────────
public virtual Job Job { get; set; } = null!;
public virtual JobItem? JobItem { get; set; }
public virtual Job? ReworkJob { get; set; }
}
@@ -0,0 +1,18 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class ShopWorker : BaseEntity
{
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
// Relationships
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
}
@@ -0,0 +1,15 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Optional per-role labor cost rate for job costing / profitability calculations.
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
/// </summary>
public class ShopWorkerRoleCost : BaseEntity
{
public ShopWorkerRole Role { get; set; }
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
public decimal HourlyRate { get; set; }
}
@@ -0,0 +1,38 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Persists every incoming Stripe webhook event for auditing and debugging.
/// Not a BaseEntity — platform-wide, no soft delete, no tenant filter.
/// </summary>
public class StripeWebhookEvent
{
public long Id { get; set; }
/// <summary>Stripe event ID (evt_...).</summary>
public string EventId { get; set; } = string.Empty;
/// <summary>Stripe event type string (e.g. customer.subscription.updated).</summary>
public string EventType { get; set; } = string.Empty;
/// <summary>Linked company, if resolvable from the event payload.</summary>
public int? CompanyId { get; set; }
/// <summary>Full raw JSON body of the event.</summary>
public string RawJson { get; set; } = string.Empty;
public StripeWebhookEventStatus Status { get; set; } = StripeWebhookEventStatus.Received;
public string? ErrorMessage { get; set; }
public DateTime ReceivedAt { get; set; } = DateTime.UtcNow;
public DateTime? ProcessedAt { get; set; }
}
public enum StripeWebhookEventStatus
{
Received = 0,
Processed = 1,
Failed = 2,
Ignored = 3
}
@@ -0,0 +1,51 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores plan limits and pricing in the database so SuperAdmins can edit without a code deploy.
/// Global (not per-company). CompanyId is set to 0 / unused. No tenant query filter applied.
/// The Plan integer is the identifier that links Company.SubscriptionPlan to a config row.
/// </summary>
public class SubscriptionPlanConfig : BaseEntity
{
public int Plan { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>-1 = unlimited</summary>
public int MaxUsers { get; set; }
/// <summary>-1 = unlimited</summary>
public int MaxActiveJobs { get; set; }
/// <summary>-1 = unlimited</summary>
public int MaxCustomers { get; set; }
/// <summary>-1 = unlimited. Counts active (non-terminal) quotes.</summary>
public int MaxQuotes { get; set; } = -1;
/// <summary>-1 = unlimited. Counts total non-deleted catalog items.</summary>
public int MaxCatalogItems { get; set; } = -1;
/// <summary>-1 = unlimited. Max job photos per job.</summary>
public int MaxJobPhotos { get; set; } = -1;
/// <summary>-1 = unlimited. Max general (non-AI) photos per quote.</summary>
public int MaxQuotePhotos { get; set; } = -1;
/// <summary>-1 = unlimited. 0 = AI photo quotes disabled for this plan. Monthly limit per company.</summary>
public int MaxAiPhotoQuotesPerMonth { get; set; } = -1;
public decimal MonthlyPrice { get; set; }
public decimal AnnualPrice { get; set; }
public string? StripePriceIdMonthly { get; set; }
public string? StripePriceIdAnnual { get; set; }
/// <summary>When true, companies on this plan can connect Stripe and accept online invoice payments.</summary>
public bool AllowOnlinePayments { get; set; } = false;
/// <summary>When true, companies on this plan can access accounting features: Chart of Accounts, Bills, Expenses, and Accounting Export.</summary>
public bool AllowAccounting { get; set; } = false;
/// <summary>When true, companies on this plan can use AI Photo Quote analysis (subject to MaxAiPhotoQuotesPerMonth).</summary>
public bool AllowAiPhotoQuotes { get; set; } = false;
/// <summary>When true, companies on this plan can use the AI Inventory Assist lookup feature.</summary>
public bool AllowAiInventoryAssist { get; set; } = false;
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,175 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class Vendor : BaseEntity
{
public string CompanyName { get; set; } = string.Empty;
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? Country { get; set; } = "USA";
public string? Website { get; set; }
// Business Information
public string? AccountNumber { get; set; }
public string? TaxId { get; set; }
public string? PaymentTerms { get; set; }
public decimal? CreditLimit { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public bool IsPreferred { get; set; } = false;
// Accounts Payable tracking
/// <summary>Running AP balance: increases with new bills, decreases with payments.</summary>
public decimal CurrentBalance { get; set; } = 0;
/// <summary>Balance owed at go-live (for migrating existing vendors).</summary>
public decimal OpeningBalance { get; set; } = 0;
public DateTime? OpeningBalanceDate { get; set; }
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
public int? DefaultExpenseAccountId { get; set; }
// Navigation
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; }
}
public class InventoryTransaction : BaseEntity
{
public int InventoryItemId { get; set; }
public InventoryTransactionType TransactionType { get; set; }
public decimal Quantity { get; set; }
public decimal UnitCost { get; set; }
public decimal TotalCost { get; set; }
public DateTime TransactionDate { get; set; } = DateTime.UtcNow;
public string? Reference { get; set; } // PO number, Job number, etc.
public string? Notes { get; set; }
public decimal BalanceAfter { get; set; }
// Optional FK to the PO that generated this purchase transaction
public int? PurchaseOrderId { get; set; }
public virtual PurchaseOrder? PurchaseOrder { get; set; }
// Optional FK to the job this material was used on (set by QR scan / manual log)
public int? JobId { get; set; }
public virtual Job? Job { get; set; }
public virtual InventoryItem InventoryItem { get; set; } = null!;
}
public class JobPhoto : BaseEntity
{
public int JobId { get; set; }
public virtual Job Job { get; set; } = null!;
/// <summary>
/// Relative path from media folder (e.g., "1/job-photos/5/1.jpg")
/// </summary>
public string FilePath { get; set; } = string.Empty;
/// <summary>
/// Original filename when uploaded
/// </summary>
public string FileName { get; set; } = string.Empty;
/// <summary>
/// File size in bytes
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// Content type (e.g., "image/jpeg")
/// </summary>
public string ContentType { get; set; } = string.Empty;
/// <summary>
/// User-provided caption/note for the photo
/// </summary>
public string? Caption { get; set; }
/// <summary>
/// Type of photo (Before, After, Progress, QualityCheck, Issue, Completed)
/// </summary>
public JobPhotoType PhotoType { get; set; } = JobPhotoType.Progress;
/// <summary>
/// Display order for sorting photos
/// </summary>
public int DisplayOrder { get; set; }
/// <summary>
/// User who uploaded the photo
/// </summary>
public string UploadedById { get; set; } = string.Empty;
public virtual ApplicationUser? UploadedBy { get; set; }
public DateTime UploadedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Comma-separated tags for this photo (e.g., colors used, finish type)
/// </summary>
public string? Tags { get; set; }
/// <summary>
/// True for photos copied from an AI quote analysis — excluded from subscription photo limits.
/// </summary>
public bool IsAiAnalysisPhoto { get; set; }
}
public class JobNote : BaseEntity
{
public int JobId { get; set; }
public string Note { get; set; } = string.Empty;
public bool IsImportant { get; set; }
public bool IsInternal { get; set; } = true; // Don't show to customer
public virtual Job Job { get; set; } = null!;
}
public class CustomerNote : BaseEntity
{
public int CustomerId { get; set; }
public string Note { get; set; } = string.Empty;
public bool IsImportant { get; set; }
public virtual Customer Customer { get; set; } = null!;
}
public class JobStatusHistory : BaseEntity
{
public int JobId { get; set; }
// Lookup foreign keys (replacing enums)
public int FromStatusId { get; set; }
public int ToStatusId { get; set; }
public DateTime ChangedDate { get; set; } = DateTime.UtcNow;
public string? Notes { get; set; }
// Navigation properties
public virtual Job Job { get; set; } = null!;
public virtual JobStatusLookup FromStatus { get; set; } = null!;
public virtual JobStatusLookup ToStatus { get; set; } = null!;
}
public class PricingTier : BaseEntity
{
public string TierName { get; set; } = string.Empty;
public string? Description { get; set; }
public decimal DiscountPercent { get; set; }
public bool IsActive { get; set; } = true;
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
}
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Immutable audit record written once at registration. Never updated or deleted.
/// </summary>
public class TermsAcceptance
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public int CompanyId { get; set; }
public string TosVersion { get; set; } = string.Empty;
public DateTime AcceptedAt { get; set; } = DateTime.UtcNow;
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
}