Compare commits

..

23 Commits

Author SHA1 Message Date
spouliot fa9fa76231 Document AI Catalog Price Check in knowledge base and help article
- HelpKnowledgeBase: full AI price check section (verdicts, confidence,
  category paths, run limit, how to use, common questions)
- Inventory help article: new 'AI Catalog Price Check' section with
  verdicts, step-by-step instructions, caveats, and on-page nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:55:05 -04:00
spouliot 4128c15bbb Remove 'Claude' brand references from all user-facing views
Replace with generic 'AI', 'AI agent', or 'AI system' throughout.
Keeps the underlying vendor implementation details off the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:51:36 -04:00
spouliot 6d9111b448 Rename button and add explanatory blurb to AI price check page
- Button: 'Run/Re-run Price Check' -> 'Analyze Catalog with AI'
- Add info card explaining what the analysis does, verdict meanings,
  and the disclaimer to verify operating costs before running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:49:49 -04:00
spouliot 37c95192ca Enforce quarterly run limit on AI price check
- GET: sets ViewBag.NextRunAvailable if last run was within 90 days;
  view disables the button and shows the next eligible date
- POST: returns early with a warning if called before the 90-day window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:48:39 -04:00
spouliot 03c10a3d77 Recalibrate progress bar to 27s/batch based on observed run time
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:45:36 -04:00
spouliot ff79c39e83 Switch to sequential batching to eliminate rate limit hits
1 concurrent + 20s pacing = ~3 batches/min × 2k tokens = 6k TPM,
safely under the 8k output TPM limit. Progress estimate updated to 22s/batch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:27:10 -04:00
spouliot 2d25f6db2b Add proactive inter-batch pacing to avoid rate limit hits
Rather than relying on reactive 65s retries, each semaphore slot is held
for at least MinBatchIntervalSeconds (20s). With 2 concurrent slots that
limits throughput to ~3 batches/min × ~2k tokens = ~6k output TPM,
safely under the 8k/min limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 22:01:22 -04:00
spouliot 47f186384f Increase progress bar estimate to account for rate-limit retry waits
25s per wave (was 10s) gives headroom for occasional 65s pauses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:55:17 -04:00
spouliot 26b8244422 Reduce to 2 concurrent batches to avoid Haiku output TPM bursting
3 concurrent batches hit the rate limit simultaneously then retry in
unison, causing repeated 429s. 2 concurrent keeps output rate lower.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:54:32 -04:00
spouliot 7b902d90a2 Restore 3 concurrent batches with Haiku; recalibrate progress bar
Haiku has generous rate limits so parallelism is safe again. Retry
logic catches any 429s. Progress estimate updated to ~8s per wave.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:49:53 -04:00
spouliot f05e16a826 Switch AI price check to Haiku for cost and speed
Testing Haiku 4.5 for catalog price analysis — structured JSON output
with explicit rules is well within its capabilities. Revert to Sonnet
if result quality is insufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:41:50 -04:00
spouliot 97d47dbd1c Fix progress bar timing for sequential batch processing
Was still estimating based on 3 concurrent waves (old model).
Sequential mode runs ~18s per batch, so 500 items ≈ 6 minutes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:13:39 -04:00
spouliot 7407d1cd96 Fix rate limit errors in AI price check
Tier 1 Anthropic accounts are capped at 8,000 output tokens/minute on
Sonnet. 3 concurrent batches burst well past that, causing 429s.

- MaxConcurrentBatches: 3 → 1 (sequential prevents burst)
- Add retry: on rate_limit_error, wait 65s then retry up to 3 times
  so the per-minute window resets before the next attempt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:54:30 -04:00
spouliot 740238a939 Drop description field from AI price check user prompt
Item name + category path give Claude sufficient context for surface area
estimation. Descriptions add input tokens without meaningfully improving
verdict quality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:44:25 -04:00
spouliot 560a2c76b8 Add full category path to AI price check for coating-type context
- Skip $0-priced items (placeholders/category headers) in RunAiPriceCheck
- Build full category path (e.g. "Cerakote > Firearms") via BuildCategoryPath
  so Claude receives coating-type context — Cerakote pricing differs significantly
  from standard powder coat
- Update AI system prompt to instruct Claude to use the category path when
  determining process type, equipment, cure times, and market rates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:35:41 -04:00
spouliot 19cc03ad1c Parallelize AI price check batches, increase batch size to 25
500-item catalog was making 50 sequential API calls, causing progressive rate-limit
throttling (explains "super slow towards the end") and ~$3 in credits.

- BatchSize: 10 → 25 (word limits are in place; 25 items × ~80 tokens ≈ 2000
  output tokens, well within MaxTokens=8192 — the original truncation cause)
- Run up to 3 batches concurrently via SemaphoreSlim(3) — independent API calls
  with no shared state, so no growing context issue
- For a 500-item catalog: 50 sequential calls → 20 calls in ~7 parallel waves,
  roughly 4× faster and 60% cheaper
- Dropped unused `costs` param from AnalyzeBatchAsync (system prompt has all costs)
- JS progress timing updated to reflect parallel waves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:27:07 -04:00
spouliot 9370fcdd8f Reduce batch size to 10 and tighten AI price check prompt
Still seeing stubs despite MaxTokens=8192 — smaller batches and explicit
word limits in the prompt eliminate any remaining truncation risk.

- BatchSize: 15 → 10 (~1200 output tokens per batch vs. potential 3000+)
- Prompt: added 20-word cap on assumptions, 25-word cap on reasoning
- Prompt: strengthened "nothing before or after the '['" instruction
- Error log: now includes item IDs and first 300 chars of raw response
  so the next failure tells us exactly what Claude returned
- JS timing: updated batch divisor from 25 → 10 to match actual batch size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:57:23 -04:00
spouliot 2c4c1a6846 Fix AI price check truncation and JSON parse errors
Root cause: MaxTokens=4096 was too low — 25 items at ~250 tokens each hit the
limit mid-array (logged error showed Path: $[17]).

- MaxTokens: 4096 → 8192
- BatchSize: 25 → 15 items (keeps each response well under the limit)
- StripJsonFences → ExtractJsonArray: now also handles prose before/after the
  JSON array, and recovers truncated responses by finding the last complete
  object and closing the array — so partial batches return whatever Claude
  finished rather than nothing
- GET action: added try-catch around ResultsJson deserialization so a bad DB
  row shows a friendly "re-run" warning instead of a raw error page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:45:53 -04:00
spouliot c9324ee0b0 Fix catalog-price-check.js served from wrong wwwroot
File was written to repo-root wwwroot/ instead of
src/PowderCoating.Web/wwwroot/ — causing a 404 and MIME type refusal.
Moved to the correct location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:33:14 -04:00
spouliot 9943c11571 Add progress overlay to AI Catalog Price Check
Shows a modal overlay with animated progress bar and batch-aware status messages
while Claude is analyzing. Progress animates in two phases: ease-out to ~85%
over the estimated duration, then a slow crawl to 99% so it never falsely
"completes" before the server responds.

- Overlay driven by CSS (hidden until .active added by JS)
- Item count passed from controller as data-item-count on the run button
- Batch count derived from item count (batches of 25) to show accurate
  "Analyzing batch N of M…" messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:27:08 -04:00
spouliot 360edace72 Fix EnrollPrompt page layout squished on desktop
The auth panel CSS (brand panel gradient, form panel flex centering, feature list,
subtext color) was only defined in Login.cshtml's @section Styles — not in the
shared auth layout. EnrollPrompt used the same class names but had no styles behind
them, so the two-column layout collapsed. Added matching styles in EnrollPrompt's
own @section Styles block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:16:40 -04:00
spouliot 54f444d981 Add AI Catalog Price Check feature
Claude reviews every active catalog item against the shop's own operating costs
and returns a per-item verdict (below-cost / thin-margin / high / ok) with a
suggested price range, cost floor, and assumptions.

- New entity: CatalogPriceCheckReport (JSON blob, archived per company)
- New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService
  batches items 25 at a time to stay within model context limits
- Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck
- AiPriceCheck view: summary cards (counts by verdict), color-coded item cards
  with Edit Price link, assumptions detail, and loading spinner on submit
