Merge dev: powder usage logging, time entry fix, Data Protection keys

- Fix time entry 500: parseInt destroyed GUID user IDs
- Inventory ledger: show edit pencil for Adjustment rows (scan-without-job)
- Inventory ledger: scan-based logs now appear in Powder Usage By Job tab
- Store Data Protection keys in SQL Server (non-production); migration AddDataProtectionKeys
- Fix mojibake characters across multiple views
- Fix subscription grace period tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 22:35:58 -04:00
44 changed files with 38950 additions and 119 deletions
@@ -258,6 +258,8 @@ public class PowderUsageLogDto
public decimal VarianceLbs { get; set; }
public DateTime RecordedAt { get; set; }
public string? Notes { get; set; }
/// <summary>Set only for rows synthesized from a scan-based InventoryTransaction (no PowderUsageLog record).</summary>
public int? SourceTransactionId { get; set; }
}
public class InventoryLedgerViewModel
@@ -404,9 +404,8 @@ public class JobTimeEntryDto
{
public int Id { get; set; }
public int JobId { get; set; }
public int ShopWorkerId { get; set; }
public string? UserId { get; set; }
public string WorkerName { get; set; } = string.Empty;
public string WorkerRole { get; set; } = string.Empty;
public DateTime WorkDate { get; set; }
public decimal HoursWorked { get; set; }
public string? Stage { get; set; }
@@ -417,7 +416,7 @@ public class JobTimeEntryDto
public class CreateJobTimeEntryDto
{
public int JobId { get; set; }
public int ShopWorkerId { get; set; }
public string UserId { get; set; } = string.Empty;
public DateTime WorkDate { get; set; }
public decimal HoursWorked { get; set; }
public string? Stage { get; set; }
@@ -427,7 +426,7 @@ public class CreateJobTimeEntryDto
public class UpdateJobTimeEntryDto
{
public int Id { get; set; }
public int ShopWorkerId { get; set; }
public string UserId { get; set; } = string.Empty;
public DateTime WorkDate { get; set; }
public decimal HoursWorked { get; set; }
public string? Stage { get; set; }
@@ -6,6 +6,7 @@ public interface IPlatformSettingsService
{
Task<string?> GetAsync(string key);
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
Task<int> GetIntAsync(string key, int defaultValue);
Task SetAsync(string key, string? value, string? updatedBy = null);
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
}
@@ -67,8 +67,8 @@ public class JobProfile : Profile
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src => src.Worker != null ? src.Worker.Name : string.Empty))
.ForMember(dest => dest.WorkerRole, opt => opt.MapFrom(src => src.Worker != null ? FormatEnumName(src.Worker.Role.ToString()) : string.Empty));
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
// CreateJobDto to Job
CreateMap<CreateJobDto, Job>()
@@ -3,7 +3,9 @@ namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity
{
public int JobId { get; set; }
public int ShopWorkerId { get; set; }
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
public string? UserId { get; set; } // FK to AspNetUsers
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
public DateTime WorkDate { get; set; }
public decimal HoursWorked { get; set; }
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
@@ -11,5 +13,5 @@ public class JobTimeEntry : BaseEntity
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual ShopWorker Worker { get; set; } = null!;
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
}
@@ -15,4 +15,6 @@ public static class PlatformSettingKeys
public const string MaxTenants = "MaxTenants";
public const string SmsEnabled = "SmsEnabled";
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
public const string GracePeriodDays = "GracePeriodDays";
public const string GracePeriodAppliesToTrials = "GracePeriodAppliesToTrials";
}
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
@@ -25,7 +26,7 @@ namespace PowderCoating.Infrastructure.Data;
/// to repository methods) — reserved for SuperAdmin operations and document-number generation.
/// </para>
/// </summary>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataProtectionKeyContext
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly IServiceProvider? _serviceProvider;
@@ -371,6 +372,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<PlatformSetting> PlatformSettings { get; set; }
/// <summary>
/// ASP.NET Core Data Protection key ring — required by <see cref="IDataProtectionKeyContext"/>.
/// Keys stored here survive deploys and IIS app pool recycles.
/// </summary>
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
/// <summary>
/// IP address ban list. Login attempts from a matching active entry are rejected
/// before Identity even checks credentials. No tenant filter; SuperAdmin-managed only.
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGracePeriodDaysSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays')
BEGIN
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
VALUES (N'GracePeriodDays', N'14', N'Grace Period (days)',
N'Number of days a company can continue to log in after their subscription expires before being fully locked out.',
N'Subscriptions');
END
""");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodDays'");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191));
}
}
}
@@ -0,0 +1,73 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGracePeriodAppliesToTrials : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials')
BEGIN
INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
VALUES (N'GracePeriodAppliesToTrials', N'false', N'Grace Period Applies to Trials',
N'When false (default), trial accounts (no Stripe subscription) are locked out immediately when their trial expires no grace period. Enable to give trial accounts the same grace period as paid accounts.',
N'Subscriptions');
END
""");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DELETE FROM [PlatformSettings] WHERE [Key] = N'GracePeriodAppliesToTrials'");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8281));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8287));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 16, 17, 52, 780, DateTimeKind.Utc).AddTicks(8289));
}
}
}
@@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MigrateTimeEntriesToUserId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.AlterColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AddColumn<string>(
name: "UserDisplayName",
table: "JobTimeEntries",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "UserId",
table: "JobTimeEntries",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612));
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "UserDisplayName",
table: "JobTimeEntries");
migrationBuilder.DropColumn(
name: "UserId",
table: "JobTimeEntries");
migrationBuilder.AlterColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4011));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4019));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 19, 13, 29, 537, DateTimeKind.Utc).AddTicks(4021));
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddDataProtectionKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612));
}
}
}
@@ -22,6 +22,25 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Xml")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
@@ -5000,7 +5019,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int>("ShopWorkerId")
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage")
@@ -5012,6 +5031,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("WorkDate")
.HasColumnType("datetime2");
@@ -6028,7 +6053,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184),
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2199),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6039,7 +6064,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189),
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2206),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6050,7 +6075,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191),
CreatedAt = new DateTime(2026, 5, 6, 2, 7, 22, 625, DateTimeKind.Utc).AddTicks(2208),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -8694,9 +8719,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
.HasForeignKey("ShopWorkerId");
b.Navigation("Job");
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Anthropic.SDK" Version="4.0.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
@@ -26,6 +26,7 @@ public class InventoryTransactionRepository : Repository<InventoryTransaction>,
.Include(t => t.InventoryItem)
.Include(t => t.PurchaseOrder)
.Include(t => t.Job)
.ThenInclude(j => j!.Customer)
.Where(t => !t.IsDeleted);
if (itemId.HasValue)
@@ -834,6 +834,10 @@ Rules:
// machine-readable price, SKU, and product info that would otherwise be lost.
var structuredData = ExtractJsonLdData(html);
// Extract visible price text BEFORE stripping HTML as a fallback when
// JSON-LD is absent or incomplete (e.g. JS-rendered stores).
var htmlPriceSnippet = ExtractHtmlPriceSnippet(html);
// Remove script/style blocks
html = System.Text.RegularExpressions.Regex.Replace(
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
@@ -851,10 +855,11 @@ Rules:
if (text.Length > maxChars)
text = text[..maxChars] + "…";
// Prepend structured data + document links — Claude treats these as high-confidence
// Prepend structured data + document links + price fallback — Claude treats these as high-confidence
var header = new StringBuilder();
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
if (!string.IsNullOrWhiteSpace(htmlPriceSnippet)) header.Append(htmlPriceSnippet);
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
if (header.Length > 0) text = header + "\n" + text;
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
@@ -897,6 +902,46 @@ Rules:
return null;
}
/// <summary>
/// Scans raw HTML for visible price elements — common WooCommerce/Shopify price class
/// names and itemprop="price" microdata — and returns them as a "[Page Price]" header
/// line. This runs on the full, untruncated HTML before tag stripping so prices that
/// would fall past the 3,500-char page-text cutoff are still surfaced to Claude.
/// Only used when JSON-LD structured data didn't already yield a price.
/// </summary>
private static string? ExtractHtmlPriceSnippet(string html)
{
// itemprop="price" microdata (e.g. <span itemprop="price">12.99</span>)
var microprice = System.Text.RegularExpressions.Regex.Match(
html,
@"itemprop=[""']price[""'][^>]*content=[""']([0-9]+\.?[0-9]*)[""']|" +
@"itemprop=[""']price[""'][^>]*>[\s]*\$?([0-9]+\.?[0-9]*)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (microprice.Success)
{
var val = (microprice.Groups[1].Value.Trim().Length > 0
? microprice.Groups[1].Value
: microprice.Groups[2].Value).Trim();
if (!string.IsNullOrEmpty(val))
return $"[Page Price] Price found in page microdata: ${val}\n";
}
// WooCommerce / common e-commerce price class names
var classPricePattern = System.Text.RegularExpressions.Regex.Match(
html,
@"class=[""'][^""']*(?:price|woocommerce-Price-amount|product-price|bdi)[^""']*[""'][^>]*>" +
@"[\s\S]{0,60}?\$\s*([0-9]+\.[0-9]{2})",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (classPricePattern.Success)
{
var val = classPricePattern.Groups[1].Value.Trim();
if (!string.IsNullOrEmpty(val))
return $"[Page Price] Price found in page HTML: ${val}\n";
}
return null;
}
/// <summary>
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
@@ -959,6 +1004,12 @@ Rules:
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
/// even when the visible price is rendered by JavaScript.
/// <para>
/// Handles three common JSON-LD shapes:
/// - Single Product object: <c>{"@type":"Product",...}</c>
/// - Top-level array: <c>[{"@type":"Product",...},...]</c>
/// - WooCommerce/Yoast @graph wrapper: <c>{"@graph":[{"@type":"Product",...},...]}</c>
/// </para>
/// </summary>
private static string? ExtractJsonLdData(string html)
{
@@ -976,12 +1027,7 @@ Rules:
using var doc = JsonDocument.Parse(jsonText);
var root = doc.RootElement;
// Handle both single-object and @graph array
IEnumerable<JsonElement> nodes = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: new[] { root };
foreach (var node in nodes)
foreach (var node in FlattenJsonLdNodes(root))
{
if (!node.TryGetProperty("@type", out var typeEl)) continue;
@@ -1013,6 +1059,34 @@ Rules:
return sb.Length > 0 ? sb.ToString() : null;
}
/// <summary>
/// Recursively flattens a JSON-LD element into individual nodes for processing.
/// Handles a top-level array, a single object, and the WooCommerce/Yoast @graph
/// wrapper pattern where the root object has no @type but contains an "@graph" array.
/// </summary>
private static IEnumerable<JsonElement> FlattenJsonLdNodes(JsonElement el)
{
if (el.ValueKind == JsonValueKind.Array)
{
foreach (var item in el.EnumerateArray())
foreach (var n in FlattenJsonLdNodes(item))
yield return n;
}
else if (el.ValueKind == JsonValueKind.Object)
{
// @graph wrapper: {"@graph": [...]} — recurse into the array
if (el.TryGetProperty("@graph", out var graph) && graph.ValueKind == JsonValueKind.Array)
{
foreach (var n in FlattenJsonLdNodes(graph))
yield return n;
}
else
{
yield return el;
}
}
}
/// <summary>
/// Dispatches JSON-LD offer extraction for both the single-object form
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
@@ -52,6 +52,17 @@ public class PlatformSettingsService : IPlatformSettingsService
return bool.TryParse(value, out var result) ? result : defaultValue;
}
/// <summary>
/// Reads a platform setting as an integer. Returns <paramref name="defaultValue"/> when
/// the key is missing or the stored value cannot be parsed as an integer.
/// </summary>
public async Task<int> GetIntAsync(string key, int defaultValue)
{
var value = await GetAsync(key);
if (value == null) return defaultValue;
return int.TryParse(value, out var result) ? result : defaultValue;
}
/// <summary>
/// Creates or updates the platform setting identified by <paramref name="key"/>.
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
@@ -27,11 +28,13 @@ public class SubscriptionService : ISubscriptionService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
private readonly IPlatformSettingsService _platformSettings;
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context)
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context, IPlatformSettingsService platformSettings)
{
_unitOfWork = unitOfWork;
_context = context;
_platformSettings = platformSettings;
}
/// <summary>
@@ -203,7 +206,8 @@ public class SubscriptionService : ISubscriptionService
if (daysUntil == null || daysUntil > 0)
return SubscriptionStatus.Active;
if (daysUntil >= -AppConstants.SubscriptionConstants.GracePeriodDays)
var graceDays = await GetEffectiveGracePeriodDaysAsync(company);
if (daysUntil >= -graceDays)
return SubscriptionStatus.GracePeriod;
return SubscriptionStatus.Expired;
@@ -226,6 +230,25 @@ public class SubscriptionService : ISubscriptionService
return (int)(expiry - today).TotalDays;
}
/// <summary>
/// Returns the effective grace period in days for a company. Trial companies (no Stripe subscription)
/// get 0 days unless the <c>GracePeriodAppliesToTrials</c> platform setting is explicitly enabled.
/// Paid companies always use the configured <c>GracePeriodDays</c> value.
/// </summary>
private async Task<int> GetEffectiveGracePeriodDaysAsync(Company company)
{
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
if (isTrial)
{
var appliesToTrials = await _platformSettings.GetBoolAsync(
PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false);
if (!appliesToTrials) return 0;
}
return await _platformSettings.GetIntAsync(
PlatformSettingKeys.GracePeriodDays,
AppConstants.SubscriptionConstants.GracePeriodDays);
}
/// <summary>
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
/// Returns a max of -1 when the plan config is absent (unlimited by default).
@@ -2,7 +2,7 @@ namespace PowderCoating.Shared.Constants;
public static class AppConstants
{
public const string ApplicationName = "Powder Coating Management System";
public const string ApplicationName = "Powder Coating Logix";
public const string Version = "1.0.0";
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
@@ -80,6 +80,12 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
var gracePeriodDays = await platformSettings.GetIntAsync(
PlatformSettingKeys.GracePeriodDays,
AppConstants.SubscriptionConstants.GracePeriodDays);
var gracePeriodAppliesToTrials = await platformSettings.GetBoolAsync(
PlatformSettingKeys.GracePeriodAppliesToTrials, defaultValue: false);
var today = DateTime.UtcNow.Date;
@@ -100,7 +106,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
foreach (var company in companies)
{
if (ct.IsCancellationRequested) break;
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, ct);
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
}
await db.SaveChangesAsync(ct);
@@ -124,10 +132,10 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
IAdminNotificationService adminNotification,
PowderCoating.Core.Entities.Company company,
DateTime today,
int gracePeriodDays,
CancellationToken ct)
{
var endDate = company.SubscriptionEndDate!.Value.Date;
var gracePeriodDays = AppConstants.SubscriptionConstants.GracePeriodDays;
var expiredDate = endDate.AddDays(gracePeriodDays);
// ── Status transitions ────────────────────────────────────────────
@@ -167,6 +175,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
gracePeriodDays,
ct);
// Notify platform admin
@@ -184,6 +193,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: daysUntilExpiry,
gracePeriodDays,
ct);
}
}
@@ -203,6 +213,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
DateTime today,
NotificationType notificationType,
int daysBeforeExpiry,
int gracePeriodDays,
CancellationToken ct)
{
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
@@ -223,7 +234,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
if (alreadySent) return;
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry);
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry, gracePeriodDays);
var (success, error) = await emailService.SendEmailAsync(
company.PrimaryContactEmail,
@@ -255,14 +266,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
/// </summary>
private static (string subject, string plain, string html) BuildEmail(
PowderCoating.Core.Entities.Company company,
int daysBeforeExpiry)
int daysBeforeExpiry,
int gracePeriodDays)
{
var endDate = company.SubscriptionEndDate!.Value.Date;
if (daysBeforeExpiry == 0)
{
// Grace period notice
var graceDays = AppConstants.SubscriptionConstants.GracePeriodDays;
var graceDays = gracePeriodDays;
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
var plain = $"Hi {company.CompanyName},\n\n" +
@@ -1424,8 +1424,8 @@ public class InventoryController : Controller
}
var userId = _userManager.GetUserId(User) ?? string.Empty;
var txnType = jobId.HasValue ? InventoryTransactionType.JobUsage
: (Enum.TryParse<InventoryTransactionType>(transactionType, out var parsed) ? parsed : InventoryTransactionType.Adjustment);
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
var txnType = InventoryTransactionType.JobUsage;
item.QuantityOnHand -= quantity;
item.UpdatedAt = DateTime.UtcNow;
@@ -1610,6 +1610,62 @@ public class InventoryController : Controller
if (inventoryItemId.HasValue)
selectedItem = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId.Value);
// Synthesize powder-usage rows for scan-based JobUsage transactions not already linked to a PowderUsageLog
var linkedTxIds = usageLogs
.Where(u => u.InventoryTransactionId.HasValue)
.Select(u => u.InventoryTransactionId!.Value)
.ToHashSet();
var powderUsageDtos = usageLogs.Select(u => new PowderUsageLogDto
{
Id = u.Id,
JobId = u.JobId,
JobNumber = u.Job?.JobNumber ?? string.Empty,
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
InventoryItemId = u.InventoryItemId,
ItemName = u.InventoryItem?.Name,
SKU = u.InventoryItem?.SKU,
CoatColor = u.JobItemCoat?.ColorName,
ActualLbsUsed = u.ActualLbsUsed,
EstimatedLbs = u.EstimatedLbs,
VarianceLbs = u.VarianceLbs,
RecordedAt = u.RecordedAt,
Notes = u.Notes
}).ToList();
// Scan-based JobUsage entries have a JobId on the transaction but no PowderUsageLog record;
// surface them in the "Powder Usage By Job" tab so they aren't invisible.
powderUsageDtos.AddRange(transactions
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage
&& !linkedTxIds.Contains(t.Id)
&& (t.JobId.HasValue || (t.Reference != null && jobRefLookup.ContainsKey(t.Reference))))
.Select(t =>
{
var jobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var r) ? r.Id : 0);
var jobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : string.Empty);
var cust = t.Job?.Customer;
var custName = cust?.CompanyName ?? $"{cust?.ContactFirstName} {cust?.ContactLastName}".Trim();
return new PowderUsageLogDto
{
Id = 0,
SourceTransactionId = t.Id,
JobId = jobId,
JobNumber = jobNumber,
CustomerName = string.IsNullOrWhiteSpace(custName) ? null : custName,
InventoryItemId = t.InventoryItemId,
ItemName = t.InventoryItem?.Name,
SKU = t.InventoryItem?.SKU,
CoatColor = null,
ActualLbsUsed = Math.Abs(t.Quantity),
EstimatedLbs = 0,
VarianceLbs = 0,
RecordedAt = t.TransactionDate,
Notes = t.Notes
};
}));
powderUsageDtos = [.. powderUsageDtos.OrderByDescending(u => u.RecordedAt)];
var vm = new InventoryLedgerViewModel
{
InventoryItemId = inventoryItemId,
@@ -1638,22 +1694,7 @@ public class InventoryController : Controller
JobId = t.JobId ?? (t.Reference != null && jobRefLookup.TryGetValue(t.Reference, out var resolved) ? resolved.Id : null),
JobNumber = t.Job?.JobNumber ?? (t.Reference != null && jobRefLookup.ContainsKey(t.Reference) ? t.Reference : null)
}).ToList(),
PowderUsageLogs = usageLogs.Select(u => new PowderUsageLogDto
{
Id = u.Id,
JobId = u.JobId,
JobNumber = u.Job?.JobNumber ?? string.Empty,
CustomerName = u.Job?.Customer?.CompanyName ?? $"{u.Job?.Customer?.ContactFirstName} {u.Job?.Customer?.ContactLastName}".Trim(),
InventoryItemId = u.InventoryItemId,
ItemName = u.InventoryItem?.Name,
SKU = u.InventoryItem?.SKU,
CoatColor = u.JobItemCoat?.ColorName,
ActualLbsUsed = u.ActualLbsUsed,
EstimatedLbs = u.EstimatedLbs,
VarianceLbs = u.VarianceLbs,
RecordedAt = u.RecordedAt,
Notes = u.Notes
}).ToList(),
PowderUsageLogs = powderUsageDtos,
TotalPurchased = transactions
.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial)
.Sum(t => t.Quantity),
@@ -1667,6 +1708,91 @@ public class InventoryController : Controller
return View(vm);
}
/// <summary>
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
/// jobs so the edit modal can be pre-populated without a full page reload.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetUsageForEdit(int id)
{
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false,
t => t.Job, t => t.InventoryItem);
if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage
&& txn.TransactionType != InventoryTransactionType.Adjustment)
return BadRequest("Only usage transactions can be edited here.");
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus,
false,
j => j.Customer,
j => j.JobStatus);
var jobs = allJobs
.OrderByDescending(j => j.CreatedAt)
.Take(200)
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? $"{j.Customer.ContactFirstName} {j.Customer.ContactLastName}".Trim())
: "No Customer"
})
.ToList();
return Json(new
{
transactionId = txn.Id,
jobId = txn.JobId,
notes = txn.Notes,
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
itemName = txn.InventoryItem?.Name,
jobs
});
}
/// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
/// Quantity and balance are not changed.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
{
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage
&& txn.TransactionType != InventoryTransactionType.Adjustment)
return BadRequest();
if (jobId.HasValue)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
txn.JobId = jobId.Value;
txn.Reference = job?.JobNumber;
}
else
{
txn.JobId = null;
txn.Reference = null;
}
// Promote Adjustment→JobUsage when a job is assigned so it shows in Powder Usage By Job tab
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
txn.TransactionType = InventoryTransactionType.JobUsage;
txn.Notes = notes?.Trim();
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
txn.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryTransactions.UpdateAsync(txn);
await _unitOfWork.SaveChangesAsync();
return Json(new { success = true });
}
}
/// <summary>Helper projection used by the Scan action for job picker data.</summary>
@@ -404,10 +404,12 @@ public class JobsController : Controller
// Workers dropdown for inline assignment
await PopulateWorkersDropdown();
// Shop workers for time entry
var shopWorkers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive, false))
.OrderBy(w => w.Name).ToList();
ViewBag.ShopWorkers = shopWorkers.Select(w => new { w.Id, w.Name, Role = System.Text.RegularExpressions.Regex.Replace(w.Role.ToString(), "([a-z])([A-Z])", "$1 $2") }).ToList();
// Company users for time entry worker dropdown
var companyUsers = await _userManager.Users
.Where(u => u.CompanyId == job.CompanyId && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.ShopWorkers = companyUsers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
@@ -2127,9 +2129,12 @@ public class JobsController : Controller
.ThenBy(j => j.JobNumber)
.ToList();
// Workers for filter dropdown
var workers = await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive && !w.IsDeleted);
ViewBag.Workers = workers.OrderBy(w => w.Name).ToList();
// Company users for worker filter chips
var mobileWorkers = await _userManager.Users
.Where(u => u.CompanyId == companyId.Value && u.IsActive)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.Workers = mobileWorkers.Select(u => new { Id = u.Id, Name = u.FullName }).ToList();
ViewBag.CurrentWorkerId = workerId;
ViewBag.AllStatuses = allStatuses;
@@ -3352,13 +3357,13 @@ public class JobsController : Controller
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false,
e => e.Worker);
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
/// <summary>
/// Adds a time entry for a shop worker on a specific job.
/// Adds a time entry for a company user on a specific job.
/// Validates hours are in the reasonable range (0.124) before saving.
/// Returns the new entry as JSON so the UI can append it to the list without a reload.
/// </summary>
@@ -3371,13 +3376,14 @@ public class JobsController : Controller
var job = await _unitOfWork.Jobs.GetByIdAsync(dto.JobId);
if (job == null) return NotFound();
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
if (worker == null) return BadRequest(new { error = "Worker not found." });
var user = await _userManager.FindByIdAsync(dto.UserId);
if (user == null) return BadRequest(new { error = "Worker not found." });
var entry = new JobTimeEntry
{
JobId = dto.JobId,
ShopWorkerId = dto.ShopWorkerId,
UserId = dto.UserId,
UserDisplayName = user.FullName,
WorkDate = dto.WorkDate.Date,
HoursWorked = Math.Round(dto.HoursWorked, 2),
Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim(),
@@ -3388,9 +3394,7 @@ public class JobsController : Controller
await _unitOfWork.JobTimeEntries.AddAsync(entry);
await _unitOfWork.CompleteAsync();
// Reload with worker navigation for the response
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
return Json(_mapper.Map<JobTimeEntryDto>(saved));
return Json(_mapper.Map<JobTimeEntryDto>(entry));
}
/// <summary>Updates an existing time entry's hours, stage, and notes in place.</summary>
@@ -3400,13 +3404,14 @@ public class JobsController : Controller
if (dto.HoursWorked <= 0 || dto.HoursWorked > 24)
return BadRequest(new { error = "Hours must be between 0.1 and 24." });
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id, false, e => e.Worker);
var entry = await _unitOfWork.JobTimeEntries.GetByIdAsync(dto.Id);
if (entry == null) return NotFound();
var worker = await _unitOfWork.ShopWorkers.GetByIdAsync(dto.ShopWorkerId);
if (worker == null) return BadRequest(new { error = "Worker not found." });
var user = await _userManager.FindByIdAsync(dto.UserId);
if (user == null) return BadRequest(new { error = "Worker not found." });
entry.ShopWorkerId = dto.ShopWorkerId;
entry.UserId = dto.UserId;
entry.UserDisplayName = user.FullName;
entry.WorkDate = dto.WorkDate.Date;
entry.HoursWorked = Math.Round(dto.HoursWorked, 2);
entry.Stage = string.IsNullOrWhiteSpace(dto.Stage) ? null : dto.Stage.Trim();
@@ -3416,9 +3421,7 @@ public class JobsController : Controller
await _unitOfWork.JobTimeEntries.UpdateAsync(entry);
await _unitOfWork.CompleteAsync();
// Reload for response
var saved = await _unitOfWork.JobTimeEntries.GetByIdAsync(entry.Id, false, e => e.Worker);
return Json(_mapper.Map<JobTimeEntryDto>(saved));
return Json(_mapper.Map<JobTimeEntryDto>(entry));
}
/// <summary>Soft-deletes a time entry. The hours are removed from the job's running total.</summary>
@@ -74,9 +74,10 @@ public class RevenueController : Controller
// ── Revenue metrics ──────────────────────────────────────────────
// Active paying companies
// Active paying companies — exclude trials (no StripeSubscriptionId = on trial)
var payingActive = companies
.Where(c =>
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
.ToList();
@@ -111,6 +112,7 @@ public class RevenueController : Controller
// ── Plan distribution ────────────────────────────────────────────
var planDistribution = companies
.Where(c => !c.IsComped &&
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
.GroupBy(c => c.SubscriptionPlan)
@@ -137,6 +139,7 @@ public class RevenueController : Controller
var activeInMonth = companies
.Where(c =>
!c.IsComped &&
!string.IsNullOrEmpty(c.StripeSubscriptionId) &&
c.CreatedAt < monthEnd &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod ||
@@ -1,3 +1,5 @@
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
@@ -152,7 +154,16 @@ public class SubscriptionMiddleware
var daysUntil = subscriptionService.DaysUntilExpiry(company);
if (daysUntil != null)
{
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
var platformSettings = context.RequestServices.GetRequiredService<IPlatformSettingsService>();
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
var graceDays = 0;
if (!isTrial || await platformSettings.GetBoolAsync(PlatformSettingKeys.GracePeriodAppliesToTrials))
{
graceDays = await platformSettings.GetIntAsync(
PlatformSettingKeys.GracePeriodDays,
AppConstants.SubscriptionConstants.GracePeriodDays);
}
if (daysUntil < -graceDays)
{
// Past grace period — hard lockout
context.Response.Redirect("/Billing/Expired");
@@ -21,6 +21,7 @@
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
+4 -2
View File
@@ -1,5 +1,6 @@
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
@@ -147,9 +148,10 @@ if (builder.Environment.IsProduction())
}
else
{
var keysPath = Path.Combine(builder.Environment.ContentRootPath, "DataProtection-Keys");
// Non-production: store keys in SQL Server so they survive deploys and IIS app pool recycles
// without needing filesystem write permissions on the web root.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keysPath))
.PersistKeysToDbContext<ApplicationDbContext>()
.SetApplicationName("PowderCoatingApp");
}
@@ -17,7 +17,7 @@ by editing this MSBuild file. In order to learn more about this please visit htt
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<ProjectGuid>f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c</ProjectGuid>
<PublishUrl>https://linuxpcl-cvatbmbch9cchmbe.scm.centralus-01.azurewebsites.net/</PublishUrl>
<UserName />
<UserName>$linuxpcl</UserName>
<_SavePWD>false</_SavePWD>
</PropertyGroup>
</Project>
@@ -6,7 +6,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58461;http://localhost:58462"
"applicationUrl": "https://localhost:58461;http://localhost:58462;http://0.0.0.0:58462"
}
}
}
@@ -154,7 +154,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
<span class="btn btn-sm btn-outline-primary">Edit </span>
</div>
</a>
}
@@ -179,7 +179,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
<span class="btn btn-sm btn-outline-primary">View </span>
</div>
</a>
}
@@ -301,7 +301,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Edit →</span>
<span class="btn btn-sm btn-outline-primary">Edit </span>
</div>
</a>
}
@@ -274,7 +274,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">View →</span>
<span class="btn btn-sm btn-outline-primary">View </span>
</div>
</a>
}
@@ -155,6 +155,7 @@
<th class="text-end">Balance After</th>
<th>Reference</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -203,6 +204,16 @@
}
</td>
<td><small class="text-muted">@t.Notes</small></td>
<td>
@if (t.TransactionType == "JobUsage" || (t.TransactionType == "Adjustment" && t.PurchaseOrderId == null))
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
title="Edit usage record"
onclick="openUsageEdit(@t.Id)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
</tr>
}
</tbody>
@@ -241,6 +252,7 @@
<th class="text-end">Actual (lbs)</th>
<th class="text-end">Variance</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -250,10 +262,17 @@
<tr>
<td class="text-nowrap">@u.RecordedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yyyy")</td>
<td class="text-nowrap">
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
class="text-decoration-none fw-semibold">
@u.JobNumber
</a>
@if (u.JobId > 0)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@u.JobId"
class="text-decoration-none fw-semibold">
@u.JobNumber
</a>
}
else
{
<span class="text-muted fst-italic">No job assigned</span>
}
</td>
<td>@u.CustomerName</td>
@if (!Model.InventoryItemId.HasValue)
@@ -279,6 +298,16 @@
@(variance > 0 ? "+" : "")@variance.ToString("N3")
</td>
<td><small class="text-muted">@u.Notes</small></td>
<td>
@if (u.SourceTransactionId.HasValue)
{
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1"
title="Edit usage record"
onclick="openUsageEdit(@u.SourceTransactionId.Value)">
<i class="bi bi-pencil"></i>
</button>
}
</td>
</tr>
}
</tbody>
@@ -291,6 +320,7 @@
@(Model.PowderUsageLogs.Sum(u => u.VarianceLbs) > 0 ? "+" : "")@Model.PowderUsageLogs.Sum(u => u.VarianceLbs).ToString("N3")
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
@@ -302,14 +332,63 @@
}
</div>
@* ── Edit Usage Modal ─────────────────────────────────────────────── *@
<div class="modal fade" id="editUsageModal" tabindex="-1" aria-labelledby="editUsageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUsageModalLabel">
<i class="bi bi-pencil me-2"></i>Edit Usage Record
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="editUsageLoading" class="text-center py-4">
<div class="spinner-border spinner-border-sm me-2"></div>Loading…
</div>
<form id="editUsageForm" class="d-none">
@Html.AntiForgeryToken()
<input type="hidden" id="euTxnId" name="id" />
<div class="mb-3">
<label class="form-label fw-semibold">Powder Item</label>
<p id="euItemName" class="form-control-plaintext text-muted"></p>
</div>
<div class="mb-3">
<label for="euJobId" class="form-label fw-semibold">Job <span class="text-muted fw-normal">(optional)</span></label>
<select id="euJobId" name="jobId" class="form-select">
<option value="">— No job —</option>
</select>
<div class="form-text">Select the job this powder was used on.</div>
</div>
<div class="mb-3">
<label for="euDate" class="form-label fw-semibold">Date / Time</label>
<input type="datetime-local" id="euDate" name="transactionDate" class="form-control" required />
</div>
<div class="mb-3">
<label for="euNotes" class="form-label fw-semibold">Notes</label>
<textarea id="euNotes" name="notes" class="form-control" rows="2" maxlength="500"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="euSaveBtn" disabled>
<span id="euSaveBtnText">Save Changes</span>
<span id="euSaveBtnSpinner" class="spinner-border spinner-border-sm ms-1 d-none"></span>
</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/inventory-ledger.js" asp-append-version="true"></script>
<script>
function switchTab(tab) {
document.getElementById('tab-transactions').classList.toggle('d-none', tab !== 'transactions');
document.getElementById('tab-usage').classList.toggle('d-none', tab !== 'usage');
document.querySelectorAll('#ledgerTabs .nav-link').forEach(el => el.classList.remove('active'));
event.currentTarget.classList.add('active');
// Update hidden tab field in filter form
document.querySelector('input[name="tab"]').value = tab;
}
</script>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@model PowderCoating.Application.DTOs.Job.JobDto
@{
ViewData["Title"] = $"Job {Model.JobNumber}";
@@ -730,7 +730,6 @@
<thead class="table-light">
<tr>
<th>Worker</th>
<th>Role</th>
<th>Date</th>
<th class="text-end">Hours</th>
<th>Stage</th>
@@ -1311,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class="bi bi-box-seam me-2"></i>@(Model.IntakeDate.HasValue ? "Intake ✓" : "Intake")
<i class=bi bi-box-seam me-2></i>@(Model.IntakeDate.HasValue ? "Intake " : "Intake")
</a>
}
@{
@@ -2198,7 +2197,7 @@
<option value="">— Select worker —</option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{
<option value="@w.Id">@w.Name (@w.Role)</option>
<option value="@w.Id">@w.Name</option>
}
</select>
</div>
@@ -2682,8 +2681,8 @@
// Notes
const notes = [];
if (!d.hasPowderData) notes.push('âš  Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('âš  Log time entries to include labor cost.');
if (!d.hasPowderData) notes.push(' Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push(' Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
@@ -2748,7 +2747,6 @@
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="fw-semibold">${esc(e.workerName)}</td>
<td class="text-muted small">${esc(e.workerRole)}</td>
<td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted">—</span>'}</td>
@@ -2786,7 +2784,7 @@
if (!e) return;
document.getElementById('timeEntryModalTitle').textContent = 'Edit Time Entry';
document.getElementById('teEntryId').value = e.id;
document.getElementById('teWorkerId').value = e.shopWorkerId;
document.getElementById('teWorkerId').value = e.userId ?? '';
document.getElementById('teWorkDate').value = new Date(e.workDate).toISOString().slice(0, 10);
document.getElementById('teHoursWorked').value = e.hoursWorked;
document.getElementById('teStage').value = e.stage ?? '';
@@ -2797,7 +2795,7 @@
async function save() {
const id = parseInt(document.getElementById('teEntryId').value);
const workerId = parseInt(document.getElementById('teWorkerId').value);
const workerId = document.getElementById('teWorkerId').value;
const workDate = document.getElementById('teWorkDate').value;
const hours = parseFloat(document.getElementById('teHoursWorked').value);
const stage = document.getElementById('teStage').value.trim();
@@ -2813,8 +2811,8 @@
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const url = id > 0 ? '/Jobs/UpdateTimeEntry' : '/Jobs/AddTimeEntry';
const body = id > 0
? { id, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
: { jobId: jid, shopWorkerId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
? { id, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null }
: { jobId: jid, userId: workerId, workDate, hoursWorked: hours, stage: stage || null, notes: notes || null };
try {
const r = await fetch(url, {
@@ -1,9 +1,7 @@
@model List<PowderCoating.Application.DTOs.Job.JobDailyPriorityDto>
@using PowderCoating.Core.Entities
@{
Layout = null;
var workers = ViewBag.Workers as List<ShopWorker> ?? new();
var workers = (ViewBag.Workers as IEnumerable<dynamic>) ?? Array.Empty<dynamic>();
var currentWorkerId = ViewBag.CurrentWorkerId as string;
var allStatuses = ViewBag.AllStatuses as List<JobStatusLookup> ?? new();
var activeCount = Model.Count;
@@ -202,7 +202,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">View →</span>
<span class="btn btn-sm btn-outline-secondary">View </span>
</div>
</a>
}
@@ -20,7 +20,7 @@
<a tabindex="0" class="help-icon ms-1" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Statuses"
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; — saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; — delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; — customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; — customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; — validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; — a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Draft&lt;/strong&gt; — saved but not yet sent. Edit freely.&lt;br&gt;&lt;strong&gt;Sent&lt;/strong&gt; — delivered to the customer, awaiting response.&lt;br&gt;&lt;strong&gt;Approved&lt;/strong&gt; — customer accepted. You can convert this to a Job.&lt;br&gt;&lt;strong&gt;Rejected&lt;/strong&gt; — customer declined.&lt;br&gt;&lt;strong&gt;Expired&lt;/strong&gt; — validity period has passed. Edit to extend it.&lt;br&gt;&lt;strong&gt;Converted&lt;/strong&gt; — a job has been created from this quote.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</p>
@@ -273,7 +273,7 @@
</div>
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-primary">Manage →</span>
<span class="btn btn-sm btn-outline-primary">Manage </span>
</div>
</a>
}
@@ -221,7 +221,7 @@
}
</div>
<div class="mobile-card-footer">
<span class="btn btn-sm btn-outline-secondary">View Details →</span>
<span class="btn btn-sm btn-outline-secondary">View Details </span>
</div>
</div>
}
@@ -0,0 +1,82 @@
// inventory-ledger.js — Edit Usage Record modal logic
async function openUsageEdit(transactionId) {
const modal = new bootstrap.Modal(document.getElementById('editUsageModal'));
const loading = document.getElementById('editUsageLoading');
const form = document.getElementById('editUsageForm');
const saveBtn = document.getElementById('euSaveBtn');
loading.classList.remove('d-none');
form.classList.add('d-none');
saveBtn.disabled = true;
modal.show();
try {
const resp = await fetch(`/Inventory/GetUsageForEdit?id=${transactionId}`);
if (!resp.ok) throw new Error('Failed to load usage record.');
const data = await resp.json();
document.getElementById('euTxnId').value = data.transactionId;
document.getElementById('euItemName').textContent = data.itemName || '—';
document.getElementById('euDate').value = data.transactionDate;
document.getElementById('euNotes').value = data.notes || '';
const jobSel = document.getElementById('euJobId');
jobSel.innerHTML = '<option value="">— No job —</option>';
(data.jobs || []).forEach(j => {
const opt = document.createElement('option');
opt.value = j.id;
opt.textContent = `${j.jobNumber}${j.customerName}`;
if (j.id === data.jobId) opt.selected = true;
jobSel.appendChild(opt);
});
loading.classList.add('d-none');
form.classList.remove('d-none');
saveBtn.disabled = false;
} catch (e) {
loading.innerHTML = `<div class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${e.message}</div>`;
}
}
document.getElementById('euSaveBtn').addEventListener('click', async () => {
const form = document.getElementById('editUsageForm');
if (!form.reportValidity()) return;
const saveBtn = document.getElementById('euSaveBtn');
const spinner = document.getElementById('euSaveBtnSpinner');
const btnText = document.getElementById('euSaveBtnText');
saveBtn.disabled = true;
spinner.classList.remove('d-none');
btnText.textContent = 'Saving…';
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value;
const params = new URLSearchParams({
id: document.getElementById('euTxnId').value,
jobId: document.getElementById('euJobId').value,
notes: document.getElementById('euNotes').value,
transactionDate: document.getElementById('euDate').value,
__RequestVerificationToken: token || ''
});
try {
const resp = await fetch('/Inventory/EditUsageTransaction', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString()
});
const result = await resp.json();
if (result.success) {
bootstrap.Modal.getInstance(document.getElementById('editUsageModal')).hide();
location.reload();
} else {
throw new Error('Save failed.');
}
} catch (e) {
saveBtn.disabled = false;
spinner.classList.add('d-none');
btnText.textContent = 'Save Changes';
alert('Error saving changes: ' + e.message);
}
});
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
@@ -23,7 +24,7 @@ public class SubscriptionServiceTests
new ApplicationUser { Id = "u2", CompanyId = 7, UserName = "u2", Email = "u2@example.com", FirstName = "B", LastName = "Two", IsActive = true });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var (used, max) = await service.GetUserCountAsync(7);
@@ -43,7 +44,7 @@ public class SubscriptionServiceTests
new Job { Id = 3, CompanyId = 8, JobNumber = "JOB-3", CustomerId = 1, Description = "Delivered", JobStatusId = 3, JobPriorityId = 1 });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var (used, max) = await service.GetJobCountAsync(8);
@@ -76,7 +77,7 @@ public class SubscriptionServiceTests
oldQuote.CreatedAt = DateTime.UtcNow.AddMonths(-1);
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var (used, max) = await service.GetQuoteCountAsync(9);
@@ -94,7 +95,7 @@ public class SubscriptionServiceTests
context.Customers.Add(new Customer { Id = 1, CompanyId = 10, CompanyName = "Customer A" });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var allowed = await service.CanAddCustomerAsync(10);
@@ -109,7 +110,7 @@ public class SubscriptionServiceTests
context.AiItemPredictions.Add(new AiItemPrediction { Id = 1, CompanyId = 11, CreatedAt = DateTime.UtcNow.AddDays(-1) });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var allowed = await service.CanUseAiPhotoQuoteAsync(11);
@@ -123,7 +124,7 @@ public class SubscriptionServiceTests
SeedCompanyAndPlan(context, companyId: 12, plan: 6, maxAiPhotoQuotesPerMonth: 10, allowAiPhotoQuotes: false);
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var allowed = await service.CanUseAiPhotoQuoteAsync(12);
@@ -143,11 +144,12 @@ public class SubscriptionServiceTests
PrimaryContactEmail = "grace@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-5),
StripeSubscriptionId = "sub_grace_test", // paid sub — qualifies for grace period
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var status = await service.GetStatusAsync(20);
@@ -167,11 +169,12 @@ public class SubscriptionServiceTests
PrimaryContactEmail = "expired@example.com",
SubscriptionStatus = SubscriptionStatus.Active,
SubscriptionEndDate = DateTime.UtcNow.Date.AddDays(-15),
StripeSubscriptionId = "sub_expired_test", // paid sub — 15 days > 14-day grace window
IsActive = true
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var status = await service.GetStatusAsync(21);
@@ -196,7 +199,7 @@ public class SubscriptionServiceTests
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var status = await service.GetStatusAsync(22);
@@ -212,7 +215,7 @@ public class SubscriptionServiceTests
company!.AiInventoryAssistEnabled = false;
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var enabled = await service.IsAiInventoryAssistEnabledAsync(13);
@@ -250,7 +253,7 @@ public class SubscriptionServiceTests
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var (used, max) = await service.GetJobPhotoCountAsync(14, 100);
@@ -290,7 +293,7 @@ public class SubscriptionServiceTests
});
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var (used, max) = await service.GetQuotePhotoCountAsync(15, 200);
@@ -308,7 +311,7 @@ public class SubscriptionServiceTests
new AiItemPrediction { Id = 2, CompanyId = 16, CreatedAt = DateTime.UtcNow.AddDays(-2) });
await context.SaveChangesAsync();
var service = new SubscriptionService(new UnitOfWork(context), context);
var service = new SubscriptionService(new UnitOfWork(context), context, new NullPlatformSettingsService());
var allowed = await service.CanUseAiPhotoQuoteAsync(16);
@@ -410,3 +413,13 @@ public class SubscriptionServiceTests
});
}
}
/// <summary>No-op stub for IPlatformSettingsService — returns defaults for all queries.</summary>
file sealed class NullPlatformSettingsService : IPlatformSettingsService
{
public Task<string?> GetAsync(string key) => Task.FromResult<string?>(null);
public Task<bool> GetBoolAsync(string key, bool defaultValue = false) => Task.FromResult(defaultValue);
public Task<int> GetIntAsync(string key, int defaultValue) => Task.FromResult(defaultValue);
public Task SetAsync(string key, string? value, string? updatedBy = null) => Task.CompletedTask;
public Task<IReadOnlyList<PlatformSetting>> GetAllAsync() => Task.FromResult<IReadOnlyList<PlatformSetting>>(Array.Empty<PlatformSetting>().ToList());
}