Commit remaining unstaged changes from this session
- Platform settings service: IPlatformSettingsService, PlatformSettingKeys, PlatformSettingsService, SubscriptionService, AppConstants, SubscriptionExpiryBackgroundService, SubscriptionMiddleware - JobTimeEntry entity, DTOs, AutoMapper profile (ShopWorker → UserId migration) - InventoryDtos: SourceTransactionId on PowderUsageLogDto - InventoryTransactionRepository: include Job.Customer in ledger query - InventoryAiLookupService: @graph unwrap + HTML price fallback - ApplicationDbContextModelSnapshot: reflect migration changes - launchSettings.json, publish profile Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -258,6 +258,8 @@ public class PowderUsageLogDto
|
|||||||
public decimal VarianceLbs { get; set; }
|
public decimal VarianceLbs { get; set; }
|
||||||
public DateTime RecordedAt { get; set; }
|
public DateTime RecordedAt { get; set; }
|
||||||
public string? Notes { 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
|
public class InventoryLedgerViewModel
|
||||||
|
|||||||
@@ -404,9 +404,8 @@ public class JobTimeEntryDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int JobId { 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 WorkerName { get; set; } = string.Empty;
|
||||||
public string WorkerRole { get; set; } = string.Empty;
|
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
@@ -417,7 +416,7 @@ public class JobTimeEntryDto
|
|||||||
public class CreateJobTimeEntryDto
|
public class CreateJobTimeEntryDto
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public string UserId { get; set; } = string.Empty;
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
@@ -427,7 +426,7 @@ public class CreateJobTimeEntryDto
|
|||||||
public class UpdateJobTimeEntryDto
|
public class UpdateJobTimeEntryDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int ShopWorkerId { get; set; }
|
public string UserId { get; set; } = string.Empty;
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; }
|
public string? Stage { get; set; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public interface IPlatformSettingsService
|
|||||||
{
|
{
|
||||||
Task<string?> GetAsync(string key);
|
Task<string?> GetAsync(string key);
|
||||||
Task<bool> GetBoolAsync(string key, bool defaultValue = false);
|
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 SetAsync(string key, string? value, string? updatedBy = null);
|
||||||
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
|
Task<IReadOnlyList<PlatformSetting>> GetAllAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ public class JobProfile : Profile
|
|||||||
|
|
||||||
// JobTimeEntry → JobTimeEntryDto
|
// JobTimeEntry → JobTimeEntryDto
|
||||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src => src.Worker != null ? src.Worker.Name : string.Empty))
|
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||||
.ForMember(dest => dest.WorkerRole, opt => opt.MapFrom(src => src.Worker != null ? FormatEnumName(src.Worker.Role.ToString()) : string.Empty));
|
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
||||||
|
|
||||||
// CreateJobDto to Job
|
// CreateJobDto to Job
|
||||||
CreateMap<CreateJobDto, Job>()
|
CreateMap<CreateJobDto, Job>()
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ namespace PowderCoating.Core.Entities;
|
|||||||
public class JobTimeEntry : BaseEntity
|
public class JobTimeEntry : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
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 DateTime WorkDate { get; set; }
|
||||||
public decimal HoursWorked { get; set; }
|
public decimal HoursWorked { get; set; }
|
||||||
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
|
public string? Stage { get; set; } // e.g. "Sandblasting", "Coating", "Masking" — free text
|
||||||
@@ -11,5 +13,5 @@ public class JobTimeEntry : BaseEntity
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Job Job { get; set; } = null!;
|
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 MaxTenants = "MaxTenants";
|
||||||
public const string SmsEnabled = "SmsEnabled";
|
public const string SmsEnabled = "SmsEnabled";
|
||||||
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
|
public const string AiCatalogPriceCheckEnabled = "AiCatalogPriceCheckEnabled";
|
||||||
|
public const string GracePeriodDays = "GracePeriodDays";
|
||||||
|
public const string GracePeriodAppliesToTrials = "GracePeriodAppliesToTrials";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5000,7 +5000,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("ShopWorkerId")
|
b.Property<int?>("ShopWorkerId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("Stage")
|
b.Property<string>("Stage")
|
||||||
@@ -5012,6 +5012,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("UpdatedBy")
|
b.Property<string>("UpdatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserDisplayName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<DateTime>("WorkDate")
|
b.Property<DateTime>("WorkDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -6028,7 +6034,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5184),
|
CreatedAt = new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8603),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6039,7 +6045,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5189),
|
CreatedAt = new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8610),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6050,7 +6056,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 3, 20, 30, 44, 955, DateTimeKind.Utc).AddTicks(5191),
|
CreatedAt = new DateTime(2026, 5, 5, 23, 10, 14, 763, DateTimeKind.Utc).AddTicks(8612),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -8694,9 +8700,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
||||||
.WithMany("TimeEntries")
|
.WithMany("TimeEntries")
|
||||||
.HasForeignKey("ShopWorkerId")
|
.HasForeignKey("ShopWorkerId");
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class InventoryTransactionRepository : Repository<InventoryTransaction>,
|
|||||||
.Include(t => t.InventoryItem)
|
.Include(t => t.InventoryItem)
|
||||||
.Include(t => t.PurchaseOrder)
|
.Include(t => t.PurchaseOrder)
|
||||||
.Include(t => t.Job)
|
.Include(t => t.Job)
|
||||||
|
.ThenInclude(j => j!.Customer)
|
||||||
.Where(t => !t.IsDeleted);
|
.Where(t => !t.IsDeleted);
|
||||||
|
|
||||||
if (itemId.HasValue)
|
if (itemId.HasValue)
|
||||||
|
|||||||
@@ -834,6 +834,10 @@ Rules:
|
|||||||
// machine-readable price, SKU, and product info that would otherwise be lost.
|
// machine-readable price, SKU, and product info that would otherwise be lost.
|
||||||
var structuredData = ExtractJsonLdData(html);
|
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
|
// Remove script/style blocks
|
||||||
html = System.Text.RegularExpressions.Regex.Replace(
|
html = System.Text.RegularExpressions.Regex.Replace(
|
||||||
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
|
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
|
||||||
@@ -851,10 +855,11 @@ Rules:
|
|||||||
if (text.Length > maxChars)
|
if (text.Length > maxChars)
|
||||||
text = text[..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();
|
var header = new StringBuilder();
|
||||||
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
|
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
|
||||||
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
|
if (!string.IsNullOrWhiteSpace(htmlPriceSnippet)) header.Append(htmlPriceSnippet);
|
||||||
|
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
|
||||||
if (header.Length > 0) text = header + "\n" + text;
|
if (header.Length > 0) text = header + "\n" + text;
|
||||||
|
|
||||||
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
||||||
@@ -897,6 +902,46 @@ Rules:
|
|||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
|
/// 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.
|
/// "[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.
|
/// 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
|
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
|
||||||
/// even when the visible price is rendered by JavaScript.
|
/// 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>
|
/// </summary>
|
||||||
private static string? ExtractJsonLdData(string html)
|
private static string? ExtractJsonLdData(string html)
|
||||||
{
|
{
|
||||||
@@ -976,12 +1027,7 @@ Rules:
|
|||||||
using var doc = JsonDocument.Parse(jsonText);
|
using var doc = JsonDocument.Parse(jsonText);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
// Handle both single-object and @graph array
|
foreach (var node in FlattenJsonLdNodes(root))
|
||||||
IEnumerable<JsonElement> nodes = root.ValueKind == JsonValueKind.Array
|
|
||||||
? root.EnumerateArray()
|
|
||||||
: new[] { root };
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
{
|
||||||
if (!node.TryGetProperty("@type", out var typeEl)) continue;
|
if (!node.TryGetProperty("@type", out var typeEl)) continue;
|
||||||
|
|
||||||
@@ -1013,6 +1059,34 @@ Rules:
|
|||||||
return sb.Length > 0 ? sb.ToString() : null;
|
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>
|
/// <summary>
|
||||||
/// Dispatches JSON-LD offer extraction for both the single-object form
|
/// Dispatches JSON-LD offer extraction for both the single-object form
|
||||||
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
|
/// (<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;
|
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>
|
/// <summary>
|
||||||
/// Creates or updates the platform setting identified by <paramref name="key"/>.
|
/// Creates or updates the platform setting identified by <paramref name="key"/>.
|
||||||
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
|
/// Records <paramref name="updatedBy"/> (typically the SuperAdmin's username) and
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -27,11 +28,13 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly IPlatformSettingsService _platformSettings;
|
||||||
|
|
||||||
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context)
|
public SubscriptionService(IUnitOfWork unitOfWork, ApplicationDbContext context, IPlatformSettingsService platformSettings)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_platformSettings = platformSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -203,7 +206,8 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
if (daysUntil == null || daysUntil > 0)
|
if (daysUntil == null || daysUntil > 0)
|
||||||
return SubscriptionStatus.Active;
|
return SubscriptionStatus.Active;
|
||||||
|
|
||||||
if (daysUntil >= -AppConstants.SubscriptionConstants.GracePeriodDays)
|
var graceDays = await GetEffectiveGracePeriodDaysAsync(company);
|
||||||
|
if (daysUntil >= -graceDays)
|
||||||
return SubscriptionStatus.GracePeriod;
|
return SubscriptionStatus.GracePeriod;
|
||||||
|
|
||||||
return SubscriptionStatus.Expired;
|
return SubscriptionStatus.Expired;
|
||||||
@@ -226,6 +230,25 @@ public class SubscriptionService : ISubscriptionService
|
|||||||
return (int)(expiry - today).TotalDays;
|
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>
|
/// <summary>
|
||||||
/// Returns the total catalog item count (including soft-deleted) and the plan maximum.
|
/// 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).
|
/// 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 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";
|
public const string Version = "1.0.0";
|
||||||
|
|
||||||
/// <summary>Set to true to enable SMS features throughout the UI.</summary>
|
/// <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 db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
|
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
|
||||||
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
|
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;
|
var today = DateTime.UtcNow.Date;
|
||||||
|
|
||||||
@@ -100,7 +106,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
foreach (var company in companies)
|
foreach (var company in companies)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) break;
|
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);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -124,10 +132,10 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
IAdminNotificationService adminNotification,
|
IAdminNotificationService adminNotification,
|
||||||
PowderCoating.Core.Entities.Company company,
|
PowderCoating.Core.Entities.Company company,
|
||||||
DateTime today,
|
DateTime today,
|
||||||
|
int gracePeriodDays,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||||
var gracePeriodDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
|
||||||
var expiredDate = endDate.AddDays(gracePeriodDays);
|
var expiredDate = endDate.AddDays(gracePeriodDays);
|
||||||
|
|
||||||
// ── Status transitions ────────────────────────────────────────────
|
// ── Status transitions ────────────────────────────────────────────
|
||||||
@@ -167,6 +175,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||||
NotificationType.SubscriptionExpiryReminder,
|
NotificationType.SubscriptionExpiryReminder,
|
||||||
daysBeforeExpiry: 0,
|
daysBeforeExpiry: 0,
|
||||||
|
gracePeriodDays,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
// Notify platform admin
|
// Notify platform admin
|
||||||
@@ -184,6 +193,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||||
NotificationType.SubscriptionExpiryReminder,
|
NotificationType.SubscriptionExpiryReminder,
|
||||||
daysBeforeExpiry: daysUntilExpiry,
|
daysBeforeExpiry: daysUntilExpiry,
|
||||||
|
gracePeriodDays,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,6 +213,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
DateTime today,
|
DateTime today,
|
||||||
NotificationType notificationType,
|
NotificationType notificationType,
|
||||||
int daysBeforeExpiry,
|
int daysBeforeExpiry,
|
||||||
|
int gracePeriodDays,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
|
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
|
||||||
@@ -223,7 +234,7 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (alreadySent) return;
|
if (alreadySent) return;
|
||||||
|
|
||||||
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry);
|
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry, gracePeriodDays);
|
||||||
|
|
||||||
var (success, error) = await emailService.SendEmailAsync(
|
var (success, error) = await emailService.SendEmailAsync(
|
||||||
company.PrimaryContactEmail,
|
company.PrimaryContactEmail,
|
||||||
@@ -255,14 +266,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static (string subject, string plain, string html) BuildEmail(
|
private static (string subject, string plain, string html) BuildEmail(
|
||||||
PowderCoating.Core.Entities.Company company,
|
PowderCoating.Core.Entities.Company company,
|
||||||
int daysBeforeExpiry)
|
int daysBeforeExpiry,
|
||||||
|
int gracePeriodDays)
|
||||||
{
|
{
|
||||||
var endDate = company.SubscriptionEndDate!.Value.Date;
|
var endDate = company.SubscriptionEndDate!.Value.Date;
|
||||||
|
|
||||||
if (daysBeforeExpiry == 0)
|
if (daysBeforeExpiry == 0)
|
||||||
{
|
{
|
||||||
// Grace period notice
|
// Grace period notice
|
||||||
var graceDays = AppConstants.SubscriptionConstants.GracePeriodDays;
|
var graceDays = gracePeriodDays;
|
||||||
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
|
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
|
||||||
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
|
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
|
||||||
var plain = $"Hi {company.CompanyName},\n\n" +
|
var plain = $"Hi {company.CompanyName},\n\n" +
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
@@ -152,7 +154,16 @@ public class SubscriptionMiddleware
|
|||||||
var daysUntil = subscriptionService.DaysUntilExpiry(company);
|
var daysUntil = subscriptionService.DaysUntilExpiry(company);
|
||||||
if (daysUntil != null)
|
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
|
// Past grace period — hard lockout
|
||||||
context.Response.Redirect("/Billing/Expired");
|
context.Response.Redirect("/Billing/Expired");
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ by editing this MSBuild file. In order to learn more about this please visit htt
|
|||||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||||
<ProjectGuid>f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c</ProjectGuid>
|
<ProjectGuid>f6a7b8c9-d0e1-4f5a-3b4c-5d6e7f8a9b0c</ProjectGuid>
|
||||||
<PublishUrl>https://linuxpcl-cvatbmbch9cchmbe.scm.centralus-01.azurewebsites.net/</PublishUrl>
|
<PublishUrl>https://linuxpcl-cvatbmbch9cchmbe.scm.centralus-01.azurewebsites.net/</PublishUrl>
|
||||||
<UserName />
|
<UserName>$linuxpcl</UserName>
|
||||||
<_SavePWD>false</_SavePWD>
|
<_SavePWD>false</_SavePWD>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
"applicationUrl": "https://localhost:58461;http://localhost:58462"
|
"applicationUrl": "https://localhost:58461;http://localhost:58462;http://0.0.0.0:58462"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user