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,135 @@
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Atomic double-entry bookkeeping primitives used across InvoicesController, BillsController,
/// and ExpensesController to keep the chart-of-accounts <see cref="PowderCoating.Core.Entities.Account.CurrentBalance"/>
/// current after every financial transaction.
/// </summary>
public class AccountBalanceService : IAccountBalanceService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILedgerService _ledgerService;
private readonly ILogger<AccountBalanceService> _logger;
/// <summary>
/// Constructs the service with the required unit-of-work, ledger service for balance
/// recalculation, and logger for error reporting during batch recalculations.
/// </summary>
public AccountBalanceService(
IUnitOfWork unitOfWork,
ILedgerService ledgerService,
ILogger<AccountBalanceService> logger)
{
_unitOfWork = unitOfWork;
_ledgerService = ledgerService;
_logger = logger;
}
/// <summary>
/// Records a debit against the specified account and persists the updated balance.
/// For debit-normal accounts (Assets, Expenses, COGS) a debit increases the balance;
/// for credit-normal accounts (Liabilities, Equity, Revenue) it decreases it.
/// Silently no-ops when <paramref name="accountId"/> is null or <paramref name="amount"/> is zero
/// so callers do not need null-guards for optional account mappings.
/// </summary>
public async Task DebitAsync(int? accountId, decimal amount)
{
if (accountId == null || amount == 0) return;
var account = await _unitOfWork.Accounts.GetByIdAsync(accountId.Value);
if (account == null) return;
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
/// <summary>
/// Records a credit against the specified account and persists the updated balance.
/// For credit-normal accounts (Liabilities, Equity, Revenue) a credit increases the balance;
/// for debit-normal accounts (Assets, Expenses, COGS) it decreases it.
/// Silently no-ops when <paramref name="accountId"/> is null or <paramref name="amount"/> is zero.
/// </summary>
public async Task CreditAsync(int? accountId, decimal amount)
{
if (accountId == null || amount == 0) return;
var account = await _unitOfWork.Accounts.GetByIdAsync(accountId.Value);
if (account == null) return;
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
/// <summary>
/// Recomputes and persists <c>CurrentBalance</c> for every active account in the company
/// by replaying all ledger activity via <see cref="LedgerService.GetAccountLedgerAsync"/>.
/// Uses the epoch date 2000-01-01 as the range start so that LedgerService treats
/// <c>OpeningBalance</c> as a prior balance and all real transactions fall within the window.
/// Errors on individual accounts are logged and swallowed so a single bad account does not
/// abort the full recalculation.
/// </summary>
public async Task RecalculateAllAsync(int companyId)
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
// Use a date range that covers all possible transactions.
// LedgerService computes opening balance separately, so starting from 2000-01-01
// means priorBalance = OpeningBalance + 0 (no transactions before that date),
// and all real transactions fall inside the period window.
var from = new DateTime(2000, 1, 1);
var to = DateTime.UtcNow;
foreach (var account in accounts)
{
try
{
var ledger = await _ledgerService.GetAccountLedgerAsync(account.Id, from, to);
if (ledger != null)
{
account.CurrentBalance = ledger.ClosingBalance;
await _unitOfWork.Accounts.UpdateAsync(account);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recalculating balance for account {AccountId} ({AccountNumber})",
account.Id, account.AccountNumber);
}
}
await _unitOfWork.CompleteAsync();
}
/// <summary>
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
/// decide the direction of the balance adjustment.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
_ => false
};
}
@@ -0,0 +1,905 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
public class AccountingAiService : IAccountingAiService
{
private readonly IConfiguration _config;
private readonly ILogger<AccountingAiService> _logger;
private const string Model = "claude-sonnet-4-6";
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Initializes a new instance of <see cref="AccountingAiService"/> with the application
/// configuration and logger. The Anthropic API key is read lazily per call via
/// <see cref="GetApiKey"/> so that configuration changes at runtime (e.g. via user secrets)
/// are picked up without restarting the application.
/// </summary>
public AccountingAiService(IConfiguration config, ILogger<AccountingAiService> logger)
{
_config = config;
_logger = logger;
}
// ── Helper ────────────────────────────────────────────────────────────────
/// <summary>
/// Reads the Anthropic API key from configuration and returns null if it is absent or
/// still set to the placeholder value. Returning null (instead of throwing) allows each
/// feature method to return a friendly error result rather than crash, which is important
/// when the key has not yet been configured in a fresh tenant environment.
/// </summary>
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
/// <summary>
/// Removes the optional <c>```json … ```</c> markdown code fence that Claude (and other
/// models) sometimes wrap around JSON responses despite being instructed not to. Stripping
/// it here lets downstream <see cref="System.Text.Json.JsonSerializer.Deserialize"/> calls
/// succeed without requiring each feature method to duplicate the cleanup logic.
/// </summary>
private static string StripJsonFences(string text)
{
// Remove optional ```json ... ``` wrapper that some models add
var trimmed = text.Trim();
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0)
trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```"))
trimmed = trimmed[..^3];
}
return trimmed.Trim();
}
// Uses raw HTTP to call the Anthropic API with a document (PDF) content block,
// since the SDK 4.0.0 does not yet have DocumentContent support.
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) };
/// <summary>
/// Sends a Claude API request with a 60-second hard timeout enforced via a
/// <see cref="CancellationTokenSource"/>. The SDK itself does not provide a built-in
/// per-call timeout, so wrapping every call here prevents AI slowdowns from holding
/// up request threads indefinitely. Callers catch <see cref="OperationCanceledException"/>
/// and return a user-friendly error result.
/// </summary>
private static async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
/// <summary>
/// Sends a multimodal document (PDF) message to the Anthropic API using raw HTTP rather
/// than the SDK, because Anthropic SDK 4.0.0 does not yet expose a <c>DocumentContent</c>
/// block type. The document is base64-encoded and sent as an Anthropic-native document
/// content block alongside a text instruction. The method extracts the first text block
/// from the response envelope and returns it as a plain string for downstream JSON parsing.
/// </summary>
private static async Task<string> ScanWithRawHttpAsync(
string apiKey, string systemPrompt, string userText, byte[] fileData, string mediaType)
{
var payload = new
{
model = Model,
max_tokens = 1024,
system = systemPrompt,
messages = new[]
{
new
{
role = "user",
content = new object[]
{
new
{
type = "document",
source = new
{
type = "base64",
media_type = mediaType,
data = Convert.ToBase64String(fileData)
}
},
new { type = "text", text = userText }
}
}
}
};
var json = JsonSerializer.Serialize(payload);
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages");
request.Headers.Add("x-api-key", apiKey);
request.Headers.Add("anthropic-version", "2023-06-01");
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await _http.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var content = doc.RootElement
.GetProperty("content")[0]
.GetProperty("text")
.GetString() ?? "";
return content;
}
// ── Feature 1: Receipt Scanning ───────────────────────────────────────────
/// <summary>
/// Analyzes a receipt or invoice image (JPEG, PNG, GIF, WebP, or PDF) using Claude vision
/// and returns structured extraction data: vendor name, date, total, invoice number, and
/// line items with suggested expense account mappings.
///
/// PDFs are routed through <see cref="ScanWithRawHttpAsync"/> because the Anthropic SDK
/// 4.0.0 does not support the document content block type required for PDF input. Image
/// formats use the standard SDK image content block. The system prompt embeds the company's
/// full chart of accounts as JSON so Claude can return a concrete <c>suggestedAccountId</c>
/// for each line item rather than a free-text category name, which avoids a second round-trip
/// lookup. Account matching rules are kept broad (materials, utilities, equipment, freight,
/// office) since receipts in a powder coating shop follow predictable categories.
/// </summary>
public async Task<ReceiptScanResult> ScanReceiptAsync(
byte[] imageData,
string mimeType,
List<AccountSummary> availableAccounts)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new ReceiptScanResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var accountsJson = JsonSerializer.Serialize(availableAccounts.Select(a => new
{
a.Id,
a.AccountNumber,
a.Name,
a.AccountType,
a.AccountSubType
}));
var systemPrompt = @"You are a receipt/invoice scanner for a powder coating business accounting system.
Analyze the provided receipt or invoice image and extract all data.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""vendorName"": ""string or null"",
""date"": ""ISO 8601 date (YYYY-MM-DD) or null"",
""total"": number or null,
""invoiceNumber"": ""string or null"",
""lineItems"": [
{
""description"": ""string"",
""amount"": number,
""suggestedAccountId"": number or null,
""suggestedAccountName"": ""string or null""
}
]
}
Account matching rules:
- Materials, powder, supplies → match to a CostOfGoods or Materials account
- Utilities (electricity, gas, water) → match to a Utilities expense account
- Equipment, tools → match to Equipment or Supplies account
- Freight, shipping → match to Freight or Shipping account
- Office supplies → match to Office Supplies account
- If unsure, leave suggestedAccountId null
Use the provided accounts list to find the best match ID.";
var userText = $"Please scan this receipt/invoice and extract all data.\n\nAvailable accounts:\n{accountsJson}";
string rawText1;
var normalizedMime = mimeType.ToLowerInvariant();
if (normalizedMime == "application/pdf")
{
// PDFs use the Anthropic document API (not supported by SDK 4.0.0, use raw HTTP)
rawText1 = await ScanWithRawHttpAsync(apiKey, systemPrompt, userText, imageData, "application/pdf");
}
else
{
var imageMime = normalizedMime switch
{
"image/jpg" => "image/jpeg",
"image/jpeg" => "image/jpeg",
"image/png" => "image/png",
"image/gif" => "image/gif",
"image/webp" => "image/webp",
_ => "image/jpeg"
};
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new ImageContent
{
Source = new ImageSource
{
MediaType = imageMime,
Data = Convert.ToBase64String(imageData)
}
},
new TextContent { Text = userText }
}
}
}
};
var response = await SendAsync(client, messageParams);
rawText1 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
}
if (string.IsNullOrWhiteSpace(rawText1))
return new ReceiptScanResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText1);
var parsed = JsonSerializer.Deserialize<ClaudeReceiptResponse>(raw, JsonOpts);
if (parsed == null)
return new ReceiptScanResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new ReceiptScanResult
{
Success = true,
VendorName = parsed.VendorName,
Date = parsed.Date,
Total = parsed.Total,
InvoiceNumber = parsed.InvoiceNumber,
LineItems = parsed.LineItems.Select(li => new ScannedLineItem
{
Description = li.Description,
Amount = li.Amount,
SuggestedAccountId = li.SuggestedAccountId,
SuggestedAccountName = li.SuggestedAccountName
}).ToList()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI receipt scan timed out after 60 seconds");
return new ReceiptScanResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning receipt with AI");
return new ReceiptScanResult { Success = false, ErrorMessage = "An error occurred while scanning the receipt." };
}
}
// ── Feature 2: AR Follow-up Email Drafts ─────────────────────────────────
/// <summary>
/// Drafts a professional accounts-receivable follow-up email for one or more overdue
/// invoices using Claude, with the tone calibrated automatically to the aging bucket:
/// gentle for ≤30 days, firm for 3160 days, and serious/urgent for 61+ days. This
/// avoids a one-size-fits-all message that would either annoy recent late payers or
/// under-communicate urgency to severely overdue accounts. The prompt explicitly forbids
/// placeholder text (e.g., "[phone number]") so the drafted email is ready to send as-is
/// without the user needing to fill in blanks. All invoice numbers and the total owing
/// are included so the customer has full context without needing to call in.
/// </summary>
public async Task<ArFollowUpResult> DraftFollowUpEmailAsync(ArFollowUpRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new ArFollowUpResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var tone = request.DaysOverdue <= 30
? "gentle and friendly — a polite reminder"
: request.DaysOverdue <= 60
? "firm and professional — clearly requesting action"
: "serious and direct — emphasizing urgency and potential consequences";
var invoiceList = string.Join("\n", request.Invoices.Select(i =>
$" - Invoice {i.InvoiceNumber}: ${i.Amount:F2} ({i.DaysOverdue} days overdue)"));
var systemPrompt = @"You are an accounts receivable specialist for a powder coating business.
Draft professional follow-up emails for overdue invoices.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""subject"": ""string"",
""body"": ""string (plain text, use \n for line breaks)""
}";
var userPrompt = $@"Draft a follow-up email for an overdue account. Tone: {tone}.
Customer: {request.CustomerName}
Our company: {request.CompanyName}
Total amount owed: ${request.AmountOwed:F2}
Days overdue: {request.DaysOverdue} days
Outstanding invoices:
{invoiceList}
Requirements:
- Address the customer by name
- List all invoice numbers
- State the total amount due clearly
- Match the tone: {tone}
- Sign off with {request.CompanyName} — Accounts Receivable
- Keep it concise (under 200 words for body)
- Do NOT include any placeholders like [phone number] — omit contact details if unknown";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText2 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText2))
return new ArFollowUpResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText2);
var parsed = JsonSerializer.Deserialize<ClaudeArEmailResponse>(raw, JsonOpts);
if (parsed == null)
return new ArFollowUpResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new ArFollowUpResult
{
Success = true,
Subject = parsed.Subject,
Body = parsed.Body
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI AR follow-up draft timed out after 60 seconds");
return new ArFollowUpResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error drafting AR follow-up email with AI");
return new ArFollowUpResult { Success = false, ErrorMessage = "An error occurred while drafting the email." };
}
}
// ── Feature 3: Smart Account Categorization ───────────────────────────────
/// <summary>
/// Suggests the best-matching expense account from the company's chart of accounts for
/// a single bill line item, given vendor name, description, and amount. Returns the
/// primary suggestion plus up to three ranked alternatives with confidence scores and
/// one-sentence reasoning for each. Alternatives with confidence below 0.3 are suppressed
/// to avoid presenting noise as options. This is called on blur of each line-item
/// description field in the Bills and Expenses forms; a keyword cache in the calling
/// controller avoids repeated API calls for identical descriptions within the same session.
/// The chart of accounts is serialized and sent in the user prompt (not the system prompt)
/// so that it varies per company without requiring a per-company system prompt build.
/// </summary>
public async Task<AccountSuggestionResult> SuggestAccountAsync(AccountSuggestionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new AccountSuggestionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var accountsJson = JsonSerializer.Serialize(request.AvailableAccounts.Select(a => new
{
a.Id,
a.AccountNumber,
a.Name,
a.AccountType,
a.AccountSubType
}));
var systemPrompt = @"You are an accounting expert for a powder coating business.
Given a vendor, description, and amount, suggest the best matching expense account from the provided list.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""suggestedAccountId"": number or null,
""suggestedAccountName"": ""string or null"",
""reasoning"": ""string — one sentence explaining the match"",
""alternatives"": [
{
""accountId"": number,
""accountName"": ""string"",
""confidence"": number (0.0 to 1.0),
""reasoning"": ""string""
}
]
}
Return up to 3 alternatives (not including the primary suggestion).
Confidence: 1.0 = perfect match, 0.0 = no match. Only include alternatives with confidence > 0.3.";
var userPrompt = new StringBuilder();
userPrompt.AppendLine("Suggest the best expense account for this transaction:");
if (!string.IsNullOrWhiteSpace(request.VendorName))
userPrompt.AppendLine($"Vendor: {request.VendorName}");
if (!string.IsNullOrWhiteSpace(request.Description))
userPrompt.AppendLine($"Description: {request.Description}");
userPrompt.AppendLine($"Amount: ${request.Amount:F2}");
userPrompt.AppendLine();
userPrompt.AppendLine("Available accounts:");
userPrompt.AppendLine(accountsJson);
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt.ToString() } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText3 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText3))
return new AccountSuggestionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText3);
var parsed = JsonSerializer.Deserialize<ClaudeAccountSuggestionResponse>(raw, JsonOpts);
if (parsed == null)
return new AccountSuggestionResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new AccountSuggestionResult
{
Success = true,
SuggestedAccountId = parsed.SuggestedAccountId,
SuggestedAccountName = parsed.SuggestedAccountName,
Reasoning = parsed.Reasoning,
Alternatives = parsed.Alternatives.Select(a => new AccountSuggestion
{
AccountId = a.AccountId,
AccountName = a.AccountName,
Confidence = a.Confidence,
Reasoning = a.Reasoning
}).ToList()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI account suggestion timed out after 60 seconds");
return new AccountSuggestionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error suggesting account with AI");
return new AccountSuggestionResult { Success = false, ErrorMessage = "An error occurred while suggesting an account." };
}
}
// ── Feature 4: Plain-English Financial Summary ────────────────────────────
/// <summary>
/// Generates a short plain-English financial health summary (46 bullet points) and an
/// overall sentiment classification ("positive", "neutral", or "concerning") from
/// month-to-date financial metrics. Only the top 8 expense categories by amount are sent
/// to Claude to keep the prompt compact and focus attention on material line items rather
/// than minor cost buckets. Revenue change versus the prior month is pre-calculated as a
/// percentage before being included in the prompt because Claude handles numeric reasoning
/// better when the computation is already done. The system prompt explicitly bans markdown
/// formatting inside bullets so the output renders cleanly in the HTML card view without
/// additional sanitization. Sentiment is validated server-side against the three allowed
/// values and defaults to "neutral" if Claude returns anything outside the expected set.
/// </summary>
public async Task<FinancialSummaryResult> GenerateFinancialSummaryAsync(FinancialSummaryRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new FinancialSummaryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a financial advisor for a powder coating business.
Analyze the provided financial data and generate a plain-English summary.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""bullets"": [""string"", ...], // 4 to 6 plain-English bullet points
""sentiment"": ""positive"" | ""neutral"" | ""concerning""
}
Rules:
- Each bullet must be one clear sentence in plain English — no accounting jargon
- Cover revenue trends, expense levels, AR health, and any notable risks or opportunities
- sentiment = ""positive"" if the business is clearly profitable and AR is healthy
- sentiment = ""concerning"" if net income is negative, AR overdue is high, or expenses grew significantly
- sentiment = ""neutral"" otherwise
- Do NOT use markdown formatting inside the bullets (no **bold**, no #headings)";
var expensesJson = JsonSerializer.Serialize(request.ExpensesByCategory
.OrderByDescending(e => e.Amount)
.Take(8)
.Select(e => new { e.Category, Amount = e.Amount }));
var revenueChange = request.PriorMonthRevenue > 0
? (request.TotalRevenue - request.PriorMonthRevenue) / request.PriorMonthRevenue * 100m
: 0m;
var userPrompt = $@"Generate a financial health summary for {request.CompanyName}.
Period: {request.Period}
Total Revenue: ${request.TotalRevenue:F2}
Total Expenses: ${request.TotalExpenses:F2}
Net Income: ${request.NetIncome:F2}
Prior Month Revenue: ${request.PriorMonthRevenue:F2}
Prior Month Expenses: ${request.PriorMonthExpenses:F2}
Revenue Change vs Prior Month: {revenueChange:F1}%
Accounts Receivable:
- Total Outstanding: ${request.TotalArOutstanding:F2}
- Amount Overdue >30 days: ${request.ArOverdue30Days:F2}
- Overdue Invoice Count: {request.OverdueInvoiceCount}
Top Expense Categories:
{expensesJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText4 = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText4))
return new FinancialSummaryResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText4);
var parsed = JsonSerializer.Deserialize<ClaudeFinancialSummaryResponse>(raw, JsonOpts);
if (parsed == null)
return new FinancialSummaryResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validSentiments = new[] { "positive", "neutral", "concerning" };
var sentiment = validSentiments.Contains(parsed.Sentiment?.ToLowerInvariant())
? parsed.Sentiment!.ToLowerInvariant()
: "neutral";
return new FinancialSummaryResult
{
Success = true,
Bullets = parsed.Bullets ?? new List<string>(),
Sentiment = sentiment
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI financial summary timed out after 60 seconds");
return new FinancialSummaryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating financial summary with AI");
return new FinancialSummaryResult { Success = false, ErrorMessage = "An error occurred while generating the summary." };
}
}
// ── Feature 5: Cash Flow Forecast ────────────────────────────────────────
/// <summary>
/// Generates a 30/60/90-day cash flow forecast from open AR invoices, outstanding vendor
/// bills, and the active job pipeline. The three data sets are serialized as JSON and
/// passed verbatim in the user prompt so Claude can reason over the actual invoice due
/// dates and amounts rather than summary statistics. Active jobs are included in the
/// 6090 day window because jobs nearing completion represent probable inflows that would
/// not appear in open AR yet. The <c>MaxTokens</c> is raised to 1500 (versus 1024 for
/// other features) to accommodate the larger structured output with three period objects
/// and multiple key-item arrays. The outlook enum ("strong", "moderate", "tight",
/// "concerning") is validated server-side and defaults to "moderate" on unexpected values.
/// </summary>
public async Task<CashFlowForecastResult> GenerateCashFlowForecastAsync(CashFlowForecastRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new CashFlowForecastResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a cash flow analyst for a powder coating business.
Given open AR invoices, outstanding vendor bills, and active jobs in the pipeline, project the business's
30, 60, and 90-day cash position.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""next30Days"": {
""expectedInflows"": number,
""expectedOutflows"": number,
""netCashFlow"": number,
""keyItems"": [""string"", ...]
},
""next60Days"": { same structure },
""next90Days"": { same structure },
""insights"": [""string"", ...],
""outlook"": ""strong"" | ""moderate"" | ""tight"" | ""concerning""
}
Rules:
- Base inflow estimates on invoice due dates and typical days-to-pay patterns provided
- Include pipeline jobs as probable inflows in the 60-90 day window (jobs near completion sooner)
- Base outflow estimates on bill due dates
- keyItems: 2-4 specific items driving the period's numbers (e.g. ""$3,200 from Acme Corp invoice INV-2601-0012 due in 15 days"")
- insights: 3-5 plain-English observations about cash position, timing risks, or opportunities
- outlook: overall 90-day cash health assessment
- All dollar amounts must be numbers (not strings)
- Do NOT include currency symbols inside number fields";
var arJson = JsonSerializer.Serialize(request.OpenInvoices);
var apJson = JsonSerializer.Serialize(request.OpenBills);
var jobJson = JsonSerializer.Serialize(request.ActiveJobs);
var userPrompt = $@"Generate a 30/60/90-day cash flow forecast for {request.CompanyName} as of {request.AsOfDate}.
Open AR Invoices (money coming in):
{arJson}
Outstanding Bills / AP (money going out):
{apJson}
Active Job Pipeline (expected future invoicing):
{jobJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new CashFlowForecastResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeCashFlowResponse>(raw, JsonOpts);
if (parsed == null)
return new CashFlowForecastResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validOutlooks = new[] { "strong", "moderate", "tight", "concerning" };
var outlook = validOutlooks.Contains(parsed.Outlook?.ToLowerInvariant())
? parsed.Outlook!.ToLowerInvariant()
: "moderate";
return new CashFlowForecastResult
{
Success = true,
Next30Days = new CashFlowPeriod
{
ExpectedInflows = parsed.Next30Days.ExpectedInflows,
ExpectedOutflows = parsed.Next30Days.ExpectedOutflows,
NetCashFlow = parsed.Next30Days.NetCashFlow,
KeyItems = parsed.Next30Days.KeyItems ?? new()
},
Next60Days = new CashFlowPeriod
{
ExpectedInflows = parsed.Next60Days.ExpectedInflows,
ExpectedOutflows = parsed.Next60Days.ExpectedOutflows,
NetCashFlow = parsed.Next60Days.NetCashFlow,
KeyItems = parsed.Next60Days.KeyItems ?? new()
},
Next90Days = new CashFlowPeriod
{
ExpectedInflows = parsed.Next90Days.ExpectedInflows,
ExpectedOutflows = parsed.Next90Days.ExpectedOutflows,
NetCashFlow = parsed.Next90Days.NetCashFlow,
KeyItems = parsed.Next90Days.KeyItems ?? new()
},
Insights = parsed.Insights ?? new(),
Outlook = outlook
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI cash flow forecast timed out after 60 seconds");
return new CashFlowForecastResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating cash flow forecast with AI");
return new CashFlowForecastResult { Success = false, ErrorMessage = "An error occurred while generating the forecast." };
}
}
// ── Feature 6: Anomaly / Duplicate Detection ─────────────────────────────
/// <summary>
/// Scans the last 90 days of vendor bills for four categories of anomaly: potential
/// duplicate payments (same vendor and near-identical amount within 30 days, or repeated
/// invoice number), amount spikes (a bill more than 2.5× the vendor's average), unusual
/// new vendors (first-time vendor with a bill over $500), and account overruns (an expense
/// account running more than 50% above its historical monthly average). Severity levels
/// are critical (duplicates, extreme overruns), warning (spikes, unusual vendors, moderate
/// overruns), and info. The thresholds (2.5×, 50%, $500) are embedded in the system prompt
/// and were chosen to reduce false positives in a typical powder coating shop where
/// occasional large material orders are normal. When no anomalies are found Claude is
/// instructed to return an empty flags array rather than null, preventing null-reference
/// errors on the <c>flags</c> enumeration. Severity is normalized to lowercase after
/// parsing to guard against Claude casing inconsistencies (e.g., "Critical" vs "critical").
/// </summary>
public async Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new AnomalyDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are an accounting auditor for a powder coating business.
Analyze the provided bills, vendor history, and account trends for anomalies.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""flags"": [
{
""type"": ""duplicate"" | ""amount_spike"" | ""unusual_vendor"" | ""account_overrun"",
""severity"": ""critical"" | ""warning"" | ""info"",
""title"": ""string — short headline"",
""description"": ""string — 1-2 sentences explaining what was found"",
""recommendedAction"": ""string — one clear action the user should take, or null"",
""billNumber"": ""string or null — bill number if this flag relates to a specific bill""
}
]
}
Detection rules:
- duplicate: same vendor + same or near-identical amount within 30 days, or same vendor invoice number appearing more than once
- amount_spike: a single bill is more than 2.5x that vendor's average invoice amount
- unusual_vendor: a vendor that has never appeared before (not in history) with a bill over $500
- account_overrun: an expense account running more than 50% above its average monthly spend this month
- severity critical: duplicates (risk of double payment), account_overrun > 100% over average
- severity warning: amount_spike, account_overrun 50-100% over average, unusual_vendor
- severity info: minor anomalies worth noting but low risk
- Only flag real anomalies — do not flag items when data is insufficient to draw a conclusion
- If no anomalies are found, return an empty flags array";
var billsJson = JsonSerializer.Serialize(request.RecentBills);
var historyJson = JsonSerializer.Serialize(request.VendorHistory);
var trendsJson = JsonSerializer.Serialize(request.AccountTrends);
var userPrompt = $@"Analyze the following data for anomalies for {request.CompanyName}.
Recent Bills (last 90 days):
{billsJson}
Vendor History (averages):
{historyJson}
Account Spend Trends (this month vs historical):
{trendsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new AnomalyDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeAnomalyResponse>(raw, JsonOpts);
if (parsed == null)
return new AnomalyDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var flags = (parsed.Flags ?? new()).Select(f => new AnomalyFlag
{
Type = f.Type,
Severity = f.Severity?.ToLowerInvariant() ?? "warning",
Title = f.Title,
Description = f.Description,
RecommendedAction = f.RecommendedAction,
BillNumber = f.BillNumber
}).ToList();
return new AnomalyDetectionResult
{
Success = true,
Flags = flags,
CriticalCount = flags.Count(f => f.Severity == "critical"),
WarningCount = flags.Count(f => f.Severity == "warning"),
InfoCount = flags.Count(f => f.Severity == "info")
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI anomaly detection timed out after 60 seconds");
return new AnomalyDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running anomaly detection with AI");
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
}
}
}
@@ -0,0 +1,309 @@
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Platform-level email notification service that alerts SuperAdmin recipients about
/// key lifecycle events: new company registrations, bug reports, subscription expirations,
/// and grace-period transitions.
/// </summary>
/// <remarks>
/// Admin email addresses are read from the <c>AdminNotificationEmail</c> platform setting
/// (a comma-separated list). If that setting is absent or empty, all notification methods
/// silently no-op rather than throwing, so a mis-configured setting never breaks the
/// action that triggered the notification.
///
/// This service sends platform-level alerts only — it does NOT send company-facing
/// emails (invoices, quote approvals, etc.), which are handled by <c>IEmailService</c>
/// directly from the relevant controllers.
/// </remarks>
public class AdminNotificationService : IAdminNotificationService
{
private readonly IEmailService _emailService;
private readonly IPlatformSettingsService _platformSettings;
private readonly ILogger<AdminNotificationService> _logger;
public AdminNotificationService(
IEmailService emailService,
IPlatformSettingsService platformSettings,
ILogger<AdminNotificationService> logger)
{
_emailService = emailService;
_platformSettings = platformSettings;
_logger = logger;
}
/// <summary>
/// Sends an email notification to all configured SuperAdmin addresses when a new tenant
/// company completes registration, including company ID, plan name, and primary contact.
/// </summary>
/// <param name="companyId">Database ID of the newly created company.</param>
/// <param name="companyName">Display name of the new company.</param>
/// <param name="planName">Subscription plan the company signed up for.</param>
/// <param name="contactName">Full name of the registration contact.</param>
/// <param name="contactEmail">Email address of the registration contact.</param>
public async Task NotifyNewCompanyRegisteredAsync(
int companyId, string companyName, string planName,
string contactName, string contactEmail)
{
var adminEmails = await GetAdminEmailsAsync();
if (adminEmails == null) return;
var subject = $"[New Signup] {companyName}";
var html = $"""
<h2>New Company Registered</h2>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Company:</strong></td><td>{Encode(companyName)} (ID: {companyId})</td></tr>
<tr><td><strong>Plan:</strong></td><td>{Encode(planName)}</td></tr>
<tr><td><strong>Contact Name:</strong></td><td>{Encode(contactName)}</td></tr>
<tr><td><strong>Contact Email:</strong></td><td>{Encode(contactEmail)}</td></tr>
<tr><td><strong>Registered:</strong></td><td>{DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC</td></tr>
</table>
""";
var plain = $"New Company Registered\n\n" +
$"Company: {companyName} (ID: {companyId})\n" +
$"Plan: {planName}\n" +
$"Contact: {contactName} <{contactEmail}>\n" +
$"Registered: {DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC";
await SendAsync(adminEmails, subject, plain, html);
}
/// <summary>
/// Sends an email notification to all configured SuperAdmin addresses when a user submits
/// a bug report, including the report ID, title, priority, submitting user, and full
/// description body so admins can triage without logging in.
/// </summary>
/// <param name="bugReportId">Database ID of the new bug report.</param>
/// <param name="title">Short title of the bug report.</param>
/// <param name="description">Full description text entered by the user.</param>
/// <param name="priority">Priority label (e.g. Low, Medium, High, Critical).</param>
/// <param name="submittedByName">Display name of the user who submitted the report.</param>
/// <param name="companyName">Name of the company the submitting user belongs to.</param>
public async Task NotifyBugReportSubmittedAsync(
int bugReportId, string title, string description,
string priority, string submittedByName, string companyName)
{
var adminEmails = await GetAdminEmailsAsync();
if (adminEmails == null) return;
var subject = $"[Bug Report] {title}";
var html = $"""
<h2>New Bug Report Submitted</h2>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Report #:</strong></td><td>{bugReportId}</td></tr>
<tr><td><strong>Title:</strong></td><td>{Encode(title)}</td></tr>
<tr><td><strong>Priority:</strong></td><td>{Encode(priority)}</td></tr>
<tr><td><strong>Submitted By:</strong></td><td>{Encode(submittedByName)}</td></tr>
<tr><td><strong>Company:</strong></td><td>{Encode(companyName)}</td></tr>
<tr><td><strong>Submitted:</strong></td><td>{DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC</td></tr>
</table>
<h3>Description</h3>
<p style="white-space:pre-wrap;">{Encode(description)}</p>
""";
var plain = $"New Bug Report #{bugReportId}\n\n" +
$"Title: {title}\n" +
$"Priority: {priority}\n" +
$"Submitted By: {submittedByName}\n" +
$"Company: {companyName}\n" +
$"Submitted: {DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC\n\n" +
$"Description:\n{description}";
await SendAsync(adminEmails, subject, plain, html);
}
/// <summary>
/// Sends an email notification to all configured SuperAdmin addresses when a company's
/// subscription has expired and its account has been deactivated.
/// </summary>
/// <param name="companyId">Database ID of the expired company.</param>
/// <param name="companyName">Display name of the company.</param>
/// <param name="contactEmail">Primary contact email for the company.</param>
/// <param name="expiredOn">The UTC date on which the subscription expired.</param>
public async Task NotifyCompanyExpiredAsync(
int companyId, string companyName, string contactEmail, DateTime expiredOn)
{
var adminEmails = await GetAdminEmailsAsync();
if (adminEmails == null) return;
var subject = $"[Subscription Expired] {companyName}";
var html = $"""
<h2>Company Subscription Expired</h2>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Company:</strong></td><td>{Encode(companyName)} (ID: {companyId})</td></tr>
<tr><td><strong>Contact Email:</strong></td><td>{Encode(contactEmail)}</td></tr>
<tr><td><strong>Expired On:</strong></td><td>{expiredOn:MMMM d, yyyy}</td></tr>
</table>
<p>This company has been deactivated and is no longer able to log in.</p>
""";
var plain = $"Company Subscription Expired\n\n" +
$"Company: {companyName} (ID: {companyId})\n" +
$"Contact Email: {contactEmail}\n" +
$"Expired On: {expiredOn:MMMM d, yyyy}\n\n" +
$"This company has been deactivated.";
await SendAsync(adminEmails, subject, plain, html);
}
/// <summary>
/// Sends an email notification to all configured SuperAdmin addresses when a company
/// enters a subscription grace period, including the grace-period end date so admins
/// know when the account will auto-deactivate if not renewed.
/// </summary>
/// <param name="companyId">Database ID of the company entering grace period.</param>
/// <param name="companyName">Display name of the company.</param>
/// <param name="contactEmail">Primary contact email for the company.</param>
/// <param name="gracePeriodEndsOn">UTC date on which the grace period expires.</param>
public async Task NotifyCompanyGracePeriodAsync(
int companyId, string companyName, string contactEmail, DateTime gracePeriodEndsOn)
{
var adminEmails = await GetAdminEmailsAsync();
if (adminEmails == null) return;
var subject = $"[Grace Period Started] {companyName}";
var html = $"""
<h2>Company Entered Grace Period</h2>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Company:</strong></td><td>{Encode(companyName)} (ID: {companyId})</td></tr>
<tr><td><strong>Contact Email:</strong></td><td>{Encode(contactEmail)}</td></tr>
<tr><td><strong>Grace Period Ends:</strong></td><td>{gracePeriodEndsOn:MMMM d, yyyy}</td></tr>
</table>
<p>If not renewed by the grace period end date, this company will be automatically deactivated.</p>
""";
var plain = $"Company Entered Grace Period\n\n" +
$"Company: {companyName} (ID: {companyId})\n" +
$"Contact Email: {contactEmail}\n" +
$"Grace Period Ends: {gracePeriodEndsOn:MMMM d, yyyy}\n\n" +
$"If not renewed, this company will be automatically deactivated.";
await SendAsync(adminEmails, subject, plain, html);
}
/// <summary>
/// Sends an email to all configured SuperAdmin addresses when a user submits the
/// Contact Us form, including the sender's name, email, company, category, subject,
/// and full message so admins can respond directly from their email client.
/// </summary>
public async Task NotifyContactFormSubmittedAsync(
string senderName, string senderEmail, string companyName,
string category, string subject, string message)
{
var adminEmails = await GetAdminEmailsAsync();
if (adminEmails == null) return;
var emailSubject = $"[Contact Us] {category} — {subject}";
var html = $"""
<h2>Contact Us Form Submission</h2>
<table cellpadding="6" style="border-collapse:collapse;">
<tr><td><strong>Name:</strong></td><td>{Encode(senderName)}</td></tr>
<tr><td><strong>Email:</strong></td><td><a href="mailto:{Encode(senderEmail)}">{Encode(senderEmail)}</a></td></tr>
<tr><td><strong>Company:</strong></td><td>{Encode(companyName)}</td></tr>
<tr><td><strong>Category:</strong></td><td>{Encode(category)}</td></tr>
<tr><td><strong>Subject:</strong></td><td>{Encode(subject)}</td></tr>
<tr><td><strong>Submitted:</strong></td><td>{DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC</td></tr>
</table>
<h3>Message</h3>
<p style="white-space:pre-wrap;">{Encode(message)}</p>
""";
var plain = $"Contact Us Form Submission\n\n" +
$"Name: {senderName}\n" +
$"Email: {senderEmail}\n" +
$"Company: {companyName}\n" +
$"Category: {category}\n" +
$"Subject: {subject}\n" +
$"Submitted: {DateTime.UtcNow:MM/dd/yyyy h:mm tt} UTC\n\n" +
$"Message:\n{message}";
await SendAsync(adminEmails, emailSubject, plain, html, replyToEmail: senderEmail, replyToName: senderName);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/// <summary>
/// Reads the <c>AdminNotificationEmail</c> platform setting and returns a list of valid
/// email addresses, or <c>null</c> if the setting is absent, blank, or contains no
/// parseable addresses.
/// </summary>
/// <remarks>
/// Returning <c>null</c> (rather than an empty list) is intentional: callers use a
/// null-check early-return pattern (<c>if (adminEmails == null) return;</c>) which is
/// cleaner than checking <c>Count == 0</c> in each notification method. A DEBUG-level
/// log entry is written so that missing configuration is detectable without generating
/// noise at WARNING or above in normal operation.
/// </remarks>
/// <returns>Non-empty list of email strings, or <c>null</c> if none are configured.</returns>
private async Task<List<string>?> GetAdminEmailsAsync()
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AdminNotificationEmail);
if (string.IsNullOrWhiteSpace(raw))
{
_logger.LogDebug("PlatformSetting '{Key}' is not configured — skipping admin notification.",
PlatformSettingKeys.AdminNotificationEmail);
return null;
}
var emails = raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(e => e.Contains('@'))
.ToList();
if (emails.Count == 0)
{
_logger.LogDebug("PlatformSetting '{Key}' contained no valid addresses — skipping.",
PlatformSettingKeys.AdminNotificationEmail);
return null;
}
return emails;
}
/// <summary>
/// Sends the notification email to each address in <paramref name="adminEmails"/> via
/// <see cref="IEmailService"/>, logging a warning for any individual send failure without
/// re-throwing so that a bad address does not prevent delivery to the remaining recipients.
/// </summary>
/// <param name="adminEmails">Validated list of recipient email addresses.</param>
/// <param name="subject">Email subject line.</param>
/// <param name="plain">Plain-text fallback body.</param>
/// <param name="html">HTML-formatted body shown by modern email clients.</param>
private async Task SendAsync(List<string> adminEmails, string subject, string plain, string html,
string? replyToEmail = null, string? replyToName = null)
{
foreach (var email in adminEmails)
{
var (success, error) = await _emailService.SendEmailAsync(
toEmail: email,
toName: "PCL Admin",
subject: subject,
plainTextBody: plain,
htmlBody: html,
replyToEmail: replyToEmail,
replyToName: replyToName);
if (!success)
_logger.LogWarning("Admin notification email failed for {Email} ({Subject}): {Error}", email, subject, error);
else
_logger.LogInformation("Admin notification sent to {Email}: {Subject}", email, subject);
}
}
/// <summary>
/// HTML-encodes a string for safe embedding in email HTML bodies, guarding against
/// XSS if user-supplied values (company names, titles, descriptions) contain angle
/// brackets or other special characters. Null input is treated as empty string.
/// </summary>
private static string Encode(string? value) =>
System.Net.WebUtility.HtmlEncode(value ?? string.Empty);
}
@@ -0,0 +1,106 @@
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
public class AiHelpService : IAiHelpService
{
private readonly ILogger<AiHelpService> _logger;
private readonly AnthropicClient? _client;
private const int MaxHistoryTurns = 10; // keep last 10 exchanges to limit tokens
private const string Model = "claude-sonnet-4-6";
/// <summary>
/// Initializes <see cref="AiHelpService"/> and eagerly creates the
/// <see cref="AnthropicClient"/> if the API key is configured. The client is stored as a
/// nullable field rather than throwing at construction time so that the rest of the
/// application continues to function when the AI key is absent — the null is checked in
/// <see cref="SendMessageAsync"/> and returns a user-friendly message instead of a 500
/// error. The client is constructed once here (rather than per call) because
/// <see cref="AnthropicClient"/> is thread-safe and reuse avoids the overhead of
/// recreating the HTTP client on each chat message.
/// </summary>
public AiHelpService(IConfiguration config, ILogger<AiHelpService> logger)
{
_logger = logger;
var apiKey = config["AI:Anthropic:ApiKey"];
if (!string.IsNullOrWhiteSpace(apiKey))
_client = new AnthropicClient(apiKey);
}
/// <summary>
/// Sends a user message to Claude with the full conversation history and the
/// <c>HelpKnowledgeBase</c>-sourced system prompt, then returns the assistant's plain-text
/// response. The conversation history is trimmed to the last
/// <see cref="MaxHistoryTurns"/> × 2 messages (10 exchanges = 20 turns) before being
/// sent to keep prompt tokens within budget — older context is dropped because users
/// asking help questions rarely need to reference what they said more than 10 turns ago,
/// and the system prompt already contains the full knowledge base as grounding. A 60-second
/// cancellation token is applied to prevent slow AI responses from holding an ASP.NET
/// request thread indefinitely; the controller's <c>[EnableRateLimiting]</c> attribute
/// provides the outer rate-limit layer so this method does not need to enforce it. When
/// the API key is not configured, a friendly "not configured" message is returned so
/// that users see a clear explanation rather than an unhandled exception or empty response.
/// </summary>
public async Task<string> SendMessageAsync(
List<AiHelpMessage> conversationHistory,
string userMessage,
string systemPrompt)
{
if (_client == null)
{
_logger.LogWarning("AI Help: Anthropic API key not configured.");
return "The AI Help Assistant is not configured yet. Please contact your system administrator.";
}
try
{
var trimmedHistory = conversationHistory
.TakeLast(MaxHistoryTurns * 2)
.ToList();
var messages = trimmedHistory
.Select(m => new Message
{
Role = m.Role == "user" ? RoleType.User : RoleType.Assistant,
Content = new List<ContentBase> { new TextContent { Text = m.Content } }
})
.ToList();
messages.Add(new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userMessage } }
});
var request = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = messages
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var response = await _client.Messages.GetClaudeMessageAsync(request, cts.Token);
return response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "I wasn't able to generate a response. Please try again.";
}
catch (OperationCanceledException)
{
_logger.LogWarning("AI Help: Request timed out.");
return "The request took too long to process. Please try again.";
}
catch (Exception ex)
{
_logger.LogError(ex, "AI Help: Error calling Anthropic API.");
return "I encountered an error processing your request. Please try again in a moment.";
}
}
}
@@ -0,0 +1,578 @@
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public class AiQuoteService : IAiQuoteService
{
private readonly IConfiguration _config;
private readonly ILogger<AiQuoteService> _logger;
/// <summary>
/// The core system prompt that defines Claude's role as a powder coating estimator.
/// It specifies the exact JSON schema the model must return, complexity definitions,
/// surface-area estimation rules, and material-specific active-labor guidance for cast
/// iron, cast aluminum, galvanized steel, and heavy steel. The prompt deliberately
/// excludes oven dwell time from <c>estimatedMinutes</c> because cure time is added at
/// the quote level to avoid double-charging when multiple items share the same oven cycle.
/// Material-specific preheat/outgassing handling is separated into <c>requiresPreheat</c>
/// and <c>preheatMinutes</c> fields so the pricing engine can charge a distinct extra oven
/// cost only for materials that truly need it. The tags list is a closed enumeration to
/// prevent free-text values that would break tag-based filtering in future reporting.
/// </summary>
private const string BaseSystemPrompt = @"You are an expert powder coating estimator. Your job is to analyze photos of items to be powder coated and provide accurate estimates.
When analyzing images, you must respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation, just the JSON:
{
""description"": ""string - concise item name (e.g., 'Steel bracket', 'Aluminum wheel rim', '5-piece railing section')"",
""surfaceAreaSqFt"": number - estimated total surface area in square feet per single item (use the reference dimension provided),
""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per single item covering: sandblasting/media blasting, chemical stripping (if needed), masking, racking/hanging, powder application, unracking, and inspection. DO NOT include oven cure/dwell time — that is priced separately. Only count minutes where a worker is actively doing something.
""requiresPreheat"": boolean - true if this material needs a dedicated outgassing or preheat oven cycle BEFORE coating (cast iron, cast aluminum, galvanized steel, wrought iron). False for standard steel, aluminum sheet/extrusion, stainless.
""preheatMinutes"": number - duration of the preheat/outgassing oven cycle in minutes. Set to 0 if requiresPreheat is false. Typical values: cast iron 45-60, cast aluminum 30-45, galvanized 30-45.
""confidence"": ""Low"" | ""Medium"" | ""High"",
""needsFollowUp"": boolean,
""followUpQuestion"": ""string or null"",
""reasoning"": ""string - brief explanation of your estimate"",
""tags"": [""string""] - 1 to 3 tags from ONLY this fixed list: automotive, railing, furniture, structural, flat-panel, tubular, ornamental, architectural, industrial, agricultural, equipment, enclosure, signage, bracket, gate, wheel, frame, sheet-metal
}
Complexity guide (affects both prep difficulty and coating time):
- Simple: flat panels, basic shapes, easy access to all surfaces, minimal masking needed
- Moderate: moderate curves, some recessed areas, standard brackets, light masking
- Complex: intricate geometry, deep recesses, welded assemblies, many surfaces, significant masking or stripping needed
- Extreme: highly ornate, very deep cavities, extreme surface variation, heavy prep and masking required
Surface area estimation:
- Use the reference dimension provided to calibrate your estimate
- Account for ALL surfaces that need coating (front, back, sides, edges)
- For hollow items (tubes, pipes), include interior only if specified
- Express as sq ft per single item
MATERIAL-SPECIFIC CONSIDERATIONS — these directly affect estimatedMinutes and complexity:
Cast Iron (active labor only — exclude oven dwell):
- Outgassing preheat setup: worker loads piece into oven, waits for outgassing, unloads — count ~10 min active handling, NOT the full oven cycle
- Porous surface needs aggressive sandblasting/media blasting — add 15-30 min depending on size
- Heavy pieces (30+ lbs) require two-person handling for racking/unracking — add 10-15 min vs lighter items
- Minimum complexity for cast iron is Moderate; anything with relief detail, fins, or recesses is Complex or Extreme
- Typical active labor for cast iron: 35-70 min depending on size and detail
Heavy Steel (30+ lbs, active labor only):
- Two-person handling for racking/unracking — add 10-15 min vs lighter items
- Heavier blasting/prep required for thick sections
Cast Aluminum (active labor only):
- Outgassing setup similar to cast iron — add 10 min active handling
- Chemical conversion coat or etch primer often needed — add 15-20 min if applicable
Galvanized Steel (active labor only):
- Must be chemically stripped or heavily blasted to ensure adhesion — add 20-30 min prep
If the user mentions the item is heavy, large, cast iron, or galvanized, ensure estimatedMinutes reflects the extra active prep and handling time for that material.
If you need clarification to give a good estimate, set needsFollowUp=true and provide one specific, answerable question in followUpQuestion.
Only ask follow-up questions if truly needed — prefer to make reasonable assumptions and note them in reasoning.";
/// <summary>
/// Builds the per-request system prompt by appending company-specific context to
/// <see cref="BaseSystemPrompt"/>. If no context is provided, or if both the profile
/// text and accepted-example list are empty, the base prompt is returned as-is to avoid
/// unnecessary string allocation. When context is present, the company's
/// <c>AiContextProfile</c> free-text block (from the AI Profile tab in Company Settings)
/// is appended first, followed by a few-shot calibration section listing previously
/// accepted quote items with their actual surface area, complexity, minutes, and unit
/// price. This two-layer approach lets shop owners nudge the AI toward their shop's
/// real-world pricing without modifying the core prompt — the profile text captures
/// qualitative rules ("we always charge a minimum of 20 min for racking") while the
/// few-shot examples capture quantitative calibration from accepted quotes.
/// </summary>
private static string BuildSystemPrompt(CompanyAiContext? context)
{
if (context == null ||
(string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
return BaseSystemPrompt;
var sb = new StringBuilder(BaseSystemPrompt);
if (!string.IsNullOrWhiteSpace(context.ProfileText))
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate your estimates for this particular shop:");
sb.AppendLine(context.ProfileText.Trim());
}
if (context.AcceptedExamples.Count > 0)
{
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("CALIBRATION EXAMPLES — items this company has previously priced and accepted:");
sb.AppendLine("Use these as reference points to calibrate your surface area, complexity, time, and pricing estimates.");
foreach (var ex in context.AcceptedExamples)
{
var tags = string.IsNullOrWhiteSpace(ex.Tags) ? "" : $" [{ex.Tags}]";
sb.AppendLine($"- {ex.Description}{tags}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, priced at ${ex.FinalUnitPrice:F2}/unit");
}
}
return sb.ToString();
}
/// <summary>
/// Initializes a new instance of <see cref="AiQuoteService"/>. The Anthropic API key is
/// read per call (not cached at construction time) so that configuration updates take
/// effect without restarting the application.
/// </summary>
public AiQuoteService(IConfiguration config, ILogger<AiQuoteService> logger)
{
_config = config;
_logger = logger;
}
/// <summary>
/// Analyzes one or more photos of an item to be powder coated and returns an estimated
/// surface area, complexity, active labor minutes, pricing breakdown, and optional
/// follow-up question. Supports up to two conversation rounds: on the first call the
/// photos are embedded as base64 image content blocks alongside the user prompt; on
/// subsequent calls the prior assistant and user turns are replayed from
/// <see cref="AiAnalyzeItemRequest.ConversationHistory"/> so Claude retains context
/// without the photos being re-uploaded. The round cap of 2 is enforced client-side
/// by forcing <c>NeedsFollowUp = false</c> on round 2 regardless of what Claude returns,
/// preventing an infinite loop if the model keeps asking questions. Pricing is calculated
/// locally in <see cref="CalculatePricingPreview"/> using the company's
/// <see cref="CompanyOperatingCosts"/> rather than asking Claude to price the item,
/// ensuring the estimate is always consistent with the company's configured rates.
/// </summary>
public async Task<AiAnalyzeItemResult> AnalyzeItemAsync(
AiAnalyzeItemRequest request,
List<(byte[] Data, string ContentType, string FileName)> photos,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb,
CompanyAiContext? context = null,
CompanyBlastSetup? selectedBlastSetup = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
try
{
var client = new AnthropicClient(apiKey);
// Build the conversation messages
var messages = new List<Message>();
// Add prior conversation history for follow-up rounds
if (request.ConversationHistory?.Count > 0)
{
foreach (var turn in request.ConversationHistory)
{
messages.Add(new Message
{
Role = turn.Role == "assistant" ? RoleType.Assistant : RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = turn.Content } }
});
}
// Add follow-up answer as latest user message
if (!string.IsNullOrWhiteSpace(request.FollowUpAnswer))
{
messages.Add(new Message
{
Role = RoleType.User,
Content = new List<ContentBase>
{
new TextContent
{
Text = $"Answer to your question: {request.FollowUpAnswer}\n\nPlease now provide your final estimate as JSON."
}
}
});
}
}
else
{
// First call — build the initial user message with photos + context
var contentParts = new List<ContentBase>();
// Attach each photo as base64 image
foreach (var (data, contentType, fileName) in photos)
{
var mediaType = contentType.ToLowerInvariant() switch
{
"image/jpeg" => "image/jpeg",
"image/jpg" => "image/jpeg",
"image/png" => "image/png",
"image/gif" => "image/gif",
"image/webp" => "image/webp",
_ => "image/jpeg"
};
contentParts.Add(new ImageContent
{
Source = new ImageSource
{
MediaType = mediaType,
Data = Convert.ToBase64String(data)
}
});
}
var userText = BuildUserPrompt(request, costs, avgPowderCostPerLb, selectedBlastSetup);
contentParts.Add(new TextContent { Text = userText });
messages.Add(new Message
{
Role = RoleType.User,
Content = contentParts
});
}
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
// Low temperature for deterministic estimation — the prompt already constrains
// the model with exact blast/coating rates, so creativity adds noise, not value.
Temperature = 0.2m,
SystemMessage = BuildSystemPrompt(context),
Messages = messages
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
_logger.LogInformation("Claude AI response for quote analysis: {Response}", rawText.Length > 500 ? rawText[..500] : rawText);
// Parse JSON response
var claudeResult = ParseClaudeResponse(rawText);
if (claudeResult == null)
{
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "AI returned an unexpected response format. Please try again."
};
}
// Determine follow-up round
var currentRound = request.ConversationHistory?.Count > 0 ? 2 : 1;
// Build updated conversation history
var newHistory = new List<AiConversationTurn>(request.ConversationHistory ?? new List<AiConversationTurn>());
if (request.FollowUpAnswer != null)
newHistory.Add(new AiConversationTurn { Role = "user", Content = $"Answer: {request.FollowUpAnswer}" });
newHistory.Add(new AiConversationTurn { Role = "assistant", Content = rawText });
// If AI wants a follow-up but we've already done 2 rounds, force completion
var needsFollowUp = claudeResult.NeedsFollowUp && currentRound < 2;
if (needsFollowUp)
{
return new AiAnalyzeItemResult
{
Success = true,
NeedsFollowUp = true,
FollowUpQuestion = claudeResult.FollowUpQuestion,
FollowUpRound = currentRound,
ConversationHistory = newHistory
};
}
// Calculate pricing preview using operating costs
var (unitPrice, total, breakdown) = CalculatePricingPreview(claudeResult, request, costs, avgPowderCostPerLb);
return new AiAnalyzeItemResult
{
Success = true,
NeedsFollowUp = false,
Description = claudeResult.Description,
SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
Complexity = claudeResult.Complexity,
EstimatedMinutes = claudeResult.EstimatedMinutes,
AiReasoning = claudeResult.Reasoning,
Confidence = claudeResult.Confidence,
EstimatedUnitPrice = unitPrice,
EstimatedTotal = total,
PowderCostPerLb = avgPowderCostPerLb,
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
ConversationHistory = newHistory,
Tags = claudeResult.Tags,
Breakdown = breakdown
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI request timed out after 60 seconds (quote analysis)");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service did not respond in time. Please try again."
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Claude AI for quote analysis");
return new AiAnalyzeItemResult
{
Success = false,
ErrorMessage = "The AI service encountered an error. Please try again."
};
}
}
/// <summary>
/// Builds the initial user message that accompanies the photo content blocks on the first
/// analysis round. It injects the reference dimension, material type, estimated weight,
/// quantity, desired finish, and coat count so Claude can calibrate surface area and active
/// labor time without asking follow-up questions for common inputs. The company's labor
/// rate and complexity multipliers are included so Claude can ground its reasoning in real
/// shop costs, even though final pricing is computed locally by
/// <see cref="CalculatePricingPreview"/>. The weight line is conditionally included only
/// when a weight is provided, because mentioning "not provided" for weight prompts Claude
/// to ask about it, which wastes a follow-up round for items where weight is irrelevant.
/// </summary>
private static string BuildUserPrompt(AiAnalyzeItemRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb, CompanyBlastSetup? selectedBlastSetup = null)
{
var materialLine = string.IsNullOrWhiteSpace(request.MaterialType)
? "- Material type: Unknown (infer from photo if possible)"
: $"- Material type: {request.MaterialType}";
var weightLine = request.EstimatedWeightLbs.HasValue && request.EstimatedWeightLbs > 0
? $"- Estimated weight: {request.EstimatedWeightLbs:F0} lbs per piece — factor in heavy-handling time and extended heat-soak/cure accordingly"
: "- Estimated weight: Not provided";
// Use the explicitly selected blast setup when provided; fall back to company-level costs.
decimal blastRate;
string blastSetupLabel;
if (selectedBlastSetup != null)
{
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(selectedBlastSetup);
blastSetupLabel = $" ({selectedBlastSetup.Name})";
}
else
{
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
blastSetupLabel = string.Empty;
}
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
// Build a shop-speed context line only when the shop has calibrated their equipment.
// An uncalibrated shop (CFM = 0, no override) gets a generic instruction so the AI
// falls back to industry-average times rather than anchoring on a misleading zero.
string shopSpeedLine;
if (blastRate > 0)
{
shopSpeedLine = $"- THIS SHOP'S blast rate{blastSetupLabel}: ~{blastRate:F0} sqft/hr — use this to derive sandblasting time (surface area ÷ blast rate), NOT generic industry averages";
}
else
{
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
}
var coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr";
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
User-provided context:
- Reference dimension: {request.ReferenceDimension}
{materialLine}
{weightLine}
- Quantity to be coated: {request.Quantity} piece(s)
- Desired color/finish: {request.DesiredColor}
- Number of coating stages: {request.CoatCount}
Company operating costs for your reference:
- Labor rate: ${costs.StandardLaborRate}/hr
- Complexity multipliers — Simple: +{costs.ComplexitySimplePercent}%, Moderate: +{costs.ComplexityModeratePercent}%, Complex: +{costs.ComplexityComplexPercent}%, Extreme: +{costs.ComplexityExtremePercent}%
{shopSpeedLine}
{coatingSpeedLine}
IMPORTANT: For estimatedMinutes, you MUST use this shop's specific blast and coating rates above, not generic industry speeds.
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
Do NOT include oven cure/dwell — that is priced separately.
Estimate the surface area for ONE item. I will multiply by quantity later.
Respond with the JSON object only.";
}
/// <summary>
/// Parses Claude's raw text response into a <see cref="ClaudeQuoteResponse"/> by stripping
/// any surrounding markdown code fences and deserializing the JSON payload. Returns null
/// (rather than throwing) when parsing fails so that <see cref="AnalyzeItemAsync"/> can
/// return a structured error result instead of an unhandled exception. The fence-stripping
/// logic searches for the first <c>{</c> and last <c>}</c> to handle cases where Claude
/// includes a language tag on the fence (e.g., <c>```json</c>).
/// </summary>
private static ClaudeQuoteResponse? ParseClaudeResponse(string rawText)
{
try
{
// Strip any markdown code fences
var json = rawText.Trim();
if (json.StartsWith("```"))
{
var start = json.IndexOf('{');
var end = json.LastIndexOf('}');
if (start >= 0 && end > start)
json = json[start..(end + 1)];
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<ClaudeQuoteResponse>(json, options);
}
catch
{
return null;
}
}
/// <summary>
/// Translates Claude's estimated surface area, complexity, and active-labor minutes into
/// a concrete unit price using the company's configured operating costs. Key design decisions:
///
/// <list type="bullet">
/// <item>Powder consumption uses fixed defaults (30 sq ft/lb coverage, 65% transfer
/// efficiency) because most shops have not entered per-powder efficiency values, and the
/// defaults represent industry averages for electrostatic spray application.</item>
/// <item>Material minimum-minute floors are applied after dividing Claude's total batch
/// minutes by quantity. This prevents the AI from returning, for example, 20 total minutes
/// for a batch of 5 cast-iron pieces when each piece realistically requires at least 90
/// active minutes of preheat setup, blasting, and handling.</item>
/// <item>The standard oven cure cycle is not embedded in the unit price because it is
/// priced at the quote level to avoid double-charging when multiple items share the same
/// oven batch. <c>OvenCost</c> is therefore always 0 in the breakdown returned here.</item>
/// <item>Preheat/outgassing cost is the exception: it is an EXTRA oven cycle unique to
/// this item (cast iron, cast aluminum, galvanized), so it IS included in the unit price.</item>
/// <item>General markup applies only to material costs (powder + consumables), not to
/// labor or oven rates, because labor and oven rates are already set as billable rates.</item>
/// <item>Complexity multiplier is applied to the entire pre-complexity subtotal
/// (materials + labor + preheat) rather than just materials, reflecting the real-world
/// cost of complex work: more masking material, higher risk of rework, slower throughput.</item>
/// <item>Only computed dollar amounts are included in the breakdown — rates and percentages
/// are omitted to avoid exposing proprietary pricing configuration to the client.</item>
/// </list>
/// </summary>
private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricingPreview(
ClaudeQuoteResponse aiResult,
AiAnalyzeItemRequest request,
CompanyOperatingCosts costs,
decimal avgPowderCostPerLb)
{
// Material cost: powder per coat
const decimal defaultCoverage = 30m; // sq ft/lb
const decimal defaultEfficiency = 0.65m; // 65%
var lbsPerCoat = aiResult.SurfaceAreaSqFt > 0
? aiResult.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
: 0m;
var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
var consumablesSurcharge = materialCost * 0.05m;
// Material-type minimum time floors — safety net for AI underestimates.
// These reflect the bare minimum realistic time for each material regardless of what the AI returns.
// Active-labor-only floors (oven dwell excluded from estimatedMinutes per prompt instructions).
var materialMinMinutes = (request.MaterialType ?? "").ToLowerInvariant() switch
{
var m when m.Contains("cast iron") => 90,
var m when m.Contains("cast aluminum") => 70,
var m when m.Contains("galvanized") => 50,
var m when m.Contains("heavy steel") => 40,
var m when m.Contains("wrought iron") => 75,
_ => 0
};
// Labor cost — AI returns total batch minutes, so divide by quantity to get per-item minutes.
// The unit price × quantity must equal the total batch labor cost.
var rawPerItemMinutes = aiResult.EstimatedMinutes / Math.Max(1m, (decimal)request.Quantity);
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
var laborHours = perItemMinutes / 60m;
var laborCost = laborHours * costs.StandardLaborRate;
// Standard cure cycle is handled at the quote level — not embedded here to avoid double-charging.
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
// Material-specific preheat/outgassing cycle — this is an EXTRA oven cycle beyond the standard cure,
// charged at the oven operating rate (not labor rate) directly to this item.
var preheatCost = 0m;
var preheatMinutes = 0;
if (aiResult.RequiresPreheat && aiResult.PreheatMinutes > 0)
{
preheatMinutes = aiResult.PreheatMinutes;
preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
}
// Markup applies to materials only — labor and oven rates are already set as billable rates.
var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
// Complexity multiplier
var complexityPct = aiResult.Complexity switch
{
"Simple" => costs.ComplexitySimplePercent / 100m,
"Moderate" => costs.ComplexityModeratePercent / 100m,
"Complex" => costs.ComplexityComplexPercent / 100m,
"Extreme" => costs.ComplexityExtremePercent / 100m,
_ => 0m
};
var complexityCharge = subtotalBeforeComplexity * complexityPct;
var subtotal = subtotalBeforeComplexity + complexityCharge;
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
// Apply shop minimum
if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
subtotal = costs.ShopMinimumCharge;
var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
var total = unitPrice * request.Quantity;
var breakdown = new AiPricingBreakdown
{
SurfaceAreaSqFt = Math.Round(aiResult.SurfaceAreaSqFt, 2),
PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
CoatCount = request.CoatCount,
MaterialCost = Math.Round(materialCost, 2),
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
EstimatedMinutes = (int)Math.Round(perItemMinutes),
MaterialMinMinutes = materialMinMinutes,
MinFloorApplied = minFloorApplied,
LaborCost = Math.Round(laborCost, 2),
OvenCycleMinutes = ovenCycleMinutes,
OvenCost = 0m,
RequiresPreheat = aiResult.RequiresPreheat,
PreheatMinutes = preheatMinutes,
PreheatCost = Math.Round(preheatCost, 2),
SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
Complexity = aiResult.Complexity,
ComplexityCharge = Math.Round(complexityCharge, 2),
SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
MarkupAmount = Math.Round(markupAmount, 2),
UnitPrice = unitPrice,
};
return (unitPrice, total, breakdown);
}
}
@@ -0,0 +1,341 @@
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Scheduling;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
public class AiSchedulingService : IAiSchedulingService
{
private readonly IConfiguration _config;
private readonly ILogger<AiSchedulingService> _logger;
/// <summary>
/// System prompt that defines the oven batch scheduling rules Claude must follow.
/// Color grouping (rule 1) is the primary optimization because color changeovers require
/// purging the booth and gun, which costs 1530 minutes of downtime per changeover.
/// Temperature separation (rule 2) prevents under-cured or over-cured parts when items
/// with different powder chemistries share a cycle. The 30-minute cool-down between batches
/// on the same oven (rule 8) is a physical constraint — oven doors cannot be opened and
/// reloaded until the element cools enough to avoid flash-curing incoming items. Multi-coat
/// sequencing (rule 4) is strictly enforced in the prompt because violating it (coating
/// before primer cures) produces adhesion failures that require complete rework. The
/// response schema requires both <c>ovenId</c> and <c>ovenName</c> because the OvenCost
/// entity uses its own identity (not the Equipment table), and the name is needed for
/// display in the scheduler UI without an additional lookup.
/// </summary>
private const string SystemPrompt = @"You are an expert powder coating shop scheduler specializing in oven batch optimization.
Your task is to analyze a list of jobs waiting to go into the oven and organize them into optimal batches.
RULES:
1. Group items that share the same powder color (ColorName/ColorCode) together in one batch — this avoids costly color changeover purges between batches.
2. If cure temperatures are specified, never mix items that require significantly different temperatures (>25°F difference) in the same batch.
3. Never exceed the oven's MaxLoadSqFt capacity. If no capacity is set, assume unlimited but note it.
4. Multi-coat jobs (primer then top coat) MUST have their coats in separate batches — primer pass first, top coat pass only after the primer batch is completed.
5. Prioritize Rush and Urgent jobs first. Factor in DueDate — overdue jobs should be in the earliest batch.
6. Respect the optimization goal: maximize_throughput = pack each batch as full as possible; minimize_lateness = schedule by urgency/due date first; minimize_color_changes = minimize the number of distinct color groups across the schedule.
7. If multiple ovens are available, distribute batches across ovens to maximize throughput.
8. Suggest realistic start times starting from 07:00, with cool-down time of 30 minutes between batches on the same oven.
You must respond ONLY with a valid JSON object — no markdown, no explanation, just the JSON:
{
""batches"": [
{
""batchName"": ""string — e.g. 'Batch 1 — Gloss Black (RAL 9005)'"",
""ovenId"": number,
""ovenName"": ""string"",
""suggestedStartTime"": ""HH:mm"",
""estimatedCycleMinutes"": number,
""cureTemperatureF"": number or null,
""estimatedSqFt"": number,
""capacityUtilization"": number (0.0 to 1.0, or null if no capacity set),
""primaryColorName"": ""string or null"",
""primaryColorCode"": ""string or null"",
""rationale"": ""string — 1-2 sentences explaining why these items are grouped"",
""items"": [
{
""jobId"": number,
""jobItemId"": number,
""jobItemCoatId"": number,
""jobNumber"": ""string"",
""description"": ""string"",
""colorName"": ""string or null"",
""colorCode"": ""string or null"",
""surfaceAreaSqFt"": number,
""coatPassNumber"": number,
""coatName"": ""string"",
""priority"": ""string""
}
]
}
],
""summary"": ""string — 2-4 sentences describing the overall schedule and key decisions"",
""warnings"": [""string""] — list any overdue jobs, capacity issues, or missing data (empty array if none)
}";
/// <summary>
/// Initializes a new instance of <see cref="AiSchedulingService"/>. The Anthropic API key
/// is read per call (not at construction time) so that changes to configuration take effect
/// without a service restart.
/// </summary>
public AiSchedulingService(IConfiguration config, ILogger<AiSchedulingService> logger)
{
_config = config;
_logger = logger;
}
/// <summary>
/// Sends the current oven queue to Claude and returns a suggested batch schedule organized
/// by oven, start time, color group, and coat sequence. The request is validated before the
/// API call to return a fast, meaningful error if the job queue is empty. <c>MaxTokens</c>
/// is set to 4096 (the highest of all AI service methods in this application) because
/// scheduling responses grow linearly with the number of jobs and each batch object
/// contains a full item list, rationale, and metadata. The scheduling service does not
/// enforce its own timeout via <see cref="CancellationTokenSource"/> (unlike other AI
/// methods) because the Anthropic SDK's default network timeout is considered sufficient
/// for scheduling requests, which are initiated manually by a manager rather than triggered
/// inline during a customer-facing workflow. Errors from the API propagate as a failed
/// <see cref="BatchScheduleSuggestion"/> with the raw exception message included to aid
/// debugging during shop onboarding.
/// </summary>
public async Task<BatchScheduleSuggestion> SuggestBatchesAsync(BatchSchedulingRequest request)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
if (!request.Jobs.Any())
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = "No jobs in the queue to schedule."
};
}
try
{
var client = new AnthropicClient(apiKey);
var userPrompt = BuildPrompt(request);
_logger.LogInformation("Sending oven scheduling request to Claude: {JobCount} jobs, {OvenCount} ovens",
request.Jobs.Count, request.Ovens.Count);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 4096,
SystemMessage = SystemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
_logger.LogInformation("Claude scheduling response received ({Length} chars)", rawText.Length);
return ParseResponse(rawText);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error calling Claude AI for oven scheduling");
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = $"AI service error: {ex.Message}"
};
}
}
/// <summary>
/// Serializes the available ovens (compact, single-line JSON) and the job queue (indented,
/// multi-line JSON) into a user prompt for Claude. The oven list is compact because it
/// rarely exceeds a handful of entries and does not need human readability. The job list
/// is indented so that Claude can more reliably parse complex nested coat and color
/// structures. Key scheduling constraints are re-stated as "Important notes" in the user
/// prompt (in addition to the system prompt rules) because reinforcing critical constraints
/// in both system and user messages improves Claude's adherence to multi-coat sequencing
/// and the <c>AlreadyBaked</c> skip rule. The note that <c>TotalSqFt</c> is pre-calculated
/// prevents Claude from re-deriving it incorrectly and potentially violating oven capacity.
/// </summary>
private static string BuildPrompt(BatchSchedulingRequest request)
{
var ovensJson = JsonSerializer.Serialize(request.Ovens, new JsonSerializerOptions { WriteIndented = false });
var jobsJson = JsonSerializer.Serialize(request.Jobs, new JsonSerializerOptions { WriteIndented = true });
return $@"Please create an optimized oven batch schedule.
Scheduling date: {request.ScheduleFromDate:yyyy-MM-dd}
Optimization goal: {request.OptimizationGoal}
Available ovens:
{ovensJson}
Jobs ready for the oven:
{jobsJson}
Important notes:
- Items with AlreadyBaked=true have already completed that coat — skip them.
- Items must be scheduled in coat sequence order (sequence 1 before sequence 2, etc.).
- A coat with sequence > 1 can only be scheduled AFTER sequence 1 for the same item is in a completed batch.
- For this schedule, assume all sequence-1 coats can be scheduled now, and plan sequence-2+ coats as separate later batches.
- If an item has no ColorName/ColorCode, it can be batched with any items of similar cure temperature.
- TotalSqFt is pre-calculated as SurfaceAreaSqFt × Quantity.
Respond with the JSON object only.";
}
/// <summary>
/// Deserializes Claude's raw JSON scheduling response into a <see cref="BatchScheduleSuggestion"/>.
/// Markdown code fences are stripped using the same brace-search approach as other AI
/// services. The <c>EstimatedCycleMinutes</c> field defaults to 45 minutes if Claude
/// returns zero or omits it, because 45 minutes is the typical minimum cure cycle for
/// standard powder coatings and an empty value would break the scheduler's timeline
/// rendering. <c>CapacityUtilization</c> defaults to 0 (not null) for display consistency
/// in the UI capacity bar even when no capacity limit is configured. Batch names fall back
/// to "Batch N" using the list index when Claude omits them, ensuring the UI always has a
/// human-readable label. Parsing errors surface the exception message in the error result
/// to aid diagnosing malformed responses during development and onboarding.
/// </summary>
private static BatchScheduleSuggestion ParseResponse(string rawText)
{
try
{
var json = rawText.Trim();
if (json.StartsWith("```"))
{
var start = json.IndexOf('{');
var end = json.LastIndexOf('}');
if (start >= 0 && end > start)
json = json[start..(end + 1)];
}
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var parsed = JsonSerializer.Deserialize<ClaudeSchedulingResponse>(json, options);
if (parsed == null)
return new BatchScheduleSuggestion { Success = false, ErrorMessage = "AI returned an unexpected format." };
return new BatchScheduleSuggestion
{
Success = true,
Summary = parsed.Summary ?? string.Empty,
Warnings = parsed.Warnings ?? new List<string>(),
Batches = (parsed.Batches ?? new List<ClaudeBatch>()).Select(b => new SuggestedBatch
{
BatchName = b.BatchName ?? $"Batch {parsed.Batches!.IndexOf(b) + 1}",
EquipmentId = b.OvenId,
OvenName = b.OvenName ?? string.Empty,
SuggestedStartTime = b.SuggestedStartTime,
EstimatedCycleMinutes = b.EstimatedCycleMinutes > 0 ? b.EstimatedCycleMinutes : 45,
CureTemperatureF = b.CureTemperatureF,
EstimatedSqFt = b.EstimatedSqFt,
CapacityUtilization = b.CapacityUtilization ?? 0,
PrimaryColorName = b.PrimaryColorName,
PrimaryColorCode = b.PrimaryColorCode,
Rationale = b.Rationale ?? string.Empty,
Items = (b.Items ?? new List<ClaudeBatchItem>()).Select(i => new SuggestedBatchItem
{
JobId = i.JobId,
JobItemId = i.JobItemId,
JobItemCoatId = i.JobItemCoatId,
JobNumber = i.JobNumber ?? string.Empty,
Description = i.Description ?? string.Empty,
ColorName = i.ColorName,
ColorCode = i.ColorCode,
SurfaceAreaSqFt = i.SurfaceAreaSqFt,
CoatPassNumber = i.CoatPassNumber,
CoatName = i.CoatName ?? string.Empty,
Priority = i.Priority ?? "Normal"
}).ToList()
}).ToList()
};
}
catch (Exception ex)
{
return new BatchScheduleSuggestion
{
Success = false,
ErrorMessage = $"Failed to parse AI response: {ex.Message}"
};
}
}
// Internal classes for JSON deserialization
/// <summary>
/// Mirrors the top-level JSON object returned by Claude for a scheduling request.
/// Fields are nullable because <see cref="System.Text.Json.JsonSerializer.Deserialize"/> returns
/// null for missing keys rather than throwing, and <see cref="ParseResponse"/> applies safe
/// defaults when these are null.
/// </summary>
private class ClaudeSchedulingResponse
{
public List<ClaudeBatch>? Batches { get; set; }
public string? Summary { get; set; }
public List<string>? Warnings { get; set; }
}
/// <summary>
/// Represents one oven batch as returned by Claude. <c>OvenId</c> maps to
/// <see cref="PowderCoating.Core.Entities.OvenCost"/>.Id (not the Equipment table).
/// <c>CapacityUtilization</c> is nullable because Claude omits it when no oven capacity
/// was configured for the selected oven.
/// </summary>
private class ClaudeBatch
{
public string? BatchName { get; set; }
public int OvenId { get; set; }
public string? OvenName { get; set; }
public string? SuggestedStartTime { get; set; }
public int EstimatedCycleMinutes { get; set; }
public decimal? CureTemperatureF { get; set; }
public decimal EstimatedSqFt { get; set; }
public decimal? CapacityUtilization { get; set; }
public string? PrimaryColorName { get; set; }
public string? PrimaryColorCode { get; set; }
public string? Rationale { get; set; }
public List<ClaudeBatchItem>? Items { get; set; }
}
/// <summary>
/// Represents a single coat pass within a batch as returned by Claude.
/// <c>JobItemCoatId</c> is the primary key used by the OvenScheduler controller to
/// mark the specific coat as queued in the DB, preventing the same coat pass from
/// appearing in future scheduling suggestions.
/// </summary>
private class ClaudeBatchItem
{
public int JobId { get; set; }
public int JobItemId { get; set; }
public int JobItemCoatId { get; set; }
public string? JobNumber { get; set; }
public string? Description { get; set; }
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
public decimal SurfaceAreaSqFt { get; set; }
public int CoatPassNumber { get; set; }
public string? CoatName { get; set; }
public string? Priority { get; set; }
}
}
@@ -0,0 +1,49 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Writes one AiUsageLog row per Anthropic API call using a fresh DI scope so the insert
/// is fully isolated from the caller's Unit-of-Work transaction. Registered as Singleton
/// because IServiceScopeFactory is singleton-safe and we want zero allocation overhead on
/// every AI request.
/// </summary>
public class AiUsageLogger : IAiUsageLogger
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AiUsageLogger> _logger;
public AiUsageLogger(IServiceScopeFactory scopeFactory, ILogger<AiUsageLogger> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc/>
public async Task LogAsync(int companyId, string userId, string feature, bool success = true, int inputLength = 0)
{
try
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
context.AiUsageLogs.Add(new AiUsageLog
{
CompanyId = companyId,
UserId = userId,
Feature = feature,
Success = success,
InputLength = inputLength,
CalledAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI usage log write failed for company {CompanyId} feature {Feature} — non-fatal", companyId, feature);
}
}
}
@@ -0,0 +1,142 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using System.Security.Claims;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Custom claims principal factory that adds company-specific claims to the user's identity
/// </summary>
public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
private readonly ApplicationDbContext _context;
public ApplicationUserClaimsPrincipalFactory(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> options,
ApplicationDbContext context)
: base(userManager, roleManager, options)
{
_context = context;
}
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
{
var identity = await base.GenerateClaimsAsync(user);
// Add CompanyId claim if user has a company
if (user.CompanyId > 0)
{
identity.AddClaim(new Claim("CompanyId", user.CompanyId.ToString()));
// Add subscription plan display name from DB (for display in nav)
var company = await _context.Companies.IgnoreQueryFilters()
.Where(c => c.Id == user.CompanyId && !c.IsDeleted)
.Select(c => new { c.SubscriptionPlan })
.FirstOrDefaultAsync();
if (company != null)
{
// Look up the DisplayName from SubscriptionPlanConfig so it reflects DB values
var planConfig = await _context.SubscriptionPlanConfigs.IgnoreQueryFilters()
.Where(p => p.Plan == company.SubscriptionPlan && p.IsActive && !p.IsDeleted)
.Select(p => new { p.DisplayName })
.FirstOrDefaultAsync();
var planName = planConfig?.DisplayName ?? company.SubscriptionPlan.ToString();
identity.AddClaim(new Claim("SubscriptionPlan", planName));
}
}
// Add CompanyRole claim if user has a company role
if (!string.IsNullOrEmpty(user.CompanyRole))
{
identity.AddClaim(new Claim("CompanyRole", user.CompanyRole));
}
// Add user's full name for display purposes
identity.AddClaim(new Claim("FullName", user.FullName));
// Add appearance claims
identity.AddClaim(new Claim("Theme", user.Theme ?? "light"));
identity.AddClaim(new Claim("SidebarColor", user.SidebarColor ?? "ocean"));
identity.AddClaim(new Claim("HasProfilePicture",
(!string.IsNullOrEmpty(user.ProfilePictureFilePath)).ToString().ToLower()));
identity.AddClaim(new Claim("ProfilePictureVersion", (user.UpdatedAt ?? DateTime.UtcNow).Ticks.ToString()));
// Add permission claims for easier authorization
if (user.CanManageJobs)
{
identity.AddClaim(new Claim("Permission", "ManageJobs"));
}
if (user.CanManageInventory)
{
identity.AddClaim(new Claim("Permission", "ManageInventory"));
}
if (user.CanManageCustomers)
{
identity.AddClaim(new Claim("Permission", "ManageCustomers"));
}
if (user.CanCreateQuotes)
{
identity.AddClaim(new Claim("Permission", "CreateQuotes"));
}
if (user.CanApproveQuotes)
{
identity.AddClaim(new Claim("Permission", "ApproveQuotes"));
}
if (user.CanManageCalendar)
{
identity.AddClaim(new Claim("Permission", "ManageCalendar"));
}
if (user.CanViewCalendar)
{
identity.AddClaim(new Claim("Permission", "ViewCalendar"));
}
if (user.CanManageProducts)
{
identity.AddClaim(new Claim("Permission", "ManageProducts"));
}
if (user.CanViewProducts)
{
identity.AddClaim(new Claim("Permission", "ViewProducts"));
}
if (user.CanManageEquipment)
{
identity.AddClaim(new Claim("Permission", "ManageEquipment"));
}
if (user.CanManageVendors)
{
identity.AddClaim(new Claim("Permission", "ManageVendors"));
}
if (user.CanManageMaintenance)
{
identity.AddClaim(new Claim("Permission", "ManageMaintenance"));
}
if (user.CanManageInvoices)
{
identity.AddClaim(new Claim("Permission", "ManageInvoices"));
}
if (user.CanViewReports)
{
identity.AddClaim(new Claim("Permission", "ViewReports"));
}
return identity;
}
}
@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Health;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Runs configuration health checks for tenant companies. Each check type is a single
/// batch DB query grouped by CompanyId — O(checks) round-trips regardless of company count.
/// </summary>
public class CompanyConfigHealthService : ICompanyConfigHealthService
{
private readonly ApplicationDbContext _db;
public CompanyConfigHealthService(ApplicationDbContext db)
{
_db = db;
}
public async Task<CompanyConfigHealth> CheckAsync(int companyId)
{
var batch = await CheckBatchAsync([companyId]);
return batch.TryGetValue(companyId, out var result)
? result
: new CompanyConfigHealth { CompanyId = companyId };
}
public async Task<Dictionary<int, CompanyConfigHealth>> CheckBatchAsync(IEnumerable<int> companyIds)
{
var ids = companyIds.ToList();
if (ids.Count == 0) return new Dictionary<int, CompanyConfigHealth>();
// --- Batch queries (one per check type) ---
var hasAccounts = new HashSet<int>(await _db.Accounts
.AsNoTracking().IgnoreQueryFilters()
.Where(a => ids.Contains(a.CompanyId) && a.IsActive && !a.IsDeleted)
.Select(a => a.CompanyId).Distinct().ToListAsync());
var hasPrepServices = new HashSet<int>(await _db.PrepServices
.AsNoTracking().IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && p.IsActive && !p.IsDeleted)
.Select(p => p.CompanyId).Distinct().ToListAsync());
var hasOperatingCosts = new HashSet<int>(await _db.CompanyOperatingCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && !o.IsDeleted)
.Select(o => o.CompanyId).Distinct().ToListAsync());
// Calibrated = has named blast setups OR (operating costs with CompressorCfm > 0 OR BlastRateSqFtPerHourOverride set)
var hasNamedBlastSetups = new HashSet<int>(await _db.CompanyBlastSetups
.AsNoTracking().IgnoreQueryFilters()
.Where(b => ids.Contains(b.CompanyId) && !b.IsDeleted && b.IsActive)
.Select(b => b.CompanyId).Distinct().ToListAsync());
var hasQuotingCalibration = new HashSet<int>(hasNamedBlastSetups.Concat(
await _db.CompanyOperatingCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && !o.IsDeleted
&& (o.CompressorCfm > 0 || o.BlastRateSqFtPerHourOverride != null))
.Select(o => o.CompanyId).Distinct().ToListAsync()));
var wizardDone = new HashSet<int>(await _db.CompanyPreferences
.AsNoTracking().IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && !p.IsDeleted && p.SetupWizardCompleted)
.Select(p => p.CompanyId).Distinct().ToListAsync());
var hasInventory = new HashSet<int>(await _db.InventoryItems
.AsNoTracking().IgnoreQueryFilters()
.Where(i => ids.Contains(i.CompanyId) && i.IsActive && !i.IsDeleted)
.Select(i => i.CompanyId).Distinct().ToListAsync());
var hasCatalogItems = new HashSet<int>(await _db.CatalogItems
.AsNoTracking().IgnoreQueryFilters()
.Where(c => ids.Contains(c.CompanyId) && c.IsActive && !c.IsDeleted)
.Select(c => c.CompanyId).Distinct().ToListAsync());
var hasOvenCosts = new HashSet<int>(await _db.OvenCosts
.AsNoTracking().IgnoreQueryFilters()
.Where(o => ids.Contains(o.CompanyId) && o.IsActive && !o.IsDeleted)
.Select(o => o.CompanyId).Distinct().ToListAsync());
// --- Build result per company ---
var results = new Dictionary<int, CompanyConfigHealth>();
foreach (var id in ids)
{
var health = new CompanyConfigHealth { CompanyId = id };
if (!hasAccounts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_ACCOUNTS",
Title = "No Chart of Accounts",
Detail = "Invoicing, expense tracking, and financial reports require at least a basic chart of accounts.",
Severity = ConfigIssueSeverity.Critical,
FixPath = "/Accounts",
FixLabel = "Set Up Accounts"
});
if (!hasOperatingCosts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_OPERATING_COSTS",
Title = "Operating Costs Not Configured",
Detail = "Labor, equipment, and overhead rates are missing — job pricing calculations will produce $0 estimates.",
Severity = ConfigIssueSeverity.Critical,
FixPath = "/CompanySettings#operating-costs",
FixLabel = "Configure Costs"
});
// Only prompt for calibration if operating costs are already set up
if (hasOperatingCosts.Contains(id) && !hasQuotingCalibration.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "QUOTING_NOT_CALIBRATED",
Title = "No Sandblasting Setups Found",
Detail = "Your shop's blast equipment hasn't been configured. AI photo quotes and calculated item time estimates will use generic industry averages instead of your actual throughput.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/CompanySettings#quoting-calibration",
FixLabel = "Configure Blast Setups"
});
if (!hasPrepServices.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_PREP_SERVICES",
Title = "No Prep Services Defined",
Detail = "The prep services step is skipped in the quote/job wizard. Workers won't see sandblasting, masking, or other prep options.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/CompanySettings#prep-services-lookup",
FixLabel = "Add Prep Services"
});
if (!wizardDone.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "WIZARD_INCOMPLETE",
Title = "Setup Wizard Not Completed",
Detail = "The initial company configuration wizard was never finished. Some rates and defaults may be missing.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/SetupWizard/Step?step=1",
FixLabel = "Resume Wizard"
});
if (!hasInventory.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_INVENTORY",
Title = "No Inventory Items",
Detail = "No powder or material items are in inventory. The coating layer step will have no powder options to select.",
Severity = ConfigIssueSeverity.Warning,
FixPath = "/Inventory",
FixLabel = "Add Inventory"
});
if (!hasCatalogItems.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_CATALOG",
Title = "No Catalog Items",
Detail = "No pre-defined products or services exist. Quotes can still be created with custom items.",
Severity = ConfigIssueSeverity.Info,
FixPath = "/CatalogItems",
FixLabel = "Add Catalog Items"
});
if (!hasOvenCosts.Contains(id))
health.Issues.Add(new ConfigHealthIssue
{
Code = "NO_OVENS",
Title = "No Named Ovens Configured",
Detail = "The oven scheduler will use the default rate only. Capacity planning and batch suggestions require named ovens.",
Severity = ConfigIssueSeverity.Info,
FixPath = "/CompanySettings#operating-costs",
FixLabel = "Configure Ovens"
});
results[id] = health;
}
return results;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace PowderCoating.Infrastructure.Services;
public class EmailService : IEmailService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EmailService> _logger;
private readonly IHostEnvironment _hostEnvironment;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger, IHostEnvironment hostEnvironment)
{
_configuration = configuration;
_logger = logger;
_hostEnvironment = hostEnvironment;
}
/// <summary>
/// Sends an email via SendGrid. In non-production environments the subject is prefixed with
/// "[ENV]" (e.g. "[Development]", "[Staging]") so recipients immediately know the email came
/// from a test environment and don't act on it as if it were real.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> SendEmailAsync(
string toEmail,
string toName,
string subject,
string plainTextBody,
string? htmlBody = null,
byte[]? attachmentData = null,
string? attachmentFilename = null,
string? attachmentContentType = null,
string? replyToEmail = null,
string? replyToName = null)
{
var apiKey = _configuration["SendGrid:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-") || apiKey == "SG.placeholder")
{
_logger.LogWarning("SendGrid API key is not configured. Email to {ToEmail} skipped.", toEmail);
return (false, "SendGrid not configured");
}
if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
try
{
var client = new SendGridClient(apiKey);
var msg = MailHelper.CreateSingleEmail(
new EmailAddress(fromEmail, fromName),
new EmailAddress(toEmail, toName),
subject, plainTextBody, htmlBody ?? plainTextBody);
if (!string.IsNullOrWhiteSpace(replyToEmail))
msg.SetReplyTo(new EmailAddress(replyToEmail, replyToName));
if (attachmentData != null && attachmentData.Length > 0 && !string.IsNullOrWhiteSpace(attachmentFilename))
{
await msg.AddAttachmentAsync(
attachmentFilename,
new MemoryStream(attachmentData),
attachmentContentType ?? "application/octet-stream");
}
return await DispatchAsync(client, msg, toEmail, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending email to {ToEmail}", toEmail);
return (false, ex.Message);
}
}
/// <summary>
/// Sends an email with one or more file attachments via SendGrid. Intended for scenarios
/// such as attaching job photos to a ready-for-pickup notification. Callers are responsible
/// for keeping total attachment size under SendGrid's 30 MB per-message limit; this method
/// skips any attachment whose Data array is empty.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> SendEmailWithAttachmentsAsync(
string toEmail,
string toName,
string subject,
string plainTextBody,
string? htmlBody = null,
IList<EmailAttachment>? attachments = null,
string? replyToEmail = null,
string? replyToName = null)
{
var apiKey = _configuration["SendGrid:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-") || apiKey == "SG.placeholder")
{
_logger.LogWarning("SendGrid API key is not configured. Email to {ToEmail} skipped.", toEmail);
return (false, "SendGrid not configured");
}
if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
try
{
var client = new SendGridClient(apiKey);
var msg = MailHelper.CreateSingleEmail(
new EmailAddress(fromEmail, fromName),
new EmailAddress(toEmail, toName),
subject, plainTextBody, htmlBody ?? plainTextBody);
if (!string.IsNullOrWhiteSpace(replyToEmail))
msg.SetReplyTo(new EmailAddress(replyToEmail, replyToName));
if (attachments != null)
{
foreach (var attachment in attachments.Where(a => a.Data.Length > 0))
{
await msg.AddAttachmentAsync(
attachment.Filename,
new MemoryStream(attachment.Data),
attachment.ContentType);
}
}
return await DispatchAsync(client, msg, toEmail, subject);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending email with attachments to {ToEmail}", toEmail);
return (false, ex.Message);
}
}
/// <summary>
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
/// send methods share identical dispatch and logging logic.
/// </summary>
private async Task<(bool Success, string? ErrorMessage)> DispatchAsync(
SendGridClient client, SendGridMessage msg, string toEmail, string subject)
{
var response = await client.SendEmailAsync(msg);
if ((int)response.StatusCode >= 200 && (int)response.StatusCode < 300)
{
_logger.LogInformation("Email sent to {ToEmail}: {Subject}", toEmail, subject);
return (true, null);
}
var body = await response.Body.ReadAsStringAsync();
_logger.LogWarning("SendGrid returned {StatusCode} for {ToEmail}: {Body}", response.StatusCode, toEmail, body);
return (false, $"HTTP {(int)response.StatusCode}");
}
}
@@ -0,0 +1,733 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Uses Anthropic Claude Sonnet 4.6 (via <c>AI:Anthropic:ApiKey</c>) to look up powder coating
/// product details — manufacturer, color, cure specs, pricing — from a product name, color code,
/// or part number. Results are used to pre-fill the Add Inventory Item form so shop staff
/// don't have to type technical specifications manually.
/// <para>
/// The lookup pipeline is:
/// 1. Optionally query Serper (Google Search API) for web snippets and product page URLs.
/// 2. Optionally fetch the manufacturer product page and extract JSON-LD structured data.
/// 3. Assemble a rich prompt (search snippets + page text) and call Claude.
/// 4. Parse Claude's JSON response into a typed <see cref="InventoryAiLookupResult"/>.
/// </para>
/// Serper is optional — when the <c>AI:Serper:ApiKey</c> is absent Claude falls back to its
/// training-data knowledge of powder coating product lines.
/// </summary>
public class InventoryAiLookupService : IInventoryAiLookupService
{
private readonly IConfiguration _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<InventoryAiLookupService> _logger;
private readonly IUnitOfWork _unitOfWork;
private const string ClaudeSystemPrompt = @"You are an expert in powder coating materials with deep knowledge of manufacturer product lines.
Given web search snippets (or just the product identity if no snippets) about a specific powder coating product, extract as much structured information as possible.
Respond ONLY with a valid JSON object — no markdown, no explanation:
{
""manufacturer"": ""string or null — the brand/manufacturer name, e.g. 'Prismatic Powders', 'Tiger Drylac', 'Sherwin-Williams'"",
""manufacturerPartNumber"": ""string or null — the manufacturer's SKU/part number for this specific color/product"",
""colorName"": ""string or null — official product color name"",
""colorCode"": ""string or null — RAL, NCS, or manufacturer color code"",
""description"": ""string or null — one concise sentence describing THIS SPECIFIC COLOR AND ITS VISUAL EFFECT, not the manufacturer or retailer. E.g. 'Deep burgundy color-shifting powder that transitions from red to purple under different lighting.' Never describe the company."",
""finish"": ""one of: Gloss, Matte, Satin, Flat, Texture, Wrinkle, Metallic, Pearl, Hammertone, Chrome, or null"",
""cureTemperatureF"": number or null,
""cureTimeMinutes"": number or null,
""colorFamilies"": ""comma-separated list from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — or null if unknown"",
""requiresClearCoat"": true or false or null,
""coverageSqFtPerLb"": number or null,
""transferEfficiency"": number or null,
""unitCostPerLb"": number or null,
""vendorName"": ""string or null — the retailer or distributor name if a price was found (not the manufacturer)"",
""reasoning"": ""one sentence: what specific product data was found and how confident you are""
}
Rules:
- description: MUST describe the specific color's appearance and any special effects (color shift, metallic flake, etc.). NEVER describe the brand, company, or product line in general. If the color shifts or changes under light, say so.
- cureTemperatureF: convert to °F if given in °C (multiply by 1.8 + 32). Most powder coatings cure 325-400°F. If a range is given, use the midpoint.
- cureTimeMinutes: hold time at cure temperature (not total oven time). Typical 10-20 min.
- colorFamilies: pick the 1-2 best matching families from the allowed list. E.g. 'Teal' → 'Green,Blue', 'Rose Gold' → 'Pink,Gold', 'Bronze' → 'Bronze,Brown'. Color-shifting powders: use the base/dominant hue(s).
- requiresClearCoat: set to TRUE in any of these cases:
* Product explicitly states a clear coat is required or recommended
* Product says a clear coat is needed to ""activate"" the color or effect
* Product says a clear coat ""brings out"" the metallic, pearl, or color-shift effect
* Product is a chameleon, color-shift, interference, or dormant powder (these almost always need clear coat)
* Product name contains ""Illusion"", ""Chameleon"", ""Shift"", ""Ghost"", ""Dormant"", or similar effect names
* ""Dormant"" powders are specially formulated to look flat/dull until a clear coat activates their true color — always true
* Product requires a base coat AND a clear coat for full effect
Set to false only if the product explicitly states no clear coat needed. Use null if genuinely unknown.
- coverageSqFtPerLb: theoretical coverage in sq ft per pound. Typical range 80-120 sq ft/lb. If given in metric (m²/kg), multiply by 4.88 to convert.
- transferEfficiency: percentage (0-100) of powder that adheres to the part. Typical 60-75%. Use null if not found — do NOT guess this.
- unitCostPerLb: price per pound (or per unit) if found in search results OR in the [Structured Data] block. Only return if an actual price is clearly stated — do NOT estimate or guess. Return as a plain number (e.g. 12.99). The [Structured Data] price block is machine-readable and highly reliable — prefer it over prose snippets.
- vendorName: if a price was found, also return the name of the retailer/distributor where that price was found (e.g. ""Prismatic Powders"", ""Powder Buy the Pound""). This helps match the vendor dropdown.
- manufacturer: return the brand name if you know it from your training data or search results. E.g. if the color name is well-known from a specific brand, return that brand.
- manufacturerPartNumber: return the SKU/item number if you know it from your training data or search results. Do not guess or invent one.
Each brand labels their SKU differently — extract these patterns:
* Prismatic Powders: labeled ""Item:"" followed by a code like PMB-6906
* Columbia Coatings: labeled ""SKU:"" followed by a code like CS1534083
* Tiger Drylac: SKU appears before the color name, formatted like ""149/30065 Pastel Pink"" — extract only the numeric portion (e.g. ""149/30065"")
* All Powder Paints: labeled ""Product ID:"" followed by a code like PSGGR833990X
* Interpon (by AkzoNobel): labeled ""Code"" followed by an alphanumeric code like G2243QF. If the manufacturer field is empty and the product is Interpon, also set manufacturer to ""AkzoNobel / Interpon"".
* Cardinal Paint & Powder: alphanumeric codes formatted like C241-BK01 (letter-digits-dash-letters-digits pattern).
* PPG Industries: labeled ""SKU:"" followed by a code like PCTT99259-55 (letters then digits, often with a dash and size suffix).
* Powder Buy the Pound: labeled ""SKU:"" followed by a code like SK16211 (letters then digits, no dash).
* Sherwin-Williams (Powdura): SKU appears on its own line directly below the color name, formatted like EGS2-90007 (letters-digits-dash-digits). There is no label prefix — it just follows the product name.
* Cerakote: labeled ""Item:"" followed by a short code like F-122 (letter-dash-digits).
* Other brands: look for ""SKU"", ""Item #"", ""Part #"", ""Product Code"", ""Product ID"", ""Code"", or similar labels
- colorCode: RAL code (e.g. RAL 9005), NCS code, or manufacturer's own color code. Return if known — do not infer from the color name alone.
- If a field cannot be confidently determined, use null.";
public InventoryAiLookupService(
IConfiguration config,
IHttpClientFactory httpClientFactory,
ILogger<InventoryAiLookupService> logger,
IUnitOfWork unitOfWork)
{
_config = config;
_httpClientFactory = httpClientFactory;
_logger = logger;
_unitOfWork = unitOfWork;
}
/// <summary>
/// Main entry point: resolves powder product details from any combination of the four
/// identifying inputs. At least one non-empty input is required; all four are optional
/// individually so callers can pass whatever the user has already typed.
/// <para>
/// The method loads <c>ManufacturerLookupPattern</c> records with
/// <c>ignoreQueryFilters: true</c> because those patterns are platform-global (not
/// tenant-scoped), so the normal company isolation filter must be bypassed.
/// </para>
/// Returns <see cref="InventoryAiLookupResult.Success"/> = <c>false</c> (never throws)
/// on API errors so the calling controller can present a friendly message.
/// </summary>
public async Task<InventoryAiLookupResult> LookupAsync(
string? manufacturer,
string? colorName,
string? colorCode,
string? partNumber)
{
var parts = new List<string?> { manufacturer, colorName, colorCode, partNumber }
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (parts.Count == 0)
{
return new InventoryAiLookupResult
{
Success = false,
ErrorMessage = "Please provide at least one of: manufacturer, color name, color code, or part number."
};
}
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new InventoryAiLookupResult
{
Success = false,
ErrorMessage = "Anthropic API key is not configured."
};
}
try
{
// Load all active manufacturer patterns (global records, ignoreQueryFilters bypasses tenant filter)
var allPatterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
var activePatterns = allPatterns.Where(p => p.IsActive).ToList();
// Build search query
var query = string.Join(" ", parts) + " powder coating cure temperature specifications";
_logger.LogInformation("AI inventory lookup for: {Query}", query);
// Try Serper search first; fall back to empty snippets if not configured
// Pass active patterns so domains are used for URL selection
// searchFetchUrl may be a TDS page (preferred for data); searchProductUrl is the product page
var (snippets, searchFetchUrl, searchProductUrl) = await SearchSerperAsync(query, colorName, colorCode, partNumber, activePatterns);
// Try to build a direct product page URL from manufacturer pattern (most reliable)
var directUrl = TryBuildDirectUrl(manufacturer, colorName, partNumber, activePatterns);
// Fetching: prefer direct URL → TDS/search URL (best data source)
var fetchUrl = directUrl ?? searchFetchUrl;
// SpecPageUrl: always point to the actual product page, never a TDS
// direct URL is always a product page; fall back to searchProductUrl (not TDS)
var specPageUrl = directUrl ?? searchProductUrl;
if (directUrl != null)
_logger.LogInformation("Using direct manufacturer URL: {Url}", directUrl);
// Fetch product page
var pageContent = fetchUrl != null ? await FetchPageTextAsync(fetchUrl) : null;
// If direct URL fetch failed, fall back to the search fetch URL
if (pageContent == null && directUrl != null && searchFetchUrl != null && searchFetchUrl != directUrl)
{
_logger.LogInformation("Direct URL fetch failed; falling back to search URL: {Url}", searchFetchUrl);
fetchUrl = searchFetchUrl;
pageContent = await FetchPageTextAsync(searchFetchUrl);
}
var userPrompt = BuildUserPrompt(manufacturer, colorName, colorCode, partNumber, snippets, fetchUrl, pageContent);
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = ClaudeSystemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty;
// Strip markdown code fences if present
rawText = rawText.Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
// If Claude returned prose instead of JSON, try to extract the JSON object
if (!rawText.StartsWith("{"))
{
var jsonStart = rawText.IndexOf('{');
var jsonEnd = rawText.LastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart)
rawText = rawText[jsonStart..(jsonEnd + 1)];
else
return new InventoryAiLookupResult
{
Success = false,
ErrorMessage = "AI returned an unexpected response format. Please try again."
};
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
var result = new InventoryAiLookupResult { Success = true };
result.Manufacturer = GetString(parsed, "manufacturer");
result.ManufacturerPartNumber = GetString(parsed, "manufacturerPartNumber");
result.ColorName = GetString(parsed, "colorName");
result.ColorCode = GetString(parsed, "colorCode");
result.Description = GetString(parsed, "description");
result.Finish = GetString(parsed, "finish");
result.CureTemperatureF = GetDecimal(parsed, "cureTemperatureF");
result.CureTimeMinutes = GetInt(parsed, "cureTimeMinutes");
result.ColorFamilies = GetString(parsed, "colorFamilies");
result.RequiresClearCoat = GetBool(parsed, "requiresClearCoat");
result.CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb");
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
result.VendorName = GetString(parsed, "vendorName");
result.SpecPageUrl = specPageUrl;
result.Reasoning = GetString(parsed, "reasoning");
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during AI inventory lookup");
return new InventoryAiLookupResult
{
Success = false,
ErrorMessage = "AI lookup failed: " + ex.Message
};
}
}
// ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary>
/// Attempts to construct a direct product page URL for a known manufacturer using
/// a <c>ProductUrlTemplate</c> stored in the <c>ManufacturerLookupPattern</c> DB table.
/// Templates contain <c>{partNumber}</c>, <c>{slug}</c>, or <c>{colorCode}</c> placeholders
/// that are replaced with the caller-supplied values.
/// <para>
/// Returns <c>null</c> when the manufacturer is unknown, the template requires a value
/// (e.g., part number) that was not supplied, or no matching pattern exists in the DB.
/// </para>
/// Slashes in part numbers are converted to hyphens because forward slashes in URL path
/// segments break most web servers (e.g., Tiger Drylac's "149/30065" pattern).
/// </summary>
private static string? TryBuildDirectUrl(
string? manufacturer, string? colorName, string? partNumber,
List<Core.Entities.ManufacturerLookupPattern> patterns)
{
if (string.IsNullOrWhiteSpace(manufacturer) || patterns.Count == 0) return null;
var mfrLower = manufacturer.ToLowerInvariant();
var pattern = patterns.FirstOrDefault(p =>
mfrLower.Contains(p.ManufacturerName.ToLowerInvariant()) ||
p.ManufacturerName.ToLowerInvariant().Contains(mfrLower));
if (pattern?.ProductUrlTemplate == null) return null;
var template = pattern.ProductUrlTemplate;
// Resolve {partNumber} placeholder
if (template.Contains("{partNumber}", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(partNumber)) return null; // can't build without it
// Normalize slashes in part numbers to hyphens for URL safety
var pn = partNumber.Replace("/", "-").Replace("\\", "-");
template = template.Replace("{partNumber}", pn, StringComparison.OrdinalIgnoreCase);
}
// Resolve {slug} / {colorName} placeholder
if (template.Contains("{slug}", StringComparison.OrdinalIgnoreCase) ||
template.Contains("{colorName}", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(colorName)) return null; // can't build without it
var slug = MakeSlug(colorName, pattern.SlugTransform);
template = template
.Replace("{slug}", slug, StringComparison.OrdinalIgnoreCase)
.Replace("{colorName}", slug, StringComparison.OrdinalIgnoreCase);
}
// Resolve {colorCode} placeholder
if (template.Contains("{colorCode}", StringComparison.OrdinalIgnoreCase))
{
template = template.Replace("{colorCode}", colorName ?? "", StringComparison.OrdinalIgnoreCase);
}
return template;
}
/// <summary>
/// Converts a human-readable color name to the URL slug format expected by a specific
/// manufacturer's website, as specified by the <c>SlugTransform</c> field on the pattern.
/// Supported transforms: <c>LowerHyphen</c> (e.g., "fire-red"), <c>LowerUnderscore</c>
/// (e.g., "fire_red"), <c>TitleHyphen</c> (e.g., "Fire-Red"), and <c>AsIs</c> (default,
/// replaces spaces with hyphens without case change).
/// </summary>
private static string MakeSlug(string input, string transform) =>
transform switch
{
"LowerHyphen" => input.ToLowerInvariant().Replace(" ", "-"),
"LowerUnderscore"=> input.ToLowerInvariant().Replace(" ", "_"),
"TitleHyphen" => string.Join("-", input.Split(' ')
.Select(w => w.Length > 0
? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant()
: "")),
_ => input.Replace(" ", "-") // AsIs
};
// ── Serper search ─────────────────────────────────────────────────────────
/// <summary>
/// Queries the Serper Google Search API for product snippets and attempts to identify
/// two distinct URLs from the results:
/// <list type="bullet">
/// <item><description><c>fetchUrl</c> — the best URL to fetch full page content from;
/// prefers a Technical Data Sheet (TDS) page because it contains machine-readable cure
/// specs, falling back to the first known-manufacturer product page.</description></item>
/// <item><description><c>productUrl</c> — always the human-facing product page URL
/// (never a TDS), used as <c>SpecPageUrl</c> in the result for the UI to link to.</description></item>
/// </list>
/// Returns empty snippets and null URLs when Serper is not configured, so the caller
/// transparently falls back to Claude's training knowledge.
/// <para>
/// Relevance filtering uses <paramref name="colorName"/>, <paramref name="colorCode"/>,
/// and <paramref name="partNumber"/> (NOT manufacturer name) as search terms to reject
/// manufacturer URLs that match the domain but point to a different product.
/// </para>
/// </summary>
private async Task<(List<string> snippets, string? fetchUrl, string? productUrl)> SearchSerperAsync(
string query, string? colorName, string? colorCode, string? partNumber,
List<Core.Entities.ManufacturerLookupPattern> patterns)
{
var serperApiKey = _config["AI:Serper:ApiKey"];
if (string.IsNullOrWhiteSpace(serperApiKey) || serperApiKey.StartsWith("your-"))
{
_logger.LogInformation("Serper API key not configured; Claude will use prior knowledge only.");
return (new List<string>(), null, null);
}
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-API-KEY", serperApiKey);
var body = JsonSerializer.Serialize(new { q = query, num = 5 });
var content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://google.serper.dev/search", content);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Serper returned {Status}", response.StatusCode);
return (new List<string>(), null, null);
}
var json = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var snippets = new List<string>();
string? tdsUrl = null;
string? productUrl = null;
// Build relevance terms from product-specific inputs ONLY (not manufacturer or generic
// words like "powder coating") so that a manufacturer URL for the wrong product is rejected.
var productTerms = new[] { colorName, colorCode, partNumber }
.Where(s => !string.IsNullOrWhiteSpace(s))
.SelectMany(s => s!.Split(' ', StringSplitOptions.RemoveEmptyEntries))
.Where(w => w.Length >= 4)
.Select(w => w.ToLowerInvariant())
.ToHashSet();
bool IsRelevantToSearch(string? link, string? title)
{
if (productTerms.Count == 0) return true;
var haystack = ((link ?? "") + " " + (title ?? "")).ToLowerInvariant();
return productTerms.Any(term => haystack.Contains(term));
}
// Combine known domains: DB patterns + hardcoded fallback set
var knownDomains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var p in patterns.Where(p => !string.IsNullOrWhiteSpace(p.Domain)))
knownDomains.Add(p.Domain!);
// Hardcoded fallback for manufacturers not yet in DB
foreach (var d in new[] {
"prismaticpowders.com", "tiger-coatings.com", "tigerdrylac.com",
"sherwin-williams.com", "columbiacoatings.com", "allpowderpaints.com",
"powderbythepound.com", "cardinalpp.com", "ppg.com", "cerakote.com",
"interpon.com", "akzonobel.com" })
knownDomains.Add(d);
if (doc.RootElement.TryGetProperty("organic", out var organic))
{
foreach (var item in organic.EnumerateArray())
{
var title = item.TryGetProperty("title", out var t) ? t.GetString() : null;
var snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() : null;
var link = item.TryGetProperty("link", out var l) ? l.GetString() : null;
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(title)) sb.Append(title).Append(": ");
if (!string.IsNullOrWhiteSpace(snippet)) sb.Append(snippet);
if (sb.Length > 0) snippets.Add(sb.ToString());
if (!string.IsNullOrWhiteSpace(link) &&
!link.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) &&
IsRelevantToSearch(link, title))
{
var lowerLink = link.ToLowerInvariant();
var lowerTitle = (title ?? "").ToLowerInvariant();
if (tdsUrl == null && (
lowerLink.Contains("tech-data") || lowerLink.Contains("/tds") ||
lowerTitle.Contains("tech data") || lowerTitle.Contains("technical data")))
{
tdsUrl = link;
}
else if (productUrl == null)
{
try
{
var host = new Uri(link).Host.Replace("www.", "");
if (knownDomains.Contains(host))
productUrl = link;
}
catch { /* ignore bad URLs */ }
}
}
}
}
var fetchUrl = tdsUrl ?? productUrl;
_logger.LogInformation("Serper returned {Count} snippets; fetch URL: {FetchUrl}, product URL: {ProductUrl}",
snippets.Count, fetchUrl ?? "none", productUrl ?? "none");
return (snippets, fetchUrl, productUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Serper search failed; proceeding with Claude knowledge only");
return (new List<string>(), null, null);
}
}
// ── Page fetching with JSON-LD extraction ─────────────────────────────────
/// <summary>
/// Downloads a product or TDS page and converts it to plain text suitable for Claude.
/// <para>
/// JSON-LD structured data blocks (<c>application/ld+json</c>) are extracted BEFORE
/// script/style tags are stripped because many Shopify/WooCommerce product pages embed
/// machine-readable price and SKU data in JSON-LD that would otherwise be silently
/// discarded with the script content. The extracted data is prepended with
/// "[Structured Data]" markers so Claude treats it as high-confidence information.
/// </para>
/// Page text is capped at 3,500 characters to leave room for the structured data header
/// and the rest of the prompt within Claude's context window.
/// Returns <c>null</c> on any HTTP or parsing error so the caller falls back gracefully.
/// A browser-like User-Agent header is sent because some manufacturer sites return 403
/// or empty responses to bare HttpClient default agents.
/// </summary>
private async Task<string?> FetchPageTextAsync(string url)
{
try
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36");
client.Timeout = TimeSpan.FromSeconds(10);
var html = await client.GetStringAsync(url);
// Extract structured data (JSON-LD) BEFORE stripping scripts — it contains
// machine-readable price, SKU, and product info that would otherwise be lost.
var structuredData = ExtractJsonLdData(html);
// Remove script/style blocks
html = System.Text.RegularExpressions.Regex.Replace(
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Strip remaining HTML tags
var text = System.Text.RegularExpressions.Regex.Replace(html, @"<[^>]+>", " ");
// Collapse whitespace and decode HTML entities
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
text = System.Net.WebUtility.HtmlDecode(text);
// Limit page text to 3500 chars to leave room for structured data header
const int maxChars = 3500;
if (text.Length > maxChars)
text = text[..maxChars] + "…";
// Prepend structured data — Claude should treat this as high-confidence
if (!string.IsNullOrWhiteSpace(structuredData))
text = structuredData + "\n" + text;
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData})",
text.Length, url, structuredData != null ? "yes" : "no");
return text;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch page content from {Url}", url);
return null;
}
}
/// <summary>
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
/// even when the visible price is rendered by JavaScript.
/// </summary>
private static string? ExtractJsonLdData(string html)
{
var sb = new StringBuilder();
var matches = System.Text.RegularExpressions.Regex.Matches(
html,
@"<script[^>]+type=[""']application/ld\+json[""'][^>]*>([\s\S]*?)</script>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in matches)
{
try
{
var jsonText = m.Groups[1].Value.Trim();
using var doc = JsonDocument.Parse(jsonText);
var root = doc.RootElement;
// Handle both single-object and @graph array
IEnumerable<JsonElement> nodes = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: new[] { root };
foreach (var node in nodes)
{
if (!node.TryGetProperty("@type", out var typeEl)) continue;
var type = typeEl.ValueKind == JsonValueKind.String
? typeEl.GetString()
: typeEl.EnumerateArray().Select(e => e.GetString()).FirstOrDefault();
if (!string.Equals(type, "Product", StringComparison.OrdinalIgnoreCase)) continue;
if (node.TryGetProperty("name", out var n))
sb.AppendLine($"[Structured Data] Product name: {n.GetString()}");
if (node.TryGetProperty("sku", out var sku))
sb.AppendLine($"[Structured Data] SKU: {sku.GetString()}");
if (node.TryGetProperty("mpn", out var mpn))
sb.AppendLine($"[Structured Data] MPN: {mpn.GetString()}");
if (node.TryGetProperty("description", out var desc))
{
var d = desc.GetString() ?? "";
sb.AppendLine($"[Structured Data] Description: {d[..Math.Min(200, d.Length)]}");
}
if (node.TryGetProperty("offers", out var offers))
ExtractOfferPrice(offers, sb);
}
}
catch { /* ignore malformed JSON-LD */ }
}
return sb.Length > 0 ? sb.ToString() : null;
}
/// <summary>
/// Dispatches JSON-LD offer extraction for both the single-object form
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
/// that e-commerce platforms use interchangeably.
/// </summary>
private static void ExtractOfferPrice(JsonElement offers, StringBuilder sb)
{
if (offers.ValueKind == JsonValueKind.Object)
AppendOffer(offers, sb);
else if (offers.ValueKind == JsonValueKind.Array)
foreach (var offer in offers.EnumerateArray())
AppendOffer(offer, sb);
}
/// <summary>
/// Formats a single JSON-LD Offer object as a "[Structured Data] Price: ..." line
/// prepended to the page text. Includes currency, unit, and in-stock status when present.
/// Price is only emitted when a non-empty value exists; incomplete Offer objects (e.g.,
/// availability without a price) are silently skipped.
/// </summary>
private static void AppendOffer(JsonElement offer, StringBuilder sb)
{
var price = offer.TryGetProperty("price", out var p) ? p.ToString() : null;
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
if (string.IsNullOrWhiteSpace(price)) return;
var priceStr = $"[Structured Data] Price: {currency} {price}";
if (!string.IsNullOrWhiteSpace(unit)) priceStr += $" per {unit}";
if (avail?.Contains("InStock", StringComparison.OrdinalIgnoreCase) == true)
priceStr += " (In Stock)";
sb.AppendLine(priceStr);
}
// ── Prompt builder ────────────────────────────────────────────────────────
/// <summary>
/// Assembles the Claude user-turn message from all available inputs.
/// The message is structured in three sections:
/// 1. The product identity block (what the user typed).
/// 2. Web search snippets (if Serper found any), or a fallback note instructing Claude
/// to rely on training knowledge.
/// 3. Full product page content (if a page was successfully fetched), preceded by an
/// explicit instruction to verify that the page content matches the requested product
/// before trusting it — this guards against TDS pages for a different product being
/// returned by the search.
/// </summary>
private static string BuildUserPrompt(
string? manufacturer, string? colorName, string? colorCode,
string? partNumber, List<string> snippets, string? fetchUrl, string? pageContent)
{
var sb = new StringBuilder();
sb.AppendLine("Product lookup request:");
if (!string.IsNullOrWhiteSpace(manufacturer)) sb.AppendLine($" Manufacturer: {manufacturer}");
if (!string.IsNullOrWhiteSpace(colorName)) sb.AppendLine($" Color Name: {colorName}");
if (!string.IsNullOrWhiteSpace(colorCode)) sb.AppendLine($" Color Code: {colorCode}");
if (!string.IsNullOrWhiteSpace(partNumber)) sb.AppendLine($" Part Number: {partNumber}");
if (snippets.Any())
{
sb.AppendLine();
sb.AppendLine("Web search results:");
for (var i = 0; i < snippets.Count; i++)
sb.AppendLine($"[{i + 1}] {snippets[i]}");
}
else
{
sb.AppendLine();
sb.AppendLine("No web search results available. Use your knowledge of this product/manufacturer to fill in what you can.");
}
if (!string.IsNullOrWhiteSpace(pageContent))
{
sb.AppendLine();
sb.AppendLine($"Product page fetched from: {fetchUrl}");
sb.AppendLine("IMPORTANT: Before using the page content below, verify it is for the product in the lookup request above.");
sb.AppendLine("If the page describes a different product, ignore all data from it and rely only on the search snippets and your training knowledge.");
sb.AppendLine("Lines starting with [Structured Data] are machine-readable product metadata extracted before HTML parsing — treat them as highly reliable.");
sb.AppendLine("Page content:");
sb.AppendLine(pageContent);
}
return sb.ToString();
}
// ── JSON helpers ──────────────────────────────────────────────────────────
/// <summary>
/// Safely reads a string property from a <see cref="JsonElement"/>, returning <c>null</c>
/// for missing keys, non-string values, and empty strings. Prevents <c>null</c> being
/// stored in result fields when Claude returns JSON <c>null</c> for unknown fields.
/// </summary>
private static string? GetString(JsonElement el, string key)
{
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString() is { Length: > 0 } s ? s : null;
return null;
}
/// <summary>
/// Safely reads a numeric property as <see cref="decimal"/>, returning <c>null</c> when
/// the key is absent or the value is not a JSON number (e.g., when Claude returns <c>null</c>
/// for fields it could not determine).
/// </summary>
private static decimal? GetDecimal(JsonElement el, string key)
{
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Number)
return v.GetDecimal();
return null;
}
/// <summary>
/// Safely reads a numeric property as <see cref="int"/>, returning <c>null</c> when the
/// key is absent or the value is not a JSON number. Used for integer-only fields such
/// as <c>cureTimeMinutes</c>.
/// </summary>
private static int? GetInt(JsonElement el, string key)
{
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Number)
return v.GetInt32();
return null;
}
/// <summary>
/// Safely reads a JSON boolean property as nullable <see cref="bool"/>, distinguishing
/// between <c>true</c>, <c>false</c>, and <c>null</c> (genuinely unknown). This is
/// important for <c>requiresClearCoat</c> where <c>null</c> means "unclear from data"
/// and should be displayed differently in the UI from an explicit <c>false</c>.
/// </summary>
private static bool? GetBool(JsonElement el, string key)
{
if (el.TryGetProperty(key, out var v))
{
if (v.ValueKind == JsonValueKind.True) return true;
if (v.ValueKind == JsonValueKind.False) return false;
}
return null;
}
}
@@ -0,0 +1,471 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Builds chronological ledger reports for individual accounts by querying every transaction
/// table that can touch a given account (Payments, Expenses, BillPayments, InvoiceItems, Bills,
/// AR, AP) and computing a running balance. Also drives <see cref="AccountBalanceService.RecalculateAllAsync"/>
/// by returning the authoritative closing balance.
/// </summary>
public class LedgerService : ILedgerService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<LedgerService> _logger;
/// <summary>
/// Constructs the service with direct <see cref="ApplicationDbContext"/> access.
/// Direct context access (rather than repositories) is intentional here: the ledger
/// must query multiple tables with heterogeneous filters and EF navigation properties,
/// which would require many separate repository calls that would be slower and less readable.
/// </summary>
public LedgerService(ApplicationDbContext context, ILogger<LedgerService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Assembles a full ledger report for an account over the given date range, including
/// nine distinct transaction sources: customer payments deposited, expenses paid from,
/// bill payments paid from, invoice revenue line items, sales tax, direct expense
/// categorizations, bill line-item categorizations, AR movements, and AP movements.
/// Returns <c>null</c> when the account does not exist or is filtered out by the global
/// query filters (company isolation / soft delete).
/// The <c>OpeningBalance</c> in the returned DTO represents the account balance on the day
/// before <paramref name="from"/>, computed by <see cref="ComputePriorBalanceAsync"/>.
/// </summary>
public async Task<AccountLedgerDto?> GetAccountLedgerAsync(int accountId, DateTime from, DateTime to)
{
// Use FirstOrDefaultAsync so global query filters (company isolation, soft delete) apply
var account = await _context.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null) return null;
var fromDate = from.Date;
var toDate = to.Date.AddDays(1).AddTicks(-1); // inclusive end of day
var entries = new List<LedgerEntryDto>();
// ── 1. Customer payments deposited INTO this account (DEBIT) ──────────
// e.g. Checking/Savings account receives a customer payment
var depositedPayments = await _context.Payments
.Include(p => p.Invoice)
.Where(p => p.DepositAccountId == accountId
&& p.PaymentDate >= fromDate && p.PaymentDate <= toDate)
.ToListAsync();
foreach (var p in depositedPayments)
entries.Add(new LedgerEntryDto
{
Date = p.PaymentDate,
Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}",
Source = "Customer Payment",
Description = p.Notes ?? p.Reference,
Debit = p.Amount,
Credit = 0,
LinkController = "Invoices",
LinkId = p.InvoiceId
});
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
// e.g. Checking account used to pay an expense
var expensesPaidFrom = await _context.Expenses
.Include(e => e.Vendor)
.Where(e => e.PaymentAccountId == accountId
&& e.Date >= fromDate && e.Date <= toDate)
.ToListAsync();
foreach (var e in expensesPaidFrom)
entries.Add(new LedgerEntryDto
{
Date = e.Date,
Reference = e.ExpenseNumber,
Source = "Expense",
Description = e.Memo ?? e.Vendor?.CompanyName,
Debit = 0,
Credit = e.Amount,
LinkController = "Expenses",
LinkId = e.Id
});
// ── 3. Bill payments made FROM this account (CREDIT) ──────────────────
// e.g. Checking account used to pay a vendor bill
var billPaymentsPaidFrom = await _context.BillPayments
.Include(bp => bp.Bill)
.Where(bp => bp.BankAccountId == accountId
&& bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate)
.ToListAsync();
foreach (var bp in billPaymentsPaidFrom)
entries.Add(new LedgerEntryDto
{
Date = bp.PaymentDate,
Reference = bp.PaymentNumber,
Source = "Bill Payment",
Description = bp.Memo ?? bp.Bill?.BillNumber,
Debit = 0,
Credit = bp.Amount,
LinkController = "Bills",
LinkId = bp.BillId
});
// ── 4. Invoice line items that post revenue to this account (CREDIT) ──
// e.g. Revenue account 4000 receives line-item revenue
var revenueItems = await _context.InvoiceItems
.Include(ii => ii.Invoice)
.Where(ii => ii.RevenueAccountId == accountId
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toDate)
.ToListAsync();
foreach (var item in revenueItems)
entries.Add(new LedgerEntryDto
{
Date = item.Invoice.InvoiceDate,
Reference = item.Invoice.InvoiceNumber,
Source = "Invoice",
Description = item.Description,
Debit = 0,
Credit = item.TotalPrice,
LinkController = "Invoices",
LinkId = item.InvoiceId
});
// ── 5. Sales tax collected to this account (CREDIT) ───────────────────
// e.g. Liability account 2200 receives sales tax from invoices
var taxInvoices = await _context.Invoices
.Where(i => i.SalesTaxAccountId == accountId
&& i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
.ToListAsync();
foreach (var inv in taxInvoices)
entries.Add(new LedgerEntryDto
{
Date = inv.InvoiceDate,
Reference = inv.InvoiceNumber,
Source = "Sales Tax",
Description = $"Tax on {inv.InvoiceNumber}",
Debit = 0,
Credit = inv.TaxAmount,
LinkController = "Invoices",
LinkId = inv.Id
});
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
// e.g. Expense account 6200 receives direct expense entries
var expensesTo = await _context.Expenses
.Include(e => e.Vendor)
.Where(e => e.ExpenseAccountId == accountId
&& e.Date >= fromDate && e.Date <= toDate)
.ToListAsync();
foreach (var e in expensesTo)
entries.Add(new LedgerEntryDto
{
Date = e.Date,
Reference = e.ExpenseNumber,
Source = "Expense",
Description = e.Memo ?? e.Vendor?.CompanyName,
Debit = e.Amount,
Credit = 0,
LinkController = "Expenses",
LinkId = e.Id
});
// ── 7. Bill line items categorized to this account (DEBIT) ────────────
// e.g. Expense/COGS account receives costs from vendor bill line items
var billLineItems = await _context.BillLineItems
.Include(bli => bli.Bill)
.Where(bli => bli.AccountId == accountId
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toDate)
.ToListAsync();
foreach (var bli in billLineItems)
entries.Add(new LedgerEntryDto
{
Date = bli.Bill.BillDate,
Reference = bli.Bill.BillNumber,
Source = "Bill",
Description = bli.Description,
Debit = bli.Amount,
Credit = 0,
LinkController = "Bills",
LinkId = bli.BillId
});
// ── 8. Accounts Receivable ─────────────────────────────────────────────
// Invoice creation increases AR (DEBIT); customer payments reduce AR (CREDIT)
if (account.AccountSubType == AccountSubType.AccountsReceivable)
{
var arInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
.ToListAsync();
foreach (var inv in arInvoices)
{
var customerName = inv.Customer?.IsCommercial == true
? inv.Customer.CompanyName
: $"{inv.Customer?.ContactFirstName} {inv.Customer?.ContactLastName}".Trim();
entries.Add(new LedgerEntryDto
{
Date = inv.InvoiceDate,
Reference = inv.InvoiceNumber,
Source = "Invoice",
Description = customerName,
Debit = inv.Total,
Credit = 0,
LinkController = "Invoices",
LinkId = inv.Id
});
}
var arPayments = await _context.Payments
.Include(p => p.Invoice)
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toDate)
.ToListAsync();
foreach (var p in arPayments)
entries.Add(new LedgerEntryDto
{
Date = p.PaymentDate,
Reference = p.Invoice?.InvoiceNumber ?? $"PMT-{p.Id}",
Source = "Invoice Payment",
Description = $"Payment — {p.Invoice?.InvoiceNumber}",
Debit = 0,
Credit = p.Amount,
LinkController = "Invoices",
LinkId = p.InvoiceId
});
}
// ── 9. Accounts Payable ────────────────────────────────────────────────
// Bill creation increases AP (CREDIT); bill payments reduce AP (DEBIT)
if (account.AccountSubType == AccountSubType.AccountsPayable)
{
var apBills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => b.APAccountId == accountId
&& b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided
&& b.BillDate >= fromDate && b.BillDate <= toDate)
.ToListAsync();
foreach (var b in apBills)
entries.Add(new LedgerEntryDto
{
Date = b.BillDate,
Reference = b.BillNumber,
Source = "Bill",
Description = b.Vendor?.CompanyName ?? b.Memo,
Debit = 0,
Credit = b.Total,
LinkController = "Bills",
LinkId = b.Id
});
var apPayments = await _context.BillPayments
.Include(bp => bp.Bill)
.Where(bp => bp.Bill.APAccountId == accountId
&& bp.PaymentDate >= fromDate && bp.PaymentDate <= toDate)
.ToListAsync();
foreach (var bp in apPayments)
entries.Add(new LedgerEntryDto
{
Date = bp.PaymentDate,
Reference = bp.PaymentNumber,
Source = "Bill Payment",
Description = bp.Memo ?? bp.Bill?.BillNumber,
Debit = bp.Amount,
Credit = 0,
LinkController = "Bills",
LinkId = bp.BillId
});
}
// ── Sort and compute running balance ──────────────────────────────────
entries = entries
.OrderBy(e => e.Date)
.ThenBy(e => e.Reference)
.ToList();
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
// since users could misconfigure AccountType while SubType is picked from a constrained list).
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType);
// Compute the balance before the selected period
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
decimal runningBalance = priorBalance;
foreach (var entry in entries)
{
runningBalance += normalDebitBalance
? entry.Debit - entry.Credit
: entry.Credit - entry.Debit;
entry.RunningBalance = runningBalance;
}
return new AccountLedgerDto
{
Id = account.Id,
AccountNumber = account.AccountNumber,
Name = account.Name,
AccountType = account.AccountType,
AccountSubType = account.AccountSubType,
From = fromDate,
To = to.Date,
OpeningBalance = priorBalance,
PeriodDebits = entries.Sum(e => e.Debit),
PeriodCredits = entries.Sum(e => e.Credit),
ClosingBalance = runningBalance,
Entries = entries
};
}
/// <summary>
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
/// <summary>
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
/// by summing all activity prior to that date across every transaction source and adding
/// the stored <c>OpeningBalance</c>. The opening balance is only included when its recorded
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
/// accounts and credits increase credit-normal accounts.
/// </summary>
private async Task<decimal> ComputePriorBalanceAsync(
PowderCoating.Core.Entities.Account account, DateTime beforeDate, DateTime periodEnd, bool normalDebitBalance)
{
var accountId = account.Id;
decimal debits = 0;
decimal credits = 0;
// 1. Customer payments deposited INTO this account (DEBIT)
debits += await _context.Payments
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// 2. Direct expenses paid FROM this account (CREDIT)
credits += await _context.Expenses
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
.SumAsync(e => (decimal?)e.Amount) ?? 0;
// 3. Bill payments made FROM this account (CREDIT)
credits += await _context.BillPayments
.Where(bp => bp.BankAccountId == accountId && bp.PaymentDate < beforeDate)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
// 4. Invoice line items posting revenue to this account (CREDIT)
credits += await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == accountId
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate < beforeDate)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
// 5. Sales tax collected to this account (CREDIT)
credits += await _context.Invoices
.Where(i => i.SalesTaxAccountId == accountId
&& i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
// 6. Direct expenses categorized to this account (DEBIT)
debits += await _context.Expenses
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
.SumAsync(e => (decimal?)e.Amount) ?? 0;
// 7. Bill line items categorized to this account (DEBIT)
debits += await _context.BillLineItems
.Where(bli => bli.AccountId == accountId
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate < beforeDate)
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
// 8. Accounts Receivable
if (account.AccountSubType == AccountSubType.AccountsReceivable)
{
debits += await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate < beforeDate)
.SumAsync(i => (decimal?)i.Total) ?? 0;
credits += await _context.Payments
.Where(p => p.PaymentDate < beforeDate)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
}
// 9. Accounts Payable
if (account.AccountSubType == AccountSubType.AccountsPayable)
{
credits += await _context.Bills
.Where(b => b.APAccountId == accountId
&& b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided
&& b.BillDate < beforeDate)
.SumAsync(b => (decimal?)b.Total) ?? 0;
debits += await _context.BillPayments
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
}
decimal netActivity = normalDebitBalance ? debits - credits : credits - debits;
// Apply the opening balance if it was established on or before the end of the viewed period.
// A null date means it predates all transactions and always applies.
decimal openingBalance = (account.OpeningBalanceDate == null || account.OpeningBalanceDate.Value.Date <= periodEnd)
? account.OpeningBalance
: 0;
return openingBalance + netActivity;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Database-backed key/value store for platform-wide configuration settings managed by
/// SuperAdmins at <c>/PlatformSettings</c>. Settings are persisted in the <c>PlatformSettings</c>
/// table as free-form strings; callers are responsible for parsing typed values (booleans, ints,
/// etc.) from the returned strings. Rows are seeded at startup so that every expected key always
/// has a row — the <see cref="SetAsync"/> guard that inserts a missing row is a safety net for
/// future migrations, not the normal path.
/// </summary>
public class PlatformSettingsService : IPlatformSettingsService
{
private readonly ApplicationDbContext _db;
/// <summary>
/// Constructs the service with a direct <see cref="ApplicationDbContext"/> reference.
/// Direct context access is used (rather than a repository) because
/// <c>PlatformSetting</c> does not inherit <c>BaseEntity</c> and therefore is not managed
/// by the generic <c>Repository&lt;T&gt;</c> or filtered by the global query filters.
/// </summary>
public PlatformSettingsService(ApplicationDbContext db)
{
_db = db;
}
/// <summary>
/// Retrieves the value for the given <paramref name="key"/>, or <c>null</c> when the key
/// does not exist. Uses <c>AsNoTracking</c> because reads are more frequent than writes and
/// the caller never modifies the returned value directly.
/// </summary>
public async Task<string?> GetAsync(string key)
{
var setting = await _db.PlatformSettings
.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == key);
return setting?.Value;
}
/// <summary>
/// Creates or updates the platform setting identified by <paramref name="key"/>.
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
/// <c>UpdatedAt</c> (UTC) for audit purposes. Inserts a new row if the key is missing —
/// this should only happen when a new setting key is introduced without a corresponding
/// seed migration. Calls <c>SaveChangesAsync</c> directly rather than going through
/// <c>IUnitOfWork</c> to avoid inadvertently flushing unrelated tracked changes from the
/// caller's scope.
/// </summary>
public async Task SetAsync(string key, string? value, string? updatedBy = null)
{
var setting = await _db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == key);
if (setting == null)
{
// Shouldn't happen in normal use (rows are seeded), but handle gracefully
setting = new PlatformSetting { Key = key };
_db.PlatformSettings.Add(setting);
}
setting.Value = value;
setting.UpdatedAt = DateTime.UtcNow;
setting.UpdatedBy = updatedBy;
await _db.SaveChangesAsync();
}
/// <summary>
/// Returns all platform settings ordered by group then key, suitable for rendering the
/// SuperAdmin settings management UI. Results are read with <c>AsNoTracking</c> for
/// performance; the UI renders them read-only and individual edits go through
/// <see cref="SetAsync"/>.
/// </summary>
public async Task<IReadOnlyList<PlatformSetting>> GetAllAsync()
{
return await _db.PlatformSettings
.AsNoTracking()
.OrderBy(s => s.GroupName)
.ThenBy(s => s.Key)
.ToListAsync();
}
}
@@ -0,0 +1,516 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Powder;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Aggregates powder coating usage data into actionable insights across three capability layers:
/// <list type="bullet">
/// <item><description>Layer 1 (always): low-stock forecasting based on active job demand.</description></item>
/// <item><description>Layer 2 (≥ <c>AppConstants.PowderInsights.Layer2MinJobs</c> jobs with actuals):
/// per-SKU coverage efficiency vs. catalog spec.</description></item>
/// <item><description>Layer 3 (≥ <c>AppConstants.PowderInsights.Layer3MinJobs</c> jobs with actuals):
/// predictive reorder suggestions and waste-pattern analysis.</description></item>
/// </list>
/// The layered approach prevents the dashboard from showing statistically meaningless averages
/// when a company has only a few recorded usage data points.
/// </summary>
public class PowderInsightsService : IPowderInsightsService
{
private readonly ApplicationDbContext _context;
private readonly ILogger<PowderInsightsService> _logger;
public PowderInsightsService(ApplicationDbContext context, ILogger<PowderInsightsService> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Counts the distinct number of jobs that have at least one <see cref="PowderUsageLog"/>
/// entry and compares it against the Layer 2 and Layer 3 thresholds defined in
/// <see cref="AppConstants.PowderInsights"/> to determine which insight tiers are unlocked.
/// The job count (not log count) is used because a single job can have many coat records
/// and would otherwise inflate the readiness score for small shops.
/// </summary>
public async Task<PowderDataReadiness> GetDataReadinessAsync(int companyId)
{
var count = await _context.PowderUsageLogs
.Where(l => !l.IsDeleted && l.CompanyId == companyId)
.Select(l => l.JobId)
.Distinct()
.CountAsync();
var layer3Min = AppConstants.PowderInsights.Layer3MinJobs;
var layer2Min = AppConstants.PowderInsights.Layer2MinJobs;
return new PowderDataReadiness
{
JobsWithActualData = count,
Layer3MinJobs = layer3Min,
Layer2MinJobs = layer2Min,
IsLayer2Ready = count >= layer2Min,
IsLayer3Ready = count >= layer3Min,
Layer3ProgressPercent = Math.Min(100, (int)(count * 100.0 / layer3Min))
};
}
/// <summary>
/// Assembles the full Powder Insights dashboard DTO, conditionally populating Layer 2
/// and Layer 3 sections based on the company's data readiness.
/// Low-stock alerts (Layer 1) are always computed because they have business value from
/// the very first job and require no historical actuals — only scheduled demand vs. stock.
/// Higher-layer computations are gated so they never return misleading statistics when the
/// sample size is too small for meaningful averages.
/// </summary>
public async Task<PowderInsightsDashboardDto> GetDashboardAsync(int companyId)
{
var readiness = await GetDataReadinessAsync(companyId);
var dashboard = new PowderInsightsDashboardDto
{
Readiness = readiness
};
// Layer 2: always compute (useful from day one)
dashboard.LowStockAlerts = await GetLowStockForecastAsync(companyId);
dashboard.TotalEstimatedPowderNeededLbs = dashboard.LowStockAlerts.Sum(a => a.ScheduledDemandLbs);
dashboard.ActiveJobsNeedingPowder = dashboard.LowStockAlerts.Sum(a => a.ActiveJobCount);
if (readiness.IsLayer2Ready)
dashboard.EfficiencyBySku = await GetEfficiencyBySkuAsync(companyId);
// Layer 3: only compute when data threshold is met
if (readiness.IsLayer3Ready)
{
dashboard.ReorderSuggestions = await GetReorderSuggestionsAsync(companyId);
dashboard.WastePatterns = await GetWastePatternsAsync(companyId);
}
return dashboard;
}
/// <summary>
/// Returns a per-coat breakdown of estimated vs. actual powder usage for a single job,
/// including variance in lbs and percentage. Used on the Job Details page to show
/// whether each coat's actual consumption matched the pre-job estimate.
/// <para>
/// Returns <c>null</c> if the job does not exist or belongs to a different company,
/// enforcing tenant isolation without throwing an exception.
/// </para>
/// <c>HasAllActuals</c> is true only when every coat in the job has been recorded —
/// the UI uses this to show a "complete" badge vs. a "partial" warning.
/// </summary>
public async Task<JobPowderSummaryDto?> GetJobPowderSummaryAsync(int jobId, int companyId)
{
var job = await _context.Jobs
.FirstOrDefaultAsync(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted);
if (job == null) return null;
var coats = await _context.JobItemCoats
.Include(c => c.JobItem)
.Include(c => c.InventoryItem)
.Where(c => !c.IsDeleted && c.CompanyId == companyId && c.JobItem.JobId == jobId)
.OrderBy(c => c.JobItem.Description)
.ThenBy(c => c.Sequence)
.ToListAsync();
var coatDtos = coats.Select(c =>
{
decimal? variance = c.ActualPowderUsedLbs.HasValue && c.PowderToOrder.HasValue
? c.ActualPowderUsedLbs.Value - c.PowderToOrder.Value
: null;
decimal? variancePct = variance.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0
? variance.Value / c.PowderToOrder.Value * 100
: null;
return new CoatPowderSummaryDto
{
JobItemCoatId = c.Id,
JobItemId = c.JobItemId,
ItemDescription = c.JobItem.Description,
CoatName = c.CoatName,
ColorName = c.ColorName ?? c.InventoryItem?.ColorName,
InventoryItemName = c.InventoryItem?.Name,
EstimatedLbs = c.PowderToOrder,
ActualLbs = c.ActualPowderUsedLbs,
VarianceLbs = variance,
VariancePct = variancePct,
IsRecorded = c.ActualPowderUsedLbs.HasValue
};
}).ToList();
return new JobPowderSummaryDto
{
JobId = jobId,
JobNumber = job.JobNumber,
Coats = coatDtos,
TotalEstimatedLbs = coatDtos.Sum(c => c.EstimatedLbs ?? 0),
TotalActualLbs = coatDtos.Sum(c => c.ActualLbs ?? 0),
TotalVarianceLbs = coatDtos.Sum(c => c.VarianceLbs ?? 0),
HasAllActuals = coatDtos.Any() && coatDtos.All(c => c.IsRecorded)
};
}
/// <summary>
/// Saves the actual powder consumed for a single coat application and appends a
/// <see cref="PowderUsageLog"/> row for analytics purposes.
/// <para>
/// Two writes are performed atomically via a single <c>SaveChangesAsync()</c>:
/// (1) <c>JobItemCoat.ActualPowderUsedLbs</c> is updated in place, and
/// (2) a new <see cref="PowderUsageLog"/> row is inserted to build the historical dataset
/// used by Layer 2 and Layer 3 insight calculations.
/// </para>
/// The log row stores both actual and estimated lbs so that variance can be computed later
/// without joining back to the coat table — enabling efficient aggregation queries.
/// Returns a result DTO (never throws) so AJAX callers get a structured response.
/// </summary>
public async Task<RecordUsageResultDto> RecordActualUsageAsync(
int jobItemCoatId, decimal actualLbs, string userId, int companyId, string? notes = null)
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.JobItem)
.FirstOrDefaultAsync(c => c.Id == jobItemCoatId && c.CompanyId == companyId && !c.IsDeleted);
if (coat == null)
return new RecordUsageResultDto { Success = false, ErrorMessage = "Coat not found." };
if (actualLbs <= 0)
return new RecordUsageResultDto { Success = false, ErrorMessage = "Actual lbs must be greater than zero." };
var estimated = coat.PowderToOrder ?? 0;
var variance = actualLbs - estimated;
// Update the coat
coat.ActualPowderUsedLbs = actualLbs;
coat.UpdatedAt = DateTime.UtcNow;
coat.UpdatedBy = userId;
// Create usage log
var log = new PowderUsageLog
{
JobId = coat.JobItem.JobId,
JobItemId = coat.JobItemId,
JobItemCoatId = jobItemCoatId,
InventoryItemId = coat.InventoryItemId,
ActualLbsUsed = actualLbs,
EstimatedLbs = estimated,
VarianceLbs = variance,
RecordedByUserId = userId,
RecordedAt = DateTime.UtcNow,
Notes = notes,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
_context.PowderUsageLogs.Add(log);
await _context.SaveChangesAsync();
var variancePct = estimated > 0 ? variance / estimated * 100 : 0;
return new RecordUsageResultDto
{
Success = true,
ActualLbs = actualLbs,
EstimatedLbs = estimated,
VarianceLbs = variance,
VariancePct = variancePct
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recording powder usage for coat {CoatId}", jobItemCoatId);
return new RecordUsageResultDto { Success = false, ErrorMessage = "An error occurred saving the record." };
}
}
// ── Layer 2: Low stock forecast ──────────────────────────────────────────
/// <summary>
/// Computes powder demand for all active (non-terminal) jobs and compares it against
/// current inventory stock levels to surface potential shortfalls before they stall
/// production.
/// <para>
/// Terminal statuses ("COMPLETED", "DELIVERED", "CANCELLED") are identified via the
/// <c>JobStatusLookup</c> table rather than an enum because job status is DB-backed
/// (see AI Accounting Features note in MEMORY.md).
/// </para>
/// Results are sorted by shortfall descending (worst first) so shop managers see the
/// most critical risks at the top of the dashboard alert list.
/// Only inventory items explicitly linked to coat rows (<c>InventoryItemId</c> not null)
/// are included; custom powders without an inventory record are excluded since their
/// stock cannot be tracked.
/// </summary>
private async Task<List<LowStockForecastDto>> GetLowStockForecastAsync(int companyId)
{
// Get IDs of active (non-terminal) jobs
var terminalStatuses = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
var activeJobIds = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && j.CompanyId == companyId
&& !terminalStatuses.Contains(j.JobStatus.StatusCode))
.Select(j => j.Id)
.ToListAsync();
if (!activeJobIds.Any()) return new List<LowStockForecastDto>();
// Sum powder demand by inventory item for active jobs
var demandRows = await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => !c.IsDeleted && c.CompanyId == companyId
&& c.InventoryItemId.HasValue
&& c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0
&& activeJobIds.Contains(c.JobItem.JobId))
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g => new
{
InventoryItemId = g.Key,
TotalDemandLbs = g.Sum(c => c.PowderToOrder!.Value),
ActiveJobCount = g.Select(c => c.JobItem.JobId).Distinct().Count()
})
.ToListAsync();
if (!demandRows.Any()) return new List<LowStockForecastDto>();
var inventoryItemIds = demandRows.Select(d => d.InventoryItemId).ToList();
var items = await _context.InventoryItems
.Where(i => !i.IsDeleted && inventoryItemIds.Contains(i.Id))
.ToListAsync();
return demandRows
.Join(items, d => d.InventoryItemId, i => i.Id, (d, i) => new LowStockForecastDto
{
InventoryItemId = i.Id,
Name = i.Name,
ColorName = i.ColorName,
ColorCode = i.ColorCode,
Manufacturer = i.Manufacturer,
CurrentStockLbs = i.QuantityOnHand,
ScheduledDemandLbs = d.TotalDemandLbs,
ReorderPoint = i.ReorderPoint,
ReorderQuantity = i.ReorderQuantity,
ShortfallLbs = Math.Max(0, d.TotalDemandLbs - i.QuantityOnHand),
IsAtRisk = i.QuantityOnHand < d.TotalDemandLbs,
IsBelowReorderPoint = i.QuantityOnHand <= i.ReorderPoint,
ActiveJobCount = d.ActiveJobCount
})
.OrderByDescending(f => f.ShortfallLbs)
.ThenBy(f => f.CurrentStockLbs)
.ToList();
}
// ── Layer 2: Per-SKU efficiency ──────────────────────────────────────────
/// <summary>
/// Computes the actual average coverage (sq ft per lb) achieved per inventory SKU across
/// all coats that have recorded actuals and compares it to the manufacturer's catalog spec.
/// <para>
/// The catalog coverage rate falls back to 30 sq ft/lb when the inventory item has no
/// <c>CoverageSqFtPerLb</c> recorded — 30 is a reasonable industry average for standard
/// powder coatings and prevents a division-by-zero while still producing a useful delta.
/// </para>
/// Coverage per coat is calculated as: <c>(SurfaceAreaSqFt × Quantity) / ActualPowderUsedLbs</c>.
/// Results are sorted by variance ascending so SKUs performing worst relative to spec
/// appear first, directing attention to powders wasting material.
/// <c>HasEnoughData</c> is true when at least 5 coat samples exist — below that threshold
/// the average can be misleading and the UI may choose to suppress actionability cues.
/// </summary>
private async Task<List<PowderEfficiencyDto>> GetEfficiencyBySkuAsync(int companyId)
{
var coats = await _context.JobItemCoats
.Include(c => c.JobItem)
.Include(c => c.InventoryItem)
.Where(c => !c.IsDeleted && c.CompanyId == companyId
&& c.InventoryItemId.HasValue
&& c.ActualPowderUsedLbs.HasValue && c.ActualPowderUsedLbs.Value > 0
&& c.JobItem.SurfaceAreaSqFt > 0)
.ToListAsync();
return coats
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g =>
{
var sample = g.First();
// CoverageSqFtPerLb is nullable on InventoryItem; fall back to 30
var catalogRate = sample.InventoryItem?.CoverageSqFtPerLb ?? 30m;
var actualCoverages = g
.Select(c => (c.JobItem.SurfaceAreaSqFt * c.JobItem.Quantity) / c.ActualPowderUsedLbs!.Value)
.ToList();
var avgActual = actualCoverages.Average();
var variancePct = catalogRate > 0 ? (avgActual - catalogRate) / catalogRate * 100 : 0;
return new PowderEfficiencyDto
{
InventoryItemId = g.Key,
Name = sample.InventoryItem?.Name ?? "Unknown",
ColorName = sample.InventoryItem?.ColorName,
Manufacturer = sample.InventoryItem?.Manufacturer,
CatalogCoverageSqFtPerLb = catalogRate,
ActualAvgCoverageSqFtPerLb = Math.Round(avgActual, 2),
VariancePct = Math.Round(variancePct, 1),
SampleCount = g.Count(),
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0),
TotalActualLbs = g.Sum(c => c.ActualPowderUsedLbs ?? 0),
IsBelowSpec = avgActual < catalogRate,
HasEnoughData = g.Count() >= 5
};
})
.OrderBy(e => e.VariancePct) // Worst performers first
.ToList();
}
// ── Layer 3: Predictive reorder ──────────────────────────────────────────
/// <summary>
/// Generates suggested reorder quantities per powder SKU by combining two demand signals:
/// <list type="number">
/// <item><description><b>Pipeline demand</b>: sum of <c>PowderToOrder</c> across active
/// jobs with a due date within the next 30 days.</description></item>
/// <item><description><b>Historical average</b>: actual monthly usage over the last
/// 6 months divided by 6 (a 1-month rolling buffer).</description></item>
/// </list>
/// The suggested order quantity is <c>max(pipeline + avgMonthly currentStock, configuredReorderQty)</c>,
/// ensuring the suggestion is never less than the shop's own configured minimum order.
/// <para>
/// <c>ConfidenceScore</c> saturates at 1.0 after 50 job samples; below that it scales
/// linearly. The UI uses this to show "low confidence" warnings when suggestions are
/// based on very few historical data points.
/// </para>
/// Only SKUs with a positive suggested quantity are returned — items that are already
/// overstocked relative to demand are omitted to keep the list actionable.
/// </summary>
private async Task<List<PowderReorderSuggestionDto>> GetReorderSuggestionsAsync(int companyId)
{
var cutoff30 = DateTime.UtcNow.AddDays(30);
// Pipeline demand: scheduled jobs in next 30 days
var terminalStatuses = new[] { "COMPLETED", "DELIVERED", "CANCELLED" };
var upcomingJobIds = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && j.CompanyId == companyId
&& !terminalStatuses.Contains(j.JobStatus.StatusCode)
&& (j.DueDate == null || j.DueDate <= cutoff30))
.Select(j => j.Id)
.ToListAsync();
var pipelineDemand = await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => !c.IsDeleted && c.CompanyId == companyId
&& c.InventoryItemId.HasValue
&& c.PowderToOrder.HasValue
&& upcomingJobIds.Contains(c.JobItem.JobId))
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g => new { InventoryItemId = g.Key, DemandLbs = g.Sum(c => c.PowderToOrder!.Value) })
.ToListAsync();
// Historical actual monthly usage (last 6 months)
var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6);
var historicalUsage = await _context.PowderUsageLogs
.Where(l => !l.IsDeleted && l.CompanyId == companyId
&& l.InventoryItemId.HasValue
&& l.RecordedAt >= sixMonthsAgo)
.GroupBy(l => l.InventoryItemId!.Value)
.Select(g => new
{
InventoryItemId = g.Key,
TotalLbs = g.Sum(l => l.ActualLbsUsed),
JobCount = g.Select(l => l.JobId).Distinct().Count()
})
.ToListAsync();
var allInventoryIds = pipelineDemand.Select(p => p.InventoryItemId)
.Union(historicalUsage.Select(h => h.InventoryItemId))
.Distinct().ToList();
var items = await _context.InventoryItems
.Where(i => !i.IsDeleted && allInventoryIds.Contains(i.Id))
.ToListAsync();
return items.Select(item =>
{
var pipeline = pipelineDemand.FirstOrDefault(p => p.InventoryItemId == item.Id);
var history = historicalUsage.FirstOrDefault(h => h.InventoryItemId == item.Id);
var avgMonthly = history != null ? history.TotalLbs / 6m : 0m;
var pipelineLbs = pipeline?.DemandLbs ?? 0m;
// Suggestion: cover pipeline + 1 month buffer, minus current stock
var needed = pipelineLbs + avgMonthly - item.QuantityOnHand;
var suggested = Math.Max(needed, item.ReorderQuantity);
// Confidence: scales with job sample count (saturates at 50 jobs = 1.0)
var confidence = history != null ? Math.Min(1.0m, history.JobCount / 50m) : 0m;
return new PowderReorderSuggestionDto
{
InventoryItemId = item.Id,
Name = item.Name,
ColorName = item.ColorName,
Manufacturer = item.Manufacturer,
CurrentStockLbs = item.QuantityOnHand,
PipelineDemand30DaysLbs = pipelineLbs,
HistoricalAvgMonthlyUsageLbs = Math.Round(avgMonthly, 2),
SuggestedOrderQtyLbs = Math.Max(0, Math.Round(suggested, 1)),
ConfiguredReorderQty = item.ReorderQuantity,
SampleJobCount = history?.JobCount ?? 0,
ConfidenceScore = confidence
};
})
.Where(s => s.SuggestedOrderQtyLbs > 0)
.OrderByDescending(s => s.ConfidenceScore)
.ThenByDescending(s => s.SuggestedOrderQtyLbs)
.ToList();
}
// ── Layer 3: Waste patterns ──────────────────────────────────────────────
/// <summary>
/// Finds the worst-performing coat applications where actual powder consumption exceeded
/// the estimate by more than 20%. The 20% threshold is intentional — minor variances
/// (operator weighing differences, humidity) are normal; outliers above 20% usually
/// indicate genuine process issues such as excessive spray distance, part geometry
/// complexity underestimation, or equipment calibration drift.
/// <para>
/// Results are ordered by overage percentage descending and capped at the 50 worst
/// records to keep the query and response size bounded. Job date is the log's
/// <c>RecordedAt</c> timestamp (when the weight was entered) rather than the job's
/// own date, which gives the most accurate temporal context for trend analysis.
/// </para>
/// </summary>
private async Task<List<WastePatternDto>> GetWastePatternsAsync(int companyId)
{
// Jobs where actual > estimated by more than 20%
return await _context.PowderUsageLogs
.Include(l => l.Job)
.Include(l => l.JobItem)
.Include(l => l.JobItemCoat)
.Include(l => l.InventoryItem)
.Where(l => !l.IsDeleted && l.CompanyId == companyId
&& l.EstimatedLbs > 0
&& l.VarianceLbs / l.EstimatedLbs > 0.20m) // > 20% over
.OrderByDescending(l => l.VarianceLbs / l.EstimatedLbs)
.Take(50)
.Select(l => new WastePatternDto
{
JobId = l.JobId,
JobNumber = l.Job.JobNumber,
ItemDescription = l.JobItem.Description,
CoatName = l.JobItemCoat.CoatName,
InventoryItemName = l.InventoryItem != null ? l.InventoryItem.Name : null,
Complexity = l.JobItem.Complexity,
EstimatedLbs = l.EstimatedLbs,
ActualLbs = l.ActualLbsUsed,
OveragePct = l.EstimatedLbs > 0 ? Math.Round(l.VarianceLbs / l.EstimatedLbs * 100, 1) : 0,
JobDate = l.RecordedAt
})
.ToListAsync();
}
}
@@ -0,0 +1,99 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a default chart of accounts for a newly onboarded company, covering all five
/// standard double-entry categories: Assets, Liabilities, Equity, Revenue (with separate
/// powder coating and sandblasting lines), Cost of Goods Sold, and Expenses.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted accounts already exist for this
/// company, so re-running seed does not produce duplicate account numbers.
/// </para>
/// <para>
/// Several accounts are marked <c>IsSystem = true</c> (Checking, AR, AP, and main Powder
/// Coating Revenue). System accounts are referenced by account number in other seeders
/// (e.g. <c>SeedInvoicesAsync</c> looks up account "4000" for invoice line items) and
/// should not be renamed or deleted by end users.
/// </para>
/// <para>
/// Account numbers follow a conventional small-business numbering scheme:
/// 1xxx = Assets, 2xxx = Liabilities, 3xxx = Equity, 4xxx = Revenue,
/// 5xxx = Cost of Goods Sold, 6xxx = Expenses. This makes the chart immediately
/// recognisable to accountants without custom configuration.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed the chart of accounts for.</param>
/// <returns>Number of account records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedDefaultChartOfAccountsAsync(Company company)
{
var existingCount = await _context.Set<Account>()
.IgnoreQueryFilters()
.CountAsync(a => a.CompanyId == company.Id && !a.IsDeleted);
if (existingCount > 0)
return 0; // Already seeded
var now = DateTime.UtcNow;
var accounts = new List<Account>
{
// ── ASSETS ────────────────────────────────────────────────────────
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1300", Name = "Equipment", AccountType = AccountType.Asset, AccountSubType = AccountSubType.FixedAsset, IsSystem = false, IsActive = true, Description = "Ovens, booths, sandblasters, and equipment", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1310", Name = "Accumulated Depreciation", AccountType = AccountType.Asset, AccountSubType = AccountSubType.FixedAsset, IsSystem = false, IsActive = true, Description = "Accumulated depreciation on equipment", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1400", Name = "Prepaid Expenses", AccountType = AccountType.Asset, AccountSubType = AccountSubType.OtherCurrentAsset, IsSystem = false, IsActive = true, Description = "Prepaid insurance, rent, and other items", CompanyId = company.Id, CreatedAt = now },
// ── LIABILITIES ───────────────────────────────────────────────────
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now },
// ── EQUITY ────────────────────────────────────────────────────────
new Account { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = false, IsActive = true, Description = "Owner's invested capital", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "3100", Name = "Retained Earnings", AccountType = AccountType.Equity, AccountSubType = AccountSubType.RetainedEarnings, IsSystem = false, IsActive = true, Description = "Cumulative net income retained in business", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "3200", Name = "Owner's Draw", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = false, IsActive = true, Description = "Withdrawals by owner", CompanyId = company.Id, CreatedAt = now },
// ── REVENUE ───────────────────────────────────────────────────────
new Account { AccountNumber = "4000", Name = "Powder Coating Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.Sales, IsSystem = true, IsActive = true, Description = "Revenue from powder coating services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
// ── COST OF GOODS SOLD ────────────────────────────────────────────
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "5100", Name = "Powder & Materials", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Powder coatings and direct materials used", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "5200", Name = "Consumables & Shop Supplies", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Masking, tape, and other job supplies", CompanyId = company.Id, CreatedAt = now },
// ── EXPENSES ──────────────────────────────────────────────────────
new Account { AccountNumber = "6000", Name = "Advertising & Marketing", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Advertising, IsSystem = false, IsActive = true, Description = "Advertising, marketing, and promotions", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6100", Name = "Equipment & Repairs", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Equipment, IsSystem = false, IsActive = true, Description = "Equipment maintenance and repair costs", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6200", Name = "Insurance", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Insurance, IsSystem = false, IsActive = true, Description = "Business, liability, and equipment insurance", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6300", Name = "Payroll & Labor", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Payroll, IsSystem = false, IsActive = true, Description = "Wages, salaries, and payroll taxes", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6400", Name = "Professional Fees", AccountType = AccountType.Expense, AccountSubType = AccountSubType.ProfessionalFees, IsSystem = false, IsActive = true, Description = "Accounting, legal, and consulting fees", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6500", Name = "Rent & Facilities", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Rent, IsSystem = false, IsActive = true, Description = "Shop rent and facility costs", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6600", Name = "Utilities", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Utilities, IsSystem = false, IsActive = true, Description = "Electric, gas, water, and internet", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6700", Name = "Vehicle & Transportation", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Vehicle, IsSystem = false, IsActive = true, Description = "Fuel, vehicle maintenance, and transport", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6800", Name = "Office Supplies", AccountType = AccountType.Expense, AccountSubType = AccountSubType.OfficeSupplies, IsSystem = false, IsActive = true, Description = "General office and administrative supplies", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6900", Name = "Bank Charges & Fees", AccountType = AccountType.Expense, AccountSubType = AccountSubType.BankCharges, IsSystem = false, IsActive = true, Description = "Bank fees, merchant processing, and wire fees", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6950", Name = "Depreciation Expense", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Depreciation, IsSystem = false, IsActive = true, Description = "Depreciation on fixed assets", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "6990", Name = "Other Expenses", AccountType = AccountType.Expense, AccountSubType = AccountSubType.Other, IsSystem = false, IsActive = true, Description = "Miscellaneous business expenses", CompanyId = company.Id, CreatedAt = now },
};
await _context.Set<Account>().AddRangeAsync(accounts);
await _context.SaveChangesAsync();
return accounts.Count;
}
}
@@ -0,0 +1,450 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds seven standard appointment status lookup rows for a company:
/// Scheduled, Confirmed, In Progress, Completed, Cancelled, No Show, and Rescheduled.
/// </summary>
/// <remarks>
/// Appointment statuses follow the same lookup-table pattern as job statuses: the
/// <c>StatusCode</c> strings are referenced by application code; the display name, colour,
/// and icon are cosmetic and operator-customisable.
///
/// Terminal statuses (Completed, Cancelled, No Show) set <c>IsTerminalStatus = true</c>
/// so the calendar and scheduler can filter them out of the "active" appointment view
/// without hard-coding status codes.
///
/// System-defined statuses (Scheduled, Completed, Cancelled) are marked
/// <c>IsSystemDefined = true</c> to prevent operators from deleting them, as the
/// appointment-creation workflow always defaults to "SCHEDULED" on new records.
///
/// Idempotency: bails early if 7 or more rows already exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed appointment statuses for.</param>
/// <returns>The number of status rows created (7), or 0 if already seeded.</returns>
private async Task<int> SeedAppointmentStatusLookupsAsync(Company company)
{
// Check if appointment statuses already exist for this company
var existingCount = await _context.Set<AppointmentStatusLookup>()
.IgnoreQueryFilters()
.CountAsync(s => s.CompanyId == company.Id && !s.IsDeleted);
if (existingCount >= 7)
{
return 0; // Already seeded
}
var statuses = new List<AppointmentStatusLookup>
{
new AppointmentStatusLookup
{
StatusCode = "SCHEDULED",
DisplayName = "Scheduled",
DisplayOrder = 1,
ColorClass = "primary",
IconClass = "bi-calendar-check",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = false,
Description = "Appointment has been scheduled",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "CONFIRMED",
DisplayName = "Confirmed",
DisplayOrder = 2,
ColorClass = "success",
IconClass = "bi-check-circle",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Customer has confirmed the appointment",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "IN_PROGRESS",
DisplayName = "In Progress",
DisplayOrder = 3,
ColorClass = "warning",
IconClass = "bi-hourglass-split",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Appointment is currently in progress",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "COMPLETED",
DisplayName = "Completed",
DisplayOrder = 4,
ColorClass = "success",
IconClass = "bi-check2-all",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
Description = "Appointment was completed successfully",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "CANCELLED",
DisplayName = "Cancelled",
DisplayOrder = 5,
ColorClass = "danger",
IconClass = "bi-x-circle",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
Description = "Appointment was cancelled",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "NO_SHOW",
DisplayName = "No Show",
DisplayOrder = 6,
ColorClass = "secondary",
IconClass = "bi-person-x",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = true,
Description = "Customer did not show up for appointment",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentStatusLookup
{
StatusCode = "RESCHEDULED",
DisplayName = "Rescheduled",
DisplayOrder = 7,
ColorClass = "info",
IconClass = "bi-arrow-repeat",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
Description = "Appointment has been rescheduled to a different time",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<AppointmentStatusLookup>().AddRangeAsync(statuses);
await _context.SaveChangesAsync();
return statuses.Count;
}
/// <summary>
/// Seeds four standard appointment type lookup rows for a company:
/// Customer Drop-Off, Customer Pick-Up, Consultation/Quote, and Scheduled Job Work.
/// </summary>
/// <remarks>
/// Appointment types categorise the purpose of a calendar entry and control optional UI
/// behaviour via <c>RequiresJobLink</c>: the JOB_WORK type sets this to <c>true</c>,
/// prompting the calendar UI to show a mandatory job selector when scheduling that type.
/// All other types leave it <c>false</c> because drop-offs, pick-ups, and consultations
/// may exist before a job has been created.
///
/// The <c>TypeCode</c> strings are referenced by <see cref="SeedAppointmentsAsync"/>
/// when building appointment titles and determining job-link probability, so they must
/// not be changed after initial seeding.
///
/// Colour classes (purple, green, blue, orange) map to custom CSS variables in the
/// calendar stylesheet — they are distinct from Bootstrap's standard colour palette to
/// give each appointment type a unique calendar stripe colour.
///
/// Idempotency: bails early if 4 or more rows already exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed appointment types for.</param>
/// <returns>The number of type rows created (4), or 0 if already seeded.</returns>
private async Task<int> SeedAppointmentTypeLookupsAsync(Company company)
{
// Check if appointment types already exist for this company
var existingCount = await _context.Set<AppointmentTypeLookup>()
.IgnoreQueryFilters()
.CountAsync(t => t.CompanyId == company.Id && !t.IsDeleted);
if (existingCount >= 4)
{
return 0; // Already seeded
}
var types = new List<AppointmentTypeLookup>
{
new AppointmentTypeLookup
{
TypeCode = "DROP_OFF",
DisplayName = "Customer Drop-Off",
DisplayOrder = 1,
ColorClass = "purple",
IconClass = "bi-box-arrow-in-down",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Customer dropping off items for coating",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "PICK_UP",
DisplayName = "Customer Pick-Up",
DisplayOrder = 2,
ColorClass = "green",
IconClass = "bi-box-arrow-up",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Customer picking up completed items",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "CONSULTATION",
DisplayName = "Consultation/Quote",
DisplayOrder = 3,
ColorClass = "blue",
IconClass = "bi-chat-dots",
RequiresJobLink = false,
IsActive = true,
IsSystemDefined = true,
Description = "Consultation meeting or quote discussion",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new AppointmentTypeLookup
{
TypeCode = "JOB_WORK",
DisplayName = "Scheduled Job Work",
DisplayOrder = 4,
ColorClass = "orange",
IconClass = "bi-tools",
RequiresJobLink = true,
IsActive = true,
IsSystemDefined = true,
Description = "Scheduled work time for a specific job",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<AppointmentTypeLookup>().AddRangeAsync(types);
await _context.SaveChangesAsync();
return types.Count;
}
/// <summary>
/// Seeds up to 50 sample appointments for a company, distributed across weekdays over
/// the next ~60 days, with randomised types, customers, durations, statuses, and optional
/// job links and worker assignments.
/// </summary>
/// <remarks>
/// <b>Purpose:</b> Provides a realistic-looking calendar on first login so that the
/// Appointments / Calendar feature is immediately useful to evaluate in a demo environment.
///
/// <b>Randomisation:</b> A seeded <see cref="Random"/> with seed 42 is used so that the
/// generated appointments are deterministic — re-seeding the same company always produces
/// the same schedule, which simplifies support and testing reproducibility.
///
/// <b>Weekend exclusion:</b> Saturday and Sunday are skipped to match typical shop hours.
/// The loop iterates calendar days (up to a 90-day safety cap) rather than counting
/// business days directly because the simpler approach avoids edge cases around month
/// boundaries and holidays.
///
/// <b>Job links:</b> JOB_WORK type appointments have a 40% chance of linking to an
/// existing job; other types have a 20% chance. Jobs and customers must already be seeded
/// (via <see cref="SeedJobsAsync"/> and <see cref="SeedCustomersAsync"/>) for links to
/// be available — if either collection is empty, the method still seeds appointments but
/// without links.
///
/// <b>Worker assignments:</b> 50% of appointments are assigned to a random active company
/// user. Workers are loaded without <c>IgnoreQueryFilters</c> because the global
/// filter for users is identity-based, not company-scoped, and the check
/// <c>u.CompanyId == company.Id</c> is applied in the LINQ predicate instead.
///
/// <b>Prerequisites:</b> Requires appointment types and statuses to already be seeded;
/// returns 0 without creating any records if either collection is empty.
///
/// Idempotency: returns 0 if 10 or more appointments already exist for the company.
/// The threshold is 10 (not 50) so that a previously partial seed is detected and skipped
/// rather than doubled up.
/// </remarks>
/// <param name="company">The tenant company to seed appointments for.</param>
/// <returns>The number of appointment records created (up to 50), or 0 if skipped.</returns>
private async Task<int> SeedAppointmentsAsync(Company company)
{
// Check if appointments already exist for this company
var existingCount = await _context.Set<Appointment>()
.IgnoreQueryFilters()
.CountAsync(a => a.CompanyId == company.Id && !a.IsDeleted);
if (existingCount >= 10)
{
return 0; // Already seeded (at least some appointments exist)
}
// Get required lookup data
var appointmentTypes = await _context.Set<AppointmentTypeLookup>()
.IgnoreQueryFilters()
.Where(t => t.CompanyId == company.Id)
.OrderBy(t => t.DisplayOrder)
.ToListAsync();
var appointmentStatuses = await _context.Set<AppointmentStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.OrderBy(s => s.DisplayOrder)
.ToListAsync();
var customers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id)
.OrderBy(c => c.Id)
.ToListAsync();
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id)
.OrderBy(j => j.Id)
.ToListAsync();
var workers = await _context.Set<ApplicationUser>()
.Where(u => u.CompanyId == company.Id && u.IsActive)
.OrderBy(u => u.Id)
.ToListAsync();
if (!appointmentTypes.Any() || !appointmentStatuses.Any() || !customers.Any())
{
return 0; // Can't seed appointments without required data
}
var random = new Random(42); // Deterministic for consistency
var appointments = new List<Appointment>();
var startDate = DateTime.Today;
var appointmentTitles = new Dictionary<string, string[]>
{
["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" },
["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
};
// Get status IDs by code for easy assignment
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
// Generate 50 appointments across next 60 days (weekdays only)
int appointmentsCreated = 0;
int daysChecked = 0;
var currentDate = startDate;
while (appointmentsCreated < 50 && daysChecked < 90) // Safety limit
{
currentDate = startDate.AddDays(daysChecked);
daysChecked++;
// Skip weekends
if (currentDate.DayOfWeek == DayOfWeek.Saturday || currentDate.DayOfWeek == DayOfWeek.Sunday)
{
continue;
}
// Create 1-3 appointments per weekday (varied distribution)
int appointmentsToday = random.Next(1, 4);
for (int i = 0; i < appointmentsToday && appointmentsCreated < 50; i++)
{
// Random type
var appointmentType = appointmentTypes[random.Next(appointmentTypes.Count)];
// Random customer
var customer = customers[random.Next(customers.Count)];
// Random time during business hours (8 AM - 4 PM for start times)
int startHour = random.Next(8, 17);
int startMinute = random.Next(0, 4) * 15; // 0, 15, 30, 45
var scheduledStart = new DateTime(currentDate.Year, currentDate.Month, currentDate.Day,
startHour, startMinute, 0, DateTimeKind.Utc);
// Duration: 30 min to 2 hours
int durationMinutes = random.Next(1, 5) * 30; // 30, 60, 90, or 120 minutes
var scheduledEnd = scheduledStart.AddMinutes(durationMinutes);
// Status: 80% SCHEDULED, 20% CONFIRMED (for future appointments)
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
// Title
string title = $"{customer.CompanyName} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
// Optional job link (40% chance if type is JOB_WORK, 20% for others)
int? jobId = null;
if (jobs.Any())
{
int jobLinkChance = appointmentType.TypeCode == "JOB_WORK" ? 40 : 20;
if (random.Next(100) < jobLinkChance)
{
jobId = jobs[random.Next(jobs.Count)].Id;
}
}
// Optional user assignment (50% chance)
string? assignedWorkerId = null;
if (workers.Any() && random.Next(100) < 50)
{
assignedWorkerId = workers[random.Next(workers.Count)].Id;
}
// Location (60% chance)
string? location = null;
if (random.Next(100) < 60)
{
var locations = new[] { "Main Office", "Loading Dock", "Shop Floor", "Coating Area", "Reception" };
location = locations[random.Next(locations.Length)];
}
var appointment = new Appointment
{
AppointmentNumber = $"APT-{currentDate:yyMM}-{(appointmentsCreated + 1):D4}",
CustomerId = customer.Id,
JobId = jobId,
AppointmentStatusId = statusId,
AppointmentTypeId = appointmentType.Id,
AssignedUserId = assignedWorkerId,
Title = title,
Description = random.Next(100) < 40 ? $"Scheduled appointment for {appointmentType.DisplayName.ToLower()}" : null,
ScheduledStartTime = scheduledStart,
ScheduledEndTime = scheduledEnd,
IsAllDay = false,
Location = location,
IsReminderEnabled = random.Next(100) < 70, // 70% have reminders
ReminderMinutesBefore = 30,
Notes = random.Next(100) < 30 ? "Auto-generated demo appointment" : null,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
};
appointments.Add(appointment);
appointmentsCreated++;
}
}
await _context.Set<Appointment>().AddRangeAsync(appointments);
await _context.SaveChangesAsync();
return appointments.Count;
}
}
@@ -0,0 +1,675 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a realistic 3-month history of vendor bills (Accounts Payable) across four
/// spending categories — powder orders, consumables/hardware, equipment repairs, and
/// utilities — with a mix of paid, partially-paid, and open statuses.
/// </summary>
/// <remarks>
/// The seeder constructs a narrative AP ledger covering approximately 90 days so that
/// the AI Cash Flow Forecast, AR Aging, and Anomaly Detection reports have meaningful
/// data to analyse from day one of a demo.
///
/// <b>Prerequisites:</b> The method resolves all required accounts by
/// <see cref="AccountSubType"/> or account number from the company's chart of accounts.
/// It also resolves vendors by partial name match (e.g., "Prismatic", "Columbia").
/// If the AP account, the checking account, or at least one vendor is missing the method
/// returns 0 (skips) rather than throwing, because those dependencies are seeded in
/// earlier steps which may have failed.
///
/// <b>Local helper <c>AddBill</c>:</b> Assigns the sequential bill number
/// (format <c>BILL-YYMM-####</c>), stamps <c>CompanyId</c> on the bill and all its
/// line items, saves, then optionally creates a <see cref="BillPayment"/> with a
/// sequential payment number (<c>BPMT-YYMM-####</c>). Separating save-per-bill from
/// the payment save preserves the FK relationship without needing explicit navigation
/// property loading.
///
/// <b>Double-entry note:</b> The bill entity itself records the AP credit (via
/// <c>APAccountId</c>); individual line items carry the debit account (e.g., powder
/// expense account 5100). The payment records the AP debit and cash credit (via
/// <c>BankAccountId</c>). The seed data mirrors this pattern but does NOT create
/// journal entry rows — those are handled by the AP payment workflow in production.
///
/// <b>Status timeline:</b>
/// - Months 3 to 1: bills with matching paid payments (closed AP).
/// - Current month: open bills (due soon) to populate the AP Aging report.
/// - One overdue specialty bill (past due date, still open) to trigger the anomaly
/// detection feature's "overdue payable" flag.
///
/// Idempotency: returns 0 immediately if any bills exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed bills for.</param>
/// <returns>The total number of <see cref="Bill"/> records created.</returns>
private async Task<int> SeedBillsAsync(Company company)
{
var existingCount = await _context.Set<Bill>()
.IgnoreQueryFilters()
.CountAsync(b => b.CompanyId == company.Id && !b.IsDeleted);
if (existingCount > 0)
return 0;
// ── Account lookups ───────────────────────────────────────────────────
var apAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.AccountsPayable);
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Checking);
var powderAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "5100");
var consumablesAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "5200");
var equipRepairsAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6100");
var utilitiesAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6600");
if (apAccount == null || checkingAccount == null)
return 0;
// ── Vendor lookups ────────────────────────────────────────────────────
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
.ToListAsync();
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault();
var aceHardware = vendors.FirstOrDefault(v => v.CompanyName.Contains("Ace")) ?? vendors.FirstOrDefault();
var fastenal = vendors.FirstOrDefault(v => v.CompanyName.Contains("Fastenal")) ?? vendors.FirstOrDefault();
var fallback = vendors.FirstOrDefault();
if (fallback == null)
return 0;
var now = DateTime.UtcNow;
var prefix = $"BILL-{now:yy}{now.Month:D2}-";
var pmtPfx = $"BPMT-{now:yy}{now.Month:D2}-";
var seeded = 0;
var billSeq = 1;
var pmtSeq = 1;
// Helper: add a bill and optional payment, save, return bill
async Task<Bill> AddBill(
Bill bill, BillPayment? payment = null)
{
bill.BillNumber = $"{prefix}{billSeq++:D4}";
bill.CompanyId = company.Id;
foreach (var li in bill.LineItems) { li.CompanyId = company.Id; li.CreatedAt = bill.CreatedAt; }
await _context.Set<Bill>().AddAsync(bill);
await _context.SaveChangesAsync();
seeded++;
if (payment != null)
{
payment.PaymentNumber = $"{pmtPfx}{pmtSeq++:D4}";
payment.BillId = bill.Id;
payment.CompanyId = company.Id;
payment.CreatedAt = payment.PaymentDate;
await _context.Set<BillPayment>().AddAsync(payment);
await _context.SaveChangesAsync();
}
return bill;
}
// ── POWDER ORDERS ─────────────────────────────────────────────────────
// Month -3: Large powder restock — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-77211",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-90),
DueDate = now.AddDays(-60),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Quarterly powder restock — month 1",
SubTotal = 1_145.00m,
Total = 1_145.00m,
AmountPaid = 1_145.00m,
CreatedAt = now.AddDays(-90),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 165.00m, Amount = 330.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Silver Powder — 25 lbs", Quantity = 2, UnitPrice = 144.50m, Amount = 289.00m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 1, UnitPrice = 170.00m, Amount = 170.00m, DisplayOrder = 4 }
}
}, new BillPayment
{
VendorId = (prismatic ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-60),
Amount = 1_145.00m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "PP-77211 — paid in full"
});
// Month -2: Mid-cycle specialty colors — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "CC-4401",
VendorId = (columbia ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-65),
DueDate = now.AddDays(-35),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Specialty metallic & candy colors",
SubTotal = 986.00m,
Total = 986.00m,
AmountPaid = 986.00m,
CreatedAt = now.AddDays(-65),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Chrome Effect Powder — 10 lbs", Quantity = 2, UnitPrice = 168.00m, Amount = 336.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Hammertone Bronze — 10 lbs", Quantity = 1, UnitPrice = 150.50m, Amount = 150.50m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps & Hooks", Quantity = 1, UnitPrice = 64.50m, Amount = 64.50m, DisplayOrder = 4 }
}
}, new BillPayment
{
VendorId = (columbia ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-35),
Amount = 986.00m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "CC-4401 — paid in full"
});
// Month -1: Regular restock — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-83440",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-45),
DueDate = now.AddDays(-15),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Monthly powder restock",
SubTotal = 892.50m,
Total = 892.50m,
AmountPaid = 892.50m,
CreatedAt = now.AddDays(-45),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 5, UnitPrice = 89.00m, Amount = 445.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 4, UnitPrice = 86.50m, Amount = 346.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Plugs Assortment", Quantity = 1, UnitPrice = 101.50m, Amount = 101.50m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (prismatic ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-15),
Amount = 892.50m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "PP-83440 — paid in full"
});
// Current month: Open (due soon)
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-88530",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-8),
DueDate = now.AddDays(22),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Current month powder order",
SubTotal = 1_050.00m,
Total = 1_050.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-8),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 6, UnitPrice = 89.00m, Amount = 534.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss Red Powder — 10 lbs", Quantity = 2, UnitPrice = 132.00m, Amount = 264.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks (10-pack)", Quantity = 2, UnitPrice = 126.00m, Amount = 252.00m, DisplayOrder = 3 }
}
});
// Overdue specialty order
await AddBill(new Bill
{
VendorInvoiceNumber = "CC-5509",
VendorId = (columbia ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-50),
DueDate = now.AddDays(-5),
Status = BillStatus.Open,
Terms = "Net 45",
Memo = "Specialty colors — overdue",
SubTotal = 1_240.00m,
Total = 1_240.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-50),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Chrome Effect — 10 lbs", Quantity = 3, UnitPrice = 168.00m, Amount = 504.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Hammertone Bronze — 10 lbs", Quantity = 2, UnitPrice = 150.50m, Amount = 301.00m, DisplayOrder = 3 }
}
});
// ── CONSUMABLES / HARDWARE ─────────────────────────────────────────────
// Month -3: Fastenal hardware — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-18822",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-85),
DueDate = now.AddDays(-55),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Hardware & consumables restock",
SubTotal = 412.50m,
Total = 412.50m,
AmountPaid = 412.50m,
CreatedAt = now.AddDays(-85),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "J-Hook Hangers Assortment", Quantity = 2, UnitPrice = 89.75m, Amount = 179.50m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Mixed (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives", Quantity = 1, UnitPrice = 113.00m, Amount = 113.00m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (fastenal ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-55),
Amount = 412.50m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1082",
Memo = "FST-18822 — paid in full"
});
// Month -1: Fastenal — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-20041",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-40),
DueDate = now.AddDays(-10),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Monthly hardware restock",
SubTotal = 298.00m,
Total = 298.00m,
AmountPaid = 298.00m,
CreatedAt = now.AddDays(-40),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Sandpaper & Abrasive Pads (assorted)", Quantity = 2, UnitPrice = 74.50m, Amount = 149.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Small (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Safety Gloves (12-pair pack)", Quantity = 1, UnitPrice = 29.00m, Amount = 29.00m, DisplayOrder = 3 }
}
}, new BillPayment
{
VendorId = (fastenal ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10),
Amount = 298.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1086",
Memo = "FST-20041 — paid in full"
});
// Current: Fastenal — Open
await AddBill(new Bill
{
VendorInvoiceNumber = "FST-20441",
VendorId = (fastenal ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-7),
DueDate = now.AddDays(23),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Monthly hardware & fastener restock",
SubTotal = 318.75m,
Total = 318.75m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-7),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 198.75m, Amount = 198.75m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Caps — Mixed (100-pack)", Quantity = 2, UnitPrice = 60.00m, Amount = 120.00m, DisplayOrder = 2 }
}
});
// ── EQUIPMENT REPAIRS ─────────────────────────────────────────────────
// Month -3: Sandblaster service — Paid
await AddBill(new Bill
{
VendorInvoiceNumber = "ACE-6901",
VendorId = (aceHardware ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-80),
DueDate = now.AddDays(-50),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Sandblaster nozzle & cabinet seal replacement",
SubTotal = 310.00m,
Total = 310.00m,
AmountPaid = 310.00m,
CreatedAt = now.AddDays(-80),
LineItems =
{
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Blast Nozzle Tungsten — 3/8\"", Quantity = 2, UnitPrice = 85.00m, Amount = 170.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Cabinet Door Seal Kit", Quantity = 1, UnitPrice = 140.00m, Amount = 140.00m, DisplayOrder = 2 }
}
}, new BillPayment
{
VendorId = (aceHardware ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-50),
Amount = 310.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1079",
Memo = "ACE-6901 — sandblaster parts"
});
// Month -1: Oven repair — Partially paid
await AddBill(new Bill
{
VendorInvoiceNumber = "ACE-7714",
VendorId = (aceHardware ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-20),
DueDate = now.AddDays(10),
Status = BillStatus.PartiallyPaid,
Terms = "Net 30",
Memo = "Oven heating element + shop supplies",
SubTotal = 540.00m,
Total = 540.00m,
AmountPaid = 200.00m,
CreatedAt = now.AddDays(-20),
LineItems =
{
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Oven Heating Element — 240V 5000W", Quantity = 1, UnitPrice = 385.00m, Amount = 385.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Shop Supplies — Wire Brushes, Sandpaper", Quantity = 1, UnitPrice = 155.00m, Amount = 155.00m, DisplayOrder = 2 }
}
}, new BillPayment
{
VendorId = (aceHardware ?? fallback).Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10),
Amount = 200.00m,
PaymentMethod = PaymentMethod.Check,
CheckNumber = "1087",
Memo = "ACE-7714 — partial payment"
});
// ── UTILITIES (3 months each) ─────────────────────────────────────────
// Electric — month -3 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-01",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-90),
DueDate = now.AddDays(-75),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — 3 months ago",
SubTotal = 592.10m,
Total = 592.10m,
AmountPaid = 592.10m,
CreatedAt = now.AddDays(-90),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 592.10m, Amount = 592.10m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-75),
Amount = 592.10m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — month -2 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-02",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-60),
DueDate = now.AddDays(-45),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — 2 months ago",
SubTotal = 618.42m,
Total = 618.42m,
AmountPaid = 618.42m,
CreatedAt = now.AddDays(-60),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 618.42m, Amount = 618.42m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-45),
Amount = 618.42m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — month -1 (paid)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-03",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-30),
DueDate = now.AddDays(-15),
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = "Electric — last month",
SubTotal = 574.88m,
Total = 574.88m,
AmountPaid = 574.88m,
CreatedAt = now.AddDays(-30),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 574.88m, Amount = 574.88m, DisplayOrder = 1 }
}
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-15),
Amount = 574.88m,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = "Electric bill — auto pay"
});
// Electric — current month (open)
await AddBill(new Bill
{
VendorInvoiceNumber = "ELEC-2024-04",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-2),
DueDate = now.AddDays(15),
Status = BillStatus.Open,
Terms = "Due on Receipt",
Memo = "Electric — current month",
SubTotal = 601.30m,
Total = 601.30m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-2),
LineItems =
{
new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = 601.30m, Amount = 601.30m, DisplayOrder = 1 }
}
});
return seeded;
}
/// <summary>
/// Seeds recurring direct-expense transactions for the company covering the past ~90 days:
/// shop rent, natural gas, business insurance, Google Ads / Yelp, software subscriptions,
/// office supplies, and monthly bank/card-processing fees.
/// </summary>
/// <remarks>
/// Expenses differ from bills in that they are single-line, immediately-paid transactions
/// (no AP intermediary) — they debit an expense account and credit a cash or credit-card
/// account directly. This mirrors the accounting pattern used by the Expenses module.
///
/// <b>Account resolution:</b> Accounts are looked up by account number (e.g., "6500" for
/// rent, "6600" for utilities) rather than by name so that customised display names do not
/// break the seeder. A cascading fallback chain ensures the method still seeds something
/// useful even if the chart of accounts was partially seeded — the first non-null expense
/// account found is used as a last-resort category. If no expense account and no checking
/// account are found, the method returns 0 and logs nothing (silent skip handled by caller).
///
/// <b>Credit card vs. checking:</b> Advertising and software subscriptions are charged to
/// the credit card account when one exists (matching real-world business practice). All
/// other expenses use the checking account. If no credit card account was seeded, the
/// checking account is used as fallback for all categories.
///
/// <b>Local helper <c>AddExp</c>:</b> Assigns a sequential expense number
/// (format <c>EXP-YYMM-####</c>), saves immediately per expense so that the sequence
/// counter increments correctly and individual failures can be diagnosed from logs.
///
/// Idempotency: returns 0 immediately if any expense records exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed expenses for.</param>
/// <returns>The total number of <see cref="Expense"/> records created.</returns>
private async Task<int> SeedExpensesAsync(Company company)
{
var existingCount = await _context.Set<Expense>()
.IgnoreQueryFilters()
.CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
if (existingCount > 0)
return 0;
// ── Account lookups ───────────────────────────────────────────────────
var checkingAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.Checking);
var creditCardAccount = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountSubType == AccountSubType.CreditCard);
var utilitiesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6600");
var rentAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6500");
var advertisingAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6000");
var officeSuppliesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6800");
var bankChargesAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6900");
var insuranceAcct = await _context.Set<Account>().IgnoreQueryFilters().FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountNumber == "6200");
if (checkingAccount == null)
return 0;
var fallbackExpense = utilitiesAcct ?? rentAcct ?? advertisingAcct
?? await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted && a.AccountType == AccountType.Expense);
if (fallbackExpense == null)
return 0;
var vendors = await _context.Set<Vendor>().IgnoreQueryFilters()
.Where(v => v.CompanyId == company.Id && !v.IsDeleted).ToListAsync();
var now = DateTime.UtcNow;
var pfx = $"EXP-{now:yy}{now.Month:D2}-";
var seeded = 0;
var expSeq = 1;
var cc = creditCardAccount ?? checkingAccount;
async Task AddExp(
DateTime date, int? vendorId, Account expAcct, Account pmtAcct,
PaymentMethod method, decimal amount, string memo)
{
await _context.Set<Expense>().AddAsync(new Expense
{
ExpenseNumber = $"{pfx}{expSeq++:D4}",
Date = date,
VendorId = vendorId,
ExpenseAccountId = expAcct.Id,
PaymentAccountId = pmtAcct.Id,
PaymentMethod = method,
Amount = amount,
Memo = memo,
CompanyId = company.Id,
CreatedAt = date
});
await _context.SaveChangesAsync();
seeded++;
}
// ── SHOP RENT — 3 months ──────────────────────────────────────────────
var rentAccount = rentAcct ?? fallbackExpense;
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago");
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago");
await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month");
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
// ── NATURAL GAS — 3 months ────────────────────────────────────────────
var utilAccount = utilitiesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago");
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago");
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month");
// ── INSURANCE ─────────────────────────────────────────────────────────
var insAccount = insuranceAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1");
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
// ── MARKETING / ADVERTISING ───────────────────────────────────────────
var adAccount = advertisingAcct ?? fallbackExpense;
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign");
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month");
// ── SOFTWARE SUBSCRIPTIONS ────────────────────────────────────────────
var swAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
// ── OFFICE SUPPLIES ───────────────────────────────────────────────────
var offAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
// ── BANK FEES ─────────────────────────────────────────────────────────
var bankAccount = bankChargesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees");
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees");
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees");
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees");
return seeded;
}
}
@@ -0,0 +1,214 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds eight catalog categories and 60 pre-priced catalog items representing the
/// most common powder coating service types: automotive wheels, engine components,
/// outdoor furniture, railings, gates and fencing, fitness equipment, office/commercial
/// fixtures, and shop merchandise (apparel and retail items).
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: checks for the existence of the sentinel category "Automotive Wheels"
/// (first category, always seeded) before inserting. Returns 0 if it already exists.
/// </para>
/// <para>
/// SKUs are prefixed with the company code (e.g. <c>DEMO-WHL-18ALU</c>) so that catalog
/// items from different tenants never collide and seeded items can be reliably identified
/// for removal by the remove seeder.
/// </para>
/// <para>
/// Shop Merchandise items (T-shirts, hats, stickers, gift cards) have
/// <c>DefaultEstimatedMinutes = 0</c> and both sandblasting/masking flags set to false
/// because they are retail items, not coating services. This is intentional and ensures
/// the job-wizard does not add prep-service costs to merchandise line items.
/// </para>
/// <para>
/// Categories and items are inserted in two separate <c>SaveChangesAsync</c> calls so that
/// EF Core has assigned IDs to categories before they are referenced as foreign keys by items.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed catalog data for.</param>
/// <returns>Number of catalog items inserted (categories are not counted), or 0 if already seeded.</returns>
private async Task<int> SeedCatalogAsync(Company company)
{
// Check if seed catalog already exists by looking for specific category
var seedCategoryExists = await _context.Set<CatalogCategory>()
.IgnoreQueryFilters()
.AnyAsync(c => c.CompanyId == company.Id && c.Name == "Automotive Wheels" && !c.IsDeleted);
if (seedCategoryExists)
{
return 0; // Seed data already exists
}
// Create root categories
var categories = new List<CatalogCategory>
{
new CatalogCategory
{
Name = "Automotive Wheels",
Description = "Car, truck, and motorcycle wheels",
DisplayOrder = 1,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Engine Components",
Description = "Engine parts, covers, and accessories",
DisplayOrder = 2,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Outdoor Furniture",
Description = "Patio and garden furniture",
DisplayOrder = 3,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Railings & Handrails",
Description = "Residential and commercial railings",
DisplayOrder = 4,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Gates & Fencing",
Description = "Gates, fencing panels, and posts",
DisplayOrder = 5,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Fitness Equipment",
Description = "Gym and exercise equipment components",
DisplayOrder = 6,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Office & Commercial",
Description = "Office furniture and commercial fixtures",
DisplayOrder = 7,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new CatalogCategory
{
Name = "Shop Merchandise",
Description = "Branded apparel, accessories, and shop retail items",
DisplayOrder = 8,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<CatalogCategory>().AddRangeAsync(categories);
await _context.SaveChangesAsync();
// Create catalog items
var items = new List<CatalogItem>
{
// Automotive Wheels (10 items)
new CatalogItem { Name = "14\" Steel Wheel", SKU = $"{company.CompanyCode}-WHL-14STL", CategoryId = categories[0].Id, DefaultPrice = 45.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 25, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "15\" Aluminum Alloy Wheel", SKU = $"{company.CompanyCode}-WHL-15ALU", CategoryId = categories[0].Id, DefaultPrice = 65.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 30, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "16\" Aluminum Alloy Wheel", SKU = $"{company.CompanyCode}-WHL-16ALU", CategoryId = categories[0].Id, DefaultPrice = 70.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 30, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "17\" Aluminum Alloy Wheel", SKU = $"{company.CompanyCode}-WHL-17ALU", CategoryId = categories[0].Id, DefaultPrice = 75.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 35, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "18\" Aluminum Alloy Wheel", SKU = $"{company.CompanyCode}-WHL-18ALU", CategoryId = categories[0].Id, DefaultPrice = 80.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 35, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "20\" Aluminum Alloy Wheel", SKU = $"{company.CompanyCode}-WHL-20ALU", CategoryId = categories[0].Id, DefaultPrice = 90.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 40, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "22\" Truck Wheel", SKU = $"{company.CompanyCode}-WHL-22TRK", CategoryId = categories[0].Id, DefaultPrice = 110.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 45, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Chrome Wheel (Re-coating)", SKU = $"{company.CompanyCode}-WHL-CHR", CategoryId = categories[0].Id, DefaultPrice = 125.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 60, DisplayOrder = 8, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Motorcycle Wheel", SKU = $"{company.CompanyCode}-WHL-MCYCLE", CategoryId = categories[0].Id, DefaultPrice = 55.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 30, DisplayOrder = 9, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Custom/Specialty Wheel", SKU = $"{company.CompanyCode}-WHL-CUSTOM", CategoryId = categories[0].Id, DefaultPrice = 95.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 50, DisplayOrder = 10, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Engine Components (8 items)
new CatalogItem { Name = "4-Cylinder Valve Cover", SKU = $"{company.CompanyCode}-ENG-VC4", CategoryId = categories[1].Id, DefaultPrice = 45.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 45, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "6-Cylinder Valve Cover", SKU = $"{company.CompanyCode}-ENG-VC6", CategoryId = categories[1].Id, DefaultPrice = 55.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 60, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "8-Cylinder Valve Cover (Pair)", SKU = $"{company.CompanyCode}-ENG-VC8", CategoryId = categories[1].Id, DefaultPrice = 85.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 90, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Intake Manifold", SKU = $"{company.CompanyCode}-ENG-IM", CategoryId = categories[1].Id, DefaultPrice = 65.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 75, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Oil Pan", SKU = $"{company.CompanyCode}-ENG-PAN", CategoryId = categories[1].Id, DefaultPrice = 40.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 40, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Timing Cover", SKU = $"{company.CompanyCode}-ENG-TC", CategoryId = categories[1].Id, DefaultPrice = 38.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 35, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Air Cleaner Housing", SKU = $"{company.CompanyCode}-ENG-AC", CategoryId = categories[1].Id, DefaultPrice = 35.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 30, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Transmission Pan", SKU = $"{company.CompanyCode}-ENG-TPAN", CategoryId = categories[1].Id, DefaultPrice = 42.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 40, DisplayOrder = 8, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Outdoor Furniture (7 items)
new CatalogItem { Name = "Patio Chair Frame", SKU = $"{company.CompanyCode}-FURN-CHAIR", CategoryId = categories[2].Id, DefaultPrice = 40.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 30, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Patio Table Frame (Small)", SKU = $"{company.CompanyCode}-FURN-TBLS", CategoryId = categories[2].Id, DefaultPrice = 75.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 50, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Patio Table Frame (Large)", SKU = $"{company.CompanyCode}-FURN-TBLL", CategoryId = categories[2].Id, DefaultPrice = 95.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 65, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Bench Frame", SKU = $"{company.CompanyCode}-FURN-BENCH", CategoryId = categories[2].Id, DefaultPrice = 85.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 55, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Umbrella Stand", SKU = $"{company.CompanyCode}-FURN-UMBR", CategoryId = categories[2].Id, DefaultPrice = 45.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 25, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Planter Box Frame", SKU = $"{company.CompanyCode}-FURN-PLTR", CategoryId = categories[2].Id, DefaultPrice = 55.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 35, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Garden Trellis", SKU = $"{company.CompanyCode}-FURN-TREL", CategoryId = categories[2].Id, DefaultPrice = 65.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 40, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Railings & Handrails (8 items)
new CatalogItem { Name = "Railing Section (Linear Foot)", SKU = $"{company.CompanyCode}-RAIL-LF", CategoryId = categories[3].Id, DefaultPrice = 12.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 8, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Stair Railing (Per Step)", SKU = $"{company.CompanyCode}-RAIL-STEP", CategoryId = categories[3].Id, DefaultPrice = 18.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 12, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Baluster (Each)", SKU = $"{company.CompanyCode}-RAIL-BAL", CategoryId = categories[3].Id, DefaultPrice = 5.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 3, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Newel Post", SKU = $"{company.CompanyCode}-RAIL-POST", CategoryId = categories[3].Id, DefaultPrice = 35.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 20, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Decorative Finial", SKU = $"{company.CompanyCode}-RAIL-FIN", CategoryId = categories[3].Id, DefaultPrice = 15.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 12, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Wall-Mount Bracket", SKU = $"{company.CompanyCode}-RAIL-BRKT", CategoryId = categories[3].Id, DefaultPrice = 8.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 5, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Corner Post Assembly", SKU = $"{company.CompanyCode}-RAIL-CRNR", CategoryId = categories[3].Id, DefaultPrice = 42.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 25, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Ornamental Panel Insert", SKU = $"{company.CompanyCode}-RAIL-PNL", CategoryId = categories[3].Id, DefaultPrice = 28.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 18, DisplayOrder = 8, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Gates & Fencing (7 items)
new CatalogItem { Name = "Single Gate (3' x 4')", SKU = $"{company.CompanyCode}-GATE-S34", CategoryId = categories[4].Id, DefaultPrice = 150.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 90, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Single Gate (4' x 6')", SKU = $"{company.CompanyCode}-GATE-S46", CategoryId = categories[4].Id, DefaultPrice = 195.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 110, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Double Gate (8' x 6')", SKU = $"{company.CompanyCode}-GATE-D86", CategoryId = categories[4].Id, DefaultPrice = 325.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 180, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Fence Panel (6' x 4')", SKU = $"{company.CompanyCode}-FENCE-64", CategoryId = categories[4].Id, DefaultPrice = 95.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 55, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Fence Panel (8' x 6')", SKU = $"{company.CompanyCode}-FENCE-86", CategoryId = categories[4].Id, DefaultPrice = 125.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 70, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Gate/Fence Post", SKU = $"{company.CompanyCode}-FENCE-POST", CategoryId = categories[4].Id, DefaultPrice = 32.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 18, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Decorative Gate Topper", SKU = $"{company.CompanyCode}-GATE-TOP", CategoryId = categories[4].Id, DefaultPrice = 45.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = true, DefaultEstimatedMinutes = 25, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Fitness Equipment (5 items)
new CatalogItem { Name = "Weight Rack Frame", SKU = $"{company.CompanyCode}-FIT-RACK", CategoryId = categories[5].Id, DefaultPrice = 185.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 100, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Bench Press Frame", SKU = $"{company.CompanyCode}-FIT-BENCH", CategoryId = categories[5].Id, DefaultPrice = 145.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 85, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Cable Machine Frame", SKU = $"{company.CompanyCode}-FIT-CABLE", CategoryId = categories[5].Id, DefaultPrice = 225.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 130, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Dumbbell Stand", SKU = $"{company.CompanyCode}-FIT-DBSTD", CategoryId = categories[5].Id, DefaultPrice = 95.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 55, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Pull-Up Bar Frame", SKU = $"{company.CompanyCode}-FIT-PULLUP", CategoryId = categories[5].Id, DefaultPrice = 110.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 65, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Office & Commercial (5 items)
new CatalogItem { Name = "Office Desk Frame", SKU = $"{company.CompanyCode}-OFF-DESK", CategoryId = categories[6].Id, DefaultPrice = 125.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 70, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Filing Cabinet Frame", SKU = $"{company.CompanyCode}-OFF-FILE", CategoryId = categories[6].Id, DefaultPrice = 95.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 55, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Shelving Unit (5-Tier)", SKU = $"{company.CompanyCode}-OFF-SHELF", CategoryId = categories[6].Id, DefaultPrice = 115.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 65, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Display Rack", SKU = $"{company.CompanyCode}-OFF-DISP", CategoryId = categories[6].Id, DefaultPrice = 85.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 50, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Safety Railing/Barrier", SKU = $"{company.CompanyCode}-OFF-SAFE", CategoryId = categories[6].Id, DefaultPrice = 145.00m, DefaultRequiresSandblasting = true, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 80, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
// Shop Merchandise (10 items)
new CatalogItem { Name = "T-Shirt (S/M/L)", SKU = $"{company.CompanyCode}-MERCH-TEE-S", CategoryId = categories[7].Id, DefaultPrice = 15.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 1, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "T-Shirt (XL)", SKU = $"{company.CompanyCode}-MERCH-TEE-XL", CategoryId = categories[7].Id, DefaultPrice = 17.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 2, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "T-Shirt (2XL/3XL)", SKU = $"{company.CompanyCode}-MERCH-TEE-2X", CategoryId = categories[7].Id, DefaultPrice = 19.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 3, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Hoodie", SKU = $"{company.CompanyCode}-MERCH-HOOD", CategoryId = categories[7].Id, DefaultPrice = 45.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 4, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Trucker Hat", SKU = $"{company.CompanyCode}-MERCH-HAT-T", CategoryId = categories[7].Id, DefaultPrice = 22.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 5, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Snapback Hat", SKU = $"{company.CompanyCode}-MERCH-HAT-S", CategoryId = categories[7].Id, DefaultPrice = 28.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 6, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Sticker Pack (5-pack)", SKU = $"{company.CompanyCode}-MERCH-STICKER", CategoryId = categories[7].Id, DefaultPrice = 8.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 7, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Can Koozie", SKU = $"{company.CompanyCode}-MERCH-KOOZ", CategoryId = categories[7].Id, DefaultPrice = 5.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 8, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Powder Coat Color Sample Board", SKU = $"{company.CompanyCode}-MERCH-SMPL", CategoryId = categories[7].Id, DefaultPrice = 25.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 9, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow },
new CatalogItem { Name = "Gift Card", SKU = $"{company.CompanyCode}-MERCH-GIFT", CategoryId = categories[7].Id, DefaultPrice = 50.00m, DefaultRequiresSandblasting = false, DefaultRequiresMasking = false, DefaultEstimatedMinutes = 0, DisplayOrder = 10, IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow }
};
await _context.Set<CatalogItem>().AddRangeAsync(items);
await _context.SaveChangesAsync();
return items.Count;
}
}
@@ -0,0 +1,263 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for
/// the given company, spanning automotive, industrial, architectural, fitness, marine,
/// furniture, government, and specialty verticals.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already
/// exist for this company, preventing duplicate customer sets on repeated seed runs.
/// </para>
/// <para>
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that
/// a duplicate-email collision on any single record is caught and converted to a warning
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent
/// the DbContext change-tracker from retrying the failed insert on the next
/// <c>SaveChangesAsync</c> call.
/// </para>
/// <para>
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier,
/// rather than throwing.
/// </para>
/// <para>
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>,
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with
/// <c>IsTaxExempt = true</c> to demonstrate the tax-exempt workflow, matching the
/// production rule that tax-exempt customers get 0 % tax on quotes and invoices.
/// </para>
/// <para>
/// The two local helper functions <c>Comm()</c> and <c>Indiv()</c> reduce the per-row
/// line count; they are defined as local functions rather than private methods because
/// they capture the <c>company</c> parameter by closure and are only needed here.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed customers for.</param>
/// <returns>
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
/// (e.g. because the email already existed).
/// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
{
var warnings = new List<string>();
int seededCount = 0;
int skippedCount = 0;
var now = DateTime.UtcNow;
// Early exit — same pattern as all other seeders
var existingCount = await _context.Set<Customer>()
.IgnoreQueryFilters()
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
if (existingCount > 0)
return (0, warnings);
var tiers = await _context.Set<PricingTier>()
.IgnoreQueryFilters()
.Where(pt => pt.CompanyId == company.Id)
.ToListAsync();
var standardTier = tiers.FirstOrDefault(t => t.TierName == "Standard");
var silverTier = tiers.FirstOrDefault(t => t.TierName == "Silver");
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
// ── Local helpers keep each customer to 2-3 lines ──────────────────────
//
// Comm() builds a commercial (B2B) Customer with credit limit, tax ID, and pricing tier.
// The LastContactDate formula scrambles the months value so that contacts are spread
// across the past 25 days rather than clustering on the same date for all customers.
Customer Comm(string co, string fn, string ln, string em, string ph,
string city, string st, string zip, string terms, decimal credit, decimal bal,
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) =>
new Customer
{
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
Phone = ph, City = city, State = st, ZipCode = zip,
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
IsActive = true,
LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)),
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
};
// Indiv() builds a non-commercial (retail) Customer with simpler fields:
// no credit limit, no tax ID, payment terms default to "Due on receipt".
Customer Indiv(string fn, string ln, string em, string ph,
string city, string st, string zip, string notes, int months) =>
new Customer
{
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
City = city, State = st, ZipCode = zip,
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)),
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
};
var customers = new List<Customer>
{
// ─── Commercial Customers (60) ────────────────────────────────────
// Auto & Motorsports (12)
Comm("Acme Manufacturing Corp", "John", "Smith", "john.smith@acmemfg.com", "(555) 234-5678", "Chicago", "IL", "60601", "Net 30", 50000m, 12500m, "12-3456789", platinumTier, "Large volume customer, weekly shipments", 18),
Comm("Precision Auto Parts LLC", "Sarah", "Johnson", "sjohnson@precisionauto.com", "(555) 345-6789", "Detroit", "MI", "48201", "Net 30", 35000m, 8750m, "23-4567890", goldTier, "Automotive parts manufacturer", 15),
Comm("Classic Wheel Restoration", "Robert", "Taylor", "rtaylor@classicwheels.com", "(555) 789-0123", "Phoenix", "AZ", "85001", "Net 15", 15000m, 3200m, "67-8901234", silverTier, "Classic car wheel specialist", 10),
Comm("MotorSports Custom Shop", "Chris", "Brown", "cbrown@motorsportscustom.com", "(555) 901-2345", "Indianapolis", "IN", "46201", "Net 15", 20000m, 9500m, "89-0123456", silverTier, "Performance parts and custom fabrication", 8),
Comm("Metro Automotive Group", "Frank", "DeNucci", "frank.dnucci@metroauto.com", "(555) 210-3311", "Detroit", "MI", "48202", "Net 30", 28000m, 6400m, "14-2233445", goldTier, "Multi-brand dealership network", 11),
Comm("Coastal Customs & Fabrication", "Danny", "Morales", "dmorales@coastalcustoms.com", "(555) 887-6543", "San Diego", "CA", "92103", "Net 15", 18000m, 4100m, "22-3344556", silverTier, "Custom truck and SUV builds", 6),
Comm("Desert Speed Shop", "Kyle", "Rennick", "kyle@desertspeedshop.com", "(555) 766-5544", "Scottsdale", "AZ", "85251", "Net 15", 12000m, 2800m, "33-4455667", standardTier, "Performance tuning and fabrication", 4),
Comm("Midwest Motorsports", "Troy", "Edelmann", "troy@midwestmotorsports.com", "(555) 342-1122", "Columbus", "OH", "43201", "Net 30", 22000m, 5300m, "44-5566778", goldTier, "Racing team equipment and parts", 9),
Comm("Track Day Performance", "Megan", "Schultz", "megan@trackdayperformance.com", "(555) 456-9988", "Charlotte", "NC", "28201", "Net 15", 16000m, 3700m, "55-6677889", silverTier, "Track prep and safety equipment", 5),
Comm("Vintage Velocity Restorations", "Harold", "Pearce", "harold@vintagevelocity.com", "(555) 321-7654", "Nashville", "TN", "37201", "Net 30", 25000m, 6100m, "66-7788990", goldTier, "High-end vintage and classic car restoration", 13),
Comm("American Iron Custom Cycles", "Bret", "Conner", "bret@americanironcycles.com", "(555) 654-3210", "Milwaukee", "WI", "53201", "Net 15", 14000m, 3100m, "77-8899001", silverTier, "Custom motorcycle builds and parts", 7),
Comm("All-American Auto Body", "Steve", "Kozlowski", "steve@allamericanautobody.com", "(555) 213-4567", "Cleveland", "OH", "44101", "Net 30", 20000m, 4800m, "88-9900112", goldTier, "Collision repair and custom coating", 10),
// Industrial & Manufacturing (10)
Comm("Industrial Furniture Co", "Jennifer","Anderson", "janderson@indfurniture.com", "(555) 890-1234", "Seattle", "WA", "98101", "Net 30", 30000m, 7800m, "78-9012345", goldTier, "Office and outdoor furniture manufacturer", 16),
Comm("Commercial HVAC Systems", "Kevin", "Garcia", "kgarcia@commercialhvac.com", "(555) 345-6780", "Atlanta", "GA", "30301", "Net 30", 32000m, 8900m, "23-4567891", goldTier, "HVAC ductwork and equipment casings", 17),
Comm("Agricultural Equipment Inc", "Sandra", "White", "swhite@agequipment.com", "(555) 678-9013", "Des Moines", "IA", "50301", "Net 30", 42000m, 16800m, "56-7890124", goldTier, "Farm equipment parts and implements", 19),
Comm("Steel City Fabricators", "Tony", "Marchetti", "tony@steelcityfab.com", "(555) 412-3344", "Pittsburgh", "PA", "15201", "Net 30", 38000m, 10200m, "99-0011223", platinumTier, "Heavy structural steel fabrication", 14),
Comm("Precision Metal Works", "Diane", "Tran", "diane@precisionmetalworks.com", "(555) 503-2211", "Portland", "OR", "97205", "Net 30", 26000m, 5900m, "10-1122334", goldTier, "CNC machined parts and assemblies", 12),
Comm("Continental Manufacturing", "Phil", "Stavros", "pstavros@continentalmfg.com", "(555) 216-8877", "Cleveland", "OH", "44115", "Net 45", 55000m, 18400m, "21-2233445", platinumTier, "Industrial component manufacturing", 20),
Comm("Eagle Industrial Coatings", "Deb", "Hensley", "deb@eagleindustrialcoatings.com", "(555) 317-5566", "Indianapolis", "IN", "46204", "Net 30", 19000m, 4300m, "32-3344556", silverTier, "Subcontract coating for industrial parts", 7),
Comm("Summit Metal Fabricators", "Russ", "Fontaine", "russ@summitmetalfab.com", "(555) 720-4433", "Denver", "CO", "80202", "Net 30", 31000m, 7700m, "43-4455667", goldTier, "Custom metal fabrication and welding", 11),
Comm("Iron Horse Manufacturing", "Craig", "Bukowski", "craig@ironhorsemfg.com", "(555) 414-7788", "Milwaukee", "WI", "53202", "Net 30", 24000m, 5600m, "54-5566778", goldTier, "Heavy equipment components and frames", 9),
Comm("Pacific Metal Works", "Yuki", "Tanaka", "ytanaka@pacificmetalworks.com", "(555) 206-3322", "Seattle", "WA", "98104", "Net 15", 17000m, 3800m, "65-6677889", silverTier, "Sheet metal fabrication and finishing", 6),
// Architectural & Construction (8)
Comm("Urban Railings & Gates", "Michael", "Chen", "mchen@urbanrailings.com", "(555) 456-7890", "San Francisco", "CA", "94102", "Net 15", 25000m, 5200m, "34-5678901", silverTier, "Ornamental iron railings and gates", 12),
Comm("Heritage Architectural Metalworks","Thomas","Miller", "tmiller@heritagemetal.com", "(555) 123-4567", "Charleston", "SC", "29401", "Net 30", 28000m, 6700m, "01-2345678", goldTier, "Historic restoration and custom architectural pieces",13),
Comm("Skyline Structural Steel", "Marcus", "Webb", "mwebb@skylinsteel.com", "(555) 312-9876", "Chicago", "IL", "60607", "Net 45", 65000m, 22000m, "76-7788990", platinumTier, "Commercial and industrial structural steel", 22),
Comm("Premier Fence & Gate Co", "Lori", "Hale", "lori@premierfenceandgate.com", "(555) 602-1199", "Phoenix", "AZ", "85004", "Net 30", 23000m, 5400m, "87-8899001", goldTier, "Residential and commercial fencing", 8),
Comm("Modern Railing Systems", "Evan", "Choi", "echoi@modernrailingsystems.com", "(555) 415-8844", "San Jose", "CA", "95110", "Net 30", 27000m, 6200m, "98-9900112", goldTier, "Interior and exterior railing design", 10),
Comm("Coastal Aluminum Products", "Patty", "Larson", "plarson@coastalaluminum.com", "(555) 904-3366", "Tampa", "FL", "33601", "Net 30", 21000m, 4700m, "09-0011223", goldTier, "Aluminum windows, doors, and structures", 7),
Comm("Metro Door & Window", "Sam", "Petrov", "spetrov@metrodoorwindow.com", "(555) 718-4455", "Brooklyn", "NY", "11201", "Net 30", 29000m, 7100m, "20-1122334", goldTier, "Commercial door and window systems", 11),
Comm("Rocky Mountain Ironworks", "Buck", "Ramsey", "buck@rockymtnironworks.com", "(555) 303-6677", "Denver", "CO", "80203", "Net 15", 18500m, 4100m, "31-2233445", silverTier, "Custom wrought iron and steel artisan work", 5),
// Fitness & Recreation (5)
Comm("Fitness Equipment Solutions", "Lisa", "Martinez", "lmartinez@fitequip.com", "(555) 567-8901", "Austin", "TX", "78701", "Net 30", 40000m, 15600m, "45-6789012", goldTier, "Gym equipment frames and accessories", 14),
Comm("Playground Equipment USA", "Nancy", "Martinez", "nmartinez@playgroundusa.com", "(555) 456-7891", "Portland", "OR", "97201", "Net 30", 38000m, 14500m, "34-5678902", platinumTier, "Commercial playground equipment manufacturer", 22),
Comm("Diamond Fitness Equipment", "Lamar", "Okafor", "lamar@diamondfitness.com", "(555) 713-2288", "Houston", "TX", "77002", "Net 30", 33000m, 8100m, "42-3344556", goldTier, "Commercial gym and fitness center equipment", 9),
Comm("Peak Performance Products", "Stacy", "Owens", "stacy@peakperformanceproducts.com", "(555) 503-7711", "Eugene", "OR", "97401", "Net 15", 16000m, 3500m, "53-4455667", silverTier, "Outdoor fitness and sports equipment", 6),
Comm("All-Star Sports Equipment", "Jerome", "Watkins", "jwatkins@allstarsports.com", "(555) 314-5533", "St. Louis", "MO", "63101", "Net 30", 22000m, 5100m, "64-5566778", goldTier, "Team sports equipment and facilities", 8),
// Marine (4)
Comm("Marine Equipment Corp", "Patricia","Wilson", "pwilson@marineequip.com", "(555) 234-5679", "Miami", "FL", "33101", "Net 30", 35000m, 11400m, "12-3456780", silverTier, "Boat hardware and marine fittings", 11),
Comm("Gulf Coast Marine Supply", "Hector", "Vega", "hvega@gulfcoastmarine.com", "(555) 985-6644", "New Orleans", "LA", "70112", "Net 30", 28000m, 6800m, "75-6677889", goldTier, "Commercial and recreational marine hardware", 9),
Comm("Pacific Yacht Hardware", "Erin", "Nakamura", "enakamura@pacificyacht.com", "(555) 310-8822", "Long Beach", "CA", "90802", "Net 15", 20000m, 4500m, "86-7788990", silverTier, "High-end yacht fittings and hardware", 6),
Comm("Lakeside Boat Works", "Walt", "Bauer", "walt@lakesideboatworks.com", "(555) 616-3311", "Grand Rapids", "MI", "49501", "Net 30", 15000m, 3200m, "97-8899001", silverTier, "Freshwater boat repair and custom builds", 4),
// Furniture & Commercial (5)
Comm("Office Systems International", "Brian", "Lee", "blee@officesystems.com", "(555) 567-8902", "Dallas", "TX", "75201", "Net 15", 27000m, 5600m, "45-6789013", silverTier, "Office furniture components and accessories", 9),
Comm("Retail Display Solutions", "Gina", "Russo", "gruso@retaildisplay.com", "(555) 312-6644", "Chicago", "IL", "60608", "Net 30", 19000m, 4300m, "08-9900112", goldTier, "Retail shelving, fixtures, and displays", 7),
Comm("Restaurant Equipment Co", "Marco", "Benetti", "mbenetti@restaurantequipment.com", "(555) 305-1122", "Miami", "FL", "33102", "Net 30", 24000m, 5800m, "19-0011223", goldTier, "Commercial kitchen and restaurant equipment", 10),
Comm("Outdoor Living Products", "Cheryl", "Dobbs", "cdobbs@outdoorlivingproducts.com", "(555) 480-7799", "Tempe", "AZ", "85281", "Net 30", 21000m, 4600m, "30-1122334", goldTier, "Patio and outdoor furniture manufacturer", 8),
Comm("Commercial Shelving Systems", "Ray", "Obasi", "robasi@commercialshelving.com", "(555) 832-5544", "Houston", "TX", "77003", "Net 30", 16000m, 3400m, "41-2233445", silverTier, "Warehouse and retail shelving solutions", 5),
// Energy, Transit & Government (7)
Comm("Metro Transit Authority", "David", "Williams", "dwilliams@metrota.gov", "(555) 678-9012", "Boston", "MA", "02101", "Net 60", 75000m, 22000m, "56-7890123", platinumTier, "Government transit contract — tax exempt", 24, true),
Comm("Green Energy Solutions", "Amanda", "Davis", "adavis@greenenergy.com", "(555) 012-3456", "Denver", "CO", "80201", "Net 30", 45000m, 18200m, "90-1234567", platinumTier, "Solar panel frames and mounting hardware", 20),
Comm("Solar Power Systems Inc", "Neil", "Ostrowski", "nostrowski@solarpowersys.com", "(555) 408-4411", "San Jose", "CA", "95112", "Net 30", 36000m, 9200m, "52-3344556", goldTier, "Solar racking and structural components", 11),
Comm("Wind Energy Components", "Tara", "Haas", "thaas@windenergy.com", "(555) 605-8833", "Austin", "TX", "78702", "Net 45", 48000m, 15600m, "63-4455667", platinumTier, "Wind turbine hardware and mounting systems", 16),
Comm("Municipal Services Group", "Roy", "Nkosi", "rnkosi@municipalservices.gov", "(555) 608-2233", "Sacramento", "CA", "95814", "Net 60", 60000m, 19800m, "74-5566778", platinumTier, "City infrastructure and public works — tax exempt", 28, true),
Comm("Regional Airport Authority", "Lisa", "Crane", "lcrane@regionairport.gov", "(555) 904-5511", "Tampa", "FL", "33602", "Net 60", 55000m, 17200m, "85-6677889", platinumTier, "Airport infrastructure — tax exempt", 21, true),
Comm("County School District", "Terry", "Vance", "tvance@countyschools.edu", "(555) 317-8866", "Indianapolis", "IN", "46205", "Net 60", 40000m, 12500m, "96-7788990", goldTier, "School facility equipment — tax exempt", 15, true),
// Specialty (9)
Comm("Medical Equipment Corp", "Paula", "Jennings", "pjennings@medicalequip.com", "(555) 215-6655", "Philadelphia", "PA", "19103", "Net 30", 42000m, 12800m, "07-8899001", goldTier, "Medical and laboratory equipment frames", 13),
Comm("Food Processing Equipment", "Luis", "Espinoza", "lespinoza@foodprocessingequip.com", "(555) 816-3388", "Indianapolis", "IN", "46206", "Net 30", 31000m, 7400m, "18-9900112", goldTier, "Food-safe coating for processing equipment", 9),
Comm("Security Solutions Group", "Dale", "Pratt", "dpratt@securitysolutionsgrp.com", "(555) 214-4477", "Dallas", "TX", "75202", "Net 30", 26000m, 5900m, "29-0011223", goldTier, "Security enclosures and equipment housing", 8),
Comm("Mining Equipment Corp", "Rex", "Harmon", "rharmon@miningequip.com", "(555) 801-6622", "Salt Lake City", "UT", "84101", "Net 30", 48000m, 16400m, "40-1122334", platinumTier, "Mining and extraction equipment components", 17),
Comm("Construction Equipment Co", "Wayne", "Briggs", "wbriggs@constructionequipco.com", "(555) 918-7733", "Oklahoma City", "OK", "73101", "Net 30", 37000m, 10100m, "51-2233445", goldTier, "Construction and earthmoving equipment parts", 12),
Comm("Water Treatment Systems", "Irene", "Kamau", "ikamau@watertreatmentsys.com", "(555) 503-9944", "Portland", "OR", "97206", "Net 45", 44000m, 14100m, "62-3344556", platinumTier, "Municipal and industrial water treatment equipment", 18),
Comm("Rail Equipment Systems", "Doug", "Stafford", "dstafford@railequipmentsys.com", "(555) 312-7766", "Chicago", "IL", "60609", "Net 45", 52000m, 17800m, "73-4455667", platinumTier, "Railway maintenance and rolling stock equipment", 23),
Comm("Telecommunications Tower Co", "Maggie", "Solis", "msolis@telcotowers.com", "(555) 469-5588", "Dallas", "TX", "75203", "Net 30", 35000m, 9500m, "84-5566778", goldTier, "Cell tower hardware and mounting equipment", 10),
Comm("Data Center Infrastructure", "Bo", "Kimura", "bkimura@datacenterinfra.com", "(555) 408-2266", "San Jose", "CA", "95113", "Net 30", 29000m, 7200m, "95-6677889", goldTier, "Server rack frames and data center equipment", 7),
// ─── Individual / Non-Commercial Customers (40) ───────────────────
Indiv("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6),
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4),
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7),
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3),
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5),
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2),
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8),
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home décor projects", 1),
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5),
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3),
Indiv("Gary", "Nelson", "gnelson@email.com", "(555) 131-4141", "Minneapolis", "MN", "55401", "Snowmobile frame and parts", 2),
Indiv("Carol", "Evans", "carol.evans@email.com", "(555) 242-5252", "Portland", "OR", "97207", "Vintage bicycle restoration", 1),
Indiv("Kenneth", "Scott", "kscott@email.com", "(555) 353-6363", "Baltimore", "MD", "21201", "Antique tool restoration", 3),
Indiv("Helen", "Green", "hgreen@email.com", "(555) 464-7474", "Memphis", "TN", "38101", "Wrought iron bed frame", 4),
Indiv("Donald", "Baker", "dbaker@email.com", "(555) 575-8585", "Louisville", "KY", "40201", "Classic truck restoration", 6),
Indiv("Donna", "Adams", "dadams@email.com", "(555) 686-9696", "Richmond", "VA", "23218", "Outdoor light fixture set", 2),
Indiv("Steven", "Nelson", "steven.n@email.com", "(555) 797-0707", "Columbus", "OH", "43202", "Motorcycle frame and tank", 5),
Indiv("Patricia", "Carter", "pcarter@email.com", "(555) 808-1818", "Austin", "TX", "78703", "Patio table and chair set — 6pc", 3),
Indiv("Mark", "Mitchell", "mmitchell@email.com", "(555) 919-2929", "Denver", "CO", "80204", "Car wheels — set of 4", 1),
Indiv("Sandra", "Perez", "sperez@email.com", "(555) 020-3030", "El Paso", "TX", "79901", "Spiral staircase railing", 4),
Indiv("George", "Roberts", "groberts@email.com", "(555) 141-4242", "Fort Worth", "TX", "76101", "Boat trailer frame", 3),
Indiv("Kathleen", "Turner", "kturner@email.com", "(555) 252-5353", "Nashville", "TN", "37202", "Fireplace grate and screen", 2),
Indiv("Eric", "Phillips", "ephillips@email.com", "(555) 363-6464", "Seattle", "WA", "98105", "Mountain bike frame", 1),
Indiv("Sharon", "Campbell", "scampbell@email.com", "(555) 474-7575", "Boston", "MA", "02102", "Iron garden bench set", 5),
Indiv("Larry", "Parker", "lparker@email.com", "(555) 585-8686", "Detroit", "MI", "48203", "Classic Mustang wheels and trim", 8),
Indiv("Shirley", "Evans", "shevans@email.com", "(555) 696-9797", "Charlotte", "NC", "28202", "Deck railing system", 3),
Indiv("Timothy", "Edwards", "tedwards@email.com", "(555) 707-0808", "Memphis", "TN", "38102", "ATV frame and fenders", 2),
Indiv("Angela", "Collins", "acollins@email.com", "(555) 818-1919", "Las Vegas", "NV", "89101", "Casino chair legs — set of 24", 4),
Indiv("Harold", "Stewart", "hstewart@email.com", "(555) 929-2020", "Tucson", "AZ", "85701", "Vintage pickup restoration parts", 6),
Indiv("Pamela", "Sanchez", "psanchez@email.com", "(555) 030-3131", "Sacramento", "CA", "95815", "Wrought iron wine rack", 1),
Indiv("Edward", "Morris", "emorris@email.com", "(555) 141-4343", "Raleigh", "NC", "27601", "Trailer hitch and receiver set", 2),
Indiv("Frances", "Rogers", "frogers@email.com", "(555) 252-5454", "Minneapolis", "MN", "55402", "Mid-century chair frames — 4pc", 3),
Indiv("Phillip", "Reed", "preed@email.com", "(555) 363-6565", "Omaha", "NE", "68101", "Go-kart frame and roll cage", 1),
Indiv("Ruth", "Cook", "rcook@email.com", "(555) 474-7676", "Tulsa", "OK", "74101", "Farmhouse shelving brackets — large set", 2),
Indiv("Andrew", "Morgan", "amorgan@email.com", "(555) 585-8787", "Atlanta", "GA", "30302", "Drift car cage and subframe", 4),
Indiv("Mildred", "Bell", "mbell@email.com", "(555) 696-9898", "Cincinnati", "OH", "45201", "Garden gate and fence panels", 5),
Indiv("Ralph", "Murphy", "rmurphy@email.com", "(555) 707-0909", "Fresno", "CA", "93701", "Custom motorcycle exhaust system", 3),
Indiv("Lois", "Rivera", "lrivera@email.com", "(555) 818-1010", "Corpus Christi", "TX", "78401", "Outdoor kitchen frame and brackets", 2),
Indiv("Roy", "Cooper", "rcooper@email.com", "(555) 929-2121", "Arlington", "TX", "76001", "Vintage tractor restoration parts", 7),
Indiv("Vera", "Richardson","vrichardson@email.com", "(555) 030-3232", "Lexington", "KY", "40502", "Wrought iron headboard and footboard", 4),
};
// Add customers one at a time to handle duplicates gracefully
foreach (var customer in customers)
{
try
{
var existingCustomer = await _context.Set<Customer>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Email == customer.Email
&& c.CompanyId == company.Id && !c.IsDeleted);
if (existingCustomer != null)
{
skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
warnings.Add($"⊘ Skipped: {name} — email {customer.Email} already exists");
continue;
}
await _context.Set<Customer>().AddAsync(customer);
await _context.SaveChangesAsync();
seededCount++;
}
catch (Exception ex)
{
skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
warnings.Add($"⊘ Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
if (_context.Entry(customer).State != EntityState.Detached)
_context.Entry(customer).State = EntityState.Detached;
}
}
return (seededCount, warnings);
}
}
@@ -0,0 +1,275 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds ten realistic pieces of powder coating shop equipment for the given company:
/// two curing ovens, two spray booths, two blast units, an air compressor, an overhead
/// conveyor, a parts washer, and a powder reclaim system.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency is enforced by checking for the sentinel equipment number
/// <c>{CompanyCode}-OVN-001</c> before inserting. If that record already exists the
/// method returns 0 without touching the database.
/// </para>
/// <para>
/// <c>IgnoreQueryFilters()</c> is used on the existence check so that a previously
/// soft-deleted seed record is still detected and does not cause duplicate inserts.
/// </para>
/// <para>
/// One equipment record is intentionally set to <see cref="EquipmentStatus.NeedsMaintenance"/>
/// (the media blast room) to provide a realistic demo that includes equipment in a degraded
/// state, triggering the maintenance alert banner.
/// </para>
/// <para>
/// Throws <see cref="InvalidOperationException"/> if <paramref name="company"/> has no
/// <c>CompanyCode</c> because all equipment numbers and serial-number fingerprints depend
/// on that value.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed equipment for.</param>
/// <returns>Number of equipment records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedEquipmentAsync(Company company)
{
// Validate company code
if (string.IsNullOrWhiteSpace(company.CompanyCode))
{
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode.");
}
// Check if seed equipment already exists by looking for specific equipment number
var seedEquipmentNumber = $"{company.CompanyCode}-OVN-001";
var seedEquipmentExists = await _context.Set<Equipment>()
.IgnoreQueryFilters()
.AnyAsync(e => e.CompanyId == company.Id && e.EquipmentNumber == seedEquipmentNumber && !e.IsDeleted);
if (seedEquipmentExists)
{
return 0;
}
var equipment = new List<Equipment>
{
new Equipment
{
EquipmentName = "Batch Powder Coating Oven #1",
EquipmentNumber = $"{company.CompanyCode}-OVN-001",
EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems",
Model = "RFS-2400",
SerialNumber = "RFS240023456",
PurchaseDate = DateTime.UtcNow.AddYears(-3),
PurchasePrice = 45000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-1),
Status = EquipmentStatus.Operational,
Location = "Bay 1",
RecommendedMaintenanceIntervalDays = 90,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-45),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(45),
Notes = "24'x8'x8' gas-fired batch oven, max temp 450°F",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-3)
},
new Equipment
{
EquipmentName = "Batch Powder Coating Oven #2",
EquipmentNumber = $"{company.CompanyCode}-OVN-002",
EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems",
Model = "RFS-1800",
SerialNumber = "RFS180012789",
PurchaseDate = DateTime.UtcNow.AddYears(-5),
PurchasePrice = 38000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-3),
Status = EquipmentStatus.Operational,
Location = "Bay 2",
RecommendedMaintenanceIntervalDays = 90,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-30),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(60),
Notes = "18'x8'x8' gas-fired batch oven",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-5)
},
new Equipment
{
EquipmentName = "Automated Powder Coating Booth #1",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-001",
EquipmentType = "Spray Booth",
Manufacturer = "Nordson Corporation",
Model = "Nordson ProBooth 1200",
SerialNumber = "NOR120045678",
PurchaseDate = DateTime.UtcNow.AddYears(-2),
PurchasePrice = 65000m,
WarrantyExpiration = DateTime.UtcNow.AddMonths(6),
Status = EquipmentStatus.Operational,
Location = "Coating Line A",
RecommendedMaintenanceIntervalDays = 30,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-15),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(15),
Notes = "Fully automated booth with cyclone recovery system",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-2)
},
new Equipment
{
EquipmentName = "Manual Powder Coating Booth #2",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-002",
EquipmentType = "Spray Booth",
Manufacturer = "Columbia Coatings",
Model = "CC-Manual-800",
SerialNumber = "CC800034512",
PurchaseDate = DateTime.UtcNow.AddYears(-6),
PurchasePrice = 28000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-4),
Status = EquipmentStatus.Operational,
Location = "Coating Line B",
RecommendedMaintenanceIntervalDays = 60,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-40),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(20),
Notes = "Manual booth for small custom jobs",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-6)
},
new Equipment
{
EquipmentName = "Pressure Blast Cabinet",
EquipmentNumber = $"{company.CompanyCode}-BLAST-001",
EquipmentType = "Sandblaster",
Manufacturer = "Empire Abrasive Equipment",
Model = "Empire Industrial 4836",
SerialNumber = "EMP483623890",
PurchaseDate = DateTime.UtcNow.AddYears(-4),
PurchasePrice = 18500m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-3),
Status = EquipmentStatus.Operational,
Location = "Prep Area A",
RecommendedMaintenanceIntervalDays = 45,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-20),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(25),
Notes = "48\"x36\" cabinet, aluminum oxide media",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-4)
},
new Equipment
{
EquipmentName = "Media Blast Room",
EquipmentNumber = $"{company.CompanyCode}-BLAST-002",
EquipmentType = "Sandblaster",
Manufacturer = "Clemco Industries",
Model = "Clemco BRT-1012",
SerialNumber = "CLM101223456",
PurchaseDate = DateTime.UtcNow.AddYears(-7),
PurchasePrice = 42000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-5),
Status = EquipmentStatus.NeedsMaintenance,
Location = "Prep Area B",
RecommendedMaintenanceIntervalDays = 60,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-75),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(-15),
Notes = "10'x12' walk-in blast room - needs filter replacement",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-7)
},
new Equipment
{
EquipmentName = "Rotary Screw Air Compressor",
EquipmentNumber = $"{company.CompanyCode}-COMP-001",
EquipmentType = "Compressor",
Manufacturer = "Atlas Copco",
Model = "GA75 VSD",
SerialNumber = "ATC7523467",
PurchaseDate = DateTime.UtcNow.AddYears(-3),
PurchasePrice = 35000m,
WarrantyExpiration = DateTime.UtcNow.AddMonths(-6),
Status = EquipmentStatus.Operational,
Location = "Compressor Room",
RecommendedMaintenanceIntervalDays = 90,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-60),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(30),
Notes = "75 HP variable speed drive compressor",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-3)
},
new Equipment
{
EquipmentName = "Overhead Conveyor System",
EquipmentNumber = $"{company.CompanyCode}-CONV-001",
EquipmentType = "Conveyor",
Manufacturer = "Pacline Conveyors",
Model = "PAC-500 Overhead",
SerialNumber = "PAC50034521",
PurchaseDate = DateTime.UtcNow.AddYears(-4),
PurchasePrice = 52000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-2),
Status = EquipmentStatus.Operational,
Location = "Main Production Line",
RecommendedMaintenanceIntervalDays = 180,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-120),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(60),
Notes = "500 lb capacity overhead conveyor with power and free sections",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-4)
},
new Equipment
{
EquipmentName = "Parts Washer System",
EquipmentNumber = $"{company.CompanyCode}-WASH-001",
EquipmentType = "Washer",
Manufacturer = "Better Engineering",
Model = "BE-4896 Power Wash",
SerialNumber = "BE489612345",
PurchaseDate = DateTime.UtcNow.AddYears(-5),
PurchasePrice = 24000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-3),
Status = EquipmentStatus.Operational,
Location = "Prep Area A",
RecommendedMaintenanceIntervalDays = 30,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-10),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(20),
Notes = "48\"x96\" spray washer with heated solution",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-5)
},
new Equipment
{
EquipmentName = "Powder Reclaim System",
EquipmentNumber = $"{company.CompanyCode}-RCLM-001",
EquipmentType = "Reclaim System",
Manufacturer = "Gema USA",
Model = "OptiCenter OC06",
SerialNumber = "GEM0623456",
PurchaseDate = DateTime.UtcNow.AddYears(-2),
PurchasePrice = 32000m,
WarrantyExpiration = DateTime.UtcNow.AddMonths(3),
Status = EquipmentStatus.Operational,
Location = "Coating Line A",
RecommendedMaintenanceIntervalDays = 60,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-35),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(25),
Notes = "Automatic powder recovery and color change system",
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-2)
}
};
await _context.Set<Equipment>().AddRangeAsync(equipment);
await _context.SaveChangesAsync();
return equipment.Count;
}
}
@@ -0,0 +1,214 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds inventory transaction history for a company, comprising four categories of
/// transactions: opening balance (Initial), three months of powder restocks (Purchase),
/// per-job powder consumption for every completed/ready/delivered job (JobUsage),
/// and one waste plus one manual adjustment record.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted inventory transactions already
/// exist for this company, preventing duplicate ledger entries on re-seed.
/// </para>
/// <para>
/// All transactions are accumulated in a single in-memory list and persisted in one
/// <c>AddRangeAsync / SaveChangesAsync</c> call for efficiency. The trade-off is that
/// <c>BalanceAfter</c> values on individual transactions are approximations based on
/// <c>QuantityOnHand</c> at seed time rather than a running sum — they are good enough
/// for demo display purposes but should not be trusted as exact ledger balances.
/// </para>
/// <para>
/// JobUsage quantities are stored as <em>negative</em> numbers (e.g. 5.5 lbs) following
/// the production convention where negative quantity = stock consumed.
/// </para>
/// <para>
/// The color-matching helper <c>PickItem()</c> attempts to link each completed job to an
/// inventory item whose color name starts with the same word as the job's first item's color
/// (e.g. "Matte Black" → key "matte"). If no match is found it falls back to round-robin
/// rotation so every completed job still gets a usage transaction.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed inventory transactions for.</param>
/// <returns>Total number of transaction records inserted, or 0 if already seeded or no inventory items exist.</returns>
private async Task<int> SeedInventoryTransactionsAsync(Company company)
{
var existingCount = await _context.Set<InventoryTransaction>()
.IgnoreQueryFilters()
.CountAsync(t => t.CompanyId == company.Id && !t.IsDeleted);
if (existingCount > 0)
return 0;
// ── Load inventory items (powder coats) ───────────────────────────────
var items = await _context.Set<InventoryItem>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.IsActive)
.ToListAsync();
if (items.Count == 0) return 0;
// Load completed/delivered jobs to generate usage transactions against
var completedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& (j.JobStatus.StatusCode == "COMPLETED"
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|| j.JobStatus.StatusCode == "DELIVERED"))
.Include(j => j.JobStatus)
.OrderBy(j => j.Id)
.ToListAsync();
var now = DateTime.UtcNow;
var seeded = 0;
var txns = new List<InventoryTransaction>();
// ── Initial stock transactions (set opening balances) ─────────────────
// These reflect inventory on hand from day-1; dated 90 days ago
foreach (var item in items)
{
var openingQty = item.QuantityOnHand > 0 ? item.QuantityOnHand : 25m;
txns.Add(new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Initial,
Quantity = openingQty,
UnitCost = item.UnitCost,
TotalCost = Math.Round(openingQty * item.UnitCost, 2),
TransactionDate = now.AddDays(-90),
Reference = "Opening balance",
Notes = "Initial stock entry",
BalanceAfter = openingQty,
CompanyId = company.Id,
CreatedAt = now.AddDays(-90)
});
}
// ── Purchase transactions — 3 months of restocks ──────────────────────
// Simulate monthly powder purchases for top items
var powderItems = items.Take(8).ToList(); // focus on powder coat items
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
{
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle
{
var qty = Math.Round(25m * qtyMult, 0);
txns.Add(new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = InventoryTransactionType.Purchase,
Quantity = qty,
UnitCost = item.UnitCost,
TotalCost = Math.Round(qty * item.UnitCost, 2),
TransactionDate = now.AddDays(-offset),
Reference = $"PO-{now.AddDays(-offset):yyMM}-{item.Id:D3}",
Notes = "Scheduled restock",
BalanceAfter = qty + (item.QuantityOnHand > 0 ? item.QuantityOnHand : 25m),
CompanyId = company.Id,
CreatedAt = now.AddDays(-offset)
});
}
}
// ── JobUsage transactions — powder consumed for completed jobs ─────────
// Each completed job consumes powder from one or two inventory items.
// Spread consumption across the past 3 months.
var colorMap = new Dictionary<string, InventoryItem?>();
foreach (var item in items)
{
var key = (item.ColorName ?? item.Name).ToLowerInvariant().Split(' ')[0];
colorMap.TryAdd(key, item);
}
// Fallback: rotate through items if no color match.
// PickItem is a local function rather than a LINQ expression because it needs to
// mutate the outer itemIdx counter across multiple calls.
int itemIdx = 0;
InventoryItem PickItem(string? colorName)
{
if (!string.IsNullOrEmpty(colorName))
{
var key = colorName.ToLowerInvariant().Split(' ')[0];
if (colorMap.TryGetValue(key, out var matched) && matched != null)
return matched;
}
return items[itemIdx++ % items.Count];
}
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
{
// Completed date spread: most within the last 60 days
var daysAgo = 10 + (idx % 55);
var usageDate = now.AddDays(-daysAgo);
// Pick a color-matched powder item (or rotate)
var firstItem = job.JobItems?.FirstOrDefault();
var inv = PickItem(firstItem?.ColorName);
// Powder used: 315 lbs depending on job size
var lbsUsed = Math.Round(3m + (idx % 5) * 2.5m, 1);
if (lbsUsed < 0.5m) lbsUsed = 0.5m;
txns.Add(new InventoryTransaction
{
InventoryItemId = inv.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -lbsUsed, // negative = consumed
UnitCost = inv.UnitCost,
TotalCost = Math.Round(lbsUsed * inv.UnitCost, 2),
TransactionDate = usageDate,
Reference = job.JobNumber,
Notes = $"Powder used — {job.JobNumber}",
BalanceAfter = Math.Max(0, inv.QuantityOnHand - lbsUsed),
CompanyId = company.Id,
CreatedAt = usageDate
});
}
// ── Waste/adjustment transactions ─────────────────────────────────────
if (items.Count >= 2)
{
txns.Add(new InventoryTransaction
{
InventoryItemId = items[0].Id,
TransactionType = InventoryTransactionType.Waste,
Quantity = -1.5m,
UnitCost = items[0].UnitCost,
TotalCost = Math.Round(1.5m * items[0].UnitCost, 2),
TransactionDate = now.AddDays(-45),
Reference = "Waste",
Notes = "Contaminated powder — disposed",
BalanceAfter = Math.Max(0, items[0].QuantityOnHand - 1.5m),
CompanyId = company.Id,
CreatedAt = now.AddDays(-45)
});
txns.Add(new InventoryTransaction
{
InventoryItemId = items[1].Id,
TransactionType = InventoryTransactionType.Adjustment,
Quantity = 2.0m,
UnitCost = items[1].UnitCost,
TotalCost = Math.Round(2.0m * items[1].UnitCost, 2),
TransactionDate = now.AddDays(-30),
Reference = "Adjustment",
Notes = "Physical count correction",
BalanceAfter = items[1].QuantityOnHand + 2.0m,
CompanyId = company.Id,
CreatedAt = now.AddDays(-30)
});
}
await _context.Set<InventoryTransaction>().AddRangeAsync(txns);
await _context.SaveChangesAsync();
seeded = txns.Count;
return seeded;
}
}
@@ -0,0 +1,224 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 26 invoices spanning three months of billing history: six paid in month 3,
/// eight paid or partially paid in month 2, nine in month 1, and three recent plus
/// one overdue in the current month. Each invoice is linked to a seeded job and
/// has one or two line items plus an optional <see cref="Payment"/> record.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted invoices already exist for this company.
/// </para>
/// <para>
/// The method resolves four accounting accounts by number/sub-type from the previously
/// seeded chart of accounts (4000 Powder Coating Revenue, 4100 Sandblasting Revenue,
/// 1000 Checking, 2200 Sales Tax Payable). If any account is not found its FK is simply
/// omitted (null) rather than aborting the seed run.
/// </para>
/// <para>
/// The private <c>Inv()</c> local async function handles building, saving, and optionally
/// recording a payment for a single invoice. It is a local function rather than a separate
/// method because it captures the outer variables <c>jobs</c>, <c>seq</c>, <c>ji</c>,
/// and the four account references by closure.
/// </para>
/// <para>
/// Payment dates follow the production convention: for fully-paid invoices the payment is
/// recorded five days before the due date; for partially-paid invoices the deposit is
/// recorded four days after the invoice date.
/// </para>
/// <para>
/// The "overdue" demo invoice is created 35 days ago with Net-14 terms, placing it 21 days
/// past due — enough to appear prominently in the AR Aging report.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed invoices for.</param>
/// <returns>Number of invoices inserted, or 0 if already seeded or no eligible jobs exist.</returns>
private async Task<int> SeedInvoicesAsync(Company company)
{
var existingCount = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.CountAsync(i => i.CompanyId == company.Id && !i.IsDeleted);
if (existingCount > 0)
return 0;
// ── Dependencies ──────────────────────────────────────────────────────
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobItems)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& j.FinalPrice > 0 && j.CustomerId > 0)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0) return 0;
var revenueAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4000" && !a.IsDeleted);
var sandblastAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4100" && !a.IsDeleted);
var checkingAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id
&& a.AccountSubType == AccountSubType.Checking && !a.IsDeleted);
var salesTaxAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2200" && !a.IsDeleted);
var preparedBy = await _userManager.Users
.Where(u => u.CompanyId == company.Id).FirstOrDefaultAsync();
var now = DateTime.UtcNow;
var pfx = $"INV-{now:yy}{now.Month:D2}-";
var seq = 1;
var seeded = 0;
var ji = 0; // rotating job index
// Rotates through the available jobs in order. Using modulo wrapping means the
// seeder never throws even if there are fewer jobs than invoices to create.
Job NextJob() { var j = jobs[ji % jobs.Count]; ji++; return j; }
// ── Core builder ──────────────────────────────────────────────────────
//
// Builds and persists one Invoice + optional Payment in a single call.
// Each invoice is saved immediately (not batched) so EF generates an ID before
// the Payment FK is set. Tax is only added as a line item when taxPct > 0.
async Task Inv(
InvoiceStatus status,
int daysAgo, // invoice creation date
int dueDays, // net terms in days
decimal taxPct,
string terms,
string? notes,
// payment fields — ignored when status is Draft or Sent
PaymentMethod pMethod = PaymentMethod.BankTransferACH,
string? pRef = null)
{
var job = NextJob();
var date = now.AddDays(-daysAgo);
var sub = job.FinalPrice;
var taxAmt = Math.Round(sub * taxPct / 100m, 2);
var total = sub + taxAmt;
var isPaid = status == InvoiceStatus.Paid;
var isPart = status == InvoiceStatus.PartiallyPaid;
var amtPaid = isPaid ? total : isPart ? Math.Round(total * 0.5m, 2) : 0m;
var desc = job.JobItems?.FirstOrDefault()?.Description ?? "Powder Coating Services";
var inv = new Invoice
{
InvoiceNumber = $"{pfx}{seq++:D4}",
JobId = job.Id,
CustomerId = job.CustomerId,
PreparedById = preparedBy?.Id,
Status = status,
InvoiceDate = date,
DueDate = date.AddDays(dueDays),
SentDate = status != InvoiceStatus.Draft ? date : null,
PaidDate = isPaid ? date.AddDays(dueDays - 5) : null,
SubTotal = sub,
TaxPercent = taxPct,
TaxAmount = taxAmt,
Total = total,
AmountPaid = amtPaid,
Terms = terms,
Notes = notes,
CustomerPO = job.CustomerPO,
SalesTaxAccountId = taxAmt > 0 ? salesTaxAcct?.Id : null,
CompanyId = company.Id,
CreatedAt = date
};
inv.InvoiceItems.Add(new InvoiceItem
{
Description = desc,
Quantity = 1,
UnitPrice = sub,
TotalPrice = sub,
RevenueAccountId = revenueAcct?.Id,
DisplayOrder = 1,
CompanyId = company.Id,
CreatedAt = date
});
if (taxAmt > 0)
inv.InvoiceItems.Add(new InvoiceItem
{
Description = $"Sales Tax ({taxPct:0.##}%)",
Quantity = 1,
UnitPrice = taxAmt,
TotalPrice = taxAmt,
RevenueAccountId = salesTaxAcct?.Id,
DisplayOrder = 2,
CompanyId = company.Id,
CreatedAt = date
});
await _context.Set<Invoice>().AddAsync(inv);
await _context.SaveChangesAsync();
seeded++;
if (amtPaid > 0)
{
// Paid: payment date = dueDate - 5d. Partial: a few days after invoice
var pDate = isPaid ? date.AddDays(dueDays - 5) : date.AddDays(4);
await _context.Set<Payment>().AddAsync(new Payment
{
InvoiceId = inv.Id,
Amount = amtPaid,
PaymentDate = pDate,
PaymentMethod = pMethod,
Reference = pRef,
Notes = isPaid ? "Paid in full" : "50% deposit",
RecordedById = preparedBy?.Id,
DepositAccountId = checkingAcct?.Id,
CompanyId = company.Id,
CreatedAt = pDate
});
await _context.SaveChangesAsync();
}
}
// ── Month 3 (6 paid) ─────────────────────────────────────────────────
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041");
await Inv(InvoiceStatus.Paid, 80, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 76, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 72, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 68, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1044");
// ── Month 2 (6 paid + 2 partial) ────────────────────────────────────
await Inv(InvoiceStatus.Paid, 62, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 58, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1048");
await Inv(InvoiceStatus.Paid, 55, 14, 0m, "Due on receipt", "Paid on completion.", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 51, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 47, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 43, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1052");
await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053");
await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH);
// ── Month 1 (5 paid + 2 partial + 2 sent) ───────────────────────────
await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056");
await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060");
await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days.");
// ── Current month (1 overdue + 2 sent + 1 draft) ─────────────────────
// Overdue: created 35 days ago on Net 14 terms → 21 days past due
await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately.");
await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null);
return seeded;
}
}
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds a plausible status-transition history for every job belonging to the company,
/// reconstructing the sequence of transitions a job must have passed through to reach
/// its current status.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for
/// this company.
/// </para>
/// <para>
/// The method does not record arbitrary transitions — it follows the canonical 14-step
/// pipeline array (<c>PENDING → QUOTED → APPROVED → … → DELIVERED</c>) and generates
/// one <see cref="JobStatusHistory"/> row per transition step, from <c>PENDING</c> up to
/// and including the job's current status.
/// </para>
/// <para>
/// Terminal side-branch statuses are handled explicitly:
/// <list type="bullet">
/// <item><c>ON_HOLD</c> — assumed to have reached <c>QUALITY_CHECK</c> before pausing.</item>
/// <item><c>CANCELLED</c> — assumed to have been cancelled from <c>IN_PREPARATION</c>.</item>
/// </list>
/// </para>
/// <para>
/// Transition timestamps are spread ~6 hours apart starting from <c>job.CreatedAt</c>.
/// This is an approximation chosen for demo realism; actual production transitions record
/// the wall-clock time at which a user changes the status. A safety clamp prevents any
/// generated timestamp from exceeding <c>DateTime.UtcNow</c>.
/// </para>
/// <para>
/// All history rows are batched into a single <c>AddRangeAsync / SaveChangesAsync</c>
/// call for performance, since the total count can be several hundred rows (50 jobs × up
/// to 14 transitions each).
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed job status history for.</param>
/// <returns>Total number of history rows inserted, or 0 if already seeded or no jobs exist.</returns>
private async Task<int> SeedJobStatusHistoryAsync(Company company)
{
var existingCount = await _context.Set<JobStatusHistory>()
.IgnoreQueryFilters()
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
if (existingCount > 0)
return 0;
// Load all job status lookups into a code → id map
var statusMap = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
// Load jobs with their current status
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0 || statusMap.Count == 0)
return 0;
// Ordered pipeline — each status code in the order a job advances through it.
// ON_HOLD and CANCELLED are terminal side-branches handled separately.
var pipeline = new[]
{
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
"SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
"COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
};
var pipelineIndex = pipeline
.Select((code, idx) => (code, idx))
.ToDictionary(t => t.code, t => t.idx);
var history = new List<JobStatusHistory>();
var now = DateTime.UtcNow;
foreach (var job in jobs)
{
var currentCode = job.JobStatus.StatusCode;
// Determine the sequence of transitions that happened to reach current state.
// For ON_HOLD: assume it came from QUALITY_CHECK before going on hold.
// For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
string[] codesTraversed;
if (currentCode == "ON_HOLD")
{
// Traversed up to QUALITY_CHECK then went ON_HOLD
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"];
}
else if (currentCode == "CANCELLED")
{
// Cancelled from IN_PREPARATION
codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"];
}
else if (pipelineIndex.TryGetValue(currentCode, out int curIdx))
{
// Normal pipeline job — traversed from PENDING up to current status
codesTraversed = pipeline.Take(curIdx + 1).ToArray();
}
else
{
// Unknown status — just record a single PENDING → currentCode entry
codesTraversed = ["PENDING", currentCode];
}
// Spread transition dates backwards from job.CreatedAt.
// Each step took roughly 48 hours, so transitions are spaced a few hours apart.
// Jobs further along in the pipeline have older start dates.
var stepCount = codesTraversed.Length - 1; // number of transitions
if (stepCount <= 0) continue;
// Job was created at job.CreatedAt; each transition is spaced ~6h apart
// so the first transition (PENDING→QUOTED) happened ~6h after creation, etc.
for (int t = 0; t < stepCount; t++)
{
var fromCode = codesTraversed[t];
var toCode = codesTraversed[t + 1];
if (!statusMap.TryGetValue(fromCode, out int fromId)) continue;
if (!statusMap.TryGetValue(toCode, out int toId)) continue;
// Spread: first transitions happened closer to job creation,
// later ones closer to now. Add a few hours per step.
var hoursOffset = (t + 1) * 6;
var changedDate = job.CreatedAt.AddHours(hoursOffset);
// Don't let transitions exceed "now"
if (changedDate > now) changedDate = now.AddMinutes(-(stepCount - t) * 10);
history.Add(new JobStatusHistory
{
JobId = job.Id,
FromStatusId = fromId,
ToStatusId = toId,
ChangedDate = changedDate,
Notes = null,
CompanyId = company.Id,
CreatedAt = changedDate
});
}
}
if (history.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(history);
await _context.SaveChangesAsync();
return history.Count;
}
}
@@ -0,0 +1,274 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses,
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs.
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company.
/// </para>
/// <para>
/// The method depends on job-status and job-priority lookup rows (populated earlier in the
/// seed sequence), and on at least one customer record. It returns 0 if any of these
/// dependencies are missing so the overall seed degrades gracefully.
/// </para>
/// <para>
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans
/// existing numbers with the current month prefix and starts its sequence above the current
/// maximum so demo jobs never collide with real jobs created in the same calendar month.
/// </para>
/// <para>
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
/// quotes). When a match is found the job inherits the quote's customer, description,
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
/// </para>
/// <para>
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
/// in-progress (past start date, no completion), and completed/terminal (both started
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
/// views display a realistic spread rather than all jobs sharing the same date.
/// </para>
/// <para>
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed jobs for.</param>
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedJobsAsync(Company company)
{
var existingCount = await _context.Set<Job>()
.IgnoreQueryFilters()
.CountAsync(j => j.CompanyId == company.Id && !j.IsDeleted);
if (existingCount > 0)
return 0;
var jobStatuses = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
var jobPriorities = await _context.Set<JobPriorityLookup>()
.IgnoreQueryFilters()
.Where(p => p.CompanyId == company.Id)
.ToDictionaryAsync(p => p.PriorityCode, p => p.Id);
if (jobStatuses.Count == 0 || jobPriorities.Count == 0)
return 0;
var customers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.OrderBy(c => c.Id)
.ToListAsync();
if (customers.Count == 0)
return 0;
// Grab approved quotes to link to jobs
var approvedQuotes = await _context.Set<Quote>()
.IgnoreQueryFilters()
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
.OrderBy(q => q.Id)
.ToListAsync();
var shopUsers = await _context.Set<ApplicationUser>()
.Where(u => u.CompanyId == company.Id && u.IsActive)
.OrderBy(u => u.Id)
.ToListAsync();
var now = DateTime.UtcNow;
var prefix = $"JOB-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => j.JobNumber)
.ToListAsync();
var maxNum = 0;
foreach (var n in existing)
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Status plan (50 jobs, covering all 16 statuses) ──────────────────
// Active pipeline: PENDING(4) QUOTED(3) APPROVED(4) IN_PREPARATION(4)
// SANDBLASTING(4) MASKING_TAPING(3) CLEANING(3) IN_OVEN(3)
// COATING(4) CURING(3) QUALITY_CHECK(3) COMPLETED(5)
// READY_FOR_PICKUP(4) DELIVERED(3) ON_HOLD(2) CANCELLED(2)
//
// Maps job index to a status code, distributing all 16 statuses across 50 jobs.
// ON_HOLD and CANCELLED are placed last (indices 4849) because they are terminal
// side-branches that affect date logic and status history traversal differently.
static string StatusFor(int i) => i switch
{
< 4 => "PENDING",
< 7 => "QUOTED",
< 11 => "APPROVED",
< 15 => "IN_PREPARATION",
< 19 => "SANDBLASTING",
< 22 => "MASKING_TAPING",
< 25 => "CLEANING",
< 28 => "IN_OVEN",
< 32 => "COATING",
< 35 => "CURING",
< 38 => "QUALITY_CHECK",
< 43 => "COMPLETED",
< 47 => "READY_FOR_PICKUP",
< 48 => "DELIVERED",
< 49 => "ON_HOLD",
_ => "CANCELLED"
};
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally
// over-represented (4 of 10) relative to production averages so the priority colour
// badges and rush-fee logic are clearly visible in demo data.
static string PriorityFor(int i) => (i % 10) switch
{
0 => "RUSH",
1 => "RUSH",
2 => "URGENT",
3 => "URGENT",
4 => "HIGH",
5 => "HIGH",
6 => "HIGH",
_ => "NORMAL"
};
// Returns description, finish color, prep flags, and estimated minutes for a job item.
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index,
// preventing every job from having the same first item.
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
((i * 3 + j) % 15) switch
{
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45),
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30),
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40),
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90),
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50),
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40)
};
var jobs = new List<Job>();
var quoteIdx = 0;
for (int i = 0; i < 50; i++)
{
var statusCode = StatusFor(i);
var priorityCode = PriorityFor(i);
var customer = customers[i % customers.Count];
// Link an approved quote to the first 25 in-progress/active jobs
Quote? linkedQuote = null;
if (i < 25 && quoteIdx < approvedQuotes.Count)
{
// Only link if the quote's customer matches OR if customers align by index
linkedQuote = approvedQuotes[quoteIdx++];
customer = customers.FirstOrDefault(c => c.Id == linkedQuote.CustomerId) ?? customer;
}
// Date logic — creation spread from -21 days to today
// Scheduled: future for early statuses, past for completed ones
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
int daysAgo = isCompleted ? 14 + (i % 7)
: isInProgress ? 5 + (i % 7)
: 0 + (i % 5);
var createdDate = now.AddDays(-daysAgo);
var scheduledDate = isCompleted ? createdDate.AddDays(2)
: isInProgress ? now.AddDays(-(i % 3))
: now.AddDays(2 + (i % 10));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = (!isEarly) ? scheduledDate : (DateTime?)null;
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
var assignedUserId = shopUsers.Count > 0 ? shopUsers[i % shopUsers.Count].Id : null;
var itemCount = 1 + (i % 3);
var items = new List<JobItem>();
for (int j = 0; j < itemCount; j++)
{
var (desc, color, sand, mask, mins) = ItemSpec(i, j);
var qty = 1 + (j % 3);
var unitPrice = linkedQuote != null && j == 0
? Math.Round((linkedQuote.Total / itemCount), 2)
: Math.Round(75m + (i % 8) * 12.5m + j * 15m, 2);
items.Add(new JobItem
{
Description = desc,
Quantity = qty,
ColorName = color,
SurfaceAreaSqFt = 10m + j * 3.5m,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
LaborCost = Math.Round(unitPrice * qty * 0.35m, 2),
RequiresSandblasting = sand,
RequiresMasking = mask,
EstimatedMinutes = mins,
CompanyId = company.Id,
CreatedAt = createdDate
});
}
var finalPrice = items.Sum(it => it.TotalPrice);
var quotedPrice = linkedQuote?.Total ?? Math.Round(finalPrice * 1.05m, 2);
jobs.Add(new Job
{
JobNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
QuoteId = linkedQuote?.Id,
AssignedUserId = assignedUserId,
Description = linkedQuote?.Description
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
JobStatusId = jobStatuses[statusCode],
JobPriorityId = jobPriorities[priorityCode],
ScheduledDate = scheduledDate,
StartedDate = startedDate,
CompletedDate = completedDate,
DueDate = dueDate,
QuotedPrice = quotedPrice,
FinalPrice = finalPrice,
IsRushJob = priorityCode == "RUSH",
CustomerPO = linkedQuote?.CustomerPO ?? (i % 3 == 0 ? $"PO-{40000 + i}" : null),
SpecialInstructions = i % 6 == 0 ? "Customer supplied parts — handle with extra care." :
i % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
InternalNotes = i % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
RequiresCustomerApproval = i % 5 == 0,
IsCustomerApproved = i % 5 != 0 || !isEarly,
JobItems = items,
CompanyId = company.Id,
CreatedAt = createdDate
});
seq++;
}
await _context.Set<Job>().AddRangeAsync(jobs);
await _context.SaveChangesAsync();
return jobs.Count;
}
}
@@ -0,0 +1,841 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds the 16 standard job status lookup rows for a company, covering the full
/// powder-coating workflow from Pending through Delivered/Cancelled.
/// </summary>
/// <remarks>
/// Job status is a lookup table (<see cref="JobStatusLookup"/>) rather than a C# enum so
/// that operators can customise display names, colours, and ordering without a code deploy.
/// The <c>StatusCode</c> string constants (e.g., "PENDING", "COATING") are referenced
/// throughout the application code and must not be changed after initial seeding.
///
/// Idempotency check: if 16 or more rows already exist for the company, the method
/// returns 0 immediately. This threshold equals the full set size, so a partially-seeded
/// company (e.g., from a previous failed run) will be re-seeded automatically.
///
/// Key flags per status row:
/// - <c>IsTerminalStatus</c> = true for Completed, Delivered, Cancelled — the AI
/// accounting service uses this to distinguish active vs closed jobs.
/// - <c>IsWorkInProgressStatus</c> = true for production stages (In Preparation through
/// Quality Check) — used by dashboard KPIs and scheduling views.
/// - <c>IsSystemDefined</c> = true for statuses that drive automated workflows
/// (Pending, Completed, Cancelled) — operators cannot delete these.
/// - <c>WorkflowCategory</c> groups statuses for reporting (Pre-Production, Production,
/// Post-Production, Other).
/// </remarks>
/// <param name="company">The tenant company to seed statuses for.</param>
/// <returns>The number of status rows created (16), or 0 if already seeded.</returns>
private async Task<int> SeedJobStatusLookupsAsync(Company company)
{
// Check if job statuses already exist for this company
var existingCount = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.CountAsync(s => s.CompanyId == company.Id && !s.IsDeleted);
if (existingCount >= 16)
{
return 0; // Already seeded
}
var statuses = new List<JobStatusLookup>
{
new JobStatusLookup
{
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1,
ColorClass = "secondary",
IconClass = "bi-clock",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = false,
IsWorkInProgressStatus = false,
WorkflowCategory = "Pre-Production",
Description = "Job has been created and is awaiting approval",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "QUOTED",
DisplayName = "Quoted",
DisplayOrder = 2,
ColorClass = "info",
IconClass = "bi-file-text",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = false,
WorkflowCategory = "Pre-Production",
Description = "Quote has been generated for this job",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 3,
ColorClass = "primary",
IconClass = "bi-check-circle",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = false,
WorkflowCategory = "Pre-Production",
Description = "Job has been approved and is ready to start",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "IN_PREPARATION",
DisplayName = "In Preparation",
DisplayOrder = 4,
ColorClass = "warning",
IconClass = "bi-tools",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Job is being prepared for processing",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "SANDBLASTING",
DisplayName = "Sandblasting",
DisplayOrder = 5,
ColorClass = "warning",
IconClass = "bi-wind",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Surface preparation in progress",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "MASKING_TAPING",
DisplayName = "Masking/Taping",
DisplayOrder = 6,
ColorClass = "warning",
IconClass = "bi-scissors",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Masking areas that should not be coated",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "CLEANING",
DisplayName = "Cleaning",
DisplayOrder = 7,
ColorClass = "warning",
IconClass = "bi-droplet",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Final cleaning before coating",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "IN_OVEN",
DisplayName = "In Oven",
DisplayOrder = 8,
ColorClass = "warning",
IconClass = "bi-thermometer-half",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Parts are being pre-heated",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "COATING",
DisplayName = "Coating",
DisplayOrder = 9,
ColorClass = "warning",
IconClass = "bi-paint-bucket",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Applying powder coating",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "CURING",
DisplayName = "Curing",
DisplayOrder = 10,
ColorClass = "warning",
IconClass = "bi-fire",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Production",
Description = "Curing the powder coating in the oven",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "QUALITY_CHECK",
DisplayName = "Quality Check",
DisplayOrder = 11,
ColorClass = "info",
IconClass = "bi-search",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = true,
WorkflowCategory = "Post-Production",
Description = "Quality inspection in progress",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "COMPLETED",
DisplayName = "Completed",
DisplayOrder = 12,
ColorClass = "success",
IconClass = "bi-check2-all",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
IsWorkInProgressStatus = false,
WorkflowCategory = "Post-Production",
Description = "Job work is completed",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "READY_FOR_PICKUP",
DisplayName = "Ready for Pickup",
DisplayOrder = 13,
ColorClass = "success",
IconClass = "bi-box-seam",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = false,
WorkflowCategory = "Post-Production",
Description = "Job is ready for customer pickup",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "DELIVERED",
DisplayName = "Delivered",
DisplayOrder = 14,
ColorClass = "success",
IconClass = "bi-truck",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = true,
IsWorkInProgressStatus = false,
WorkflowCategory = "Post-Production",
Description = "Job has been delivered to customer",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "ON_HOLD",
DisplayName = "On Hold",
DisplayOrder = 15,
ColorClass = "dark",
IconClass = "bi-pause-circle",
IsActive = true,
IsSystemDefined = false,
IsTerminalStatus = false,
IsWorkInProgressStatus = false,
WorkflowCategory = "Other",
Description = "Job has been paused",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobStatusLookup
{
StatusCode = "CANCELLED",
DisplayName = "Cancelled",
DisplayOrder = 16,
ColorClass = "danger",
IconClass = "bi-x-circle",
IsActive = true,
IsSystemDefined = true,
IsTerminalStatus = true,
IsWorkInProgressStatus = false,
WorkflowCategory = "Other",
Description = "Job has been cancelled",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<JobStatusLookup>().AddRangeAsync(statuses);
await _context.SaveChangesAsync();
return statuses.Count;
}
/// <summary>
/// Seeds the five standard job priority levels (Low, Normal, High, Urgent, Rush) as
/// lookup rows for a company, each with a Bootstrap colour class and Bootstrap Icon.
/// </summary>
/// <remarks>
/// Like job statuses, priorities are a lookup table so display names and colours can be
/// adjusted per tenant without a code change. The <c>PriorityCode</c> strings
/// (e.g., "NORMAL", "RUSH") are referenced by the job creation UI and report filters
/// and must not be renamed after seeding.
///
/// Idempotency check: bails early if 5 or more rows already exist for the company.
///
/// Both URGENT and RUSH use the "danger" colour class intentionally — they signal
/// different urgency levels but both demand immediate visual attention on the shop floor.
/// The distinction matters for rush-charge calculation: a Rush priority triggers the
/// rush-charge percentage defined in <see cref="CompanyOperatingCosts"/>.
/// </remarks>
/// <param name="company">The tenant company to seed priorities for.</param>
/// <returns>The number of priority rows created (5), or 0 if already seeded.</returns>
private async Task<int> SeedJobPriorityLookupsAsync(Company company)
{
// Check if job priorities already exist for this company
var existingCount = await _context.Set<JobPriorityLookup>()
.IgnoreQueryFilters()
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
if (existingCount >= 5)
{
return 0; // Already seeded
}
var priorities = new List<JobPriorityLookup>
{
new JobPriorityLookup
{
PriorityCode = "LOW",
DisplayName = "Low",
DisplayOrder = 1,
ColorClass = "secondary",
IconClass = "bi-arrow-down",
IsActive = true,
IsSystemDefined = false,
Description = "Low priority - no rush",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobPriorityLookup
{
PriorityCode = "NORMAL",
DisplayName = "Normal",
DisplayOrder = 2,
ColorClass = "info",
IconClass = "bi-dash",
IsActive = true,
IsSystemDefined = false,
Description = "Normal priority - standard turnaround",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobPriorityLookup
{
PriorityCode = "HIGH",
DisplayName = "High",
DisplayOrder = 3,
ColorClass = "warning",
IconClass = "bi-arrow-up",
IsActive = true,
IsSystemDefined = false,
Description = "High priority - expedited processing",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobPriorityLookup
{
PriorityCode = "URGENT",
DisplayName = "Urgent",
DisplayOrder = 4,
ColorClass = "danger",
IconClass = "bi-exclamation-triangle",
IsActive = true,
IsSystemDefined = false,
Description = "Urgent - requires immediate attention",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new JobPriorityLookup
{
PriorityCode = "RUSH",
DisplayName = "Rush",
DisplayOrder = 5,
ColorClass = "danger",
IconClass = "bi-lightning",
IsActive = true,
IsSystemDefined = false,
Description = "Rush order - top priority",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<JobPriorityLookup>().AddRangeAsync(priorities);
await _context.SaveChangesAsync();
return priorities.Count;
}
/// <summary>
/// Seeds the seven standard quote status lookup rows for a company
/// (Draft, Sent, Approved, Rejected, Expired, Converted, Revised).
/// </summary>
/// <remarks>
/// Quote status uses a lookup table (like job status) so operators can adjust display
/// names and colours. The <c>StatusCode</c> strings and the boolean semantic flags
/// (<c>IsApprovedStatus</c>, <c>IsConvertedStatus</c>, <c>IsDraftStatus</c>,
/// <c>IsRejectedStatus</c>) are what the application code queries — UI text is cosmetic.
///
/// Idempotency check: if 7+ rows exist the method skips insertion, but it performs
/// a retroactive fix for the REJECTED status: older seeded databases may have
/// <c>IsRejectedStatus = false</c> because the field was added after initial release.
/// The fix is applied on every call when the row already exists and the flag is wrong,
/// ensuring the quote portal correctly blocks re-approval of rejected quotes.
///
/// Key semantic flags:
/// - <c>IsApprovedStatus</c>: only APPROVED — triggers quote-to-job conversion eligibility.
/// - <c>IsConvertedStatus</c>: only CONVERTED — marks quotes that have already become jobs.
/// - <c>IsDraftStatus</c>: only DRAFT — controls edit permissions (draft quotes are fully editable).
/// - <c>IsRejectedStatus</c>: only REJECTED — prevents the customer approval portal from
/// re-activating a quote the customer already declined.
/// </remarks>
/// <param name="company">The tenant company to seed quote statuses for.</param>
/// <returns>The number of status rows created (7), or 0 if already seeded (retroactive fix may still apply).</returns>
private async Task<int> SeedQuoteStatusLookupsAsync(Company company)
{
// Check if quote statuses already exist for this company
var existingCount = await _context.Set<QuoteStatusLookup>()
.IgnoreQueryFilters()
.CountAsync(s => s.CompanyId == company.Id && !s.IsDeleted);
if (existingCount >= 7)
{
// Retroactive fix: ensure REJECTED status has IsRejectedStatus = true
var rejectedStatus = await _context.Set<QuoteStatusLookup>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == company.Id && s.StatusCode == "REJECTED" && !s.IsDeleted);
if (rejectedStatus != null && !rejectedStatus.IsRejectedStatus)
{
rejectedStatus.IsRejectedStatus = true;
await _context.SaveChangesAsync();
}
return 0; // Already seeded
}
var statuses = new List<QuoteStatusLookup>
{
new QuoteStatusLookup
{
StatusCode = "DRAFT",
DisplayName = "Draft",
DisplayOrder = 1,
ColorClass = "secondary",
IconClass = "bi-pencil",
IsActive = true,
IsSystemDefined = true,
IsDraftStatus = true,
IsApprovedStatus = false,
IsConvertedStatus = false,
Description = "Quote is being prepared",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "SENT",
DisplayName = "Sent",
DisplayOrder = 2,
ColorClass = "info",
IconClass = "bi-send",
IsActive = true,
IsSystemDefined = false,
IsDraftStatus = false,
IsApprovedStatus = false,
IsConvertedStatus = false,
Description = "Quote has been sent to customer",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 3,
ColorClass = "success",
IconClass = "bi-check-circle",
IsActive = true,
IsSystemDefined = true,
IsDraftStatus = false,
IsApprovedStatus = true,
IsConvertedStatus = false,
Description = "Quote has been approved by customer",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 4,
ColorClass = "danger",
IconClass = "bi-x-circle",
IsActive = true,
IsSystemDefined = false,
IsDraftStatus = false,
IsApprovedStatus = false,
IsRejectedStatus = true,
IsConvertedStatus = false,
Description = "Quote has been rejected by customer",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "EXPIRED",
DisplayName = "Expired",
DisplayOrder = 5,
ColorClass = "warning",
IconClass = "bi-clock-history",
IsActive = true,
IsSystemDefined = false,
IsDraftStatus = false,
IsApprovedStatus = false,
IsConvertedStatus = false,
Description = "Quote has passed its expiration date",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 6,
ColorClass = "primary",
IconClass = "bi-arrow-right-circle",
IsActive = true,
IsSystemDefined = true,
IsDraftStatus = false,
IsApprovedStatus = false,
IsConvertedStatus = true,
Description = "Quote has been converted to a job",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new QuoteStatusLookup
{
StatusCode = "REVISED",
DisplayName = "Revised",
DisplayOrder = 7,
ColorClass = "info",
IconClass = "bi-arrow-repeat",
IsActive = true,
IsSystemDefined = false,
IsDraftStatus = false,
IsApprovedStatus = false,
IsConvertedStatus = false,
Description = "Quote has been revised",
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<QuoteStatusLookup>().AddRangeAsync(statuses);
await _context.SaveChangesAsync();
return statuses.Count;
}
/// <summary>
/// Seeds nine inventory category lookup rows for a company: Powder, Primer, Cleaner,
/// Masking Supplies, Abrasive Media, Chemicals, Consumables, Tools, and Other.
/// </summary>
/// <remarks>
/// Inventory categories are a lookup table so operators can rename them or add custom
/// categories. The <c>CategoryCode</c> strings (e.g., "POWDER", "CLEANER") are used by
/// <see cref="SeedInventoryItemsAsync"/> to resolve the correct category FK when creating
/// demo inventory items — they must not be changed without also updating that seeder.
///
/// The <c>IsCoating = true</c> flag on POWDER and PRIMER distinguishes coating materials
/// from consumables; the pricing engine uses this flag to identify which inventory items
/// can be selected as powder coats on a quote/job item.
///
/// Idempotency check: bails early if 9 or more rows exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed inventory categories for.</param>
/// <returns>The number of category rows created (9), or 0 if already seeded.</returns>
private async Task<int> SeedInventoryCategoryLookupsAsync(Company company)
{
// Check if categories already exist for this company
var existingCount = await _context.Set<InventoryCategoryLookup>()
.IgnoreQueryFilters()
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
if (existingCount >= 9)
{
return 0; // Already seeded
}
var categories = new List<InventoryCategoryLookup>
{
new InventoryCategoryLookup
{
CategoryCode = "POWDER",
DisplayName = "Powder",
DisplayOrder = 1,
Description = "Powder coating materials",
IsActive = true,
IsSystemDefined = false,
IsCoating = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "PRIMER",
DisplayName = "Primer",
DisplayOrder = 2,
Description = "Primer coatings",
IsActive = true,
IsSystemDefined = false,
IsCoating = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "CLEANER",
DisplayName = "Cleaner",
DisplayOrder = 3,
Description = "Cleaning solutions and materials",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "MASKING",
DisplayName = "Masking Supplies",
DisplayOrder = 4,
Description = "Masking tape, plugs, and supplies",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "ABRASIVE",
DisplayName = "Abrasive Media",
DisplayOrder = 5,
Description = "Sandblasting and abrasive materials",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "CHEMICAL",
DisplayName = "Chemicals",
DisplayOrder = 6,
Description = "Chemical supplies and solutions",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "CONSUMABLE",
DisplayName = "Consumables",
DisplayOrder = 7,
Description = "General consumable supplies",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "TOOL",
DisplayName = "Tools",
DisplayOrder = 8,
Description = "Tools and equipment supplies",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryCategoryLookup
{
CategoryCode = "OTHER",
DisplayName = "Other",
DisplayOrder = 9,
Description = "Other miscellaneous inventory items",
IsActive = true,
IsSystemDefined = false,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<InventoryCategoryLookup>().AddRangeAsync(categories);
await _context.SaveChangesAsync();
return categories.Count;
}
/// <summary>
/// Seeds eight standard surface-preparation services for a company: Sandblasting, Chemical
/// Stripping, Hand Sanding, Media Blasting, Iron Phosphate Wash, Degreasing, Masking, and
/// Outgassing.
/// </summary>
/// <remarks>
/// Prep services are selectable line-item additions on quote and job items, each adding
/// labour cost to the pricing calculation. They are stored per-company so operators can
/// rename, deactivate, or add custom services (e.g., "Zinc Phosphate Wash") without
/// affecting other tenants.
///
/// Idempotency check: bails if any rows exist for the company (count > 0), unlike
/// status lookups which use a minimum-count threshold. This is intentional: if a
/// partial seed left only some services, the operator can remove them and re-run to get
/// the full set.
///
/// Outgassing (pre-bake to release trapped gases from castings) is included because it is
/// a mandatory step for cast aluminium and cast iron parts and is a common source of
/// rework if omitted — including it in the default list helps new operators remember to
/// quote it.
/// </remarks>
/// <param name="company">The tenant company to seed prep services for.</param>
/// <returns>The number of prep service rows created (8), or 0 if any already existed.</returns>
private async Task<int> SeedPrepServicesAsync(Company company)
{
var existingCount = await _context.Set<PrepService>()
.IgnoreQueryFilters()
.CountAsync(s => s.CompanyId == company.Id && !s.IsDeleted);
if (existingCount > 0)
return 0; // Already seeded
var services = new List<PrepService>
{
new PrepService
{
ServiceName = "Sandblasting",
Description = "Abrasive blasting to remove rust, old coatings, and surface contaminants",
DisplayOrder = 1,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Chemical Stripping",
Description = "Chemical bath or application to strip existing paint or coatings",
DisplayOrder = 2,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Hand Sanding",
Description = "Manual sanding to smooth surfaces and improve adhesion",
DisplayOrder = 3,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Media Blasting",
Description = "Blasting with glass beads, walnut shells, or other media for delicate surfaces",
DisplayOrder = 4,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Iron Phosphate Wash",
Description = "Chemical pre-treatment wash to improve powder adhesion and corrosion resistance",
DisplayOrder = 5,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Degreasing",
Description = "Solvent or aqueous cleaning to remove oils, grease, and shop soils",
DisplayOrder = 6,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Masking",
Description = "Masking of threads, holes, or areas that must remain uncoated",
DisplayOrder = 7,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new PrepService
{
ServiceName = "Outgassing",
Description = "Pre-bake cycle to release trapped gases from castings before coating",
DisplayOrder = 8,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<PrepService>().AddRangeAsync(services);
await _context.SaveChangesAsync();
return services.Count;
}
}
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds the global set of powder-coat manufacturer URL patterns used by the AI inventory
/// lookup service to construct direct product-page links (e.g. a Prismatic Powders color
/// page from a part number + color name).
/// </summary>
/// <remarks>
/// <para>
/// These records use <c>CompanyId = 0</c> (a sentinel value meaning "platform-wide") rather
/// than a specific tenant ID, so a single set of patterns is shared across all companies.
/// The global query filter that restricts queries to the current tenant's <c>CompanyId</c>
/// does not apply here; <c>IgnoreQueryFilters()</c> is used for the existence check so the
/// count includes records belonging to any company including the platform-level 0.
/// </para>
/// <para>
/// Idempotency: returns 0 if any patterns already exist (regardless of which manufacturer)
/// rather than checking per-manufacturer, because the full set is always inserted together
/// and partial sets should not occur in practice.
/// </para>
/// <para>
/// <c>ProductUrlTemplate</c> is nullable; a null value (e.g. Powder Buy The Pound) tells
/// the lookup service to fall back to a search URL rather than a direct product page.
/// </para>
/// <para>
/// This method is <c>public</c> (unlike other seed helpers) because it is called from the
/// Platform Management → Seed Data page independently of company-scoped seed operations.
/// </para>
/// </remarks>
/// <returns>Number of patterns inserted, or 0 if patterns were already present.</returns>
public async Task<int> SeedManufacturerPatternsAsync()
{
// Check if any patterns already exist (global records, CompanyId = 0)
var existingCount = await _context.Set<ManufacturerLookupPattern>()
.IgnoreQueryFilters()
.CountAsync(p => !p.IsDeleted);
if (existingCount > 0)
{
return 0; // Already seeded
}
var patterns = new List<ManufacturerLookupPattern>
{
new ManufacturerLookupPattern
{
ManufacturerName = "Prismatic Powders",
Domain = "prismaticpowders.com",
ProductUrlTemplate = "https://www.prismaticpowders.com/shop/powder-coating-colors/{partNumber}/{slug}",
SlugTransform = "LowerHyphen",
IsActive = true,
Notes = "Requires both part number and color name",
CompanyId = 0,
CreatedAt = DateTime.UtcNow
},
new ManufacturerLookupPattern
{
ManufacturerName = "Columbia Coatings",
Domain = "columbiacoatings.com",
ProductUrlTemplate = "https://www.columbiacoatings.com/product/{slug}/",
SlugTransform = "LowerHyphen",
IsActive = true,
Notes = "Color name slug only",
CompanyId = 0,
CreatedAt = DateTime.UtcNow
},
new ManufacturerLookupPattern
{
ManufacturerName = "All Powder Paints",
Domain = "allpowderpaints.com",
ProductUrlTemplate = "https://www.allpowderpaints.com/powder-coating-colors/{slug}/",
SlugTransform = "LowerHyphen",
IsActive = true,
Notes = "Color name slug only",
CompanyId = 0,
CreatedAt = DateTime.UtcNow
},
new ManufacturerLookupPattern
{
ManufacturerName = "Tiger Drylac",
Domain = "tiger-coatings.com",
ProductUrlTemplate = "https://www.tiger-coatings.com/us-en/shop/show/{partNumber}",
SlugTransform = "LowerHyphen",
IsActive = true,
Notes = "Part number required; normalize slashes to hyphens",
CompanyId = 0,
CreatedAt = DateTime.UtcNow
},
new ManufacturerLookupPattern
{
ManufacturerName = "Powder Buy The Pound",
Domain = "powderbuythepound.com",
ProductUrlTemplate = null,
SlugTransform = "LowerHyphen",
IsActive = true,
Notes = "Complex slug; domain used for search URL selection only",
CompanyId = 0,
CreatedAt = DateTime.UtcNow
}
};
await _context.Set<ManufacturerLookupPattern>().AddRangeAsync(patterns);
await _context.SaveChangesAsync();
return patterns.Count;
}
}
@@ -0,0 +1,272 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 75 realistic powder coating quotes spread across seven item categories
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc)
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35),
/// Rejected (10), and Expired (10).
/// </summary>
/// <remarks>
/// <para>
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for
/// this company, preventing duplicate quote sets on repeated seed runs.
/// </para>
/// <para>
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans
/// existing numbers with the current month prefix and starts its sequence above the
/// current maximum so seeded quotes never collide with real quotes created in the
/// same month.
/// </para>
/// <para>
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost
/// config that may not yet be populated when seed runs.
/// </para>
/// <para>
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote.
/// </para>
/// <para>
/// The method requires that customers and quote-status lookup rows already exist for the
/// company; it returns 0 if either dependency is missing so that the overall seed
/// operation degrades gracefully rather than throwing.
/// </para>
/// </remarks>
/// <param name="company">The tenant company to seed quotes for.</param>
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedQuotesAsync(Company company)
{
var existingCount = await _context.Set<Quote>()
.IgnoreQueryFilters()
.CountAsync(q => q.CompanyId == company.Id && !q.IsDeleted);
if (existingCount > 0)
return 0;
var quoteStatuses = await _context.Set<QuoteStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
if (quoteStatuses.Count == 0)
return 0;
// Load all commercial customers
var customers = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted)
.OrderBy(c => c.Id)
.ToListAsync();
if (customers.Count == 0)
return 0;
var preparedByUser = await _userManager.Users
.Where(u => u.CompanyId == company.Id)
.FirstOrDefaultAsync();
var now = DateTime.UtcNow;
// Avoid duplicate quote numbers
var prefix = $"QT-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Quote>()
.IgnoreQueryFilters()
.Where(q => q.QuoteNumber.StartsWith(prefix))
.Select(q => q.QuoteNumber)
.ToListAsync();
var maxNum = 0;
foreach (var n in existing)
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1;
// ── Data arrays for varied, realistic content ─────────────────────────
// Returns an array of realistic item descriptions for a given category bucket (06).
// Using a local static function keeps the description data close to where it is
// consumed and avoids polluting the partial class with per-seeder detail arrays.
static string[] ItemDescs(int category) => category switch
{
0 => new[] {
"18\" Aluminum Wheels — Matte Black",
"17\" Steel Wheels — Gloss White",
"20\" Alloy Wheels — Satin Silver",
"16\" Chrome Replica Wheels — Gloss Black",
"Motorcycle Frame — Flat Black",
"Motorcycle Swingarm & Forks — Gloss Black",
"Exhaust Headers — High-Temp Flat Black",
"Intake Manifold — Wrinkle Red",
"Valve Covers — Gloss Red",
"Brake Calipers — Gloss Yellow" },
1 => new[] {
"Steel Shelving Units (10-shelf set)",
"Industrial Equipment Frame",
"Machine Guard Panels",
"Conveyor Frame Sections",
"Heavy Equipment Brackets",
"Pump Housing Assembly",
"Control Panel Enclosure",
"Storage Rack System",
"Scissor Lift Platform",
"Compressor Tank" },
2 => new[] {
"Aluminum Window Frames (set of 8)",
"Steel Handrail System — 40 ft",
"Wrought Iron Fence Panels (6-panel set)",
"Entry Gate — Custom Design",
"Structural Steel Columns (set of 4)",
"Balcony Railing — Satin Black",
"Steel Door Frames (3 units)",
"Architectural Steel Beams",
"Decorative Ironwork — Stair Baluster",
"Aluminum Storefront Frame" },
3 => new[] {
"Commercial Gym Equipment Frame",
"Weight Rack & Benches",
"Outdoor Playground Equipment Parts",
"Bicycle Frame — Gloss Blue",
"BMX Frame Set — Candy Red" },
4 => new[] {
"Boat Trailer Frame — Marine Grade",
"Aluminum Dock Cleats & Hardware",
"Outboard Motor Bracket",
"Marine Fuel Tank Brackets" },
5 => new[] {
"Restaurant Chair Frames (set of 20)",
"Steel Dining Table Bases (set of 8)",
"Patio Furniture Set — 6 Pieces",
"Café Chairs — Hammered Bronze (12-pc)",
"Commercial Bar Stools (set of 10)" },
_ => new[] {
"Custom Steel Parts — Batch Order",
"Agricultural Equipment Panels",
"Traffic Sign Frames (set of 15)",
"Utility Trailer Hitch Assembly",
"Solar Panel Mounting Brackets" }
};
// Returns finish color, prep flags, estimated minutes, and surface area for item index i.
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table.
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch
{
0 => ("Matte Black", true, false, 45, 12.0m),
1 => ("Gloss White", false, false, 30, 8.5m),
2 => ("Satin Silver", true, true, 60, 15.0m),
3 => ("Candy Red", false, true, 35, 9.0m),
4 => ("Textured Gray", true, false, 50, 18.0m),
5 => ("Gloss Black", true, false, 40, 11.0m),
6 => ("Hammered Bronze", false, false, 55, 20.0m),
7 => ("Satin Graphite", true, true, 65, 25.0m),
_ => ("Flat Black", true, false, 35, 10.0m)
};
// Maps quote index to a status code following the distribution plan above.
// APPROVED is the majority (35/75) to give SeedJobsAsync enough approved quotes to link jobs to.
static string StatusFor(int i) => i switch
{
< 8 => "DRAFT",
< 20 => "SENT",
< 55 => "APPROVED",
< 65 => "REJECTED",
_ => "EXPIRED"
};
var quotes = new List<Quote>();
for (int i = 0; i < 75; i++)
{
var customer = customers[i % customers.Count];
var statusCode = StatusFor(i);
// Spread creation dates over the past 90 days; older first
var daysAgo = 90 - (int)(i * 1.2);
var quoteDate = now.AddDays(-daysAgo);
var expireDate = quoteDate.AddDays(30);
var category = i % 7;
var descs = ItemDescs(category);
var itemCount = 1 + (i % 3);
var items = new List<QuoteItem>();
for (int j = 0; j < itemCount; j++)
{
var desc = descs[(i + j) % descs.Length];
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
var qty = 1 + (j % 4);
// Unit price scales with surface area and adds a modest multiplier per customer tier
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
items.Add(new QuoteItem
{
Description = desc,
Quantity = qty,
SurfaceAreaSqFt = sqft * qty,
UnitPrice = unitPrice,
TotalPrice = unitPrice * qty,
RequiresSandblasting = sand,
RequiresMasking = mask,
EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null,
CompanyId = company.Id,
CreatedAt = quoteDate
});
}
var subtotal = items.Sum(it => it.TotalPrice);
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee;
var quote = new Quote
{
QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id,
PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial,
IsRushJob = i % 12 == 0,
QuoteDate = quoteDate,
ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
ItemsSubtotal = subtotal,
SubTotal = subtotal,
DiscountPercent = discountPct,
DiscountAmount = discountAmt,
TaxPercent = taxPct,
TaxAmount = taxAmt,
RushFee = rushFee,
Total = total,
Description = $"Powder coating services — {descs[i % descs.Length].Split('—')[0].Trim()}",
Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 7 == 0 ? "Customer requested color sample before full run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null,
RequiresDeposit = i % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m,
QuoteItems = items,
CompanyId = company.Id,
CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
};
quotes.Add(quote);
seq++;
}
await _context.Set<Quote>().AddRangeAsync(quotes);
await _context.SaveChangesAsync();
return quotes.Count;
}
}
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds platform-level release notes (changelog entries) that are displayed to all
/// tenants in the What's New / Release Notes section. Safe to call repeatedly —
/// each version string is checked individually and skipped if it already exists,
/// so new entries can be added to the list over time without re-inserting old ones.
/// </summary>
/// <remarks>
/// <para>
/// Unlike other seed methods, this one does NOT use <c>IgnoreQueryFilters()</c> because
/// release notes are global platform records with no <c>CompanyId</c> tenant filter, so
/// the default query already sees all rows.
/// </para>
/// <para>
/// The idempotency check is per-version (not a blanket "any rows exist?" guard) so that
/// future versions can be appended to the <c>notes</c> list and seeded incrementally.
/// </para>
/// <para>
/// This method is <c>public</c> because it is callable from the Platform Management →
/// Seed Data UI independently of company-scoped operations.
/// </para>
/// </remarks>
/// <returns>Number of new release note entries inserted, or 0 if all versions already exist.</returns>
public async Task<int> SeedReleaseNotesAsync()
{
var existingVersions = (await _context.ReleaseNotes
.Select(r => r.Version)
.ToListAsync())
.ToHashSet();
var notes = new List<ReleaseNote>
{
new()
{
Version = "2.1.0",
Title = "AI Accounting Suite, Cash Flow Forecast & Anomaly Detection",
Tag = "Feature",
IsPublished = true,
ReleasedAt = new DateTime(2026, 4, 4, 0, 0, 0, DateTimeKind.Utc),
CreatedAt = DateTime.UtcNow,
Body = """
## What's New
### AI Accounting Features
Four new AI-powered tools to save time and catch problems:
- **Receipt Scanning** Upload a photo or PDF receipt when creating a bill. AI extracts the vendor, date, invoice number, total, and line items, and attaches the file automatically.
- **Smart Account Categorisation** As you type each bill line item description, the system silently suggests the best expense account. No button to click it fills in as you work.
- **AR Follow-up Email Drafts** On the AR Aging report, click *Draft Reminder* next to any overdue customer. AI writes a professional collections email with tone that scales from gentle (30 days) to serious (60+ days).
- **Plain-English Financial Summary** In Full Analytics, click *Generate AI Summary* for a short, readable paragraph explaining what your numbers mean this period.
### Cash Flow Forecast *(New Report)*
- Projects your 30, 60, and 90-day cash position
- Factors in open AR invoices (with each customer's historical average days-to-pay), outstanding bills, and your active job pipeline
- Displays an overall outlook Strong, Moderate, Tight, or Concerning with plain-English insights per period
### Anomaly Detection *(New Report)*
- Scans your last 90 days of bills for duplicate entries, unusual amounts, and expense accounts running above their historical average
- Flags ranked by severity: Critical, Warning, Info
- Each flag includes a plain-English description and a recommended action
### Bills / Expenses Unified List
- Bills and Expenses are now combined under **Bills / Expenses** in the navigation
- Single *New* split-button to create either type
- Type filter to view all entries, bills only, or expenses only
### Other Improvements
- Bills now save as **Open** by default Record Payment is available immediately without a status change
- New **I Already Paid This** toggle on the bill create form records the bill and payment in one step
- PDF receipts are now accepted everywhere image receipts were previously supported
- Reports landing page reorganised into two clear sections: Charts & Dashboards, and Detailed Reports
"""
},
};
var toAdd = notes.Where(n => !existingVersions.Contains(n.Version)).ToList();
if (toAdd.Count == 0) return 0;
_context.ReleaseNotes.AddRange(toAdd);
await _context.SaveChangesAsync();
return toAdd.Count;
}
}
@@ -0,0 +1,350 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Canonical email addresses of all customers created by the seed operation.
/// Used during removal to identify seeded customers without relying on a flag column —
/// matching on email is stable even if the records were soft-deleted between seed and remove.
/// </summary>
private static readonly string[] SeededCustomerEmails =
[
"john.smith@acmemfg.com", "sjohnson@precisionauto.com", "mchen@urbanrailings.com",
"lmartinez@fitequip.com", "dwilliams@metrota.gov", "rtaylor@classicwheels.com",
"janderson@indfurniture.com", "cbrown@motorsportscustom.com", "adavis@greenenergy.com",
"tmiller@heritagemetal.com", "pwilson@marineequip.com", "kgarcia@commercialhvac.com",
"nmartinez@playgroundusa.com", "blee@officesystems.com", "swhite@agequipment.com",
"jthompson@email.com", "mharris@email.com", "wclark@email.com", "elewis@email.com",
"rwalker@email.com", "bhall@email.com", "jallen@email.com", "syoung@email.com",
"cking@email.com", "lwright@email.com"
];
/// <summary>
/// Serial numbers assigned to all equipment records created by the seed operation.
/// Serial numbers are manufacturer-assigned strings that are stable identifiers even across
/// soft-delete cycles, making them safe fingerprints for seeded equipment detection.
/// </summary>
private static readonly string[] SeededEquipmentSerials =
[
"RFS240023456", "RFS180012789", "NOR120045678", "CC800034512",
"EMP483623890", "CLM101223456", "ATC7523467", "PAC50034521",
"BE489612345", "GEM0623456"
];
/// <summary>
/// Display names of the catalog categories created by the seed operation.
/// Category name is the only reliable fingerprint because seeded categories carry no
/// special flag; the name list is kept in sync with <see cref="SeedCatalogAsync"/>.
/// </summary>
private static readonly string[] SeededCatalogCategoryNames =
[
"Automotive Wheels", "Engine Components", "Outdoor Furniture",
"Railings & Handrails", "Gates & Fencing", "Fitness Equipment", "Office & Commercial"
];
/// <summary>
/// SKU suffixes appended to the company code when seeding inventory items
/// (e.g. <c>DEMO-PWD-BLK-001</c>). The full SKU is reconstructed at removal time
/// as <c>{CompanyCode}{suffix}</c>, matching the pattern used in the inventory seeder.
/// </summary>
private static readonly string[] SeededInventorySkuSuffixes =
[
"-PWD-BLK-001", "-PWD-WHT-001", "-PWD-RED-001", "-PWD-BLU-001",
"-PWD-GRY-001", "-PWD-YEL-001", "-PWD-ORG-001", "-PWD-GRN-001",
"-CLN-001", "-MSK-001"
];
/// <summary>
/// Display names of the pricing tiers created by the seed operation.
/// Tiers are matched by name at removal time; the list must stay in sync with the
/// pricing tier seeder to avoid leaving orphaned tiers behind.
/// </summary>
private static readonly string[] SeededPricingTierNames =
[
"Standard", "Silver", "Gold", "Platinum"
];
/// <summary>
/// Physically removes previously seeded demo data for the specified company, respecting
/// the caller-supplied <paramref name="options"/> flags so operators can selectively
/// remove only the data categories they want to clean up.
/// </summary>
/// <remarks>
/// <para>
/// All queries use <c>IgnoreQueryFilters()</c> so that records already soft-deleted by users
/// are still found and physically removed — this prevents orphaned data from accumulating
/// in the database after partial cleanup.
/// </para>
/// <para>
/// Child records (job items, quote items, transactions, maintenance records, etc.) are deleted
/// first before their parent to avoid FK constraint violations. Each category is committed
/// with its own <c>SaveChangesAsync()</c> call so a failure in one category does not roll
/// back deletions already completed in an earlier category.
/// </para>
/// <para>
/// Lookup tables (job status, job priority, quote status) are intentionally NOT removed —
/// they are system-level data shared across the company's real records.
/// </para>
/// </remarks>
/// <param name="companyId">ID of the tenant company whose seed data should be removed.</param>
/// <param name="options">Flags controlling which data categories to delete.</param>
/// <returns>
/// A <see cref="SeedDataResult"/> with <c>Success = true</c> and a count of records removed,
/// or <c>Success = false</c> with an error message if the company was not found or an
/// exception was thrown.
/// </returns>
public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options)
{
var result = new SeedDataResult { Success = true };
var details = new List<string>();
int totalRemoved = 0;
try
{
var company = await _context.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == companyId && !c.IsDeleted);
if (company == null)
{
result.Success = false;
result.Message = "Company not found";
return result;
}
// --- Customers (+ their jobs, quotes, and related items) ---
if (options.Customers)
{
var seededCustomerIds = await _context.Customers
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCustomerEmails.Contains(c.Email))
.Select(c => c.Id)
.ToListAsync();
if (seededCustomerIds.Any())
{
// Jobs and their child records
var seededJobIds = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId))
.Select(j => j.Id)
.ToListAsync();
if (seededJobIds.Any())
{
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPhotos.Any()) _context.JobPhotos.RemoveRange(jobPhotos);
var jobNotes = await _context.JobNotes.IgnoreQueryFilters()
.Where(n => seededJobIds.Contains(n.JobId)).ToListAsync();
if (jobNotes.Any()) _context.JobNotes.RemoveRange(jobNotes);
var jobItems = await _context.JobItems.IgnoreQueryFilters()
.Where(i => seededJobIds.Contains(i.JobId)).ToListAsync();
if (jobItems.Any()) _context.JobItems.RemoveRange(jobItems);
var jobStatusHistory = await _context.JobStatusHistory.IgnoreQueryFilters()
.Where(h => seededJobIds.Contains(h.JobId)).ToListAsync();
if (jobStatusHistory.Any()) _context.JobStatusHistory.RemoveRange(jobStatusHistory);
var jobPrepServices = await _context.JobPrepServices.IgnoreQueryFilters()
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
var jobs = await _context.Jobs.IgnoreQueryFilters()
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
_context.Jobs.RemoveRange(jobs);
totalRemoved += jobs.Count;
details.Add($"✓ Removed {jobs.Count} seeded job(s)");
}
// Quotes and their child records
var seededQuoteIds = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue && seededCustomerIds.Contains(q.CustomerId.Value))
.Select(q => q.Id)
.ToListAsync();
if (seededQuoteIds.Any())
{
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
var quotePrepServices = await _context.QuotePrepServices.IgnoreQueryFilters()
.Where(p => seededQuoteIds.Contains(p.QuoteId)).ToListAsync();
if (quotePrepServices.Any()) _context.QuotePrepServices.RemoveRange(quotePrepServices);
var quotes = await _context.Quotes.IgnoreQueryFilters()
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
_context.Quotes.RemoveRange(quotes);
totalRemoved += quotes.Count;
details.Add($"✓ Removed {quotes.Count} seeded quote(s)");
}
// Customer notes
var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters()
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
var customers = await _context.Customers.IgnoreQueryFilters()
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
_context.Customers.RemoveRange(customers);
totalRemoved += customers.Count;
details.Add($"✓ Removed {customers.Count} seeded customer(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded customers found");
}
}
// --- Inventory Items ---
if (options.InventoryItems)
{
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
var inventoryItems = await _context.InventoryItems
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && seededSkus.Contains(i.SKU))
.ToListAsync();
if (inventoryItems.Any())
{
var inventoryIds = inventoryItems.Select(i => i.Id).ToList();
var transactions = await _context.InventoryTransactions.IgnoreQueryFilters()
.Where(t => inventoryIds.Contains(t.InventoryItemId)).ToListAsync();
if (transactions.Any()) _context.InventoryTransactions.RemoveRange(transactions);
_context.InventoryItems.RemoveRange(inventoryItems);
totalRemoved += inventoryItems.Count;
details.Add($"✓ Removed {inventoryItems.Count} seeded inventory item(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded inventory items found");
}
}
// --- Equipment (+ maintenance records) ---
if (options.Equipment)
{
var seededEquipment = await _context.Equipment
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId && SeededEquipmentSerials.Contains(e.SerialNumber))
.ToListAsync();
if (seededEquipment.Any())
{
var equipmentIds = seededEquipment.Select(e => e.Id).ToList();
var maintenance = await _context.MaintenanceRecords.IgnoreQueryFilters()
.Where(m => equipmentIds.Contains(m.EquipmentId)).ToListAsync();
if (maintenance.Any()) _context.MaintenanceRecords.RemoveRange(maintenance);
_context.Equipment.RemoveRange(seededEquipment);
totalRemoved += seededEquipment.Count;
details.Add($"✓ Removed {seededEquipment.Count} seeded equipment record(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded equipment found");
}
}
// --- Catalog Items & Categories ---
if (options.Catalog)
{
var seededCategories = await _context.CatalogCategories
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCatalogCategoryNames.Contains(c.Name))
.ToListAsync();
if (seededCategories.Any())
{
var categoryIds = seededCategories.Select(c => c.Id).ToList();
var catalogItems = await _context.CatalogItems.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && categoryIds.Contains(i.CategoryId))
.ToListAsync();
if (catalogItems.Any())
{
_context.CatalogItems.RemoveRange(catalogItems);
totalRemoved += catalogItems.Count;
details.Add($"✓ Removed {catalogItems.Count} seeded catalog item(s)");
}
_context.CatalogCategories.RemoveRange(seededCategories);
totalRemoved += seededCategories.Count;
details.Add($"✓ Removed {seededCategories.Count} seeded catalog categor(y/ies)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded catalog data found");
}
}
// --- Pricing Tiers ---
if (options.PricingTiers)
{
var tiers = await _context.PricingTiers
.IgnoreQueryFilters()
.Where(t => t.CompanyId == companyId && SeededPricingTierNames.Contains(t.TierName))
.ToListAsync();
if (tiers.Any())
{
_context.PricingTiers.RemoveRange(tiers);
totalRemoved += tiers.Count;
details.Add($"✓ Removed {tiers.Count} seeded pricing tier(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No seeded pricing tiers found");
}
}
// --- Operating Costs ---
if (options.OperatingCosts)
{
var costs = await _context.CompanyOperatingCosts
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId)
.ToListAsync();
if (costs.Any())
{
_context.CompanyOperatingCosts.RemoveRange(costs);
totalRemoved += costs.Count;
details.Add($"✓ Removed operating costs record");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No operating costs record found");
}
}
result.ItemsSeeded = totalRemoved;
result.Details = details;
result.Message = totalRemoved > 0
? $"Removed {totalRemoved} seeded record(s) from {company.CompanyName}"
: $"No matching seeded records found for {company.CompanyName}";
}
catch (Exception ex)
{
result.Success = false;
result.Message = $"Error removing seed data: {ex.Message}";
}
return result;
}
}
@@ -0,0 +1,21 @@
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Partial class stub for the dashboard tips portion of <see cref="SeedDataService"/>.
/// </summary>
/// <remarks>
/// Dashboard tips (the rotating "Tip of the Day" shown on the main dashboard) are seeded
/// exactly once via EF Core migration <c>20260404200000_SeedInitialDashboardTips</c>, which
/// inserts the full set of 40 tips as raw SQL so they are available from the very first
/// database creation without requiring a manual seed run.
///
/// New tips should be added through the Tips management UI
/// (<em>Platform Management → Tips</em>) for one-off additions, or via a new EF migration
/// for bulk additions that must be version-controlled. Do not add a runtime seed method
/// here — the migration approach guarantees tips exist even before the Platform Management
/// seed action is invoked by a SuperAdmin.
/// </remarks>
public partial class SeedDataService
{
// No runtime seed method for tips — see remarks above.
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;
namespace PowderCoating.Infrastructure.Services;
public class SmsService : ISmsService
{
private readonly IConfiguration _configuration;
private readonly ILogger<SmsService> _logger;
/// <summary>
/// Initializes a new instance of <see cref="SmsService"/>. Twilio credentials are read
/// per call (not cached here) because they may be rotated without restarting the application,
/// and Twilio's client initialization (<c>TwilioClient.Init</c>) is idempotent and cheap.
/// </summary>
public SmsService(IConfiguration configuration, ILogger<SmsService> logger)
{
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Sends an SMS message via the Twilio REST API and returns a success/error tuple.
/// The method short-circuits with a logged warning (not an exception) when Twilio is not
/// configured, so callers in notification workflows are not broken in development
/// environments where Twilio credentials are absent. Phone numbers are normalized via
/// <see cref="NormalizePhone"/> before sending because users enter phone numbers in many
/// formats (with dashes, parentheses, spaces, or a leading "1") and Twilio requires strict
/// E.164 format. The method returns the Twilio message SID on success so callers can log
/// it for delivery tracking. Carrier regulations (TCPA in the US) require that the first
/// SMS to a new number include opt-out instructions ("Reply STOP to unsubscribe") — this
/// is enforced in the message body by the calling controller or notification service before
/// this method is invoked; <see cref="SmsService"/> itself is transport-only and does not
/// inspect or modify the message body.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> SendSmsAsync(string toPhone, string message)
{
var accountSid = _configuration["Twilio:AccountSid"];
var authToken = _configuration["Twilio:AuthToken"];
var fromNumber = _configuration["Twilio:FromNumber"];
if (string.IsNullOrWhiteSpace(accountSid) || accountSid.StartsWith("your-") ||
string.IsNullOrWhiteSpace(authToken) || authToken.StartsWith("your-") ||
string.IsNullOrWhiteSpace(fromNumber) || fromNumber.Contains("XXXXXXXXXX"))
{
_logger.LogWarning("Twilio is not configured. SMS to {ToPhone} skipped.", toPhone);
return (false, "Twilio not configured");
}
var normalizedPhone = NormalizePhone(toPhone);
if (string.IsNullOrEmpty(normalizedPhone))
{
_logger.LogWarning("Invalid phone number: {ToPhone}", toPhone);
return (false, "Invalid phone number");
}
try
{
TwilioClient.Init(accountSid, authToken);
var smsMessage = await MessageResource.CreateAsync(
to: new PhoneNumber(normalizedPhone),
from: new PhoneNumber(fromNumber),
body: message);
_logger.LogInformation("SMS sent to {ToPhone}: SID={Sid}", normalizedPhone, smsMessage.Sid);
return (true, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending SMS to {ToPhone}", normalizedPhone);
return (false, ex.Message);
}
}
/// <summary>
/// Normalizes a phone number string to E.164 format (e.g., "+12025551234") required by
/// Twilio. Strips all non-digit characters, then applies three rules in priority order:
/// an 11-digit string starting with "1" is assumed to be a North American number with
/// country code already included; a 10-digit string is assumed to be a US/Canada number
/// and gets "+1" prepended; a string that already starts with "+" and has more than 10
/// digits is treated as an international number and reformatted with "+". Numbers that
/// do not match any pattern return null, causing <see cref="SendSmsAsync"/> to abort with
/// a warning rather than sending to a malformed number (which would generate a Twilio 400
/// error and count against the account's error rate).
/// </summary>
private static string? NormalizePhone(string phone)
{
if (string.IsNullOrWhiteSpace(phone))
return null;
// Strip everything except digits
var digits = new string(phone.Where(char.IsDigit).ToArray());
// Already E.164 with country code
if (digits.Length == 11 && digits[0] == '1')
return "+" + digits;
// 10-digit US number — prepend +1
if (digits.Length == 10)
return "+1" + digits;
// Already formatted (e.g. +44...)
if (phone.StartsWith("+") && digits.Length > 10)
return "+" + digits;
return null;
}
}
@@ -0,0 +1,260 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Stripe;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Implements Stripe Connect OAuth onboarding and embedded payment processing for tenants.
/// Each tenant company gets its own Stripe Connect account; payments made by their end customers
/// go directly into that connected account. The platform uses the OAuth flow (not the hosted
/// onboarding link) so tenants can connect an existing Stripe account rather than creating a new one.
/// Connect status on the <c>Company</c> entity uses <see cref="StripeConnectStatus"/>:
/// NotStarted → Pending (OAuth started) → Active (token exchanged) → Deauthorized (tenant revoked).
/// </summary>
public class StripeConnectService : IStripeConnectService
{
private readonly IConfiguration _configuration;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<StripeConnectService> _logger;
/// <summary>Secret key for the Connect platform Stripe account (separate from the subscription secret key).</summary>
private string SecretKey => _configuration["Stripe:Connect:SecretKey"]!;
/// <summary>OAuth client ID (ca_...) of the platform's Connect application in the Stripe dashboard.</summary>
private string ConnectClientId => _configuration["Stripe:Connect:ConnectClientId"]!;
/// <summary>
/// Constructs the service. Configuration values are read lazily via properties rather than
/// cached in the constructor so that configuration changes in integration tests take effect
/// without rebuilding the DI container.
/// </summary>
public StripeConnectService(
IConfiguration configuration,
IUnitOfWork unitOfWork,
ILogger<StripeConnectService> logger)
{
_configuration = configuration;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Builds the Stripe Connect OAuth authorization URL that the tenant's admin is redirected to.
/// The <paramref name="companyId"/> is passed as the OAuth <c>state</c> parameter so the
/// callback endpoint can identify which company is completing onboarding without relying on
/// session state, which may be lost if the browser opens a new tab for the Stripe flow.
/// <c>scope=read_write</c> is required to create charges on behalf of the connected account.
/// </summary>
public string GetOAuthUrl(int companyId, string redirectUri)
{
var state = companyId.ToString(); // passed back in callback to identify the company
return $"https://connect.stripe.com/oauth/authorize" +
$"?response_type=code" +
$"&client_id={ConnectClientId}" +
$"&scope=read_write" +
$"&redirect_uri={Uri.EscapeDataString(redirectUri)}" +
$"&state={state}";
}
/// <summary>
/// Completes the Stripe Connect OAuth flow by exchanging the one-time authorization
/// <paramref name="code"/> for a permanent Stripe account ID (<c>stripe_user_id</c>).
/// Persists the account ID on the company and sets <see cref="StripeConnectStatus.Active"/>.
/// Returns a structured result tuple rather than throwing so the controller can surface a
/// user-friendly error message without catching exceptions. Stripe errors (invalid code,
/// already used code, etc.) are caught as <see cref="StripeException"/> and returned as
/// a failure with the Stripe error message.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> HandleOAuthCallbackAsync(string code, int companyId)
{
try
{
var client = new StripeClient(SecretKey);
var oauthService = new OAuthTokenService(client);
var response = await oauthService.CreateAsync(new OAuthTokenCreateOptions
{
GrantType = "authorization_code",
Code = code
});
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null)
return (false, "Company not found.");
company.StripeAccountId = response.StripeUserId;
company.StripeConnectStatus = StripeConnectStatus.Active;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} connected Stripe account {AccountId}", companyId, response.StripeUserId);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe OAuth error for company {CompanyId}", companyId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Deauthorizes the tenant's connected Stripe account and clears the stored account ID.
/// The Stripe deauthorize call revokes the platform's access token, preventing any future
/// charges on behalf of the tenant. If the company has no stored account ID the Stripe call
/// is skipped (e.g. partially-onboarded company) and only the local record is cleared.
/// Sets <see cref="StripeConnectStatus.NotConnected"/> on the company record so the UI
/// correctly shows the "Connect Stripe" button again.
/// </summary>
public async Task<(bool Success, string? ErrorMessage)> DisconnectAsync(int companyId)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null)
return (false, "Company not found.");
if (!string.IsNullOrEmpty(company.StripeAccountId))
{
var client = new StripeClient(SecretKey);
var oauthService = new OAuthTokenService(client);
await oauthService.DeauthorizeAsync(new OAuthDeauthorizeOptions
{
ClientId = ConnectClientId,
StripeUserId = company.StripeAccountId
});
}
company.StripeAccountId = null;
company.StripeConnectStatus = StripeConnectStatus.NotConnected;
company.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} disconnected Stripe account", companyId);
return (true, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe disconnect error for company {CompanyId}", companyId);
return (false, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Creates a Stripe PaymentIntent on the tenant's connected account so the customer can pay
/// their invoice online via embedded Stripe Elements in the customer portal. The intent is
/// created with <c>StripeAccount</c> set to <paramref name="connectedAccountId"/> so funds
/// settle directly into the tenant's Stripe account rather than the platform account.
/// The optional <paramref name="surchargeAmount"/> (credit-card processing fee pass-through)
/// is added to the invoice total before converting to cents; the surcharge is stored in
/// intent metadata so the fulfillment logic can record it separately. Returns the
/// <c>client_secret</c> (needed by Stripe.js on the client) and the <c>PaymentIntentId</c>
/// (needed to confirm the payment server-side).
/// </summary>
public async Task<(bool Success, string? ClientSecret, string? PaymentIntentId, string? ErrorMessage)> CreatePaymentIntentAsync(
string connectedAccountId,
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId)
{
try
{
var totalWithSurcharge = invoiceTotal + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var options = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string>
{
{ "invoice_id", invoiceId.ToString() },
{ "invoice_number", invoiceNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") }
},
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new PaymentIntentService(client);
var intent = await service.CreateAsync(options, requestOptions);
return (true, intent.ClientSecret, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create PaymentIntent for invoice {InvoiceId}", invoiceId);
return (false, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
/// <summary>
/// Creates a Stripe PaymentIntent for a quote deposit on the tenant's connected account.
/// Mirrors <see cref="CreatePaymentIntentAsync"/> but attaches <c>type=deposit</c> and
/// <c>quote_id</c> metadata instead of invoice metadata so the payment confirmation handler
/// can route the received funds to the <c>Deposits</c> table rather than as an invoice
/// payment. Deposits can later be auto-applied to the invoice when it is created.
/// </summary>
public async Task<(bool Success, string? ClientSecret, string? PaymentIntentId, string? ErrorMessage)> CreateDepositPaymentIntentAsync(
string connectedAccountId,
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId)
{
try
{
var totalWithSurcharge = depositAmount + surchargeAmount;
var amountInCents = (long)Math.Round(totalWithSurcharge * 100, MidpointRounding.AwayFromZero);
var options = new PaymentIntentCreateOptions
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string>
{
{ "type", "deposit" },
{ "quote_id", quoteId.ToString() },
{ "quote_number", quoteNumber },
{ "surcharge_amount", surchargeAmount.ToString("F2") }
},
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
{
Enabled = true
}
};
var client = new StripeClient(SecretKey);
var requestOptions = new RequestOptions { StripeAccount = connectedAccountId };
var service = new PaymentIntentService(client);
var intent = await service.CreateAsync(options, requestOptions);
return (true, intent.ClientSecret, intent.Id, null);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Failed to create deposit PaymentIntent for quote {QuoteId}", quoteId);
return (false, null, null, ex.StripeError?.Message ?? ex.Message);
}
}
}
@@ -0,0 +1,568 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Stripe;
using Stripe.Checkout;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Manages Stripe subscription billing for the platform: creates checkout sessions for plan
/// upgrades, fulfills completed checkouts, syncs subscription state, opens the customer billing
/// portal, and handles inbound Stripe webhook events. All webhook events are verified with
/// HMAC signature validation before processing to ensure idempotent, tamper-proof delivery.
/// </summary>
public class StripeService : IStripeService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IConfiguration _configuration;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<StripeService> _logger;
private readonly string _webhookSecret;
/// <summary>
/// Constructs the service and sets the Stripe global API key from configuration.
/// The webhook secret is cached at construction time rather than read per-request
/// because <see cref="HandleWebhookAsync"/> is called on every inbound webhook and
/// configuration reads are synchronous/cheap but the intent is to make it obvious
/// where the secret comes from.
/// </summary>
public StripeService(
IUnitOfWork unitOfWork,
IConfiguration configuration,
IInAppNotificationService inApp,
ILogger<StripeService> logger)
{
_unitOfWork = unitOfWork;
_configuration = configuration;
_inApp = inApp;
_logger = logger;
StripeConfiguration.ApiKey = configuration["Stripe:SecretKey"] ?? string.Empty;
_webhookSecret = configuration["Stripe:WebhookSecret"] ?? string.Empty;
}
/// <summary>
/// Creates a Stripe Checkout session for an existing tenant company that is upgrading
/// or changing its subscription plan. Persists the chosen billing period preference
/// (<paramref name="isAnnual"/>) before creating the session so renewals default to the same
/// cadence. Creates a Stripe Customer record on first checkout and stores the resulting
/// <c>StripeCustomerId</c> on the company. The price ID is resolved from
/// <c>SubscriptionPlanConfig</c> in the database; the method validates that the stored ID
/// starts with <c>price_</c> (not <c>prod_</c>) and provides a human-readable error message
/// if misconfigured. Returns the Stripe-hosted checkout URL to which the caller should redirect.
/// </summary>
public async Task<string> CreateCheckoutSessionAsync(
int companyId,
int newPlan,
bool isAnnual,
string successUrl,
string cancelUrl)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true)
?? throw new InvalidOperationException($"Company {companyId} not found");
// Persist the billing period preference so future renewals default to the same
company.IsAnnualBilling = isAnnual;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
// Get plan config for Stripe price ID
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == newPlan && c.IsActive,
ignoreQueryFilters: true);
// Use annual price ID when requested, fall back to monthly
var planName = planConfig?.DisplayName ?? newPlan.ToString();
var priceId = isAnnual
? (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdAnnual) ? planConfig!.StripePriceIdAnnual : null)
: (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdMonthly)
? planConfig!.StripePriceIdMonthly
: _configuration[$"Stripe:Prices:{planName}"]);
if (string.IsNullOrWhiteSpace(priceId))
throw new InvalidOperationException(
$"No Stripe price ID configured for the {planName} plan. " +
"Go to Platform Management → Subscription Plans → Edit and enter the price ID.");
// Product IDs (prod_...) are a common mistake — price IDs must start with price_
if (!priceId!.StartsWith("price_"))
throw new InvalidOperationException(
$"The value '{priceId}' saved for the {planName} plan is a Stripe product ID, not a price ID. " +
"In your Stripe dashboard, open the product, find the specific price under the Pricing section, " +
"and copy the ID that starts with 'price_'. " +
"Then update it in Platform Management → Subscription Plans → Edit.");
// Create Stripe customer if needed
var stripeCustomerId = company.StripeCustomerId;
if (string.IsNullOrEmpty(stripeCustomerId))
{
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(new CustomerCreateOptions
{
Email = company.PrimaryContactEmail,
Name = company.CompanyName,
Metadata = new Dictionary<string, string>
{
["companyId"] = companyId.ToString()
}
});
stripeCustomerId = customer.Id;
// Persist customer ID
company.StripeCustomerId = stripeCustomerId;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
}
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
{
Customer = stripeCustomerId,
Mode = "subscription",
PaymentMethodTypes = new List<string> { "card" },
LineItems = new List<SessionLineItemOptions>
{
new SessionLineItemOptions
{
Price = priceId,
Quantity = 1
}
},
// Stripe replaces {CHECKOUT_SESSION_ID} with the real session ID on redirect
SuccessUrl = (successUrl.Contains('?') ? successUrl + "&" : successUrl + "?") + "session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
["companyId"] = companyId.ToString(),
["plan"] = newPlan.ToString() // stored as int string
}
});
return session.Url;
}
/// <summary>
/// Creates a Stripe Checkout session for a new registrant who has not yet been issued a
/// company record in the database. Unlike <see cref="CreateCheckoutSessionAsync"/>, this
/// session is keyed by <paramref name="email"/> (via <c>CustomerEmail</c>) rather than a
/// stored <c>StripeCustomerId</c>, and carries a <c>registration=true</c> metadata flag so
/// the subsequent <see cref="FulfillRegistrationCheckoutAsync"/> call can distinguish
/// registration sessions from plan-upgrade sessions in webhook handlers.
/// Returns the Stripe-hosted checkout URL.
/// </summary>
public async Task<string> CreateRegistrationCheckoutSessionAsync(
int plan, bool isAnnual, string email, string companyName, string successUrl, string cancelUrl)
{
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == plan && c.IsActive, ignoreQueryFilters: true);
var planName = planConfig?.DisplayName ?? plan.ToString();
var priceId = isAnnual
? (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdAnnual) ? planConfig!.StripePriceIdAnnual : null)
: (!string.IsNullOrWhiteSpace(planConfig?.StripePriceIdMonthly) ? planConfig!.StripePriceIdMonthly : _configuration[$"Stripe:Prices:{planName}"]);
if (string.IsNullOrWhiteSpace(priceId))
throw new InvalidOperationException(
$"No Stripe price ID configured for the {planName} plan. " +
"Go to Platform Management → Subscription Plans → Edit and enter the price ID.");
if (!priceId!.StartsWith("price_"))
throw new InvalidOperationException(
$"The value '{priceId}' for the {planName} plan is not a valid Stripe price ID (must start with 'price_').");
var sessionService = new SessionService();
var session = await sessionService.CreateAsync(new SessionCreateOptions
{
CustomerEmail = email,
Mode = "subscription",
PaymentMethodTypes = new List<string> { "card" },
LineItems = new List<SessionLineItemOptions>
{
new() { Price = priceId, Quantity = 1 }
},
SuccessUrl = (successUrl.Contains('?') ? successUrl + "&" : successUrl + "?") + "session_id={CHECKOUT_SESSION_ID}",
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
["registration"] = "true",
["plan"] = plan.ToString()
}
});
return session.Url;
}
/// <summary>
/// Finalizes a registration checkout after payment is confirmed. Called from the
/// registration success redirect (not the webhook path). Retrieves the session from Stripe
/// with the subscription expanded to access the real <c>CurrentPeriodEnd</c>, then updates
/// the newly created company record with the Stripe customer ID, subscription ID, plan, and
/// end date. Silently returns if the session is not yet paid or the company record is missing.
/// </summary>
public async Task FulfillRegistrationCheckoutAsync(string sessionId, int companyId, int plan)
{
var sessionService = new SessionService();
var session = await sessionService.GetAsync(sessionId, new SessionGetOptions
{
Expand = new List<string> { "subscription" }
});
if (session.PaymentStatus != "paid" && session.Status != "complete")
{
_logger.LogWarning("FulfillRegistrationCheckoutAsync: session {SessionId} not paid/complete", sessionId);
return;
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return;
var subscription = session.Subscription;
var periodEnd = subscription?.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd
?? DateTime.UtcNow.AddMonths(1);
company.SubscriptionPlan = plan;
company.SubscriptionStatus = SubscriptionStatus.Active;
company.StripeCustomerId = session.CustomerId;
company.StripeSubscriptionId = session.SubscriptionId;
company.SubscriptionStartDate = DateTime.UtcNow;
company.SubscriptionEndDate = periodEnd;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Registration checkout fulfilled for company {CompanyId}: plan={Plan}, ends={EndDate}",
companyId, plan, periodEnd);
}
/// <summary>
/// Fulfills an existing-tenant plan-upgrade checkout after Stripe confirms payment.
/// This method is the shared fulfillment path used by both the success-redirect controller
/// action and the <c>checkout.session.completed</c> webhook handler — keeping both paths
/// identical prevents divergence if only one fires (e.g. user closes browser before redirect).
/// The subscription is expanded in the Stripe API call so the real <c>CurrentPeriodEnd</c>
/// is available without a second round-trip. The company ID and target plan are read from
/// session metadata set by <see cref="CreateCheckoutSessionAsync"/>.
/// </summary>
public async Task FulfillCheckoutAsync(string sessionId)
{
// Retrieve the session and expand the subscription so we get the real period end date
var sessionService = new SessionService();
var session = await sessionService.GetAsync(sessionId, new SessionGetOptions
{
Expand = new List<string> { "subscription" }
});
if (session.PaymentStatus != "paid" && session.Status != "complete")
{
_logger.LogWarning("FulfillCheckoutAsync called for unpaid/incomplete session {SessionId}", sessionId);
return;
}
if (!session.Metadata.TryGetValue("companyId", out var companyIdStr) ||
!int.TryParse(companyIdStr, out var companyId))
{
_logger.LogWarning("FulfillCheckoutAsync: missing companyId metadata on session {SessionId}", sessionId);
return;
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return;
// Parse the plan from metadata
var plan = company.SubscriptionPlan;
if (session.Metadata.TryGetValue("plan", out var planStr) &&
int.TryParse(planStr, out var parsedPlan))
plan = parsedPlan;
// Get the real subscription period end from the expanded subscription
var subscription = session.Subscription;
var periodEnd = subscription?.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1);
company.SubscriptionPlan = plan;
company.SubscriptionStatus = SubscriptionStatus.Active;
company.StripeCustomerId = session.CustomerId;
company.StripeSubscriptionId = session.SubscriptionId;
company.SubscriptionStartDate = DateTime.UtcNow;
company.SubscriptionEndDate = periodEnd;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Company {CompanyId} subscription fulfilled: plan={Plan}, ends={EndDate}",
companyId, plan, periodEnd);
}
/// <summary>
/// Pulls the current subscription state from Stripe and overwrites the local company record
/// with the authoritative Stripe values (status, plan, period end). Used by SuperAdmins via
/// the Platform Management UI when the local record is believed to be stale. Matches the
/// Stripe price ID back to a <c>SubscriptionPlanConfig</c> row so the <c>SubscriptionPlan</c>
/// int is kept in sync even if the price ID was rotated. Logs a warning (but does not throw)
/// when no plan config matches the active price ID.
/// </summary>
public async Task SyncSubscriptionAsync(int companyId)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true)
?? throw new InvalidOperationException($"Company {companyId} not found");
if (string.IsNullOrEmpty(company.StripeSubscriptionId))
throw new InvalidOperationException("No Stripe subscription ID on record. Complete a checkout first.");
var subscriptionService = new Stripe.SubscriptionService();
var subscription = await subscriptionService.GetAsync(company.StripeSubscriptionId, new Stripe.SubscriptionGetOptions
{
Expand = new List<string> { "items.data.price" }
});
// Map Stripe status to our enum
company.SubscriptionStatus = subscription.Status switch
{
"active" => SubscriptionStatus.Active,
"past_due" => SubscriptionStatus.GracePeriod,
"canceled" => SubscriptionStatus.Canceled,
"unpaid" => SubscriptionStatus.GracePeriod,
_ => SubscriptionStatus.Active
};
company.SubscriptionEndDate = subscription.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1);
// Match the Stripe price ID back to one of our plan configs
var stripePriceId = subscription.Items?.Data?.FirstOrDefault()?.Price?.Id;
if (!string.IsNullOrEmpty(stripePriceId))
{
var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true);
var matchedConfig = planConfigs.FirstOrDefault(c =>
c.StripePriceIdMonthly == stripePriceId ||
c.StripePriceIdAnnual == stripePriceId);
if (matchedConfig != null)
company.SubscriptionPlan = matchedConfig.Plan; // int
else
_logger.LogWarning(
"SyncSubscriptionAsync: price {PriceId} did not match any plan config for company {CompanyId}",
stripePriceId, companyId);
}
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation(
"Company {CompanyId} synced from Stripe: plan={Plan}, status={Status}, ends={EndDate}",
companyId, company.SubscriptionPlan, company.SubscriptionStatus, company.SubscriptionEndDate);
}
/// <summary>
/// Creates a Stripe Billing Portal session that lets a tenant self-manage their subscription
/// (update payment method, view invoices, cancel) without surfacing Stripe credentials to the
/// tenant. Returns the portal URL to which the controller should redirect the user. The portal
/// session is short-lived (minutes) and bound to the Stripe customer ID; the
/// <paramref name="returnUrl"/> is where Stripe sends the user when they close the portal.
/// </summary>
public async Task<string> CreateCustomerPortalSessionAsync(string stripeCustomerId, string returnUrl)
{
var service = new Stripe.BillingPortal.SessionService();
var session = await service.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = stripeCustomerId,
ReturnUrl = returnUrl
});
return session.Url;
}
/// <summary>
/// Verifies and dispatches an inbound Stripe webhook event. The HMAC signature in
/// <paramref name="stripeSignature"/> is validated against <c>_webhookSecret</c> before any
/// processing; a <see cref="StripeException"/> is re-thrown so the caller (StripeController)
/// can return HTTP 400, which tells Stripe the event was rejected and it should retry.
/// Four event types are handled: <c>checkout.session.completed</c>,
/// <c>customer.subscription.updated</c>, <c>customer.subscription.deleted</c>, and
/// <c>invoice.payment_failed</c>. Unknown event types are logged and ignored, as Stripe
/// delivers many event types that are irrelevant to this platform.
/// </summary>
public async Task HandleWebhookAsync(string json, string stripeSignature)
{
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, stripeSignature, _webhookSecret);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe webhook signature verification failed");
throw;
}
_logger.LogInformation("Processing Stripe webhook event: {EventType}", stripeEvent.Type);
switch (stripeEvent.Type)
{
case EventTypes.CheckoutSessionCompleted:
await HandleCheckoutSessionCompletedAsync(stripeEvent);
break;
case EventTypes.CustomerSubscriptionUpdated:
await HandleSubscriptionUpdatedAsync(stripeEvent);
break;
case EventTypes.CustomerSubscriptionDeleted:
await HandleSubscriptionDeletedAsync(stripeEvent);
break;
case EventTypes.InvoicePaymentFailed:
await HandleInvoicePaymentFailedAsync(stripeEvent);
break;
default:
_logger.LogInformation("Unhandled Stripe event type: {EventType}", stripeEvent.Type);
break;
}
}
/// <summary>
/// Handles the <c>checkout.session.completed</c> webhook by delegating to
/// <see cref="FulfillCheckoutAsync"/>. Using the same fulfillment method as the
/// success-redirect path ensures webhook delivery and browser redirect are idempotent —
/// the second call is a no-op because Stripe subscription IDs do not change between calls.
/// </summary>
private async Task HandleCheckoutSessionCompletedAsync(Event stripeEvent)
{
var session = stripeEvent.Data.Object as Session;
if (session == null) return;
// Reuse FulfillCheckoutAsync so the webhook path has identical logic to the success redirect
await FulfillCheckoutAsync(session.Id);
}
/// <summary>
/// Handles the <c>customer.subscription.updated</c> webhook, which fires on any subscription
/// change (plan switch, renewal, trial ending, status change). Looks up the company first by
/// subscription ID, then falls back to customer ID in case the subscription ID was not yet
/// persisted (e.g. during initial provisioning race conditions). Updates <c>SubscriptionStatus</c>
/// and <c>SubscriptionEndDate</c> from the live Stripe subscription object.
///
/// When a customer cancels via the billing portal, Stripe keeps the subscription in
/// <c>"active"</c> status but sets <c>cancel_at_period_end = true</c>. Without this check the
/// handler would leave the company as Active. We treat <c>cancel_at_period_end</c> as
/// <c>Canceled</c> immediately so the status reflects the customer's intent, while
/// <c>IsActive</c> and <c>SubscriptionEndDate</c> are left unchanged so they retain access
/// until the period actually ends (at which point <c>customer.subscription.deleted</c> fires
/// and <see cref="HandleSubscriptionDeletedAsync"/> deactivates the company).
/// </summary>
private async Task HandleSubscriptionUpdatedAsync(Event stripeEvent)
{
var subscription = stripeEvent.Data.Object as Subscription;
if (subscription == null) return;
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.StripeSubscriptionId == subscription.Id,
ignoreQueryFilters: true);
if (company == null)
{
company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.StripeCustomerId == subscription.CustomerId,
ignoreQueryFilters: true);
}
if (company == null) return;
company.StripeSubscriptionId = subscription.Id;
company.SubscriptionEndDate = subscription.Items?.Data?.FirstOrDefault()?.CurrentPeriodEnd ?? DateTime.UtcNow.AddMonths(1);
// cancel_at_period_end means the customer cancelled via the portal — reflect that
// immediately even though Stripe still reports status "active" until the period ends.
if (subscription.CancelAtPeriodEnd)
{
company.SubscriptionStatus = SubscriptionStatus.Canceled;
}
else
{
company.SubscriptionStatus = subscription.Status switch
{
"active" => SubscriptionStatus.Active,
"past_due" => SubscriptionStatus.GracePeriod,
"canceled" => SubscriptionStatus.Canceled,
"unpaid" => SubscriptionStatus.GracePeriod,
_ => SubscriptionStatus.Active
};
}
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} subscription updated: Stripe status={StripeStatus}, CancelAtPeriodEnd={CancelAtPeriodEnd}, LocalStatus={LocalStatus}",
company.Id, subscription.Status, subscription.CancelAtPeriodEnd, company.SubscriptionStatus);
}
/// <summary>
/// Handles the <c>customer.subscription.deleted</c> webhook, which fires when a subscription
/// is fully ended in Stripe — either because the cancel-at-period-end date passed or because
/// it was immediately cancelled. Sets <c>SubscriptionStatus</c> to
/// <see cref="SubscriptionStatus.Canceled"/> and <c>IsActive = false</c> to block access, then
/// fires a SuperAdmin in-app notification so the team can follow up with the churned tenant.
/// The notification is fire-and-forget (discarded task) because a failure there should not roll
/// back the deactivation.
/// </summary>
private async Task HandleSubscriptionDeletedAsync(Event stripeEvent)
{
var subscription = stripeEvent.Data.Object as Subscription;
if (subscription == null) return;
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.StripeSubscriptionId == subscription.Id,
ignoreQueryFilters: true);
if (company == null) return;
company.SubscriptionStatus = SubscriptionStatus.Canceled;
company.IsActive = false;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} subscription deleted — deactivated", company.Id);
_ = _inApp.CreateForSuperAdminsAsync(
"Subscription Canceled",
$"{company.CompanyName} subscription was canceled via Stripe.",
"SubscriptionCanceled",
$"/Companies/Details/{company.Id}");
}
/// <summary>
/// Handles the <c>invoice.payment_failed</c> webhook, which fires when Stripe cannot charge
/// the tenant's saved payment method during renewal. Moves the company to
/// <see cref="SubscriptionStatus.GracePeriod"/> rather than immediately canceling, giving the
/// tenant time to update their payment method via the Stripe Billing Portal. A SuperAdmin
/// in-app notification is created as a fire-and-forget task so the team can proactively
/// reach out to the at-risk tenant.
/// </summary>
private async Task HandleInvoicePaymentFailedAsync(Event stripeEvent)
{
var invoice = stripeEvent.Data.Object as Invoice;
if (invoice == null || string.IsNullOrEmpty(invoice.CustomerId)) return;
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.StripeCustomerId == invoice.CustomerId,
ignoreQueryFilters: true);
if (company == null) return;
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogWarning("Company {CompanyId} payment failed — set to grace period", company.Id);
_ = _inApp.CreateForSuperAdminsAsync(
"Subscription Payment Failed",
$"{company.CompanyName} subscription payment failed — moved to grace period.",
"PaymentFailed",
$"/Companies/Details/{company.Id}");
}
}
@@ -0,0 +1,386 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Manages subscription-based feature gating and resource-limit enforcement for tenant companies.
/// All limit checks follow a consistent priority order:
/// <list type="number">
/// <item><description>Comped companies (<c>Company.IsComped == true</c>) bypass every limit
/// and are always treated as having unlimited allowances — useful for demo accounts,
/// partners, and internal testing.</description></item>
/// <item><description>Per-company overrides (<c>Company.Max*Override</c>) take precedence
/// over plan defaults, enabling SuperAdmins to grant exceptions without changing the plan.</description></item>
/// <item><description><see cref="SubscriptionPlanConfig"/> provides the plan-level defaults
/// looked up by <c>Company.SubscriptionPlan</c>.</description></item>
/// <item><description>Hard-coded fallbacks in each method guard against missing plan config rows.</description></item>
/// </list>
/// All count queries use <c>IgnoreQueryFilters()</c> to include soft-deleted records where
/// relevant, preventing a company from circumventing limits by deleting and re-adding records.
/// </summary>
public class SubscriptionService : ISubscriptionService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context)
{
_unitOfWork = unitOfWork;
_context = context;
}
/// <summary>
/// Returns <c>true</c> if the company can add another active user account.
/// Unlimited is indicated by <see cref="AppConstants.SubscriptionConstants.UnlimitedValue"/>
/// (-1) which short-circuits the count check.
/// </summary>
public async Task<bool> CanAddUserAsync(int companyId)
{
var (used, max) = await GetUserCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can create another active (non-terminal) job.
/// Terminal statuses are Completed, Delivered, and Cancelled — jobs in those states
/// do not count against the active-job limit because they no longer consume shop capacity.
/// </summary>
public async Task<bool> CanAddJobAsync(int companyId)
{
var (used, max) = await GetJobCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can add another customer record.
/// The count includes soft-deleted customers (<c>IgnoreQueryFilters</c>) to prevent
/// the limit from being gamed by deleting and re-creating customers.
/// </summary>
public async Task<bool> CanAddCustomerAsync(int companyId)
{
var (used, max) = await GetCustomerCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can create another quote this calendar month.
/// Quote limits are intentionally per-calendar-month (not per-billing-period) so that
/// the reset is predictable for end users regardless of when their subscription renews.
/// </summary>
public async Task<bool> CanAddQuoteAsync(int companyId)
{
var (used, max) = await GetQuoteCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns <c>true</c> if the company can add another catalog service item.
/// </summary>
public async Task<bool> CanAddCatalogItemAsync(int companyId)
{
var (used, max) = await GetCatalogItemCountAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the current active user count and the plan maximum for the company.
/// Comped companies always return (0, Unlimited) to short-circuit all UI limit displays.
/// The per-company override (<c>MaxUsersOverride</c>) is checked before the plan config,
/// letting SuperAdmins grant a higher seat count without upgrading the plan.
/// Falls back to 3 when neither override nor plan config is present.
/// </summary>
public async Task<(int Used, int Max)> GetUserCountAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var used = await _context.Users
.Where(u => u.CompanyId == companyId && u.IsActive)
.CountAsync();
var max = company?.MaxUsersOverride ?? config?.MaxUsers ?? 3;
return (used, max);
}
/// <summary>
/// Returns the active job count and the plan maximum.
/// Only non-terminal jobs contribute to the count; a JOIN against <c>JobStatusLookups</c>
/// is required because job status is a lookup-table entity, not an enum (see AI Accounting
/// Features in MEMORY.md). <c>IgnoreQueryFilters()</c> is needed here because the global
/// multi-tenancy filter would normally restrict to the current HTTP context's company, but
/// this service is also called from background middleware where no HTTP context exists.
/// Falls back to 50 active jobs when no plan config is found.
/// </summary>
public async Task<(int Used, int Max)> GetJobCountAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var terminalStatusCodes = new[] { "Completed", "Delivered", "Cancelled" };
var used = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && !j.IsDeleted)
.Join(_context.JobStatusLookups.IgnoreQueryFilters(),
j => j.JobStatusId,
s => s.Id,
(j, s) => new { j, s })
.Where(x => !terminalStatusCodes.Contains(x.s.StatusCode))
.CountAsync();
var max = company?.MaxActiveJobsOverride ?? config?.MaxActiveJobs ?? 50;
return (used, max);
}
/// <summary>
/// Returns the total customer count (including soft-deleted) and the plan maximum.
/// Soft-deleted records are counted to prevent limit circumvention.
/// Falls back to 100 customers when no plan config is present.
/// </summary>
public async Task<(int Used, int Max)> GetCustomerCountAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var used = await _context.Customers
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.CountAsync();
var max = company?.MaxCustomersOverride ?? config?.MaxCustomers ?? 100;
return (used, max);
}
/// <summary>
/// Returns the number of quotes created this calendar month and the monthly plan limit.
/// The window is anchored to UTC midnight on the first of the current month.
/// A limit of -1 (<see cref="AppConstants.SubscriptionConstants.UnlimitedValue"/>) means no cap.
/// </summary>
public async Task<(int Used, int Max)> GetQuoteCountAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var used = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.CreatedAt >= startOfMonth)
.CountAsync();
var max = company?.MaxQuotesOverride ?? config?.MaxQuotes ?? -1;
return (used, max);
}
/// <summary>
/// Determines the overall subscription status of a company.
/// The status progression is: Active → GracePeriod → Expired.
/// <list type="bullet">
/// <item><description>Comped companies are permanently <see cref="SubscriptionStatus.Active"/>
/// regardless of <c>SubscriptionEndDate</c>.</description></item>
/// <item><description>Companies with no <c>SubscriptionEndDate</c> are treated as Active
/// (e.g., manual invoicing arrangements that don't set an end date).</description></item>
/// <item><description>Companies within the grace period
/// (<see cref="AppConstants.SubscriptionConstants.GracePeriodDays"/> days after expiry)
/// can still log in but see an expiry banner.</description></item>
/// <item><description>Companies past the grace period are <see cref="SubscriptionStatus.Expired"/>
/// and are blocked by <c>SubscriptionMiddleware</c>.</description></item>
/// </list>
/// </summary>
public async Task<SubscriptionStatus> GetStatusAsync(int companyId)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || !company.IsActive)
return SubscriptionStatus.Inactive;
// Comped companies are always Active regardless of end date
if (company.IsComped)
return SubscriptionStatus.Active;
if (company.SubscriptionEndDate == null)
return SubscriptionStatus.Active;
var daysUntil = DaysUntilExpiry(company);
if (daysUntil == null || daysUntil > 0)
return SubscriptionStatus.Active;
if (daysUntil >= -AppConstants.SubscriptionConstants.GracePeriodDays)
return SubscriptionStatus.GracePeriod;
return SubscriptionStatus.Expired;
}
/// <summary>
/// Calculates the number of days remaining until <c>SubscriptionEndDate</c> (positive)
/// or the number of days since expiry (negative). Returns <c>null</c> when the company
/// has no end date, which the caller interprets as "does not expire."
/// Date-only comparison (using <c>.Date</c>) ensures the result does not fluctuate
/// based on the time of day the check is performed.
/// </summary>
public int? DaysUntilExpiry(Company company)
{
if (company.SubscriptionEndDate == null)
return null;
var today = DateTime.UtcNow.Date;
var expiry = company.SubscriptionEndDate.Value.Date;
return (int)(expiry - today).TotalDays;
}
/// <summary>
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
/// Returns a max of -1 when the plan config is absent (unlimited by default).
/// </summary>
public async Task<(int Used, int Max)> GetCatalogItemCountAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var used = await _context.CatalogItems
.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
.CountAsync();
return (used, company?.MaxCatalogItemsOverride ?? config?.MaxCatalogItems ?? -1);
}
/// <summary>
/// Returns <c>true</c> if the company can attach another photo to the specified job.
/// Photo limits are enforced per-job (not per-company) to prevent any single job from
/// accumulating an unreasonable number of attachments while still allowing the overall
/// company photo volume to grow with the number of jobs.
/// </summary>
public async Task<bool> CanAddJobPhotoAsync(int companyId, int jobId)
{
var (used, max) = await GetJobPhotoCountAsync(companyId, jobId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the current photo count and the per-job limit for the specified job.
/// AI analysis photos (<c>IsAiAnalysisPhoto == true</c>) are excluded from the count
/// because they are transient artifacts of the AI quoting process, not permanent
/// customer-uploaded reference photos.
/// </summary>
public async Task<(int Used, int Max)> GetJobPhotoCountAsync(int companyId, int jobId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var max = company?.MaxJobPhotosOverride ?? config?.MaxJobPhotos ?? -1;
var used = await _context.JobPhotos
.IgnoreQueryFilters()
.Where(p => p.JobId == jobId && p.CompanyId == companyId && !p.IsDeleted && !p.IsAiAnalysisPhoto)
.CountAsync();
return (used, max);
}
/// <summary>
/// Returns <c>true</c> if the company can attach another photo to the specified quote.
/// </summary>
public async Task<bool> CanAddQuotePhotoAsync(int companyId, int quoteId)
{
var (used, max) = await GetQuotePhotoCountAsync(companyId, quoteId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the current photo count and the per-quote limit for the specified quote.
/// AI analysis photos are excluded for the same reason as <see cref="GetJobPhotoCountAsync"/>.
/// </summary>
public async Task<(int Used, int Max)> GetQuotePhotoCountAsync(int companyId, int quoteId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company?.IsComped == true) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var max = company?.MaxQuotePhotosOverride ?? config?.MaxQuotePhotos ?? -1;
var used = await _context.QuotePhotos
.IgnoreQueryFilters()
.Where(p => p.QuoteId == quoteId && p.CompanyId == companyId && !p.IsDeleted && !p.IsAiAnalysisPhoto)
.CountAsync();
return (used, max);
}
/// <summary>
/// Returns <c>true</c> when the AI Inventory Assist feature is available for the company.
/// Two gates must both pass: (1) the plan must have <c>AllowAiInventoryAssist</c> set to
/// <c>true</c> in <see cref="SubscriptionPlanConfig"/>, and (2) the company-level
/// <c>AiInventoryAssistEnabled</c> flag must be <c>true</c> (a SuperAdmin toggle).
/// Comped companies bypass both gates.
/// </summary>
public async Task<bool> IsAiInventoryAssistEnabledAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company == null) return false;
if (company.IsComped) return true;
// Plan-level gate: feature must be enabled on the plan
if (config != null && !config.AllowAiInventoryAssist) return false;
return company.AiInventoryAssistEnabled;
}
/// <summary>
/// Returns <c>true</c> if the company can submit another AI Photo Quote analysis this month.
/// Three gates are evaluated in sequence, stopping at the first failure:
/// (1) plan-level <c>AllowAiPhotoQuotes</c> flag in <see cref="SubscriptionPlanConfig"/>,
/// (2) company-level <c>AiPhotoQuotesEnabled</c> toggle (SuperAdmin override),
/// (3) monthly usage quota from <see cref="GetAiPhotoQuoteUsageAsync"/>.
/// Comped companies bypass all three gates.
/// </summary>
public async Task<bool> CanUseAiPhotoQuoteAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company == null) return false;
if (company.IsComped) return true;
// Plan-level gate: feature must be enabled on the plan
if (config != null && !config.AllowAiPhotoQuotes) return false;
// Company-level override: SuperAdmin can disable per company
if (!company.AiPhotoQuotesEnabled) return false;
var (used, max) = await GetAiPhotoQuoteUsageAsync(companyId);
return max == AppConstants.SubscriptionConstants.UnlimitedValue || used < max;
}
/// <summary>
/// Returns the number of AI photo quote analyses used this calendar month and the
/// monthly cap. Usage is measured against <see cref="AiItemPrediction"/> rows because
/// each prediction corresponds to one Claude API call with an uploaded photo.
/// The per-company override (<c>MaxAiPhotoQuotesPerMonthOverride</c>) takes precedence
/// over the plan config value; -1 means unlimited.
/// Returns (0, 0) when the company does not exist, which causes <see cref="CanUseAiPhotoQuoteAsync"/>
/// to return <c>false</c> safely.
/// </summary>
public async Task<(int Used, int Max)> GetAiPhotoQuoteUsageAsync(int companyId)
{
var (company, config) = await GetCompanyAndConfigAsync(companyId);
if (company == null) return (0, 0);
if (company.IsComped) return (0, AppConstants.SubscriptionConstants.UnlimitedValue);
var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var used = await _context.AiItemPredictions
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && p.CreatedAt >= startOfMonth && !p.IsDeleted)
.CountAsync();
var max = company.MaxAiPhotoQuotesPerMonthOverride ?? config?.MaxAiPhotoQuotesPerMonth ?? -1;
return (used, max);
}
/// <summary>
/// Shared helper that loads the <see cref="Company"/> record and its associated
/// <see cref="SubscriptionPlanConfig"/> in two queries. Both queries use
/// <c>ignoreQueryFilters: true</c> so that inactive or soft-deleted companies can still
/// have their subscription status evaluated (e.g., by middleware during login).
/// Returns <c>(null, null)</c> when the company does not exist; callers are expected
/// to handle this as a worst-case "no access" state.
/// </summary>
private async Task<(Company? company, SubscriptionPlanConfig? config)> GetCompanyAndConfigAsync(int companyId)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return (null, null);
var config = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
c => c.Plan == company.SubscriptionPlan && c.IsActive,
ignoreQueryFilters: true);
return (company, config);
}
}
@@ -0,0 +1,148 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using System.Security.Claims;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Resolves the current tenant's <c>CompanyId</c> from the active HTTP request principal.
/// Consumed by <c>ApplicationDbContext</c>'s global query filters to enforce company-level
/// data isolation: every EF query automatically appends <c>WHERE CompanyId = @currentCompanyId</c>
/// unless the caller passes <c>ignoreQueryFilters: true</c>.
/// SuperAdmins bypass isolation entirely (no company filter applied) unless they are
/// actively impersonating a company via the session-stored override.
/// </summary>
public class TenantContext : ITenantContext
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
/// <summary>
/// Constructs the context. All three dependencies are scoped per-request, matching
/// the scoped lifetime of this service and <c>ApplicationDbContext</c>.
/// </summary>
public TenantContext(
IHttpContextAccessor httpContextAccessor,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context)
{
_httpContextAccessor = httpContextAccessor;
_userManager = userManager;
_context = context;
}
/// <summary>
/// Returns the <c>CompanyId</c> of the currently authenticated user, or <c>null</c>
/// for unauthenticated requests. Resolution order:
/// <list type="number">
/// <item>SuperAdmin impersonation override from <c>ISession["ImpersonatingCompanyId"]</c>.</item>
/// <item>The <c>CompanyId</c> claim baked into the authentication cookie/JWT at login time
/// (fast path — no DB hit).</item>
/// <item>Synchronous database fallback via <see cref="UserManager{TUser}.Users"/> when the
/// claim is absent (e.g. user logged in before claim issuance was added). This path
/// blocks the thread and should be treated as a migration path — users re-logging in
/// will pick up the claim and avoid the DB call.</item>
/// </list>
/// </summary>
public int? GetCurrentCompanyId()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
// SuperAdmin impersonation override — checked before claims
if (user.IsInRole("SuperAdmin"))
{
var overrideId = _httpContextAccessor.HttpContext?.Session.GetInt32("ImpersonatingCompanyId");
if (overrideId.HasValue) return overrideId.Value;
}
// Try to get from claims first (performance optimization)
var companyIdClaim = user.FindFirst("CompanyId")?.Value;
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
// Fallback: Get from database if claim is missing
// This can happen if user logged in before claims were set up
var userName = user.Identity.Name;
if (!string.IsNullOrEmpty(userName))
{
// Note: This is synchronous and may impact performance
// Consider requiring users to re-login to get claims
var appUser = _userManager.Users
.FirstOrDefault(u => u.UserName == userName);
if (appUser != null && appUser.CompanyId > 0)
return appUser.CompanyId;
}
return null;
}
/// <summary>
/// Asynchronously retrieves the full <see cref="PowderCoating.Core.Entities.Company"/> entity
/// for the currently authenticated user. Uses <see cref="UserManager{TUser}.GetUserAsync"/>
/// which reads from the Identity user cache when available. Returns <c>null</c> for
/// unauthenticated requests or when the user record cannot be found.
/// </summary>
public async Task<Company?> GetCurrentCompanyAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
var appUser = await _userManager.GetUserAsync(user);
return appUser?.Company;
}
/// <summary>
/// Returns <c>true</c> when the current user is in the <c>SuperAdmin</c> ASP.NET Identity role.
/// SuperAdmins see all companies' data (no company filter) and have access to Platform
/// Management pages. Used by <c>ApplicationDbContext</c> to decide whether to suppress the
/// global <c>CompanyId</c> query filter entirely.
/// </summary>
public bool IsSuperAdmin()
{
var user = _httpContextAccessor.HttpContext?.User;
return user?.IsInRole("SuperAdmin") == true;
}
/// <summary>
/// Returns <c>true</c> when the current user is a SuperAdmin who is not actively scoped to
/// a specific tenant company. Platform admins have unrestricted access to all companies,
/// global settings, and the seeding UI. A SuperAdmin impersonating a tenant company (via the
/// session override in <see cref="GetCurrentCompanyId"/>) is treated as a company admin for
/// that session, not a platform admin.
/// </summary>
public bool IsPlatformAdmin()
{
if (!IsSuperAdmin()) return false;
var companyId = GetCurrentCompanyId();
// No company assigned or explicitly on company 1 = full platform admin
return companyId == null || companyId == 1;
}
/// <summary>
/// Returns <c>true</c> when the current company has opted into metric units (kg/m²) rather
/// than the default Imperial system (lb/ft²). Used by views and PDF generators to display
/// the correct unit labels and conversion factors. Defaults to <c>false</c> (Imperial) when
/// there is no company context or no preference row exists.
/// </summary>
public async Task<bool> UseMetricSystemAsync()
{
var companyId = GetCurrentCompanyId();
if (companyId == null)
return false; // Default to Imperial if no company context
var preferences = await _context.CompanyPreferences
.Where(p => p.CompanyId == companyId.Value)
.FirstOrDefaultAsync();
return preferences?.UseMetricSystem ?? false;
}
}