- AI Price Check button added to catalog Index header
- Migration AddCatalogPriceCheckReport applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:41:56 -04:00
spouliot dbe4170986 Add unit tests for 9 new services/controllers and expand existing test coverage
116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService,
QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService,
TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and
Subscription tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:27:30 -04:00
36 changed files with 13626 additions and 23 deletions
@@ -0,0 +1,80 @@
namespace PowderCoating.Application.DTOs.AI;
// ── Input ─────────────────────────────────────────────────────────────────────
/// <summary>Lightweight representation of a catalog item sent to Claude for analysis.</summary>
public class CatalogItemForPriceCheck
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string CategoryName { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
public decimal? ApproximateAreaSqFt { get; set; }
public int? EstimatedMinutes { get; set; }
public bool RequiresSandblasting { get; set; }
public bool RequiresMasking { get; set; }
}
/// <summary>Operating cost summary injected into the Claude system prompt.</summary>
public class ShopOperatingCostSummary
{
public decimal LaborRatePerHour { get; set; }
public decimal OvenCostPerHour { get; set; }
public decimal SandblasterCostPerHour { get; set; }
public decimal CoatingBoothCostPerHour { get; set; }
public decimal PowderCostPerSqFt { get; set; }
public decimal ShopSuppliesRatePercent { get; set; }
public decimal MarkupOrMarginPercent { get; set; }
public string PricingMode { get; set; } = "markup"; // "markup" or "margin"
public decimal ShopMinimumCharge { get; set; }
public string? AiContextProfile { get; set; }
}
// ── Per-Item Result ───────────────────────────────────────────────────────────
/// <summary>Verdict on a single catalog item's price health.</summary>
public class CatalogItemPriceVerdict
{
public int CatalogItemId { get; set; }
public string Name { get; set; } = string.Empty;
public decimal CurrentPrice { get; set; }
/// <summary>Assumptions Claude made about size/complexity to estimate costs.</summary>
public string Assumptions { get; set; } = string.Empty;
public decimal EstimatedSqFtMin { get; set; }
public decimal EstimatedSqFtMax { get; set; }
public int EstimatedMinutesMin { get; set; }
public int EstimatedMinutesMax { get; set; }
/// <summary>Calculated cost floor using the shop's own rates.</summary>
public decimal CostFloor { get; set; }
/// <summary>ok | low | high | below-cost</summary>
public string Verdict { get; set; } = "ok";
public decimal SuggestedPriceMin { get; set; }
public decimal SuggestedPriceMax { get; set; }
/// <summary>high | medium | low</summary>
public string Confidence { get; set; } = "medium";
public string Reasoning { get; set; } = string.Empty;
}
// ── Report ────────────────────────────────────────────────────────────────────
public class CatalogPriceCheckReportDto
{
public int Id { get; set; }
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
public int BelowCostCount { get; set; }
public int LowMarginCount { get; set; }
public int HighPriceCount { get; set; }
public int OkCount { get; set; }
public List<CatalogItemPriceVerdict> Results { get; set; } = new();
public string OperatingCostsSummary { get; set; } = string.Empty;
}
@@ -0,0 +1,16 @@
using PowderCoating.Application.DTOs.AI;
namespace PowderCoating.Application.Interfaces;
public interface IAiCatalogPriceCheckService
{
/// <summary>
/// Analyzes the provided catalog items against the shop's operating costs and returns
/// a verdict for each item. Items are batched into groups of 25 to stay within Claude's
/// context limits. Returns null results for any item that could not be analyzed.
/// </summary>
Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default);
}
@@ -120,8 +120,11 @@ public class StorageMigrationService : IStorageMigrationService
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
await using var stream = File.OpenRead(fullPath);
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
(bool Success, string ErrorMessage) uploadResult;
await using (var stream = File.OpenRead(fullPath))
{
uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
}
if (!uploadResult.Success)
{
@@ -0,0 +1,20 @@
namespace PowderCoating.Core.Entities
{
/// <summary>
/// Stores the result of the most recent AI catalog price check run for a company.
/// ResultsJson holds the full per-item verdict array serialized as JSON, avoiding the
/// need for a wide per-item table while still persisting the report across sessions.
/// Only one report is kept per company — each new run overwrites the previous one.
/// </summary>
public class CatalogPriceCheckReport : BaseEntity
{
public DateTime RunAt { get; set; }
public int ItemsChecked { get; set; }
/// <summary>JSON-serialized List&lt;CatalogItemPriceVerdict&gt;.</summary>
public string ResultsJson { get; set; } = "[]";
/// <summary>Human-readable summary of the operating costs used for this run.</summary>
public string OperatingCostsSummary { get; set; } = string.Empty;
}
}
@@ -63,6 +63,7 @@ public interface IUnitOfWork : IDisposable
// Product Catalog
IRepository<CatalogCategory> CatalogCategories { get; }
IRepository<CatalogItem> CatalogItems { get; }
IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports { get; }
// Oven Scheduling
IRepository<OvenBatch> OvenBatches { get; }
@@ -260,6 +260,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
public DbSet<CatalogCategory> CatalogCategories { get; set; }
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
public DbSet<CatalogItem> CatalogItems { get; set; }
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
// Notifications
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
@@ -508,6 +510,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCatalogPriceCheckReport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CatalogPriceCheckReports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RunAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ItemsChecked = table.Column<int>(type: "int", nullable: false),
ResultsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
OperatingCostsSummary = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CatalogPriceCheckReports", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CatalogPriceCheckReports");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
}
}
}
@@ -1465,6 +1465,57 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CatalogItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CatalogPriceCheckReport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ItemsChecked")
.HasColumnType("int");
b.Property<string>("OperatingCostsSummary")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ResultsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RunAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("CatalogPriceCheckReports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Company", b =>
{
b.Property<int>("Id")
@@ -5782,7 +5833,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6987),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5793,7 +5844,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6993),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5804,7 +5855,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563),
CreatedAt = new DateTime(2026, 4, 25, 22, 34, 50, 1, DateTimeKind.Utc).AddTicks(6994),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -84,6 +84,7 @@ public class UnitOfWork : IUnitOfWork
// Product Catalog
private IRepository<CatalogCategory>? _catalogCategories;
private IRepository<CatalogItem>? _catalogItems;
private IRepository<CatalogPriceCheckReport>? _catalogPriceCheckReports;
// Notifications
private IRepository<NotificationLog>? _notificationLogs;
@@ -344,6 +345,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<CatalogItem> CatalogItems =>
_catalogItems ??= new Repository<CatalogItem>(_context);
/// <summary>Repository for <see cref="CatalogPriceCheckReport"/> AI price-check results archived per company.</summary>
public IRepository<CatalogPriceCheckReport> CatalogPriceCheckReports =>
_catalogPriceCheckReports ??= new Repository<CatalogPriceCheckReport>(_context);
// Notifications
/// <summary>Repository for <see cref="NotificationLog"/> outbound notification audit records; tenant-filtered with soft delete.</summary>
public IRepository<NotificationLog> NotificationLogs =>
@@ -0,0 +1,328 @@
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;
/// <summary>
/// Sends catalog items to Claude in batches of 25 and collects per-item price verdicts.
/// Each batch produces one Claude call so large catalogs stay within the model's context
/// limits. Results across all batches are merged into a single flat list before returning.
/// </summary>
public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
{
private readonly IConfiguration _config;
private readonly ILogger<AiCatalogPriceCheckService> _logger;
private const string Model = "claude-haiku-4-5-20251001";
private const int BatchSize = 25;
private const int MaxConcurrentBatches = 1; // sequential avoids bursting past the 8k output TPM limit
private const int RateLimitRetrySeconds = 65;
private const int MinBatchIntervalSeconds = 20; // proactive pacing: ~3 batches/min × ~2k tokens = ~6k TPM, under the 8k limit
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
public AiCatalogPriceCheckService(IConfiguration config, ILogger<AiCatalogPriceCheckService> logger)
{
_config = config;
_logger = logger;
}
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
/// <summary>
/// Extracts a JSON array from Claude's response, handling three common failure modes:
/// (1) ```json ... ``` fences wrapping the array,
/// (2) prose text before or after the JSON array,
/// (3) truncated responses where the closing ] is missing — in that case we close any
/// open string and append ]} to produce a parseable (though incomplete) array so
/// we recover whatever items Claude did finish.
/// </summary>
private static string ExtractJsonArray(string text)
{
var trimmed = text.Trim();
// Strip code fences
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```")) trimmed = trimmed[..^3];
trimmed = trimmed.Trim();
}
// Find the outermost [ ... ] even when Claude adds prose around it
var arrayStart = trimmed.IndexOf('[');
if (arrayStart < 0) return "[]";
trimmed = trimmed[arrayStart..];
var arrayEnd = trimmed.LastIndexOf(']');
if (arrayEnd >= 0)
return trimmed[..(arrayEnd + 1)];
// No closing bracket — response was truncated. Patch it so we can recover
// whatever complete objects Claude did return.
// Strategy: find the last complete }, and close the array after it.
var lastComplete = trimmed.LastIndexOf("},");
if (lastComplete < 0) lastComplete = trimmed.LastIndexOf('}');
if (lastComplete >= 0)
return trimmed[..(lastComplete + 1)] + "]";
return "[]";
}
/// <summary>
/// Sends a message to Claude with up to 3 attempts. On a rate-limit 429, waits
/// RateLimitRetrySeconds before retrying so the per-minute token window can reset.
/// </summary>
private async Task<MessageResponse> SendAsync(AnthropicClient client, MessageParameters parameters)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
catch (HttpRequestException ex) when (attempt < maxAttempts && ex.Message.Contains("rate_limit_error"))
{
_logger.LogWarning("Rate limit hit (attempt {Attempt}/{Max}), waiting {Seconds}s before retry",
attempt, maxAttempts, RateLimitRetrySeconds);
await Task.Delay(TimeSpan.FromSeconds(RateLimitRetrySeconds));
}
}
// Final attempt — let any exception propagate to the batch error handler
using var finalCts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, finalCts.Token);
}
/// <inheritdoc/>
public async Task<List<CatalogItemPriceVerdict>> AnalyzeAsync(
List<CatalogItemForPriceCheck> items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default)
{
var apiKey = GetApiKey();
if (apiKey == null)
{
_logger.LogWarning("AI Catalog Price Check called but Anthropic API key is not configured.");
return new List<CatalogItemPriceVerdict>();
}
var client = new AnthropicClient(apiKey);
var systemPrompt = BuildSystemPrompt(costs);
// Split into independent batches upfront
var batches = Enumerable.Range(0, (int)Math.Ceiling(items.Count / (double)BatchSize))
.Select(i => items.Skip(i * BatchSize).Take(BatchSize).ToList())
.ToList();
// Run up to MaxConcurrentBatches in parallel. Each batch is an independent API call
// with its own fresh MessageParameters — no shared state, no growing context.
var semaphore = new SemaphoreSlim(MaxConcurrentBatches, MaxConcurrentBatches);
var batchTasks = batches.Select(async (batch, index) =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
_logger.LogInformation("Starting price check batch {Index}/{Total} ({Count} items)",
index + 1, batches.Count, batch.Count);
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await AnalyzeBatchAsync(client, systemPrompt, batch);
// Pace output token rate: hold the slot until MinBatchIntervalSeconds has elapsed
// so we stay under the per-minute output token limit without relying solely on retries.
var pad = (int)(MinBatchIntervalSeconds * 1000 - sw.ElapsedMilliseconds);
if (pad > 0) await Task.Delay(pad, cancellationToken);
return result;
}
finally
{
semaphore.Release();
}
}).ToList();
var batchResults = await Task.WhenAll(batchTasks);
// Preserve original catalog order
return batches.Zip(batchResults, (batch, results) => results)
.SelectMany(r => r)
.ToList();
}
private async Task<List<CatalogItemPriceVerdict>> AnalyzeBatchAsync(
AnthropicClient client,
string systemPrompt,
List<CatalogItemForPriceCheck> batch)
{
var userPrompt = BuildUserPrompt(batch);
var parameters = new MessageParameters
{
Model = Model,
MaxTokens = 8192,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new() { Role = RoleType.User, Content = new List<ContentBase> { new TextContent { Text = userPrompt } } }
}
};
var raw = string.Empty;
try
{
var response = await SendAsync(client, parameters);
raw = response.Content.OfType<TextContent>().FirstOrDefault()?.Text ?? "[]";
var json = ExtractJsonArray(raw);
var claudeItems = JsonSerializer.Deserialize<List<ClaudePriceCheckItem>>(json, JsonOpts) ?? new();
return claudeItems.Select(ci =>
{
var source = batch.FirstOrDefault(b => b.Id == ci.catalogItemId);
return new CatalogItemPriceVerdict
{
CatalogItemId = ci.catalogItemId,
Name = source?.Name ?? $"Item #{ci.catalogItemId}",
CurrentPrice = source?.CurrentPrice ?? 0,
Assumptions = ci.assumptions,
EstimatedSqFtMin = ci.estimatedSqFtMin,
EstimatedSqFtMax = ci.estimatedSqFtMax,
EstimatedMinutesMin = ci.estimatedMinutesMin,
EstimatedMinutesMax = ci.estimatedMinutesMax,
CostFloor = ci.costFloor,
Verdict = ci.verdict,
SuggestedPriceMin = ci.suggestedPriceMin,
SuggestedPriceMax = ci.suggestedPriceMax,
Confidence = ci.confidence,
Reasoning = ci.reasoning
};
}).ToList();
}
catch (Exception ex)
{
var preview = raw.Length > 300 ? raw[..300] + "…" : raw;
_logger.LogError(ex,
"AI price check batch failed for items [{ItemIds}]. Raw response preview: {RawPreview}",
string.Join(", ", batch.Select(b => b.Id)), preview);
return batch.Select(item => new CatalogItemPriceVerdict
{
CatalogItemId = item.Id,
Name = item.Name,
CurrentPrice = item.CurrentPrice,
Verdict = "ok",
Confidence = "low",
Assumptions = "Analysis unavailable for this item.",
Reasoning = "An error occurred during analysis. Please re-run the price check."
}).ToList();
}
}
private static string BuildSystemPrompt(ShopOperatingCostSummary costs)
{
var sb = new StringBuilder();
sb.AppendLine("You are a pricing consultant for a powder coating business. Your job is to review catalog items and flag potential pricing problems against the shop's actual operating costs.");
sb.AppendLine();
sb.AppendLine("## Shop Operating Costs");
sb.AppendLine($"- Labor rate: ${costs.LaborRatePerHour:F2}/hr");
sb.AppendLine($"- Oven operating cost: ${costs.OvenCostPerHour:F2}/hr");
sb.AppendLine($"- Sandblaster cost: ${costs.SandblasterCostPerHour:F2}/hr");
sb.AppendLine($"- Coating booth cost: ${costs.CoatingBoothCostPerHour:F2}/hr");
sb.AppendLine($"- Powder material cost: ${costs.PowderCostPerSqFt:F2}/sqft");
sb.AppendLine($"- Shop supplies surcharge: {costs.ShopSuppliesRatePercent:F1}%");
if (costs.PricingMode == "margin")
sb.AppendLine($"- Target gross margin: {costs.MarkupOrMarginPercent:F1}%");
else
sb.AppendLine($"- Markup on material: {costs.MarkupOrMarginPercent:F1}%");
if (costs.ShopMinimumCharge > 0)
sb.AppendLine($"- Shop minimum charge: ${costs.ShopMinimumCharge:F2}");
if (!string.IsNullOrWhiteSpace(costs.AiContextProfile))
{
sb.AppendLine();
sb.AppendLine("## Shop Profile");
sb.AppendLine(costs.AiContextProfile);
}
sb.AppendLine();
sb.AppendLine("## Instructions");
sb.AppendLine("For each item, use industry knowledge to estimate a plausible surface area and processing time. Then compute a cost floor = (labor + equipment + material) using the shop's rates above. Compare the cost floor to the item's current price and return a verdict.");
sb.AppendLine();
sb.AppendLine("Verdict values:");
sb.AppendLine("- \"below-cost\" — price is at or below cost floor (the shop loses money)");
sb.AppendLine("- \"low\" — price is above cost floor but margin is thin (< the shop's target margin)");
sb.AppendLine("- \"high\" — price appears significantly above comparable market rates (risk of losing work)");
sb.AppendLine("- \"ok\" — price is within a reasonable range");
sb.AppendLine();
sb.AppendLine("Confidence values:");
sb.AppendLine("- \"high\" — item name clearly identifies part type and standard dimensions");
sb.AppendLine("- \"medium\" — reasonable assumptions were possible");
sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')");
sb.AppendLine();
sb.AppendLine("The \"category\" field contains the full path, e.g. \"Cerakote > Firearms\" or \"Powder Coat > Wheels\". Use this to determine the coating process — Cerakote items have a very different cost profile than standard powder coat (different equipment, cure times, and market rates). Price accordingly.");
sb.AppendLine();
sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing.");
sb.AppendLine();
sb.AppendLine("IMPORTANT: Keep responses concise to avoid truncation. Limit assumptions to 20 words max. Limit reasoning to 25 words max.");
sb.AppendLine();
sb.AppendLine("Return ONLY a JSON array — no prose, no markdown fences, nothing before or after the '['. Use this exact schema for each element:");
sb.AppendLine(@"{
""catalogItemId"": <int>,
""assumptions"": ""<≤20 words>"",
""estimatedSqFtMin"": <decimal>,
""estimatedSqFtMax"": <decimal>,
""estimatedMinutesMin"": <int>,
""estimatedMinutesMax"": <int>,
""costFloor"": <decimal>,
""verdict"": ""ok|low|high|below-cost"",
""suggestedPriceMin"": <decimal>,
""suggestedPriceMax"": <decimal>,
""confidence"": ""high|medium|low"",
""reasoning"": ""<≤25 words>""
}");
return sb.ToString();
}
// Local schema — mirrors the JSON shape Claude is asked to return. Kept private to
// the Infrastructure layer because it's a transport detail, not a domain concept.
private sealed class ClaudePriceCheckItem
{
public int catalogItemId { get; set; }
public string assumptions { get; set; } = string.Empty;
public decimal estimatedSqFtMin { get; set; }
public decimal estimatedSqFtMax { get; set; }
public int estimatedMinutesMin { get; set; }
public int estimatedMinutesMax { get; set; }
public decimal costFloor { get; set; }
public string verdict { get; set; } = "ok";
public decimal suggestedPriceMin { get; set; }
public decimal suggestedPriceMax { get; set; }
public string confidence { get; set; } = "medium";
public string reasoning { get; set; } = string.Empty;
}
private static string BuildUserPrompt(List<CatalogItemForPriceCheck> batch)
{
var itemsJson = JsonSerializer.Serialize(batch.Select(item => new
{
catalogItemId = item.Id,
name = item.Name,
category = item.CategoryName,
currentPrice = item.CurrentPrice,
approximateAreaSqFt = item.ApproximateAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking
}), new JsonSerializerOptions { WriteIndented = false });
return $"Analyze these {batch.Count} catalog items and return the JSON verdict array:\n{itemsJson}";
}
}
@@ -4,14 +4,17 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.DTOs.Catalog;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
namespace PowderCoating.Web.Controllers
@@ -35,6 +38,7 @@ namespace PowderCoating.Web.Controllers
private readonly IMeasurementConversionService _measurementService;
private readonly ISubscriptionService _subscriptionService;
private readonly ICatalogImageService _catalogImageService;
private readonly IAiCatalogPriceCheckService _priceCheckService;
public CatalogItemsController(
IUnitOfWork unitOfWork,
@@ -45,7 +49,8 @@ namespace PowderCoating.Web.Controllers
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ISubscriptionService subscriptionService,
ICatalogImageService catalogImageService)
ICatalogImageService catalogImageService,
IAiCatalogPriceCheckService priceCheckService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -56,6 +61,7 @@ namespace PowderCoating.Web.Controllers
_measurementService = measurementService;
_subscriptionService = subscriptionService;
_catalogImageService = catalogImageService;
_priceCheckService = priceCheckService;
}
/// <summary>
@@ -915,6 +921,215 @@ namespace PowderCoating.Web.Controllers
return RedirectToAction(nameof(Index));
}
}
// ── AI Price Check ────────────────────────────────────────────────────
/// <summary>
/// Displays the most recent AI price-check report for this company, or an empty state
/// if the check has never been run. The report is stored as JSON in the database so
/// users can review it later without re-running the AI call.
/// </summary>
public async Task<IActionResult> AiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
ViewBag.ActiveItemCount = pricedItems.Count();
if (report != null)
{
var nextRun = report.RunAt.AddDays(90);
if (nextRun > DateTime.UtcNow)
ViewBag.NextRunAvailable = nextRun.ToLocalTime().ToString("MMMM d, yyyy");
}
CatalogPriceCheckReportDto? dto = null;
if (report != null)
{
List<CatalogItemPriceVerdict> results;
try
{
results = JsonSerializer.Deserialize<List<CatalogItemPriceVerdict>>(
report.ResultsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deserialize stored price check report {ReportId}", report.Id);
TempData["Warning"] = "The previous report could not be loaded. Please re-run the price check.";
results = new();
}
dto = new CatalogPriceCheckReportDto
{
Id = report.Id,
RunAt = report.RunAt,
ItemsChecked = report.ItemsChecked,
Results = results,
OperatingCostsSummary = report.OperatingCostsSummary,
BelowCostCount = results.Count(r => r.Verdict == "below-cost"),
LowMarginCount = results.Count(r => r.Verdict == "low"),
HighPriceCount = results.Count(r => r.Verdict == "high"),
OkCount = results.Count(r => r.Verdict == "ok")
};
}
return View(dto);
}
/// <summary>
/// Runs the AI price check against all active catalog items using the company's current
/// operating costs. Batches items in groups of 25 to stay within model context limits.
/// Overwrites any existing report for this company rather than accumulating a history,
/// since operating costs change and old reports become misleading.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunAiPriceCheck()
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Forbid();
// Enforce quarterly run limit — check the most recent report for this company.
var lastReport = (await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId))
.OrderByDescending(r => r.RunAt).FirstOrDefault();
if (lastReport != null)
{
var nextRun = lastReport.RunAt.AddDays(90);
if (nextRun > DateTime.UtcNow)
{
TempData["Warning"] = $"Price check can only be run once per quarter. Next run available: {nextRun.ToLocalTime():MMMM d, yyyy}.";
return RedirectToAction(nameof(AiPriceCheck));
}
}
try
{
// Load active catalog items with a real price — skip $0 items (placeholders,
// category headers, etc.) since there's no pricing to evaluate.
var items = (await _unitOfWork.CatalogItems.FindAsync(
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
if (items.Count == 0)
{
TempData["Warning"] = "No priced catalog items to analyze. Add prices to your catalog items first.";
return RedirectToAction(nameof(AiPriceCheck));
}
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
.ToDictionary(c => c.Id);
// Load company operating costs
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(
c => c.CompanyId == currentUser.CompanyId)).FirstOrDefault();
var costSummary = BuildCostSummary(costs);
// Map to service DTOs
var itemDtos = items.Select(i => new CatalogItemForPriceCheck
{
Id = i.Id,
Name = i.Name,
Description = i.Description,
CategoryName = BuildCategoryPath(i.CategoryId, allCategories),
CurrentPrice = i.DefaultPrice,
ApproximateAreaSqFt = i.ApproximateArea,
EstimatedMinutes = i.DefaultEstimatedMinutes,
RequiresSandblasting = i.DefaultRequiresSandblasting,
RequiresMasking = i.DefaultRequiresMasking
}).ToList();
// Run AI analysis
var verdicts = await _priceCheckService.AnalyzeAsync(itemDtos, costSummary);
// Soft-delete any previous report for this company
var existing = await _unitOfWork.CatalogPriceCheckReports.FindAsync(
r => r.CompanyId == currentUser.CompanyId);
foreach (var old in existing)
await _unitOfWork.CatalogPriceCheckReports.SoftDeleteAsync(old.Id);
// Save new report
var report = new CatalogPriceCheckReport
{
CompanyId = currentUser.CompanyId,
RunAt = DateTime.UtcNow,
ItemsChecked = items.Count,
ResultsJson = JsonSerializer.Serialize(verdicts),
OperatingCostsSummary = BuildCostSummaryText(costSummary)
};
await _unitOfWork.CatalogPriceCheckReports.AddAsync(report);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"AI price check complete — {items.Count} items analyzed.";
}
catch (OperationCanceledException)
{
TempData["Error"] = "The AI analysis timed out. Try again or reduce your catalog size.";
}
catch (Exception ex)
{
_logger.LogError(ex, "AI catalog price check failed");
TempData["Error"] = "An error occurred during the AI price check. Please try again.";
}
return RedirectToAction(nameof(AiPriceCheck));
}
private static ShopOperatingCostSummary BuildCostSummary(CompanyOperatingCosts? costs)
{
if (costs == null)
return new ShopOperatingCostSummary();
return new ShopOperatingCostSummary
{
LaborRatePerHour = costs.StandardLaborRate,
OvenCostPerHour = costs.OvenOperatingCostPerHour,
SandblasterCostPerHour = costs.SandblasterCostPerHour,
CoatingBoothCostPerHour = costs.CoatingBoothCostPerHour,
PowderCostPerSqFt = costs.PowderCoatingCostPerSqFt,
ShopSuppliesRatePercent = costs.ShopSuppliesRate,
MarkupOrMarginPercent = costs.PricingMode == PricingMode.MarginOnTotalCost
? costs.TargetMarginPercent
: costs.GeneralMarkupPercentage,
PricingMode = costs.PricingMode == PricingMode.MarginOnTotalCost ? "margin" : "markup",
ShopMinimumCharge = costs.ShopMinimumCharge,
AiContextProfile = costs.AiContextProfile
};
}
private static string BuildCostSummaryText(ShopOperatingCostSummary c) =>
$"Labor ${c.LaborRatePerHour:F2}/hr | Oven ${c.OvenCostPerHour:F2}/hr | " +
$"Blaster ${c.SandblasterCostPerHour:F2}/hr | Booth ${c.CoatingBoothCostPerHour:F2}/hr | " +
$"Powder ${c.PowderCostPerSqFt:F2}/sqft | " +
$"{(c.PricingMode == "margin" ? "Margin" : "Markup")} {c.MarkupOrMarginPercent:F1}%";
/// <summary>
/// Walks up the category parent chain to produce a full path like "Cerakote > Firearms",
/// giving Claude the coating-type context it needs for accurate pricing analysis.
/// </summary>
private static string BuildCategoryPath(int? categoryId, Dictionary<int, CatalogCategory> all)
{
if (categoryId == null) return "Uncategorized";
var parts = new List<string>();
var current = all.GetValueOrDefault(categoryId.Value);
while (current != null)
{
parts.Insert(0, current.Name);
current = current.ParentCategoryId.HasValue
? all.GetValueOrDefault(current.ParentCategoryId.Value)
: null;
}
return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized";
}
}
// Helper class for hierarchical display
@@ -959,6 +959,42 @@ public static class HelpKnowledgeBase
---
## AI CATALOG PRICE CHECK
**Where:** [Catalog Items](/CatalogItems) "AI Price Check" button (top-right of the catalog list)
**What it does:** Reviews every active, priced catalog item against your shop's actual operating costs. For each item the AI estimates a realistic surface area and processing time, calculates a cost floor (labor + equipment + materials), then compares that to the item's current price and returns a verdict. Results are saved so you can review them any time without re-running the analysis.
**Verdicts:**
- **Below Cost** price is at or below the cost floor; the shop loses money on this item
- **Thin Margin** price is above cost but below the shop's target margin
- **High** price appears significantly above typical market rates
- **OK** price is within a reasonable range
**Confidence levels:** Each result shows a confidence level (High / Medium / Low) based on how specific the item name is. Vague items like "Custom Part" or "Special Job" will be flagged as Low confidence treat those results with extra skepticism.
**Category paths:** The AI uses the full category path (e.g. "Cerakote > Firearms") to determine the coating process. Cerakote and other specialty coatings have very different cost profiles than standard powder coat make sure items are in the correct category for the most accurate results.
**Run limit:** Analysis can be run once per quarter (every 90 days). The button shows the next available date when a recent run exists. This limit applies per company.
**Before running:** Make sure your operating costs (labor rate, oven cost, powder cost, etc.) are up to date in [Company Settings](/CompanySettings). Stale costs will produce inaccurate verdicts.
**How to use the results:**
1. Go to Catalog Items click "AI Price Check"
2. Click "Analyze Catalog with AI" a progress overlay shows estimated completion time (allow 710 minutes for large catalogs)
3. Review the results sorted by severity (Below Cost first, then Thin Margin, High, OK)
4. For flagged items, click "Edit Price" to update the price directly from the results page
5. Items marked Low confidence should be verified manually the AI had limited information to work with
**Common questions:**
- "How accurate is the AI analysis?" Results are estimates based on industry knowledge and your operating costs. Always apply your own judgment before changing prices.
- "Why is an item showing $0.00 cost floor?" The item likely had an error during analysis. Re-run the check or verify the item has a valid name and category.
- "Can I run it more often?" The quarterly limit is enforced to manage costs. Contact your administrator if you need an exception.
- "Items in my Cerakote category are priced wrong" Make sure those items are in a category whose full path includes 'Cerakote' (e.g. "Cerakote > Firearms"). The AI uses the category path to determine the coating type.
- "The progress bar finished but the page didn't load" The analysis is still running server-side. Wait for the page to redirect automatically do not close the tab.
---
## USER PROFILE
**Where:** [My Profile](/Profile) via the user menu (top-right)
+1
View File
@@ -200,6 +200,7 @@ builder.Services.AddSingleton<IAiUsageLogger, AiUsageLogger>();
builder.Services.AddScoped<IAiSchedulingService, AiSchedulingService>();
builder.Services.AddScoped<IAccountingAiService, AccountingAiService>();
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
builder.Services.AddHttpClient();
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
@@ -0,0 +1,328 @@
@model PowderCoating.Application.DTOs.AI.CatalogPriceCheckReportDto?
@{
ViewData["Title"] = "AI Catalog Price Check";
ViewData["PageIcon"] = "bi-robot";
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates — always apply your own judgment before changing prices.";
var sortedResults = Model?.Results
.OrderBy(r => r.Verdict switch
{
"below-cost" => 0,
"low" => 1,
"high" => 2,
_ => 3
})
.ThenBy(r => r.Name)
.ToList() ?? new List<PowderCoating.Application.DTOs.AI.CatalogItemPriceVerdict>();
}
@section Styles {
<style>
.verdict-badge { font-size: 0.8rem; font-weight: 600; padding: 0.3em 0.7em; border-radius: 20px; }
.verdict-below-cost { background: #fee2e2; color: #991b1b; }
.verdict-low { background: #fef3c7; color: #92400e; }
.verdict-high { background: #e0e7ff; color: #3730a3; }
.verdict-ok { background: #d1fae5; color: #065f46; }
.confidence-low { opacity: 0.6; }
.price-card { border-left: 4px solid #e5e7eb; }
.price-card.below-cost { border-left-color: #ef4444; }
.price-card.low { border-left-color: #f59e0b; }
.price-card.high { border-left-color: #6366f1; }
.price-card.ok { border-left-color: #10b981; }
.cost-table td { font-size: 0.85rem; }
.summary-stat { text-align: center; }
.summary-stat .num { font-size: 2rem; font-weight: 700; line-height: 1; }
.summary-stat .lbl { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
.run-btn-wrap { min-height: 3rem; }
/* Progress overlay */
#price-check-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
z-index: 1050;
align-items: center;
justify-content: center;
}
#price-check-overlay.active { display: flex; }
.progress-card {
background: #fff;
border-radius: 1rem;
padding: 2.5rem 2rem;
width: 100%;
max-width: 440px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
.progress-card .icon { font-size: 3rem; color: #4f46e5; margin-bottom: 1rem; }
.progress-card h5 { font-weight: 700; margin-bottom: 0.25rem; }
.progress-card .status-msg { font-size: 0.9rem; color: #64748b; min-height: 1.4em; margin-bottom: 1.25rem; }
.progress-bar-track {
height: 8px;
background: #e2e8f0;
border-radius: 99px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
border-radius: 99px;
width: 0%;
transition: width 0.6s ease;
}
.progress-card .pct-label { font-size: 0.8rem; color: #94a3b8; }
</style>
}
<!-- Progress overlay (shown while AI is running) -->
<div id="price-check-overlay">
<div class="progress-card">
<div class="icon"><i class="bi bi-robot"></i></div>
<h5>Analyzing your catalog</h5>
<p class="status-msg" id="overlay-status">Preparing items…</p>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="overlay-bar"></div>
</div>
<div class="pct-label"><span id="overlay-pct">0</span>% complete</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i> Back to Catalog
</a>
@if (ViewBag.NextRunAvailable != null)
{
<div class="text-end">
<button class="btn btn-primary" disabled>
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
<div class="small text-muted mt-1">Next run available: @ViewBag.NextRunAvailable</div>
</div>
}
else
{
<form asp-action="RunAiPriceCheck" method="post" id="runForm" class="run-btn-wrap">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-primary" id="runBtn"
data-item-count="@(ViewBag.ActiveItemCount ?? 0)">
<i class="bi bi-robot me-2"></i>Analyze Catalog with AI
</button>
</form>
}
</div>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
</div>
}
@if (TempData["Warning"] != null)
{
<div class="alert alert-warning alert-permanent mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Warning"]
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-permanent mb-4">
<i class="bi bi-x-circle me-2"></i>@TempData["Error"]
</div>
}
<!-- What this does -->
<div class="card mb-4 border-0 bg-light">
<div class="card-body">
<div class="d-flex gap-3">
<div class="flex-shrink-0 text-primary" style="font-size:1.75rem;"><i class="bi bi-info-circle"></i></div>
<div>
<h6 class="fw-semibold mb-1">What this analysis does</h6>
<p class="small text-muted mb-2">
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs —
labor, oven time, sandblasting, coating booth, and powder material. For each item it estimates a
realistic surface area and processing time, calculates a cost floor, then compares that to your
current price and returns one of four verdicts:
</p>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">— you're losing money on this item</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">— above cost floor but below your target margin</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">— significantly above typical market rates</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">— price is within a reasonable range</span>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
Results are estimates based on industry knowledge and your shop's rates. Always apply your own
judgment before changing prices. Make sure your
<a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date for the most accurate results.
Analysis can be run once per quarter.
</p>
</div>
</div>
</div>
</div>
@if (Model == null)
{
<!-- Empty state -->
<div class="card text-center py-5">
<div class="card-body">
<i class="bi bi-robot text-muted" style="font-size: 4rem;"></i>
<h4 class="mt-3">No analysis has been run yet</h4>
<p class="text-muted mb-4">
Click <strong>Analyze Catalog with AI</strong> above to get started.
</p>
</div>
</div>
}
else
{
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-danger">@Model.BelowCostCount</div>
<div class="lbl mt-1">Below Cost</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-warning">@Model.LowMarginCount</div>
<div class="lbl mt-1">Thin Margin</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-primary">@Model.HighPriceCount</div>
<div class="lbl mt-1">Possibly High</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card h-100">
<div class="card-body summary-stat">
<div class="num text-success">@Model.OkCount</div>
<div class="lbl mt-1">Looks Good</div>
</div>
</div>
</div>
</div>
<!-- Meta / costs used -->
<div class="card mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3">
<i class="bi bi-clock-history text-muted"></i>
<span class="text-muted small">
Run @Model.RunAt.ToLocalTime().ToString("MMM d, yyyy h:mm tt") &bull;
@Model.ItemsChecked items checked
</span>
<span class="badge bg-light text-secondary small ms-auto">
Costs used: @Model.OperatingCostsSummary
</span>
</div>
</div>
<!-- Results list -->
<div class="row g-3">
@foreach (var item in sortedResults!)
{
var cardClass = item.Verdict switch
{
"below-cost" => "below-cost",
"low" => "low",
"high" => "high",
_ => "ok"
};
var verdictClass = item.Verdict switch
{
"below-cost" => "verdict-below-cost",
"low" => "verdict-low",
"high" => "verdict-high",
_ => "verdict-ok"
};
var verdictLabel = item.Verdict switch
{
"below-cost" => "Below Cost",
"low" => "Thin Margin",
"high" => "High",
_ => "OK"
};
<div class="col-12 col-lg-6">
<div class="card price-card @cardClass @(item.Confidence == "low" ? "confidence-low" : "")">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong>@item.Name</strong>
@if (item.Confidence == "low")
{
<span class="badge bg-light text-secondary ms-2" title="Item name was too vague for a confident estimate">
<i class="bi bi-question-circle me-1"></i>Low confidence
</span>
}
</div>
<span class="verdict-badge @verdictClass">@verdictLabel</span>
</div>
<div class="row g-2 mb-2">
<div class="col-4 text-center">
<div class="small text-muted">Current</div>
<div class="fw-semibold">@item.CurrentPrice.ToString("C")</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Cost Floor</div>
<div class="fw-semibold @(item.CostFloor > item.CurrentPrice ? "text-danger" : "")">
@item.CostFloor.ToString("C")
</div>
</div>
<div class="col-4 text-center">
<div class="small text-muted">Suggested</div>
<div class="fw-semibold text-primary">
@item.SuggestedPriceMin.ToString("C") @item.SuggestedPriceMax.ToString("C")
</div>
</div>
</div>
<div class="small text-muted mb-1">
<i class="bi bi-rulers me-1"></i>
Est. @item.EstimatedSqFtMin@item.EstimatedSqFtMax sqft &bull;
@item.EstimatedMinutesMin@item.EstimatedMinutesMax min
</div>
<p class="small mb-1">@item.Reasoning</p>
<details class="small">
<summary class="text-muted" style="cursor:pointer;">Assumptions</summary>
<p class="mt-1 mb-0 text-muted">@item.Assumptions</p>
</details>
<div class="mt-2 text-end">
<a asp-action="Edit" asp-route-id="@item.CatalogItemId"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit Price
</a>
</div>
</div>
</div>
</div>
}
</div>
}
@section Scripts {
<script src="~/js/catalog-price-check.js"></script>
}
@@ -22,7 +22,12 @@
<script src="~/js/catalog.js"></script>
}
<div class="d-flex justify-content-end align-items-center mb-4">
<div class="d-flex justify-content-end align-items-center gap-2 mb-4">
<a asp-action="AiPriceCheck" class="btn btn-outline-primary text-nowrap">
<i class="bi bi-robot me-2"></i>
<span class="d-none d-sm-inline">AI Price Check</span>
<span class="d-inline d-sm-none">AI</span>
</a>
<a asp-action="ExportCatalogPdf" class="btn btn-primary text-nowrap">
<i class="bi bi-file-pdf me-2"></i>
<span class="d-none d-sm-inline">Export Product Catalog to PDF</span>
@@ -430,6 +430,51 @@
</div>
</section>
<section id="ai-price-check" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-robot text-primary me-2"></i>AI Catalog Price Check
</h2>
<p>
The AI Price Check reviews every active, priced item in your
<a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> list against your
shop's actual operating costs. It estimates a realistic surface area and processing time
for each item, calculates a cost floor, and compares that to your current price — flagging
anything that may be losing money, leaving margin on the table, or priced above market rates.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Verdicts</h3>
<ul class="mb-3">
<li class="mb-2"><strong>Below Cost</strong> — price is at or below the estimated cost floor. The shop loses money on every sale of this item.</li>
<li class="mb-2"><strong>Thin Margin</strong> — price covers costs but falls below your target margin percentage.</li>
<li class="mb-2"><strong>High</strong> — price appears significantly above typical market rates, which may cost you work.</li>
<li class="mb-2"><strong>OK</strong> — price is within a reasonable range given your costs and market context.</li>
</ul>
<h3 class="h6 fw-semibold mt-3 mb-2">How to run it</h3>
<ol class="mb-3">
<li class="mb-2">Make sure your <a asp-controller="CompanySettings" asp-action="Index">operating costs</a> are up to date — stale rates produce inaccurate verdicts.</li>
<li class="mb-2">Go to <a asp-controller="CatalogItems" asp-action="Index">Catalog Items</a> and click <strong>AI Price Check</strong> in the top-right.</li>
<li class="mb-2">Click <strong>Analyze Catalog with AI</strong>. A progress overlay appears while the analysis runs (allow 710 minutes for large catalogs).</li>
<li class="mb-2">Review results sorted by severity — Below Cost items appear first. Click <strong>Edit Price</strong> on any item to update it directly from the results page.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">Things to know</h3>
<ul class="mb-3">
<li class="mb-2"><strong>Run limit:</strong> Analysis can be run once per quarter (90 days). The button shows the next available date when a recent run exists.</li>
<li class="mb-2"><strong>Confidence levels:</strong> Each result shows High, Medium, or Low confidence. Vague item names like "Custom Part" will be Low — verify those manually.</li>
<li class="mb-2"><strong>Category paths matter:</strong> The AI uses the full category path (e.g. "Cerakote &rsaquo; Firearms") to determine the coating type. Make sure specialty items are in the correct category.</li>
<li class="mb-2"><strong>$0 items skipped:</strong> Placeholder items and category headers with no price are automatically excluded from analysis.</li>
</ul>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Results are estimates based on industry knowledge and your shop's rates. Always apply
your own judgment before changing prices — especially for items flagged as Low confidence.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@@ -449,6 +494,7 @@
<a class="nav-link py-1 px-3 small text-body" href="#powder-insights">Powder Insights</a>
<a class="nav-link py-1 px-3 small text-body" href="#inventory-categories">Inventory Categories &amp; Is Coating</a>
<a class="nav-link py-1 px-3 small text-body" href="#powder-usage">Powder Usage on Jobs</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-price-check">AI Catalog Price Check</a>
</nav>
</div>
</div>
+1 -1
View File
@@ -229,7 +229,7 @@
one-off work that does not fit the standard calculation model.
</li>
<li class="mb-2">
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let AI (Claude) estimate
<strong>AI Photo Quote Item</strong> — upload photos of the parts and let our AI agent estimate
the surface area, complexity, and labor time. Review and override any value before accepting.
Up to two follow-up rounds of questions are supported.
</li>
@@ -176,7 +176,7 @@
<i class="bi bi-robot text-primary me-2"></i>AI-Powered Reports
</h2>
<p>
Several reports use AI (Claude by Anthropic) to analyze your data and return insights in plain
Several reports use AI to analyze your data and return insights in plain
English. These are found either on the Reports landing page or as buttons within other reports.
</p>
@@ -498,7 +498,7 @@
<div id="aiLoading" class="d-none p-4 text-center">
<div class="spinner-border text-purple mb-3" style="color:#6f42c1; width:3rem; height:3rem;"></div>
<div class="fw-semibold">Analyzing your queue...</div>
<div class="text-muted small mt-1">Claude is grouping jobs by color, temperature, and priority</div>
<div class="text-muted small mt-1">AI is grouping jobs by color, temperature, and priority</div>
</div>
<!-- Error state -->
@@ -534,7 +534,7 @@
<!-- Initial state (before running) -->
<div id="aiInitial" class="p-4">
<p class="text-muted small">
Claude will analyze all <strong>@Model.QueuedJobs.Count job(s)</strong> in the queue and suggest
Our AI agent will analyze all <strong>@Model.QueuedJobs.Count job(s)</strong> in the queue and suggest
optimized batches for your <strong>@Model.Ovens.Count oven(s)</strong>, grouping by:
</p>
<ul class="small text-muted">
@@ -4,6 +4,86 @@
var returnUrl = ViewBag.ReturnUrl as string ?? "/";
}
@section Styles {
<style>
.auth-brand-panel {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem 2.5rem;
color: white;
}
.auth-brand-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
}
.auth-brand-panel .tagline {
font-size: 1rem;
color: rgba(255,255,255,0.65);
margin-bottom: 2.5rem;
text-align: center;
}
.feature-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 280px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
font-size: 0.95rem;
color: rgba(255,255,255,0.82);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.feature-list li:last-child { border-bottom: none; }
.feature-list li i {
color: #4fc3f7;
font-size: 1.1rem;
flex-shrink: 0;
}
.auth-form-panel {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
background-color: #ffffff;
min-height: 100vh;
}
.auth-form-container {
width: 100%;
max-width: 420px;
}
.auth-form-container h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
}
.auth-form-container .subtext {
color: #64748b;
font-size: 0.95rem;
}
</style>
}
<div class="d-flex" style="min-height:100vh;">
<!-- Left brand panel — hidden on mobile, same as login page -->
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
@@ -77,7 +77,7 @@
<div id="flagsList"></div>
<div class="text-muted small text-end mt-3">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="analysisTimestamp"></span>
<i class="bi bi-robot me-1"></i>Generated by AI &middot; <span id="analysisTimestamp"></span>
&middot; <a href="#" onclick="runAnalysis(); return false;">Re-run</a>
</div>
</div>
@@ -140,7 +140,7 @@
</div>
<div class="text-muted small text-end">
<i class="bi bi-robot me-1"></i>Generated by Claude AI &middot; <span id="forecastTimestamp"></span>
<i class="bi bi-robot me-1"></i>Generated by AI &middot; <span id="forecastTimestamp"></span>
&middot; <a href="#" onclick="runForecast(); return false;">Refresh</a>
</div>
</div>
@@ -0,0 +1,86 @@
(function () {
'use strict';
var form = document.getElementById('runForm');
var btn = document.getElementById('runBtn');
var overlay = document.getElementById('price-check-overlay');
var bar = document.getElementById('overlay-bar');
var pctLabel = document.getElementById('overlay-pct');
var statusMsg = document.getElementById('overlay-status');
if (!form || !btn || !overlay) return;
// Estimate total seconds based on item count.
// Haiku sequential: 1 batch at a time, ~27s each (API time + 20s pacing gap).
function estimateDuration(itemCount) {
var batches = Math.max(1, Math.ceil(itemCount / 25));
return Math.max(30, batches * 27);
}
// Messages keyed to approximate progress milestones (0100).
function messageAt(pct, batchCount) {
if (pct < 10) return 'Loading catalog items…';
if (pct < 20) return 'Sending items for analysis…';
if (batchCount <= 1) {
if (pct < 75) return 'Analyzing your catalog with AI…';
if (pct < 92) return 'Reviewing pricing data…';
} else {
var batchDone = Math.floor((pct / 100) * batchCount);
if (batchDone < batchCount) {
return 'Analyzing batch ' + (batchDone + 1) + ' of ' + batchCount + '…';
}
}
if (pct < 97) return 'Compiling results…';
return 'Almost done…';
}
function setProgress(pct, batchCount) {
var clamped = Math.min(99, Math.max(0, pct));
bar.style.width = clamped + '%';
pctLabel.textContent = Math.round(clamped);
statusMsg.textContent = messageAt(clamped, batchCount);
}
form.addEventListener('submit', function () {
btn.disabled = true;
var itemCount = parseInt(btn.getAttribute('data-item-count') || '0', 10);
var batchCount = Math.max(1, Math.ceil(itemCount / 25));
var totalSecs = estimateDuration(itemCount);
overlay.classList.add('active');
setProgress(0, batchCount);
// Animate progress: fast to ~85%, then slow crawl toward 99%.
// Uses two easing phases so it never "finishes" before the server responds.
var start = Date.now();
var phase1End = totalSecs * 0.80 * 1000; // 80% of time -> 85% progress
var raf;
function tick() {
var elapsed = Date.now() - start;
var pct;
if (elapsed < phase1End) {
// Phase 1: ease-out from 0 -> 85
var t = elapsed / phase1End;
pct = 85 * (1 - Math.pow(1 - t, 2));
} else {
// Phase 2: slow crawl 85 -> 99 (never quite reaches 99)
var t2 = (elapsed - phase1End) / (totalSecs * 1000);
pct = 85 + 14 * (1 - Math.exp(-t2 * 1.5));
}
setProgress(pct, batchCount);
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
// The page navigation itself tears down the overlay; cancel the RAF to avoid
// running after the page is gone.
window.addEventListener('pagehide', function () {
cancelAnimationFrame(raf);
});
});
}());
@@ -0,0 +1,173 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Enums;
namespace PowderCoating.UnitTests;
public class JobPhotoServiceTests
{
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenFileMissing()
{
var service = CreateService();
var result = await service.SaveJobPhotoAsync(null!, 1, 2);
Assert.False(result.Success);
Assert.Equal("No file was uploaded.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenFileTooLarge()
{
var service = CreateService();
var file = CreateFormFile("big.jpg", 10 * 1024 * 1024 + 1);
var result = await service.SaveJobPhotoAsync(file, 1, 2);
Assert.False(result.Success);
Assert.Equal("Photo must be smaller than 10 MB.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsError_WhenExtensionNotAllowed()
{
var service = CreateService();
var file = CreateFormFile("notes.txt");
var result = await service.SaveJobPhotoAsync(file, 1, 2);
Assert.False(result.Success);
Assert.Equal("Only JPG, PNG, GIF, and WebP images are allowed.", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_ReturnsBlobError_WhenUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService);
var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.png"), 9, 7);
Assert.False(result.Success);
Assert.Equal("upload failed", result.ErrorMessage);
}
[Fact]
public async Task SaveJobPhotoAsync_UsesTenantScopedBlobPath_WhenSuccessful()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("jobimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/webp"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.SaveJobPhotoAsync(CreateFormFile("photo.webp"), 9, 7, "caption", JobPhotoType.After);
Assert.True(result.Success);
Assert.StartsWith("7/job-photos/9/", result.FilePath);
Assert.EndsWith(".webp", result.FilePath);
}
[Fact]
public async Task DeleteJobPhotoAsync_ReturnsError_WhenPathMissing()
{
var service = CreateService();
var result = await service.DeleteJobPhotoAsync(string.Empty);
Assert.False(result.Success);
Assert.Equal("File path is required.", result.ErrorMessage);
}
[Fact]
public async Task GetJobPhotoAsync_ReturnsError_WhenPathMissing()
{
var service = CreateService();
var result = await service.GetJobPhotoAsync(" ");
Assert.False(result.Success);
Assert.Equal("File path is required.", result.ErrorMessage);
Assert.Empty(result.FileContent);
}
[Fact]
public async Task JobPhotoExistsAsync_ReturnsFalse_WhenPathMissing()
{
var service = CreateService();
var result = await service.JobPhotoExistsAsync(null!);
Assert.False(result);
}
[Fact]
public async Task GetJobPhotoAsync_ProxiesBlobDownload()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.DownloadAsync("jobimages", "7/job-photos/9/photo.jpg"))
.ReturnsAsync((true, new byte[] { 1, 2 }, "image/jpeg", string.Empty));
var service = CreateService(blobService);
var result = await service.GetJobPhotoAsync("7/job-photos/9/photo.jpg");
Assert.True(result.Success);
Assert.Equal("image/jpeg", result.ContentType);
Assert.Equal(new byte[] { 1, 2 }, result.FileContent);
}
[Fact]
public async Task JobPhotoExistsAsync_UsesBlobServiceForValidPath()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("jobimages", "7/job-photos/9/photo.jpg"))
.ReturnsAsync(true);
var service = CreateService(blobService);
var result = await service.JobPhotoExistsAsync("7/job-photos/9/photo.jpg");
Assert.True(result);
}
private static JobPhotoService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
JobImages = "jobimages"
}
});
return new JobPhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<JobPhotoService>>());
}
private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null)
{
var dataLength = lengthOverride.HasValue
? (int)Math.Min(lengthOverride.Value, 1024)
: 16;
var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray();
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName);
}
}
@@ -0,0 +1,62 @@
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class MeasurementConversionServiceTests
{
private readonly MeasurementConversionService _service = new();
[Fact]
public void SquareFeetToMeters_AndBack_RoundTripsToCurrencyStylePrecision()
{
var squareMeters = _service.SquareFeetToMeters(100m);
var squareFeet = _service.SquareMetersToFeet(squareMeters);
Assert.Equal(9.29m, squareMeters);
Assert.Equal(100m, squareFeet, 1);
}
[Fact]
public void PoundsToKilograms_AndBack_RoundTripsToCurrencyStylePrecision()
{
var kilograms = _service.PoundsToKilograms(10m);
var pounds = _service.KilogramsToPounds(kilograms);
Assert.Equal(4.54m, kilograms);
Assert.Equal(10.01m, pounds, 2);
}
[Fact]
public void ConvertArea_WhenImperialToMetric_UsesSquareFeetConversion()
{
var result = _service.ConvertArea(50m, fromImperial: true, toMetric: true);
Assert.Equal(4.65m, result);
}
[Fact]
public void ConvertArea_WhenSourceAndTargetAreSameSystem_ReturnsOriginalValue()
{
Assert.Equal(12.34m, _service.ConvertArea(12.34m, fromImperial: true, toMetric: false));
Assert.Equal(56.78m, _service.ConvertArea(56.78m, fromImperial: false, toMetric: true));
}
[Fact]
public void ConvertWeight_WhenMetricToImperial_UsesKilogramsToPounds()
{
var result = _service.ConvertWeight(5m, fromImperial: false, toMetric: false);
Assert.Equal(11.02m, result);
}
[Fact]
public void UnitLabelHelpers_ReturnExpectedMetricAndImperialLabels()
{
Assert.Equal("sq ft", _service.GetAreaUnitLabel(false));
Assert.Equal("sq m", _service.GetAreaUnitLabel(true));
Assert.Equal("lb", _service.GetWeightUnitLabel(false));
Assert.Equal("kg", _service.GetWeightUnitLabel(true));
Assert.Equal("sq ft/lb", _service.GetCoverageUnitLabel(false));
Assert.Equal("sq m/kg", _service.GetCoverageUnitLabel(true));
}
}
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class PlatformSettingsServiceTests
{
[Fact]
public async Task GetAsync_ReturnsStoredValue()
{
await using var context = CreateContext();
context.PlatformSettings.Add(new PlatformSetting
{
Key = "BrandName",
Value = "Powder Coating Pro"
});
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
var value = await service.GetAsync("BrandName");
Assert.Equal("Powder Coating Pro", value);
}
[Fact]
public async Task GetAsync_WhenMissing_ReturnsNull()
{
await using var context = CreateContext();
var service = new PlatformSettingsService(context);
var value = await service.GetAsync("MissingKey");
Assert.Null(value);
}
[Fact]
public async Task SetAsync_WhenSettingExists_UpdatesValueAndAuditFields()
{
await using var context = CreateContext();
context.PlatformSettings.Add(new PlatformSetting
{
Key = "TrialsEnabled",
Value = "true",
UpdatedBy = "old-user"
});
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
await service.SetAsync("TrialsEnabled", "false", "superadmin@example.com");
var setting = await context.PlatformSettings.SingleAsync();
Assert.Equal("false", setting.Value);
Assert.Equal("superadmin@example.com", setting.UpdatedBy);
Assert.True(setting.UpdatedAt.HasValue);
}
[Fact]
public async Task SetAsync_WhenSettingMissing_InsertsRow()
{
await using var context = CreateContext();
var service = new PlatformSettingsService(context);
await service.SetAsync("SupportEmail", "help@example.com", "setup");
var setting = await context.PlatformSettings.SingleAsync();
Assert.Equal("SupportEmail", setting.Key);
Assert.Equal("help@example.com", setting.Value);
Assert.Equal("setup", setting.UpdatedBy);
}
[Fact]
public async Task GetAllAsync_OrdersByGroupThenKey()
{
await using var context = CreateContext();
context.PlatformSettings.AddRange(
new PlatformSetting { Key = "Zeta", GroupName = "Billing", Value = "1" },
new PlatformSetting { Key = "Alpha", GroupName = "Billing", Value = "2" },
new PlatformSetting { Key = "Bravo", GroupName = "Alerts", Value = "3" });
await context.SaveChangesAsync();
var service = new PlatformSettingsService(context);
var settings = await service.GetAllAsync();
Assert.Equal(new[] { "Bravo", "Alpha", "Zeta" }, settings.Select(s => s.Key).ToArray());
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -106,6 +106,435 @@ public class PricingCalculationServiceTests
Assert.Equal(246m, result.TotalPrice);
}
[Fact]
public async Task CalculateCoatPriceAsync_InventoryPowder_UsesCalculatedUsageOnly()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
inventoryItem: new InventoryItem
{
Id = 12,
CompanyId = 1,
Name = "Gloss Black",
UnitCost = 12m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Gloss Black",
InventoryItemId = 12,
CoverageSqFtPerLb = 24m,
TransferEfficiency = 50m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 10m,
quantity: 2m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(20m, result.CoatMaterialCost, 2);
Assert.Equal(60m, result.CoatLaborCost);
Assert.Equal(80m, result.CoatTotalCost, 2);
}
[Fact]
public async Task CalculateCoatPriceAsync_MetricTenant_ConvertsSurfaceAreaBeforePricing()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(true);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Metric Blue",
PowderCostPerLb = 5m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 1m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 0,
companyId: 1);
Assert.Equal(5.38m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(5.38m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_AdditionalCoatWithNoExtraLayerCharge_SkipsLabor()
{
var unitOfWork = CreateUnitOfWorkMock(CreateOperatingCosts());
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var coat = new CreateQuoteItemCoatDto
{
CoatName = "Clear Coat",
PowderCostPerLb = 4m,
PowderToOrder = 1m,
CoverageSqFtPerLb = 20m,
TransferEfficiency = 100m,
NoExtraLayerCharge = true
};
var result = await service.CalculateCoatPriceAsync(
coat,
itemSurfaceAreaSqFt: 0m,
quantity: 2m,
coatIndex: 1,
estimatedMinutesBase: 45,
companyId: 1);
Assert.Equal(4m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(4m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateCoatPriceAsync_WhenOperatingCostsMissing_ReturnsZeros()
{
var unitOfWork = CreateUnitOfWorkMock(costs: null);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var result = await service.CalculateCoatPriceAsync(
new CreateQuoteItemCoatDto { CoatName = "Unpriced" },
itemSurfaceAreaSqFt: 10m,
quantity: 1m,
coatIndex: 0,
estimatedMinutesBase: 30,
companyId: 1);
Assert.Equal(0m, result.CoatMaterialCost);
Assert.Equal(0m, result.CoatLaborCost);
Assert.Equal(0m, result.CoatTotalCost);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_UsesPowderCostOverrideAsBasePrice()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 50,
CompanyId = 1,
Name = "Wheel",
DefaultPrice = 10m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Override catalog item",
CatalogItemId = 50,
PowderCostOverride = 77m,
Quantity = 3m
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(0m, result.MaterialCost);
Assert.Equal(0m, result.LaborCost);
Assert.Equal(77m, result.UnitPrice);
Assert.Equal(231m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_CatalogItem_AddsPrepCostAndCustomPowder()
{
var unitOfWork = CreateUnitOfWorkMock(
CreateOperatingCosts(),
catalogItem: new CatalogItem
{
Id = 51,
CompanyId = 1,
Name = "Bracket",
DefaultPrice = 50m
});
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Bracket with prep",
CatalogItemId = 51,
Quantity = 2m,
IncludePrepCost = true,
PrepServices = new List<CreateQuoteItemPrepServiceDto>
{
new() { PrepServiceId = 1, EstimatedMinutes = 30 }
},
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Custom Green",
PowderCostPerLb = 5m,
PowderToOrder = 2m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10m, result.MaterialCost);
Assert.Equal(30m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(70m, result.UnitPrice);
Assert.Equal(140m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteItemPriceAsync_MarginMode_AppliesAdditionalCoatAndComplexity()
{
var costs = CreateOperatingCosts();
costs.PricingMode = PowderCoating.Core.Enums.PricingMode.MarginOnTotalCost;
costs.TargetMarginPercent = 50m;
costs.CoatingBoothCostPerHour = 0m;
costs.ComplexityModeratePercent = 5m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var item = new CreateQuoteItemDto
{
Description = "Complex fabricated part",
Quantity = 1m,
SurfaceAreaSqFt = 10m,
EstimatedMinutes = 60,
Complexity = "Moderate",
Coats = new List<CreateQuoteItemCoatDto>
{
new()
{
CoatName = "Base",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
},
new()
{
CoatName = "Top",
PowderCostPerLb = 10m,
CoverageSqFtPerLb = 10m,
TransferEfficiency = 100m
}
}
};
var result = await service.CalculateQuoteItemPriceAsync(item, companyId: 1);
Assert.Equal(10.5m, result.MaterialCost);
Assert.Equal(60m, result.LaborCost);
Assert.Equal(0m, result.EquipmentCost);
Assert.Equal(222.075m, result.ItemSubtotal);
Assert.Equal(222.075m, result.TotalPrice);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_MixedAiAndManualItems_ScalesOvenCostBySurfaceAreaAndUsesManualTax()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 30m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 5m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI estimate",
IsAiItem = true,
ManualUnitPrice = 200m,
Quantity = 1m,
SurfaceAreaSqFt = 50m
},
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 1m,
SurfaceAreaSqFt = 50m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
manualTaxPercent: 8m);
Assert.Equal(260m, result.ItemsSubtotal);
Assert.Equal(15m, result.OvenBatchCost);
Assert.Equal(275m, result.SubtotalBeforeDiscount);
Assert.Equal(8m, result.TaxPercent);
Assert.Equal(22m, result.TaxAmount);
Assert.Equal(297m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_ZeroSurfaceAreaFallback_UsesItemCountForOvenFraction()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 20m;
costs.DefaultOvenCycleMinutes = 60;
costs.ShopSuppliesRate = 0m;
costs.TaxPercent = 0m;
costs.MonthlyRent = 0m;
costs.MonthlyUtilities = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "AI item",
IsAiItem = true,
ManualUnitPrice = 100m,
Quantity = 1m
},
new()
{
Description = "Shelf item",
IsSalesItem = true,
ManualUnitPrice = 40m,
Quantity = 1m
}
};
var result = await service.CalculateQuoteTotalsAsync(items, companyId: 1);
Assert.Equal(140m, result.ItemsSubtotal);
Assert.Equal(10m, result.OvenBatchCost);
Assert.Equal(150m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_FixedRushAndFacilityOverhead_AreAppliedBeforeTotal()
{
var costs = CreateOperatingCosts();
costs.OvenOperatingCostPerHour = 0m;
costs.MonthlyRent = 1600m;
costs.MonthlyUtilities = 0m;
costs.MonthlyBillableHours = 160;
costs.ShopSuppliesRate = 10m;
costs.RushChargeType = "FixedAmount";
costs.RushChargeFixedAmount = 25m;
costs.TaxPercent = 0m;
var unitOfWork = CreateUnitOfWorkMock(costs);
var tenantContext = new Mock<ITenantContext>();
tenantContext.Setup(x => x.UseMetricSystemAsync()).ReturnsAsync(false);
var service = new PricingCalculationService(
unitOfWork.Object,
Mock.Of<ILogger<PricingCalculationService>>(),
new MeasurementConversionService(),
tenantContext.Object);
var items = new List<CreateQuoteItemDto>
{
new()
{
Description = "Labor item",
IsLaborItem = true,
Quantity = 2m,
EstimatedMinutes = 60
}
};
var result = await service.CalculateQuoteTotalsAsync(
items,
companyId: 1,
isRushJob: true);
Assert.Equal(120m, result.ItemsSubtotal);
Assert.Equal(10m, result.FacilityOverheadRatePerHour);
Assert.Equal(20m, result.FacilityOverheadCost);
Assert.Equal(12m, result.ShopSuppliesAmount);
Assert.Equal(152m, result.SubtotalBeforeDiscount);
Assert.Equal(25m, result.RushFee);
Assert.Equal(177m, result.Total);
}
[Fact]
public async Task CalculateQuoteTotalsAsync_AppliesTierDiscount_QuoteDiscount_RushFee_AndTax()
{
@@ -174,24 +603,27 @@ public class PricingCalculationServiceTests
Assert.Equal(243.18m, result.Total);
}
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(CompanyOperatingCosts costs)
private static Mock<IUnitOfWork> CreateUnitOfWorkMock(
CompanyOperatingCosts? costs,
InventoryItem? inventoryItem = null,
CatalogItem? catalogItem = null)
{
var unitOfWork = new Mock<IUnitOfWork>();
var companyOperatingCostsRepo = new Mock<IRepository<CompanyOperatingCosts>>();
companyOperatingCostsRepo
.Setup(x => x.FindAsync(It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, bool>>>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CompanyOperatingCosts, object>>[]>()))
.ReturnsAsync(new[] { costs });
.ReturnsAsync(costs != null ? new[] { costs } : Array.Empty<CompanyOperatingCosts>());
var inventoryRepo = new Mock<IRepository<InventoryItem>>();
inventoryRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<InventoryItem, object>>[]>()))
.ReturnsAsync((InventoryItem?)null);
.ReturnsAsync(inventoryItem);
var catalogRepo = new Mock<IRepository<CatalogItem>>();
catalogRepo
.Setup(x => x.GetByIdAsync(It.IsAny<int>(), false, It.IsAny<System.Linq.Expressions.Expression<Func<CatalogItem, object>>[]>()))
.ReturnsAsync((CatalogItem?)null);
.ReturnsAsync(catalogItem);
var customerRepo = new Mock<IRepository<Customer>>();
customerRepo
@@ -0,0 +1,449 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
namespace PowderCoating.UnitTests;
public class QuoteApprovalControllerTests
{
[Fact]
public async Task ShowApprovalPage_WhenTokenExpired_ReturnsTokenExpiredView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "expired-token", expiresAt: DateTime.UtcNow.AddMinutes(-1)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("expired-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("TokenExpired", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("expired-token", model.Token);
}
[Fact]
public async Task ShowApprovalPage_WhenQuoteAlreadyInTerminalStatus_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "approved-token", statusId: 2, declineReason: "Old decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.ShowApprovalPage("approved-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Approved", model.CurrentStatus);
Assert.Equal("Old decline", model.DeclineReason);
}
[Fact]
public async Task Approve_WhenQuoteIsProspect_ReturnsConfirmDetailsView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-token"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("prospect-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.True(model.IsProspect);
}
[Fact]
public async Task SubmitDetails_WhenRequiredFieldsMissing_ReturnsConfirmDetailsWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "missing-details"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.SubmitDetails(
"missing-details",
contactName: " ",
email: " prospect@example.com ",
phone: null,
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ConfirmDetails", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter your name and at least one contact method (email or phone).", model.DeclineError);
Assert.Equal(" prospect@example.com ", model.ProspectEmail);
Assert.Equal(" Prospect Co ", model.ProspectCompanyName);
}
[Fact]
public async Task SubmitDetails_WhenValidProspect_ApprovesQuoteAndTrimsFields()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: null, token: "prospect-approve"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null))
.Returns(Task.CompletedTask);
var inApp = new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(1, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
var clientProxy = new Mock<IClientProxy>();
clientProxy
.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var controller = CreateController(
context,
notifications: notifications,
inApp: inApp,
clientProxy: clientProxy);
var result = await controller.SubmitDetails(
"prospect-approve",
contactName: " Pat Prospect ",
email: " prospect@example.com ",
phone: " 555-0100 ",
companyName: " Prospect Co ",
address: " 123 Main ",
city: " Akron ",
state: " OH ",
zipCode: " 44301 ");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/prospect-approve/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Equal("Pat Prospect", quote.ProspectContactName);
Assert.Equal("prospect@example.com", quote.ProspectEmail);
Assert.Equal("555-0100", quote.ProspectPhone);
Assert.Equal("Prospect Co", quote.ProspectCompanyName);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), true, null), Times.Once);
inApp.Verify(x => x.CreateAsync(1, "Quote Approved", It.IsAny<string>(), "QuoteApproved", "/Quotes/Details/1", 1, null, null), Times.Once);
clientProxy.Verify(x => x.SendCoreAsync("QuoteActedByCustomer", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Approve_WhenCustomerQuoteRequiresDeposit_GeneratesDepositLinkAndClearsPriorDecline()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, stripeStatus: StripeConnectStatus.Active);
context.Customers.Add(new Customer
{
Id = 10,
CompanyId = 1,
CompanyName = "Acme Customer"
});
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "deposit-token",
requiresDeposit: true,
depositPercent: 50m,
declineReason: "Need more time"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("deposit-token");
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/deposit-token/confirmation?action=approved", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(2, quote.QuoteStatusId);
Assert.Null(quote.DeclineReason);
Assert.NotNull(quote.DepositPaymentLinkToken);
Assert.True(quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow.AddDays(6));
var history = await context.QuoteChangeHistories.IgnoreQueryFilters().SingleAsync();
Assert.Contains("previously declined", history.ChangeDescription);
}
[Fact]
public async Task Approve_WhenTokenAlreadyUsed_ReturnsAlreadyActedView()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "used-token",
approvalUsedAt: DateTime.UtcNow.AddMinutes(-5)));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Approve("used-token");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("AlreadyActed", view.ViewName);
}
[Fact]
public async Task Decline_WhenReasonBlank_ReturnsApprovalPageWithError()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "blank-decline"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Decline("blank-decline", " ");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("ApprovalPage", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Equal("Please enter a reason for declining.", model.DeclineError);
}
[Fact]
public async Task Decline_UsesRejectedStatusCodeFallbackAndTruncatesStoredReason()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1, useRejectedFlag: false);
context.Quotes.Add(CreateQuote(1, customerId: 10, token: "decline-token"));
await context.SaveChangesAsync();
var notifications = new Mock<INotificationService>();
notifications
.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()))
.Returns(Task.CompletedTask);
var reason = $" {new string('x', 1005)} ";
var controller = CreateController(
context,
notifications: notifications,
remoteIpAddress: IPAddress.Parse("203.0.113.9"));
var result = await controller.Decline("decline-token", reason);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("/quote-approval/decline-token/confirmation?action=declined", redirect.Url);
var quote = await context.Quotes.IgnoreQueryFilters().SingleAsync();
Assert.Equal(3, quote.QuoteStatusId);
Assert.Equal(1000, quote.DeclineReason!.Length);
Assert.Equal("203.0.113.9", quote.DeclinedByIp);
Assert.NotNull(quote.ApprovalTokenUsedAt);
Assert.Single(await context.QuoteChangeHistories.IgnoreQueryFilters().ToListAsync());
notifications.Verify(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), false, It.IsAny<string>()), Times.Once);
}
[Fact]
public async Task Confirmation_HidesExpiredDepositLink()
{
await using var context = CreateContext();
SeedCompanyAndStatuses(context, companyId: 1);
context.Quotes.Add(CreateQuote(
1,
customerId: 10,
token: "confirm-token",
requiresDeposit: true,
depositPercent: 25m,
depositLinkToken: "expired-link",
depositLinkExpiresAt: DateTime.UtcNow.AddMinutes(-2),
total: 120m));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.Confirmation("confirm-token", "APPROVED");
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Confirmation", view.ViewName);
var model = Assert.IsType<QuoteApprovalViewModel>(view.Model);
Assert.Null(model.DepositPaymentLinkToken);
Assert.Equal(30m, model.DepositAmount);
Assert.Equal("approved", controller.ViewBag.Action);
}
private static QuoteApprovalController CreateController(
ApplicationDbContext context,
Mock<INotificationService>? notifications = null,
Mock<IInAppNotificationService>? inApp = null,
Mock<IClientProxy>? clientProxy = null,
IPAddress? remoteIpAddress = null)
{
notifications ??= new Mock<INotificationService>();
notifications.Setup(x => x.NotifyQuoteActedByCustomerAsync(It.IsAny<Quote>(), It.IsAny<bool>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
inApp ??= new Mock<IInAppNotificationService>();
inApp.Setup(x => x.CreateAsync(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<int?>()))
.Returns(Task.CompletedTask);
clientProxy ??= new Mock<IClientProxy>();
clientProxy.Setup(x => x.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var hubClients = new Mock<IHubClients>();
hubClients.Setup(x => x.Group(It.IsAny<string>())).Returns(clientProxy.Object);
var hubContext = new Mock<IHubContext<NotificationHub>>();
hubContext.SetupGet(x => x.Clients).Returns(hubClients.Object);
var controller = new QuoteApprovalController(
context,
notifications.Object,
inApp.Object,
Mock.Of<IStripeConnectService>(),
Mock.Of<ILogger<QuoteApprovalController>>(),
new ConfigurationBuilder().Build(),
hubContext.Object);
var httpContext = new DefaultHttpContext();
if (remoteIpAddress != null)
{
httpContext.Connection.RemoteIpAddress = remoteIpAddress;
}
controller.ControllerContext = new ControllerContext
{
HttpContext = httpContext
};
return controller;
}
private static void SeedCompanyAndStatuses(
ApplicationDbContext context,
int companyId,
StripeConnectStatus stripeStatus = StripeConnectStatus.NotConnected,
bool useRejectedFlag = true)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = $"Company {companyId}",
Phone = "555-0100",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
StripeConnectStatus = stripeStatus
});
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = companyId,
CompanyId = companyId,
EmailFromAddress = $"quotes{companyId}@example.com"
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = 1,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = 2,
CompanyId = companyId,
StatusCode = "APPROVED",
DisplayName = "Approved",
DisplayOrder = 2,
IsApprovedStatus = true
},
new QuoteStatusLookup
{
Id = 3,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 3,
IsRejectedStatus = useRejectedFlag
},
new QuoteStatusLookup
{
Id = 4,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 4,
IsConvertedStatus = true
});
}
private static Quote CreateQuote(
int id,
int? customerId,
string token,
int statusId = 1,
DateTime? expiresAt = null,
DateTime? approvalUsedAt = null,
bool requiresDeposit = false,
decimal depositPercent = 0m,
string? declineReason = null,
string? depositLinkToken = null,
DateTime? depositLinkExpiresAt = null,
decimal total = 100m)
{
return new Quote
{
Id = id,
CompanyId = 1,
QuoteNumber = $"Q-{id:000}",
CustomerId = customerId,
QuoteStatusId = statusId,
ApprovalToken = token,
ApprovalTokenExpiresAt = expiresAt ?? DateTime.UtcNow.AddDays(2),
ApprovalTokenUsedAt = approvalUsedAt,
RequiresDeposit = requiresDeposit,
DepositPercent = depositPercent,
DeclineReason = declineReason,
DepositPaymentLinkToken = depositLinkToken,
DepositPaymentLinkExpiresAt = depositLinkExpiresAt,
Total = total,
SubTotal = total,
QuoteItems = []
};
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}
@@ -0,0 +1,239 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class QuotePhotoServiceTests
{
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileMissing()
{
var service = CreateService();
var result = await service.SaveTempPhotoAsync(null!, companyId: 1);
Assert.False(result.Success);
Assert.Equal("No file provided.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenFileTooLarge()
{
var service = CreateService();
var file = CreateFormFile("huge.jpg", 10 * 1024 * 1024 + 1);
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File exceeds the 10 MB limit.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsError_WhenExtensionNotAllowed()
{
var service = CreateService();
var file = CreateFormFile("photo.bmp");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("File type '.bmp' is not allowed.", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_ReturnsBlobError_WhenUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "blob upload failed"));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.png");
var result = await service.SaveTempPhotoAsync(file, companyId: 1);
Assert.False(result.Success);
Assert.Equal("blob upload failed", result.ErrorMessage);
}
[Fact]
public async Task SaveTempPhotoAsync_UploadsToTempPath_WhenValid()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/jpeg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var file = CreateFormFile("photo.jpg");
var result = await service.SaveTempPhotoAsync(file, companyId: 5);
Assert.True(result.Success);
Assert.False(string.IsNullOrWhiteSpace(result.TempId));
Assert.StartsWith($"temp/{result.TempId}/", result.FilePath);
Assert.EndsWith(".jpg", result.FilePath);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempPhotoMissing()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(Array.Empty<string>());
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Temp photo not found.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenTempDownloadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "download failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to read temp photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_ReturnsError_WhenPermanentUploadFails()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.webp" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.webp"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/webp", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/webp"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.False(result.Success);
Assert.Equal("Failed to save permanent photo.", result.ErrorMessage);
}
[Fact]
public async Task PromoteTempPhotoAsync_PromotesAndDeletesTempBlob_WhenSuccessful()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/original.png" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, new byte[] { 1, 2, 3 }, "image/png", string.Empty));
blobService
.Setup(x => x.UploadAsync("quoteimages", It.IsAny<string>(), It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((true, string.Empty));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
var result = await service.PromoteTempPhotoAsync("temp123", quoteId: 10, companyId: 3);
Assert.True(result.Success);
Assert.StartsWith("3/quote-photos/10/", result.FilePath);
Assert.EndsWith(".png", result.FilePath);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/original.png"), Times.Once);
}
[Fact]
public async Task ReadTempPhotosAsync_ReturnsOnlySuccessfulDownloads()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/one.jpg"))
.ReturnsAsync((true, new byte[] { 1 }, "image/jpeg", string.Empty));
blobService
.Setup(x => x.DownloadAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((false, Array.Empty<byte>(), string.Empty, "failed"));
var service = CreateService(blobService: blobService);
var result = await service.ReadTempPhotosAsync("temp123");
Assert.Single(result);
Assert.Equal("one.jpg", result[0].FileName);
Assert.Equal("image/jpeg", result[0].ContentType);
}
[Fact]
public async Task CleanupTempAsync_ContinuesDeleting_WhenOneDeleteThrows()
{
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ListBlobsByPrefixAsync("quoteimages", "temp/temp123/"))
.ReturnsAsync(new[] { "temp/temp123/one.jpg", "temp/temp123/two.jpg" });
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"))
.ThrowsAsync(new InvalidOperationException("boom"));
blobService
.Setup(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService: blobService);
await service.CleanupTempAsync("temp123");
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/one.jpg"), Times.Once);
blobService.Verify(x => x.DeleteAsync("quoteimages", "temp/temp123/two.jpg"), Times.Once);
}
private static QuotePhotoService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
QuoteImages = "quoteimages"
}
});
return new QuotePhotoService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<QuotePhotoService>>());
}
private static IFormFile CreateFormFile(string fileName, long? lengthOverride = null)
{
var dataLength = lengthOverride.HasValue
? (int)Math.Min(lengthOverride.Value, 1024)
: 16;
var bytes = Enumerable.Repeat((byte)65, dataLength).ToArray();
var stream = new MemoryStream(bytes);
return new FormFile(stream, 0, lengthOverride ?? bytes.Length, "file", fileName);
}
}
@@ -1,13 +1,18 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using PowderCoating.Application.DTOs.Registration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using System.Text.Json;
using PowderCoating.Web.Controllers;
using Xunit;
@@ -95,18 +100,315 @@ public class RegistrationControllerTests
Assert.True((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
}
[Fact]
public async Task Create_WhenEmailAlreadyExists_ReturnsIndexWithModelError()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var existingUser = new ApplicationUser
{
Id = "existing",
Email = "owner@example.com",
UserName = "owner@example.com",
FirstName = "Existing",
LastName = "User"
};
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("owner@example.com")).ReturnsAsync(existingUser);
var controller = CreateController(context, userManager: userManager);
var model = new RegisterCompanyDto
{
CompanyName = "Dup Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "owner@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.False(controller.ModelState.IsValid);
Assert.Contains(controller.ModelState["Email"]!.Errors, e => e.ErrorMessage.Contains("already exists"));
}
[Fact]
public async Task Create_WhenRegistrationIsClosed_ReturnsIndexWithTempDataError()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "Existing Company",
CompanyCode = "EXC",
PrimaryContactName = "Owner",
PrimaryContactEmail = "existing@example.com",
IsActive = true
});
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var controller = CreateController(context, platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "New Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "owner@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Equal("Registration is currently closed. Please contact us for more information.", controller.TempData["Error"]);
Assert.False((bool)controller.ViewBag.RegistrationOpen);
}
[Fact]
public async Task Create_WhenTrialsDisabledAndStripeCheckoutStarts_RedirectsAndPersistsPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
1, false, "paid@example.com", "Paid Co", It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync("https://checkout.example/session");
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var redirect = Assert.IsType<RedirectResult>(result);
Assert.Equal("https://checkout.example/session", redirect.Url);
var pending = await context.PendingRegistrationSessions.SingleAsync();
Assert.Equal("Paid Co", pending.CompanyName);
Assert.Equal("paid@example.com", pending.Email);
Assert.False(pending.IsCompleted);
}
[Fact]
public async Task Create_WhenStripeConfigFails_DoesNotPersistPendingSession()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(PlatformSettingKeys.TrialsEnabled)).ReturnsAsync("false");
platformSettings.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.TrialsEnabled))).ReturnsAsync((string?)null);
var stripeService = new Mock<IStripeService>();
stripeService
.Setup(x => x.CreateRegistrationCheckoutSessionAsync(
It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException("Stripe prices are not configured."));
var controller = CreateController(
context,
stripeService: stripeService,
platformSettings: platformSettings);
var model = new RegisterCompanyDto
{
CompanyName = "Paid Co",
CompanyPhone = "555-0100",
FirstName = "Pat",
LastName = "Owner",
Email = "paid@example.com",
Plan = 1
};
var result = await controller.Create(model);
var view = Assert.IsType<ViewResult>(result);
Assert.Equal("Index", view.ViewName);
Assert.Empty(context.PendingRegistrationSessions);
Assert.Contains(controller.ModelState[string.Empty]!.Errors, e => e.ErrorMessage.Contains("Stripe prices are not configured."));
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationClosedAfterPayment_ReleasesPendingSessionWithoutCreatingCompany()
{
await using var context = CreateContext();
SeedPlanConfig(context, plan: 1);
context.Companies.Add(new Company
{
Id = 1,
CompanyId = 1,
CompanyName = "At Capacity",
CompanyCode = "CAP",
PrimaryContactName = "Owner",
PrimaryContactEmail = "capacity@example.com",
IsActive = true
});
context.PendingRegistrationSessions.Add(CreatePendingSession("token-closed", "closed@example.com"));
await context.SaveChangesAsync();
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings
.Setup(x => x.GetAsync(PlatformSettingKeys.MaxTenants))
.ReturnsAsync("1");
platformSettings
.Setup(x => x.GetAsync(It.Is<string>(key => key != PlatformSettingKeys.MaxTenants)))
.ReturnsAsync((string?)null);
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("closed@example.com")).ReturnsAsync((ApplicationUser?)null);
var stripeService = new Mock<IStripeService>();
stripeService.Setup(x => x.IsRegistrationCheckoutPaidAsync("sess_paid")).ReturnsAsync(true);
var controller = CreateController(
context,
userManager: userManager,
stripeService: stripeService,
platformSettings: platformSettings);
var result = await controller.PaymentSuccess("sess_paid", "token-closed");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Registration is currently closed. Your payment has been received but no account was created. Please contact support.", controller.TempData["Error"]);
Assert.False((await context.PendingRegistrationSessions.SingleAsync()).IsCompleted);
Assert.Single(context.Companies);
}
[Fact]
public async Task PaymentSuccess_WhenSessionIdMissing_RedirectsToIndex()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess(null, "token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
}
[Fact]
public async Task PaymentSuccess_WhenRegistrationTokenMissing_SetsExpiredError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", null);
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session has expired. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenPendingSessionMissing_SetsNotFoundError()
{
await using var context = CreateContext();
var controller = CreateController(context);
var result = await controller.PaymentSuccess("sess_123", "missing-token");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Equal("Your registration session was not found. Please fill in your details again.", controller.TempData["Error"]);
}
[Fact]
public async Task PaymentSuccess_WhenSessionAlreadyCompletedButUserMissing_ShowsSupportError()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-missing-user", "retry@example.com", isCompleted: true));
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.FindByEmailAsync("retry@example.com")).ReturnsAsync((ApplicationUser?)null);
var controller = CreateController(context, userManager: userManager);
var result = await controller.PaymentSuccess("sess_done", "token-missing-user");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
Assert.Contains("couldn't finish signing you in", controller.TempData["Error"]?.ToString());
}
[Fact]
public async Task PaymentCancelled_WhenPendingSessionExists_PrefillsTempDataAndDeletesSession()
{
await using var context = CreateContext();
context.PendingRegistrationSessions.Add(CreatePendingSession("token-cancel", "cancel@example.com"));
await context.SaveChangesAsync();
var controller = CreateController(context);
var result = await controller.PaymentCancelled("token-cancel");
var redirect = Assert.IsType<RedirectToActionResult>(result);
Assert.Equal("Index", redirect.ActionName);
var json = Assert.IsType<string>(controller.TempData["PendingRegistrationJson"]);
var model = JsonSerializer.Deserialize<RegisterCompanyDto>(json);
Assert.NotNull(model);
Assert.Equal("Retry Co", model!.CompanyName);
Assert.Equal("cancel@example.com", model.Email);
Assert.Empty(context.PendingRegistrationSessions);
}
private static RegistrationController CreateController(
ApplicationDbContext context,
Mock<UserManager<ApplicationUser>>? userManager = null,
SignInManager<ApplicationUser>? signInManager = null,
Mock<IStripeService>? stripeService = null)
Mock<IStripeService>? stripeService = null,
Mock<IPlatformSettingsService>? platformSettings = null)
{
var unitOfWork = new UnitOfWork(context);
var userManagerMock = userManager ?? CreateUserManagerMock();
var signInManagerInstance = signInManager ?? CreateSignInManagerMock(userManagerMock.Object).Object;
var platformSettings = new Mock<IPlatformSettingsService>();
platformSettings.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
var platformSettingsMock = platformSettings ?? new Mock<IPlatformSettingsService>();
if (platformSettings is null)
{
platformSettingsMock.Setup(x => x.GetAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
}
var controller = new RegistrationController(
unitOfWork,
@@ -116,7 +418,7 @@ public class RegistrationControllerTests
Mock.Of<ISeedDataService>(),
Mock.Of<IAdminNotificationService>(),
Mock.Of<IInAppNotificationService>(),
platformSettings.Object,
platformSettingsMock.Object,
(stripeService ?? new Mock<IStripeService>()).Object,
Mock.Of<IEmailService>(),
Mock.Of<ILogger<RegistrationController>>());
@@ -126,6 +428,11 @@ public class RegistrationControllerTests
{
HttpContext = httpContext
};
var urlHelper = new Mock<IUrlHelper>();
urlHelper
.Setup(x => x.Action(It.IsAny<UrlActionContext>()))
.Returns<UrlActionContext>(ctx => $"https://example.test/{ctx.Action}");
controller.Url = urlHelper.Object;
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
return controller;
@@ -172,6 +479,19 @@ public class RegistrationControllerTests
return new ApplicationDbContext(options);
}
private static void SeedPlanConfig(ApplicationDbContext context, int plan)
{
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
{
Id = plan,
CompanyId = 0,
Plan = plan,
DisplayName = $"Plan {plan}",
SortOrder = plan,
IsActive = true
});
}
private static PendingRegistrationSession CreatePendingSession(string token, string email, bool isCompleted = false)
{
return new PendingRegistrationSession
@@ -0,0 +1,107 @@
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.UnitTests;
public class ShopCapabilityCalculatorTests
{
[Fact]
public void GetBlastRateSqFtPerHour_WithOverride_ReturnsOverride()
{
var costs = new CompanyOperatingCosts
{
BlastRateSqFtPerHourOverride = 42.5m,
CompressorCfm = 150m,
BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(42.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_WithNoCompressorCfm_ReturnsZero()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 0m
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(0m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_DerivesRateFromEquipmentInputs()
{
var costs = new CompanyOperatingCosts
{
CompressorCfm = 150m,
BlastNozzleSize = 6,
BlastSetupType = BlastSetupType.PressurePot,
PrimaryBlastSubstrate = BlastSubstrateType.Paint
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
Assert.Equal(58.5m, result);
}
[Fact]
public void GetBlastRateSqFtPerHour_ForNamedSetup_UsesSetupOverload()
{
var setup = new CompanyBlastSetup
{
Name = "Main Cabinet",
CompressorCfm = 7m,
BlastNozzleSize = 4,
SetupType = BlastSetupType.SiphonCabinet,
PrimarySubstrate = BlastSubstrateType.Mixed
};
var result = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
Assert.Equal(1.7m, result);
}
[Theory]
[InlineData(CoatingGunType.Corona, 40)]
[InlineData(CoatingGunType.Tribo, 35)]
[InlineData(CoatingGunType.Both, 40)]
public void GetCoatingRateSqFtPerHour_ReturnsExpectedDefaultByGunType(CoatingGunType gunType, decimal expected)
{
var costs = new CompanyOperatingCosts
{
CoatingGunType = gunType
};
var result = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(ShopCapabilityTier.Garage, BlastSetupType.SiphonCabinet, 7, 4, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Small, BlastSetupType.PressurePot, 40, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Medium, BlastSetupType.PressurePot, 80, 5, BlastSubstrateType.Mixed)]
[InlineData(ShopCapabilityTier.Large, BlastSetupType.PressurePot, 150, 6, BlastSubstrateType.Mixed)]
public void TierDefaults_ReturnExpectedPresetValues(
ShopCapabilityTier tier,
BlastSetupType expectedSetup,
decimal expectedCfm,
int expectedNozzle,
BlastSubstrateType expectedSubstrate)
{
var defaults = ShopCapabilityCalculator.TierDefaults(tier);
Assert.Equal(expectedSetup, defaults.SetupType);
Assert.Equal(expectedCfm, defaults.Cfm);
Assert.Equal(expectedNozzle, defaults.NozzleSize);
Assert.Equal(expectedSubstrate, defaults.Substrate);
}
}
@@ -0,0 +1,213 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using PowderCoating.Application.Configuration;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
namespace PowderCoating.UnitTests;
public class StorageMigrationServiceTests
{
[Fact]
public async Task MigrateFilesystemToAzureAsync_ReturnsError_WhenDirectoryMissing()
{
var service = CreateService();
var missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
var result = await service.MigrateFilesystemToAzureAsync(missingPath);
Assert.Equal(0, result.Failed);
Assert.Equal(0, result.Total);
Assert.Contains("Media directory not found", result.Errors.Single());
Assert.True(result.HasErrors);
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_MarksUnknownPathsAsFailed()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var unknownPath = Path.Combine(mediaRoot, "1", "misc");
Directory.CreateDirectory(unknownPath);
await File.WriteAllTextAsync(Path.Combine(unknownPath, "file.bin"), "abc");
var service = CreateService();
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Contains(result.Errors, e => e.Contains("Unknown file type"));
Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_SkipsExistingBlobs()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var filePath = WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile");
var relativePath = "1/profile-photos/user.jpg";
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("profileimages", relativePath))
.ReturnsAsync(true);
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Skipped);
Assert.Equal(0, result.Migrated);
Assert.Equal(MigrationFileStatus.Skipped, result.Files.Single().Status);
Assert.True(File.Exists(filePath));
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_RecordsUploadFailures()
{
var mediaRoot = CreateTempMediaRoot();
try
{
WriteMediaFile(mediaRoot, "1/job-photos/9/photo.png", "photo");
const string relativePath = "1/job-photos/9/photo.png";
var blobService = new Mock<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("jobimages", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("jobimages", relativePath, It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((false, "upload failed"));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Contains(result.Errors, e => e.Contains("upload failed"));
Assert.Equal(MigrationFileStatus.Failed, result.Files.Single().Status);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_DeletesLocalFileAfterSuccessfulMigration_WhenRequested()
{
var mediaRoot = CreateTempMediaRoot();
try
{
var fullPath = WriteMediaFile(mediaRoot, "1/company-logo.png", "logo");
const string relativePath = "1/company-logo.png";
var blobService = new Mock<IAzureBlobStorageService>();
blobService.Setup(x => x.ExistsAsync("companylogos", relativePath)).ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("companylogos", relativePath, It.IsAny<Stream>(), "image/png"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot, deleteLocalAfterMigration: true);
Assert.Equal(1, result.Migrated);
Assert.Contains(result.Files, f => f.RelativePath == relativePath && f.Status == MigrationFileStatus.Migrated);
Assert.False(File.Exists(fullPath));
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
[Fact]
public async Task MigrateFilesystemToAzureAsync_ContinuesAfterPerFileException()
{
var mediaRoot = CreateTempMediaRoot();
try
{
WriteMediaFile(mediaRoot, "1/profile-photos/user.jpg", "profile");
WriteMediaFile(mediaRoot, "1/equipment-manuals/manual.pdf", "manual");
var blobService = new Mock<IAzureBlobStorageService>();
blobService
.Setup(x => x.ExistsAsync("profileimages", "1/profile-photos/user.jpg"))
.ThrowsAsync(new InvalidOperationException("broken exists"));
blobService
.Setup(x => x.ExistsAsync("manuals", "1/equipment-manuals/manual.pdf"))
.ReturnsAsync(false);
blobService
.Setup(x => x.UploadAsync("manuals", "1/equipment-manuals/manual.pdf", It.IsAny<Stream>(), "application/pdf"))
.ReturnsAsync((true, string.Empty));
var service = CreateService(blobService);
var result = await service.MigrateFilesystemToAzureAsync(mediaRoot);
Assert.Equal(1, result.Failed);
Assert.Equal(1, result.Migrated);
Assert.Contains(result.Errors, e => e.Contains("broken exists"));
Assert.Equal(2, result.Total);
}
finally
{
SafeDeleteDirectory(mediaRoot);
}
}
private static StorageMigrationService CreateService(Mock<IAzureBlobStorageService>? blobService = null)
{
var settings = Options.Create(new StorageSettings
{
Containers = new StorageContainers
{
ProfileImages = "profileimages",
JobImages = "jobimages",
Manuals = "manuals",
CompanyLogos = "companylogos"
}
});
return new StorageMigrationService(
(blobService ?? new Mock<IAzureBlobStorageService>()).Object,
settings,
Mock.Of<ILogger<StorageMigrationService>>());
}
private static string CreateTempMediaRoot()
{
var path = Path.Combine(Path.GetTempPath(), "pca-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static string WriteMediaFile(string mediaRoot, string relativePath, string content)
{
var fullPath = Path.Combine(mediaRoot, relativePath.Replace("/", Path.DirectorySeparatorChar.ToString()));
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, content);
return fullPath;
}
private static void SafeDeleteDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
}
@@ -130,6 +130,191 @@ public class SubscriptionServiceTests
Assert.False(allowed);
}
[Fact]
public async Task GetStatusAsync_ReturnsGracePeriod_WhenSubscriptionRecentlyExpired()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 20,
CompanyId = 20,
CompanyName = "Grace Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "grace@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5),
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(20);
Assert.Equal(SubscriptionStatus.GracePeriod, status);
}
[Fact]
public async Task GetStatusAsync_ReturnsExpired_WhenPastGraceWindow()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 21,
CompanyId = 21,
CompanyName = "Expired Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "expired@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15),
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(21);
Assert.Equal(SubscriptionStatus.Expired, status);
}
[Fact]
public async Task GetStatusAsync_ReturnsActive_ForCompedCompanyEvenWhenExpired()
{
await using var context = CreateContext();
context.Companies.Add(new Company
{
Id = 22,
CompanyId = 22,
CompanyName = "Comped Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "comped@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-30),
IsActive = true,
IsComped = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var status = await service.GetStatusAsync(22);
Assert.Equal(SubscriptionStatus.Active, status);
}
[Fact]
public async Task IsAiInventoryAssistEnabledAsync_RequiresCompanyToggle()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 13, plan: 7, allowAiInventoryAssist: true);
var company = await context.Companies.FindAsync(13);
company!.AiInventoryAssistEnabled = false;
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var enabled = await service.IsAiInventoryAssistEnabledAsync(13);
Assert.False(enabled);
}
[Fact]
public async Task GetJobPhotoCountAsync_ExcludesAiAnalysisPhotos()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 14, plan: 8, maxJobPhotos: 5);
context.JobPhotos.AddRange(
new JobPhoto
{
Id = 1,
CompanyId = 14,
JobId = 100,
FilePath = "jobs/100/1.jpg",
FileName = "1.jpg",
FileSize = 100,
ContentType = "image/jpeg",
UploadedById = "u1"
},
new JobPhoto
{
Id = 2,
CompanyId = 14,
JobId = 100,
FilePath = "jobs/100/2.jpg",
FileName = "2.jpg",
FileSize = 100,
ContentType = "image/jpeg",
UploadedById = "u1",
IsAiAnalysisPhoto = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var (used, max) = await service.GetJobPhotoCountAsync(14, 100);
Assert.Equal(1, used);
Assert.Equal(5, max);
}
[Fact]
public async Task GetQuotePhotoCountAsync_ExcludesAiAnalysisPhotos()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 15, plan: 9, maxQuotePhotos: 4);
context.QuotePhotos.AddRange(
new QuotePhoto
{
Id = 1,
CompanyId = 15,
QuoteId = 200,
TempId = "temp-1",
FilePath = "quotes/200/1.jpg",
FileName = "1.jpg",
FileSize = 100,
ContentType = "image/jpeg",
IsAiAnalysisPhoto = false
},
new QuotePhoto
{
Id = 2,
CompanyId = 15,
QuoteId = 200,
TempId = "temp-2",
FilePath = "quotes/200/2.jpg",
FileName = "2.jpg",
FileSize = 100,
ContentType = "image/jpeg",
IsAiAnalysisPhoto = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var (used, max) = await service.GetQuotePhotoCountAsync(15, 200);
Assert.Equal(1, used);
Assert.Equal(4, max);
}
[Fact]
public async Task CanUseAiPhotoQuoteAsync_ReturnsFalse_WhenUsageEqualsLimit()
{
await using var context = CreateContext();
SeedCompanyAndPlan(context, companyId: 16, plan: 10, maxAiPhotoQuotesPerMonth: 2, allowAiPhotoQuotes: true);
context.AiItemPredictions.AddRange(
new AiItemPrediction { Id = 1, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-1) },
new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
Assert.False(allowed);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@@ -148,7 +333,10 @@ public class SubscriptionServiceTests
int maxCustomers = -1,
int maxQuotes = -1,
int maxAiPhotoQuotesPerMonth = -1,
bool allowAiPhotoQuotes = true)
int maxJobPhotos = -1,
int maxQuotePhotos = -1,
bool allowAiPhotoQuotes = true,
bool allowAiInventoryAssist = true)
{
context.Companies.Add(new Company
{
@@ -173,8 +361,11 @@ public class SubscriptionServiceTests
MaxActiveJobs = maxActiveJobs,
MaxCustomers = maxCustomers,
MaxQuotes = maxQuotes,
MaxJobPhotos = maxJobPhotos,
MaxQuotePhotos = maxQuotePhotos,
MaxAiPhotoQuotesPerMonth = maxAiPhotoQuotesPerMonth,
AllowAiPhotoQuotes = allowAiPhotoQuotes
AllowAiPhotoQuotes = allowAiPhotoQuotes,
AllowAiInventoryAssist = allowAiInventoryAssist
});
context.JobPriorityLookups.Add(new JobPriorityLookup
@@ -0,0 +1,300 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Moq;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Services;
namespace PowderCoating.UnitTests;
public class TenantContextTests
{
[Fact]
public void GetCurrentCompanyId_WhenUnauthenticated_ReturnsNull()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Null(companyId);
}
[Fact]
public void GetCurrentCompanyId_WhenSuperAdminIsImpersonating_ReturnsSessionOverride()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var session = new TestSession();
session.SetInt32("ImpersonatingCompanyId", 42);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]),
session);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(42, companyId);
}
[Fact]
public void GetCurrentCompanyId_PrefersCompanyClaim()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(Enumerable.Empty<ApplicationUser>().AsQueryable());
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "user@example.com", companyIdClaim: 9));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(9, companyId);
}
[Fact]
public async Task GetCurrentCompanyId_WhenClaimMissing_FallsBackToUserLookup()
{
await using var context = CreateContext();
context.Users.Add(new ApplicationUser
{
Id = "user-1",
UserName = "legacy@example.com",
Email = "legacy@example.com",
FirstName = "Legacy",
LastName = "User",
CompanyId = 17
});
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(context.Users);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "legacy@example.com"));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var companyId = tenantContext.GetCurrentCompanyId();
Assert.Equal(17, companyId);
}
[Fact]
public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminWithoutTenantScope()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.Users).Returns(Enumerable.Empty<ApplicationUser>().AsQueryable());
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, roles: ["SuperAdmin"]));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.True(isPlatformAdmin);
}
[Fact]
public void IsPlatformAdmin_ReturnsFalse_ForSuperAdminImpersonatingCompany()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var session = new TestSession();
session.SetInt32("ImpersonatingCompanyId", 2);
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "admin@example.com", roles: ["SuperAdmin"]),
session);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.False(isPlatformAdmin);
}
[Fact]
public async Task UseMetricSystemAsync_ReturnsStoredPreference()
{
await using var context = CreateContext();
context.CompanyPreferences.Add(new CompanyPreferences
{
Id = 1,
CompanyId = 25,
UseMetricSystem = true
});
await context.SaveChangesAsync();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "metric@example.com", companyIdClaim: 25));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var useMetric = await tenantContext.UseMetricSystemAsync();
Assert.True(useMetric);
}
[Fact]
public async Task GetCurrentCompanyAsync_ReturnsCompanyFromUserManager()
{
await using var context = CreateContext();
var company = new Company
{
Id = 31,
CompanyId = 31,
CompanyName = "Current Co",
PrimaryContactName = "Owner",
PrimaryContactEmail = "owner@example.com"
};
var principal = CreatePrincipal(isAuthenticated: true, name: "current@example.com", companyIdClaim: 31);
var user = new ApplicationUser
{
Id = "user-31",
UserName = "current@example.com",
Email = "current@example.com",
FirstName = "Current",
LastName = "User",
CompanyId = 31,
Company = company
};
var userManager = CreateUserManagerMock();
userManager.Setup(x => x.GetUserAsync(principal)).ReturnsAsync(user);
var accessor = CreateHttpContextAccessor(principal);
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var currentCompany = await tenantContext.GetCurrentCompanyAsync();
Assert.NotNull(currentCompany);
Assert.Equal("Current Co", currentCompany!.CompanyName);
}
[Fact]
public void IsPlatformAdmin_ReturnsTrue_ForSuperAdminOnCompanyOne()
{
using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(
CreatePrincipal(isAuthenticated: true, name: "platform@example.com", companyIdClaim: 1, roles: ["SuperAdmin"]));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var isPlatformAdmin = tenantContext.IsPlatformAdmin();
Assert.True(isPlatformAdmin);
}
[Fact]
public async Task UseMetricSystemAsync_WhenNoCompanyContext_ReturnsFalse()
{
await using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(CreatePrincipal(isAuthenticated: true, name: "nocompany@example.com"));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var useMetric = await tenantContext.UseMetricSystemAsync();
Assert.False(useMetric);
}
[Fact]
public async Task GetCurrentCompanyAsync_WhenUnauthenticated_ReturnsNull()
{
await using var context = CreateContext();
var userManager = CreateUserManagerMock();
var accessor = CreateHttpContextAccessor(new ClaimsPrincipal(new ClaimsIdentity()));
var tenantContext = new TenantContext(accessor.Object, userManager.Object, context);
var currentCompany = await tenantContext.GetCurrentCompanyAsync();
Assert.Null(currentCompany);
}
private static Mock<IHttpContextAccessor> CreateHttpContextAccessor(ClaimsPrincipal principal, ISession? session = null)
{
var httpContext = new Mock<HttpContext>();
httpContext.SetupGet(x => x.User).Returns(principal);
httpContext.SetupGet(x => x.Session).Returns(session ?? new TestSession());
var accessor = new Mock<IHttpContextAccessor>();
accessor.SetupGet(x => x.HttpContext).Returns(httpContext.Object);
return accessor;
}
private static ClaimsPrincipal CreatePrincipal(
bool isAuthenticated,
string? name = null,
int? companyIdClaim = null,
string[]? roles = null)
{
if (!isAuthenticated)
{
return new ClaimsPrincipal(new ClaimsIdentity());
}
var claims = new List<Claim>();
if (!string.IsNullOrWhiteSpace(name))
{
claims.Add(new Claim(ClaimTypes.Name, name));
}
if (companyIdClaim.HasValue)
{
claims.Add(new Claim("CompanyId", companyIdClaim.Value.ToString()));
}
foreach (var role in roles ?? [])
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var identity = new ClaimsIdentity(claims, "TestAuth", ClaimTypes.Name, ClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
null!,
null!,
null!,
null!,
null!,
null!,
null!,
null!);
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private sealed class TestSession : ISession
{
private readonly Dictionary<string, byte[]> _values = new();
public IEnumerable<string> Keys => _values.Keys;
public string Id => "test-session";
public bool IsAvailable => true;
public void Clear() => _values.Clear();
public Task CommitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task LoadAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
public void Remove(string key) => _values.Remove(key);
public void Set(string key, byte[] value) => _values[key] = value;
public bool TryGetValue(string key, out byte[] value) => _values.TryGetValue(key, out value!);
}
}
@@ -0,0 +1,329 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Controllers;
namespace PowderCoating.UnitTests;
public class UsageQuotaControllerTests
{
[Fact]
public async Task Index_UsesOverridesAndCountsOnlyActiveResources()
{
await using var context = CreateContext();
SeedPlan(context, plan: 1, maxUsers: 10, maxJobs: 5, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 1, plan: 1, maxUsersOverride: 5);
SeedLookupRows(context, companyId: 1);
context.Users.AddRange(
CreateUser("u1", 1),
CreateUser("u2", 1),
CreateUser("u3", 1),
CreateUser("u4", 1));
context.Customers.AddRange(
new Customer { Id = 1, CompanyId = 1, CompanyName = "Cust 1" },
new Customer { Id = 2, CompanyId = 1, CompanyName = "Cust 2" });
context.Jobs.AddRange(
new Job { Id = 1, CompanyId = 1, CustomerId = 1, Description = "Active", JobNumber = "JOB-1", JobStatusId = 10, JobPriorityId = 1 },
new Job { Id = 2, CompanyId = 1, CustomerId = 1, Description = "Done", JobNumber = "JOB-2", JobStatusId = 11, JobPriorityId = 1 });
context.Quotes.AddRange(
new Quote { Id = 1, CompanyId = 1, QuoteNumber = "Q-1", QuoteStatusId = 10 },
new Quote { Id = 2, CompanyId = 1, QuoteNumber = "Q-2", QuoteStatusId = 11 },
new Quote { Id = 3, CompanyId = 1, QuoteNumber = "Q-3", QuoteStatusId = 12 });
context.CatalogItems.AddRange(
new CatalogItem { Id = 1, CompanyId = 1, Name = "Wheel", CategoryId = 1 },
new CatalogItem { Id = 2, CompanyId = 1, Name = "Frame", CategoryId = 1 });
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal(4, row.Users);
Assert.Equal(5, row.MaxUsers);
Assert.Equal(1, row.ActiveJobs);
Assert.Equal(1, row.ActiveQuotes);
Assert.Equal(2, row.Customers);
Assert.Equal(2, row.CatalogItems);
Assert.True(row.IsNearLimit);
Assert.False(row.IsAtLimit);
Assert.Equal(0, controller.ViewBag.AtLimitCount);
Assert.Equal(1, controller.ViewBag.NearLimitCount);
}
[Fact]
public async Task Index_ForCompedCompany_ReturnsUnlimitedLimitsWithoutFlags()
{
await using var context = CreateContext();
SeedPlan(context, plan: 2, maxUsers: 1, maxJobs: 1, maxCustomers: 1, maxQuotes: 1, maxCatalogItems: 1);
SeedCompany(context, companyId: 2, plan: 2, isComped: true);
SeedLookupRows(context, companyId: 2);
context.Users.AddRange(CreateUser("u1", 2), CreateUser("u2", 2));
context.Customers.Add(new Customer { Id = 10, CompanyId = 2, CompanyName = "Comped Customer" });
context.Jobs.Add(new Job { Id = 10, CompanyId = 2, CustomerId = 10, Description = "Active", JobNumber = "JOB-C", JobStatusId = 20, JobPriorityId = 2 });
context.Quotes.Add(new Quote { Id = 10, CompanyId = 2, QuoteNumber = "Q-C", QuoteStatusId = 20 });
context.CatalogItems.Add(new CatalogItem { Id = 10, CompanyId = 2, Name = "Rack", CategoryId = 1 });
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.True(row.IsComped);
Assert.Equal(-1, row.MaxUsers);
Assert.Equal(-1, row.MaxActiveJobs);
Assert.Equal(-1, row.MaxCustomers);
Assert.Equal(-1, row.MaxActiveQuotes);
Assert.Equal(-1, row.MaxCatalogItems);
Assert.False(row.IsNearLimit);
Assert.False(row.IsAtLimit);
}
[Fact]
public async Task Index_ConcernFilters_SeparateNearAndAtLimitRows()
{
await using var context = CreateContext();
SeedPlan(context, plan: 3, maxUsers: 5, maxJobs: 5, maxCustomers: 5, maxQuotes: 5, maxCatalogItems: 5);
SeedCompany(context, companyId: 3, plan: 3, companyName: "Near Co");
SeedCompany(context, companyId: 4, plan: 3, companyName: "At Co");
SeedCompany(context, companyId: 5, plan: 3, companyName: "Safe Co");
SeedLookupRows(context, 3);
SeedLookupRows(context, 4);
SeedLookupRows(context, 5);
context.Users.AddRange(
CreateUser("n1", 3), CreateUser("n2", 3), CreateUser("n3", 3), CreateUser("n4", 3),
CreateUser("a1", 4), CreateUser("a2", 4), CreateUser("a3", 4), CreateUser("a4", 4), CreateUser("a5", 4),
CreateUser("s1", 5));
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var limitResult = await controller.Index(null, null, null, "limit");
var limitRows = Assert.IsAssignableFrom<List<UsageRow>>(Assert.IsType<ViewResult>(limitResult).Model);
Assert.Equal(2, limitRows.Count);
Assert.Contains(limitRows, r => r.CompanyName == "Near Co" && r.IsNearLimit);
Assert.Contains(limitRows, r => r.CompanyName == "At Co" && r.IsAtLimit);
var atLimitResult = await controller.Index(null, null, null, "atlimit");
var atLimitRows = Assert.IsAssignableFrom<List<UsageRow>>(Assert.IsType<ViewResult>(atLimitResult).Model);
var atLimitRow = Assert.Single(atLimitRows);
Assert.Equal("At Co", atLimitRow.CompanyName);
Assert.True(atLimitRow.IsAtLimit);
}
[Fact]
public async Task Index_AppliesSearchStatusAndPlanFilters()
{
await using var context = CreateContext();
SeedPlan(context, plan: 6, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Six");
SeedPlan(context, plan: 7, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10, displayName: "Plan Seven");
SeedCompany(context, companyId: 6, plan: 6, companyName: "Acme Powder", status: SubscriptionStatus.Active);
SeedCompany(context, companyId: 7, plan: 6, companyName: "Beta Powder", status: SubscriptionStatus.Expired);
SeedCompany(context, companyId: 8, plan: 7, companyName: "Acme East", status: SubscriptionStatus.Active);
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index("Acme", nameof(SubscriptionStatus.Active), "6", null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal("Acme Powder", row.CompanyName);
Assert.Equal(nameof(SubscriptionStatus.Active), controller.ViewBag.StatusFilter);
Assert.Equal("6", controller.ViewBag.PlanFilter);
}
[Fact]
public async Task Index_WhenUsageIsExactlyEightyPercent_MarksRowNearLimit()
{
await using var context = CreateContext();
SeedPlan(context, plan: 8, maxUsers: 5, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 9, plan: 8, companyName: "Threshold Co");
await context.SaveChangesAsync();
context.Users.AddRange(
CreateUser("t1", 9),
CreateUser("t2", 9),
CreateUser("t3", 9),
CreateUser("t4", 9));
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, null, null, null);
var view = Assert.IsType<ViewResult>(result);
var row = Assert.Single(Assert.IsAssignableFrom<List<UsageRow>>(view.Model));
Assert.Equal(4, row.Users);
Assert.Equal(5, row.MaxUsers);
Assert.True(row.IsNearLimit);
Assert.False(row.IsAtLimit);
}
[Fact]
public async Task Index_WhenFiltersAreInvalid_IgnoresThem()
{
await using var context = CreateContext();
SeedPlan(context, plan: 9, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedPlan(context, plan: 10, maxUsers: 10, maxJobs: 10, maxCustomers: 10, maxQuotes: 10, maxCatalogItems: 10);
SeedCompany(context, companyId: 10, plan: 9, companyName: "Alpha Co", status: SubscriptionStatus.Active);
SeedCompany(context, companyId: 11, plan: 10, companyName: "Beta Co", status: SubscriptionStatus.Expired);
await context.SaveChangesAsync();
var controller = new UsageQuotaController(context);
var result = await controller.Index(null, "NotARealStatus", "not-a-plan", null);
var view = Assert.IsType<ViewResult>(result);
var rows = Assert.IsAssignableFrom<List<UsageRow>>(view.Model);
Assert.Equal(2, rows.Count);
Assert.Equal(2, controller.ViewBag.TotalCount);
Assert.Equal("NotARealStatus", controller.ViewBag.StatusFilter);
Assert.Equal("not-a-plan", controller.ViewBag.PlanFilter);
}
private static ApplicationUser CreateUser(string id, int companyId)
{
return new ApplicationUser
{
Id = id,
CompanyId = companyId,
UserName = $"{id}@example.com",
Email = $"{id}@example.com",
FirstName = "Test",
LastName = "User"
};
}
private static void SeedPlan(
ApplicationDbContext context,
int plan,
int maxUsers,
int maxJobs,
int maxCustomers,
int maxQuotes,
int maxCatalogItems,
string? displayName = null)
{
context.SubscriptionPlanConfigs.Add(new SubscriptionPlanConfig
{
Id = plan,
CompanyId = 0,
Plan = plan,
DisplayName = displayName ?? $"Plan {plan}",
SortOrder = plan,
IsActive = true,
MaxUsers = maxUsers,
MaxActiveJobs = maxJobs,
MaxCustomers = maxCustomers,
MaxQuotes = maxQuotes,
MaxCatalogItems = maxCatalogItems
});
}
private static void SeedCompany(
ApplicationDbContext context,
int companyId,
int plan,
string? companyName = null,
SubscriptionStatus status = SubscriptionStatus.Active,
bool isComped = false,
int? maxUsersOverride = null)
{
context.Companies.Add(new Company
{
Id = companyId,
CompanyId = companyId,
CompanyName = companyName ?? $"Company {companyId}",
PrimaryContactName = "Owner",
PrimaryContactEmail = $"owner{companyId}@example.com",
SubscriptionPlan = plan,
SubscriptionStatus = status,
IsComped = isComped,
MaxUsersOverride = maxUsersOverride,
IsActive = true
});
}
private static void SeedLookupRows(ApplicationDbContext context, int companyId)
{
context.JobPriorityLookups.Add(new JobPriorityLookup
{
Id = companyId,
CompanyId = companyId,
PriorityCode = "NORMAL",
DisplayName = "Normal",
DisplayOrder = 1
});
context.JobStatusLookups.AddRange(
new JobStatusLookup
{
Id = companyId * 10,
CompanyId = companyId,
StatusCode = "ACTIVE",
DisplayName = "Active",
DisplayOrder = 1,
IsTerminalStatus = false
},
new JobStatusLookup
{
Id = companyId * 10 + 1,
CompanyId = companyId,
StatusCode = "DONE",
DisplayName = "Done",
DisplayOrder = 2,
IsTerminalStatus = true
});
context.QuoteStatusLookups.AddRange(
new QuoteStatusLookup
{
Id = companyId * 10,
CompanyId = companyId,
StatusCode = "PENDING",
DisplayName = "Pending",
DisplayOrder = 1
},
new QuoteStatusLookup
{
Id = companyId * 10 + 1,
CompanyId = companyId,
StatusCode = "REJECTED",
DisplayName = "Rejected",
DisplayOrder = 2,
IsRejectedStatus = true
},
new QuoteStatusLookup
{
Id = companyId * 10 + 2,
CompanyId = companyId,
StatusCode = "CONVERTED",
DisplayName = "Converted",
DisplayOrder = 3,
IsConvertedStatus = true
});
}
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
}