diff --git a/CLAUDE.md b/CLAUDE.md
index 27ed08f..b4e9de0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,7 +126,9 @@ Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
> **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
-> Full rationale and migration roadmap: `docs/DATA_ACCESS_ARCHITECTURE.md`
+> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
+> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
+> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
diff --git a/docs/DATA_ACCESS_ARCHITECTURE.md b/docs/DATA_ACCESS_ARCHITECTURE.md
index 3d97f98..cd68102 100644
--- a/docs/DATA_ACCESS_ARCHITECTURE.md
+++ b/docs/DATA_ACCESS_ARCHITECTURE.md
@@ -1,6 +1,6 @@
# Data Access Architecture
-## Status: Migration In Progress
+## Status: Complete ✓ (2026-04-28)
This document defines the target data access architecture for Powder Coating Logix and tracks
the migration from the current mixed pattern to the clean layered pattern.
@@ -213,6 +213,14 @@ This is not a smell — it is correct for their use cases. Each file has a comme
| `SystemInfoController` | Infrastructure diagnostics; queries metadata, not business data |
| `SystemLogsController` | Log table queries; not a business entity |
| `CompanyHealthController` | Cross-tenant health checks for SuperAdmin; ignores all filters |
+| `PasskeyController` | WebAuthn/FIDO2 identity infrastructure; UserPasskeys is an ASP.NET Identity concern outside IUnitOfWork; anonymous login path has no tenant context |
+| `AuditLogController` | Append-only audit log with `long` PK; platform infrastructure table outside the business entity graph; same reasoning as `SystemLogsController` |
+| `UserActivityController` | Queries ASP.NET Identity `ApplicationUser` across all tenants with `Include(u => u.Company)`; Identity entities live outside IUnitOfWork |
+| `EmailBroadcastController` | Cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork |
+| `RevenueController` | Cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as `CompanyHealthController` |
+| `StripeEventsController` | `StripeWebhookEvents` is a platform infrastructure table, not a business entity; same reasoning as `StripeWebhookController` |
+| `SubscriptionManagementController` | Cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern |
+| `UsageQuotaController` | Cross-tenant bulk GROUP BY quota queries; routing through IUnitOfWork would require O(n) repository round-trips |
If you think you need to add a controller to this list, you almost certainly don't. Ask first.
@@ -232,57 +240,59 @@ If you think you need to add a controller to this list, you almost certainly don
- [ ] Register all new types in `Program.cs`
- [ ] Build passes, all tests green — no controller has changed yet
-### Phase 2 — Complex controller migration
-- [ ] `InvoicesController` → `IInvoiceRepository`
-- [ ] `JobsController` → `IJobRepository`
-- [ ] `QuotesController` → `IQuoteRepository`
-- [ ] `CustomersController` → `ICustomerRepository`
-- [ ] `BillsController` → `IBillRepository`
-- [ ] `PurchaseOrdersController` → `IPurchaseOrderRepository`
-- [ ] `ReportsController` → `IFinancialReportService` + `IOperationalReportService`
+### Phase 2 — Complex controller migration ✓ COMPLETE (2026-04-27)
+- [x] `InvoicesController` → `IInvoiceRepository`
+- [x] `JobsController` → `IJobRepository`
+- [x] `QuotesController` → `IQuoteRepository`
+- [x] `CustomersController` → `ICustomerRepository`
+- [x] `BillsController` → `IBillRepository`
+- [x] `PurchaseOrdersController` → `IPurchaseOrderRepository`
+- [x] `ReportsController` → `IFinancialReportService` + `IOperationalReportService`
-### Phase 3 — Simple controller sweep
+### Phase 3 — Simple controller sweep ✓ COMPLETE (2026-04-28)
Remove `ApplicationDbContext` injection from all controllers not in the permanent exceptions list,
replacing with existing `IUnitOfWork` generic repository calls.
-- [ ] `AnnouncementsController`
-- [ ] `AiQuickQuoteController`
-- [ ] `AiUsageReportController`
-- [ ] `AuditLogController`
-- [ ] `BannedIpsController`
-- [ ] `BugReportController`
-- [ ] `CompaniesController`
-- [ ] `CompanySettingsController`
-- [ ] `CompanyUsersController`
-- [ ] `DashboardController`
-- [ ] `DashboardTipsController`
-- [ ] `DepositsController`
-- [ ] `EmailBroadcastController`
-- [ ] `ExpensesController`
-- [ ] `InAppNotificationsController`
-- [ ] `InventoryController`
-- [ ] `JobsPriorityController`
-- [ ] `JobTemplatesController`
-- [ ] `NotificationLogsController`
-- [ ] `PasskeyController`
-- [ ] `PlatformNotificationsController`
-- [ ] `QuoteApprovalController`
-- [ ] `ReleaseNotesController`
-- [ ] `RevenueController`
-- [ ] `SetupWizardController`
-- [ ] `SmsConsentAuditController`
-- [ ] `StripeEventsController`
-- [ ] `SubscriptionManagementController`
-- [ ] `UnsubscribeController`
-- [ ] `UsageQuotaController`
-- [ ] `UserActivityController`
-- [ ] `VendorsController`
+- [x] `AnnouncementsController`
+- [x] `AiQuickQuoteController`
+- [x] `AiUsageReportController`
+- [x] `AuditLogController` → permanent exception (Identity/platform infra)
+- [x] `BannedIpsController`
+- [x] `BugReportController`
+- [x] `CompaniesController`
+- [x] `CompanySettingsController`
+- [x] `CompanyUsersController`
+- [x] `DashboardController`
+- [x] `DashboardTipsController`
+- [x] `DepositsController`
+- [x] `EmailBroadcastController` → permanent exception (Identity fan-out)
+- [x] `ExpensesController`
+- [x] `InAppNotificationsController`
+- [x] `InventoryController`
+- [x] `JobsPriorityController`
+- [x] `JobTemplatesController`
+- [x] `NotificationLogsController`
+- [x] `PasskeyController` → permanent exception (WebAuthn/FIDO2 identity infra)
+- [x] `PlatformNotificationsController`
+- [x] `QuoteApprovalController`
+- [x] `ReleaseNotesController`
+- [x] `RevenueController` → permanent exception (cross-tenant MRR/ARR)
+- [x] `SetupWizardController`
+- [x] `SmsConsentAuditController`
+- [x] `StripeEventsController` → permanent exception (platform infra table)
+- [x] `SubscriptionManagementController` → permanent exception (platform-level cross-tenant)
+- [x] `UnsubscribeController`
+- [x] `UsageQuotaController` → permanent exception (bulk GROUP BY)
+- [x] `UserActivityController` → permanent exception (Identity entities)
+- [x] `VendorsController`
-### Phase 4 — Enforcement
-- [ ] Remove `ApplicationDbContext` from controller DI scope in `Program.cs`
- (controllers that still need it will get a compile error — the compiler enforces the rule)
-- [ ] Update `CLAUDE.md` to mark migration complete
-- [ ] Update this document status from "Migration In Progress" to "Complete"
+### Phase 4 — Enforcement ✓ COMPLETE (2026-04-28)
+- [x] `EnforceDataAccessArchitecture()` added to `Program.cs` — scans all Controller subclasses at
+ startup via reflection and throws `InvalidOperationException` if any non-exempt controller
+ has `ApplicationDbContext` in its constructor. The app cannot start with a violation.
+- [x] Permanent exceptions list hardcoded in the enforcement function (18 controllers).
+- [x] This document status updated to Complete.
+- [ ] Update `CLAUDE.md` to mark migration complete (optional — CLAUDE.md already reflects the rule)
---
diff --git a/scripts/042626_deploy_migration.sql b/scripts/042626_deploy_migration.sql
new file mode 100644
index 0000000..c24b04f
--- /dev/null
+++ b/scripts/042626_deploy_migration.sql
@@ -0,0 +1,6219 @@
+IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
+BEGIN
+ CREATE TABLE [__EFMigrationsHistory] (
+ [MigrationId] nvarchar(150) NOT NULL,
+ [ProductVersion] nvarchar(32) NOT NULL,
+ CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
+ );
+END;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260316155002_Baseline'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377851Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260316155002_Baseline'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377856Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260316155002_Baseline'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-16T15:49:58.7377858Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260316155002_Baseline'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260316155002_Baseline', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317121938_AddAiContextProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [AiContextProfile] nvarchar(2000) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317121938_AddAiContextProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894690Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317121938_AddAiContextProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894696Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317121938_AddAiContextProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T12:19:34.4894698Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317121938_AddAiContextProfile'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260317121938_AddAiContextProfile', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ DECLARE @var0 sysname;
+ SELECT @var0 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[QuoteItems]') AND [c].[name] = N'Quantity');
+ IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [QuoteItems] DROP CONSTRAINT [' + @var0 + '];');
+ ALTER TABLE [QuoteItems] ALTER COLUMN [Quantity] decimal(18,2) NOT NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ DECLARE @var1 sysname;
+ SELECT @var1 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[JobItems]') AND [c].[name] = N'Quantity');
+ IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [JobItems] DROP CONSTRAINT [' + @var1 + '];');
+ ALTER TABLE [JobItems] ALTER COLUMN [Quantity] decimal(18,2) NOT NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463737Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463746Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-17T20:59:24.2463748Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260317205927_FixLaborItemQuantityDecimal'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260317205927_FixLaborItemQuantityDecimal', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ CREATE TABLE [JobTimeEntries] (
+ [Id] int NOT NULL IDENTITY,
+ [JobId] int NOT NULL,
+ [ShopWorkerId] int NOT NULL,
+ [WorkDate] datetime2 NOT NULL,
+ [HoursWorked] decimal(18,2) NOT NULL,
+ [Stage] nvarchar(max) NULL,
+ [Notes] nvarchar(max) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_JobTimeEntries] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_JobTimeEntries_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId] FOREIGN KEY ([ShopWorkerId]) REFERENCES [ShopWorkers] ([Id]) ON DELETE CASCADE
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462691Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462697Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T12:48:44.7462699Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ CREATE INDEX [IX_JobTimeEntries_JobId] ON [JobTimeEntries] ([JobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ CREATE INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries] ([ShopWorkerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318124847_AddJobTimeEntries'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260318124847_AddJobTimeEntries', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [ShopAccessCode] uniqueidentifier NOT NULL DEFAULT (NEWID());
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203832Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203838Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:14:57.2203839Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_Jobs_ShopAccessCode] ON [Jobs] ([ShopAccessCode]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318131500_AddJobShopAccessCode'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260318131500_AddJobShopAccessCode', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ CREATE TABLE [ShopWorkerRoleCosts] (
+ [Id] int NOT NULL IDENTITY,
+ [Role] int NOT NULL,
+ [HourlyRate] decimal(18,2) NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_ShopWorkerRoleCosts] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854802Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854849Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:28:54.6854851Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_ShopWorkerRoleCosts_CompanyId_Role] ON [ShopWorkerRoleCosts] ([CompanyId], [Role]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318132857_AddShopWorkerRoleCosts'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260318132857_AddShopWorkerRoleCosts', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IsReworkJob] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [OriginalJobId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ CREATE TABLE [ReworkRecords] (
+ [Id] int NOT NULL IDENTITY,
+ [JobId] int NOT NULL,
+ [JobItemId] int NULL,
+ [ReworkJobId] int NULL,
+ [ReworkType] int NOT NULL,
+ [Reason] int NOT NULL,
+ [DefectDescription] nvarchar(max) NOT NULL,
+ [DiscoveredBy] int NOT NULL,
+ [DiscoveredDate] datetime2 NOT NULL,
+ [ReportedByName] nvarchar(max) NULL,
+ [EstimatedReworkCost] decimal(18,2) NOT NULL,
+ [ActualReworkCost] decimal(18,2) NOT NULL,
+ [IsBillableToCustomer] bit NOT NULL,
+ [BillingNotes] nvarchar(max) NULL,
+ [Status] int NOT NULL,
+ [Resolution] int NULL,
+ [ResolvedDate] datetime2 NULL,
+ [ResolutionNotes] nvarchar(max) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_ReworkRecords] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_ReworkRecords_JobItems_JobItemId] FOREIGN KEY ([JobItemId]) REFERENCES [JobItems] ([Id]),
+ CONSTRAINT [FK_ReworkRecords_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]) ON DELETE NO ACTION,
+ CONSTRAINT [FK_ReworkRecords_Jobs_ReworkJobId] FOREIGN KEY ([ReworkJobId]) REFERENCES [Jobs] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9092998Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9093003Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T13:42:32.9093005Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ CREATE INDEX [IX_Jobs_OriginalJobId] ON [Jobs] ([OriginalJobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ CREATE INDEX [IX_ReworkRecords_JobId] ON [ReworkRecords] ([JobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ CREATE INDEX [IX_ReworkRecords_JobItemId] ON [ReworkRecords] ([JobItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ CREATE INDEX [IX_ReworkRecords_ReworkJobId] ON [ReworkRecords] ([ReworkJobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD CONSTRAINT [FK_Jobs_Jobs_OriginalJobId] FOREIGN KEY ([OriginalJobId]) REFERENCES [Jobs] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318134236_AddReworkTracking'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260318134236_AddReworkTracking', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [CreditApplied] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ ALTER TABLE [Customers] ADD [CreditBalance] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE TABLE [CreditMemos] (
+ [Id] int NOT NULL IDENTITY,
+ [MemoNumber] nvarchar(450) NOT NULL,
+ [CustomerId] int NOT NULL,
+ [OriginalInvoiceId] int NULL,
+ [ReworkRecordId] int NULL,
+ [Amount] decimal(18,2) NOT NULL,
+ [AmountApplied] decimal(18,2) NOT NULL,
+ [IssueDate] datetime2 NOT NULL,
+ [ExpiryDate] datetime2 NULL,
+ [Reason] nvarchar(max) NOT NULL,
+ [Notes] nvarchar(max) NULL,
+ [Status] int NOT NULL,
+ [IssuedById] nvarchar(450) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_CreditMemos] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_CreditMemos_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_CreditMemos_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE NO ACTION,
+ CONSTRAINT [FK_CreditMemos_Invoices_OriginalInvoiceId] FOREIGN KEY ([OriginalInvoiceId]) REFERENCES [Invoices] ([Id]),
+ CONSTRAINT [FK_CreditMemos_ReworkRecords_ReworkRecordId] FOREIGN KEY ([ReworkRecordId]) REFERENCES [ReworkRecords] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE TABLE [Refunds] (
+ [Id] int NOT NULL IDENTITY,
+ [InvoiceId] int NOT NULL,
+ [PaymentId] int NULL,
+ [Amount] decimal(18,2) NOT NULL,
+ [RefundDate] datetime2 NOT NULL,
+ [RefundMethod] int NOT NULL,
+ [Reason] nvarchar(max) NOT NULL,
+ [Reference] nvarchar(max) NULL,
+ [Notes] nvarchar(max) NULL,
+ [Status] int NOT NULL,
+ [IssuedDate] datetime2 NULL,
+ [IssuedById] nvarchar(450) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_Refunds] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_Refunds_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_Refunds_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE NO ACTION,
+ CONSTRAINT [FK_Refunds_Payments_PaymentId] FOREIGN KEY ([PaymentId]) REFERENCES [Payments] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE TABLE [CreditMemoApplications] (
+ [Id] int NOT NULL IDENTITY,
+ [CreditMemoId] int NOT NULL,
+ [InvoiceId] int NOT NULL,
+ [AmountApplied] decimal(18,2) NOT NULL,
+ [AppliedDate] datetime2 NOT NULL,
+ [AppliedById] nvarchar(450) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_CreditMemoApplications] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_CreditMemoApplications_AspNetUsers_AppliedById] FOREIGN KEY ([AppliedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_CreditMemoApplications_CreditMemos_CreditMemoId] FOREIGN KEY ([CreditMemoId]) REFERENCES [CreditMemos] ([Id]) ON DELETE NO ACTION,
+ CONSTRAINT [FK_CreditMemoApplications_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE NO ACTION
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349567Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349573Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-18T22:26:44.9349575Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemoApplications_AppliedById] ON [CreditMemoApplications] ([AppliedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemoApplications_CreditMemoId] ON [CreditMemoApplications] ([CreditMemoId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemoApplications_InvoiceId] ON [CreditMemoApplications] ([InvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_CreditMemos_CompanyId_MemoNumber] ON [CreditMemos] ([CompanyId], [MemoNumber]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemos_CustomerId] ON [CreditMemos] ([CustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemos_IssuedById] ON [CreditMemos] ([IssuedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemos_OriginalInvoiceId] ON [CreditMemos] ([OriginalInvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_CreditMemos_ReworkRecordId] ON [CreditMemos] ([ReworkRecordId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_Refunds_InvoiceId] ON [Refunds] ([InvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_Refunds_IssuedById] ON [Refunds] ([IssuedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ CREATE INDEX [IX_Refunds_PaymentId] ON [Refunds] ([PaymentId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260318222648_AddRefundsAndCreditMemos'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260318222648_AddRefundsAndCreditMemos', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE TABLE [JobTemplates] (
+ [Id] int NOT NULL IDENTITY,
+ [Name] nvarchar(max) NOT NULL,
+ [Description] nvarchar(max) NULL,
+ [CustomerId] int NULL,
+ [SpecialInstructions] nvarchar(max) NULL,
+ [IsActive] bit NOT NULL,
+ [UsageCount] int NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_JobTemplates] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_JobTemplates_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE TABLE [JobTemplateItems] (
+ [Id] int NOT NULL IDENTITY,
+ [JobTemplateId] int NOT NULL,
+ [Description] nvarchar(max) NOT NULL,
+ [Quantity] decimal(18,2) NOT NULL,
+ [SurfaceAreaSqFt] decimal(18,2) NOT NULL,
+ [CatalogItemId] int NULL,
+ [IsGenericItem] bit NOT NULL,
+ [IsLaborItem] bit NOT NULL,
+ [ManualUnitPrice] decimal(18,2) NULL,
+ [RequiresSandblasting] bit NOT NULL,
+ [RequiresMasking] bit NOT NULL,
+ [IncludePrepCost] bit NOT NULL,
+ [EstimatedMinutes] int NOT NULL,
+ [Complexity] nvarchar(max) NULL,
+ [Notes] nvarchar(max) NULL,
+ [DisplayOrder] int NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_JobTemplateItems] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_JobTemplateItems_CatalogItems_CatalogItemId] FOREIGN KEY ([CatalogItemId]) REFERENCES [CatalogItems] ([Id]),
+ CONSTRAINT [FK_JobTemplateItems_JobTemplates_JobTemplateId] FOREIGN KEY ([JobTemplateId]) REFERENCES [JobTemplates] ([Id]) ON DELETE CASCADE
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE TABLE [JobTemplateItemCoats] (
+ [Id] int NOT NULL IDENTITY,
+ [JobTemplateItemId] int NOT NULL,
+ [CoatName] nvarchar(max) NOT NULL,
+ [Sequence] int NOT NULL,
+ [InventoryItemId] int NULL,
+ [ColorName] nvarchar(max) NULL,
+ [VendorId] int NULL,
+ [ColorCode] nvarchar(max) NULL,
+ [Finish] nvarchar(max) NULL,
+ [CoverageSqFtPerLb] decimal(18,2) NOT NULL,
+ [TransferEfficiency] decimal(18,2) NOT NULL,
+ [PowderCostPerLb] decimal(18,2) NULL,
+ [Notes] nvarchar(max) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_JobTemplateItemCoats] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_JobTemplateItemCoats_InventoryItems_InventoryItemId] FOREIGN KEY ([InventoryItemId]) REFERENCES [InventoryItems] ([Id]),
+ CONSTRAINT [FK_JobTemplateItemCoats_JobTemplateItems_JobTemplateItemId] FOREIGN KEY ([JobTemplateItemId]) REFERENCES [JobTemplateItems] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_JobTemplateItemCoats_Vendors_VendorId] FOREIGN KEY ([VendorId]) REFERENCES [Vendors] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE TABLE [JobTemplateItemPrepServices] (
+ [Id] int NOT NULL IDENTITY,
+ [JobTemplateItemId] int NOT NULL,
+ [PrepServiceId] int NOT NULL,
+ [EstimatedMinutes] int NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_JobTemplateItemPrepServices] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_JobTemplateItemPrepServices_JobTemplateItems_JobTemplateItemId] FOREIGN KEY ([JobTemplateItemId]) REFERENCES [JobTemplateItems] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_JobTemplateItemPrepServices_PrepServices_PrepServiceId] FOREIGN KEY ([PrepServiceId]) REFERENCES [PrepServices] ([Id]) ON DELETE CASCADE
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195291Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195296Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T02:38:23.4195298Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItemCoats_InventoryItemId] ON [JobTemplateItemCoats] ([InventoryItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItemCoats_JobTemplateItemId] ON [JobTemplateItemCoats] ([JobTemplateItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItemCoats_VendorId] ON [JobTemplateItemCoats] ([VendorId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItemPrepServices_JobTemplateItemId] ON [JobTemplateItemPrepServices] ([JobTemplateItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItemPrepServices_PrepServiceId] ON [JobTemplateItemPrepServices] ([PrepServiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItems_CatalogItemId] ON [JobTemplateItems] ([CatalogItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplateItems_JobTemplateId] ON [JobTemplateItems] ([JobTemplateId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ CREATE INDEX [IX_JobTemplates_CustomerId] ON [JobTemplates] ([CustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319023827_AddJobTemplates'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260319023827_AddJobTemplates', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [GiftCertificateRedeemed] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE TABLE [GiftCertificates] (
+ [Id] int NOT NULL IDENTITY,
+ [CertificateCode] nvarchar(max) NOT NULL,
+ [OriginalAmount] decimal(18,2) NOT NULL,
+ [RedeemedAmount] decimal(18,2) NOT NULL,
+ [RecipientCustomerId] int NULL,
+ [RecipientName] nvarchar(max) NULL,
+ [RecipientEmail] nvarchar(max) NULL,
+ [IssuedReason] int NOT NULL,
+ [PurchasePrice] decimal(18,2) NULL,
+ [PurchasingCustomerId] int NULL,
+ [Status] int NOT NULL,
+ [IssueDate] datetime2 NOT NULL,
+ [ExpiryDate] datetime2 NULL,
+ [Notes] nvarchar(max) NULL,
+ [IssuedById] nvarchar(450) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_GiftCertificates] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_GiftCertificates_AspNetUsers_IssuedById] FOREIGN KEY ([IssuedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_GiftCertificates_Customers_PurchasingCustomerId] FOREIGN KEY ([PurchasingCustomerId]) REFERENCES [Customers] ([Id]),
+ CONSTRAINT [FK_GiftCertificates_Customers_RecipientCustomerId] FOREIGN KEY ([RecipientCustomerId]) REFERENCES [Customers] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE TABLE [GiftCertificateRedemptions] (
+ [Id] int NOT NULL IDENTITY,
+ [GiftCertificateId] int NOT NULL,
+ [InvoiceId] int NOT NULL,
+ [AmountRedeemed] decimal(18,2) NOT NULL,
+ [RedeemedDate] datetime2 NOT NULL,
+ [RedeemedById] nvarchar(450) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_GiftCertificateRedemptions] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_GiftCertificateRedemptions_AspNetUsers_RedeemedById] FOREIGN KEY ([RedeemedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_GiftCertificateRedemptions_GiftCertificates_GiftCertificateId] FOREIGN KEY ([GiftCertificateId]) REFERENCES [GiftCertificates] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_GiftCertificateRedemptions_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE CASCADE
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454465Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454472Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-19T15:45:03.1454474Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificateRedemptions_GiftCertificateId] ON [GiftCertificateRedemptions] ([GiftCertificateId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificateRedemptions_InvoiceId] ON [GiftCertificateRedemptions] ([InvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificateRedemptions_RedeemedById] ON [GiftCertificateRedemptions] ([RedeemedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificates_IssuedById] ON [GiftCertificates] ([IssuedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificates_PurchasingCustomerId] ON [GiftCertificates] ([PurchasingCustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ CREATE INDEX [IX_GiftCertificates_RecipientCustomerId] ON [GiftCertificates] ([RecipientCustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260319154506_AddGiftCertificates'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260319154506_AddGiftCertificates', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ ALTER TABLE [Refunds] ADD [CreditMemoId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611509Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611518Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:24:47.3611521Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ CREATE INDEX [IX_Refunds_CreditMemoId] ON [Refunds] ([CreditMemoId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ ALTER TABLE [Refunds] ADD CONSTRAINT [FK_Refunds_CreditMemos_CreditMemoId] FOREIGN KEY ([CreditMemoId]) REFERENCES [CreditMemos] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320002450_AddRefundStoreCreditLink'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260320002450_AddRefundStoreCreditLink', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [IsAiItem] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423766Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423772Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T00:51:03.2423774Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320005106_AddQuoteItemIsAiItem'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260320005106_AddQuoteItemIsAiItem', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [ItemsSubtotal] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [OvenBatchCost] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [OverheadAmount] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [OverheadPercent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [ProfitMargin] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [ProfitPercent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [ShopSuppliesAmount] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [ShopSuppliesPercent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468159Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468166Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T01:10:54.1468176Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320011057_AddQuotePricingSnapshot'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260320011057_AddQuotePricingSnapshot', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowOnlinePayments] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [DepositPercent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [RequiresDeposit] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [OnlineAmountPaid] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [OnlinePaymentStatus] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [OnlineSurchargeCollected] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [PaymentLinkExpiresAt] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [PaymentLinkToken] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [StripePaymentIntentId] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [OnlinePaymentSurchargeType] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [OnlinePaymentSurchargeValue] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [OnlineSurchargeAcknowledged] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [StripeAccountId] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [StripeConnectStatus] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886302Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886308Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-20T23:15:05.6886310Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260320231509_AddStripeConnectAndOnlinePayments'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260320231509_AddStripeConnectAndOnlinePayments', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [MaxQuotePhotos] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [MaxQuotePhotosOverride] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353265Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353273Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-26T23:04:35.1353275Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260326230438_AddQuotePhotoSubscriptionLimits'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260326230438_AddQuotePhotoSubscriptionLimits', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548411Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548419Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-28T13:36:24.1548421Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260328133627_AddJobPhotoIsAiAnalysisPhoto'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260328133627_AddJobPhotoIsAiAnalysisPhoto', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [DiscountReason] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [DiscountType] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [DiscountValue] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IsRushJob] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ ALTER TABLE [JobPhotos] ADD [IsAiAnalysisPhoto] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368710Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368717Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:32:56.7368718Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329003300_AddJobDiscountRushFields'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260329003300_AddJobDiscountRushFields', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE TABLE [Deposits] (
+ [Id] int NOT NULL IDENTITY,
+ [ReceiptNumber] nvarchar(max) NOT NULL,
+ [CustomerId] int NOT NULL,
+ [JobId] int NULL,
+ [QuoteId] int NULL,
+ [Amount] decimal(18,2) NOT NULL,
+ [PaymentMethod] int NOT NULL,
+ [ReceivedDate] datetime2 NOT NULL,
+ [Reference] nvarchar(max) NULL,
+ [Notes] nvarchar(max) NULL,
+ [RecordedById] nvarchar(450) NULL,
+ [AppliedToInvoiceId] int NULL,
+ [AppliedDate] datetime2 NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_Deposits] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_Deposits_AspNetUsers_RecordedById] FOREIGN KEY ([RecordedById]) REFERENCES [AspNetUsers] ([Id]),
+ CONSTRAINT [FK_Deposits_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE,
+ CONSTRAINT [FK_Deposits_Invoices_AppliedToInvoiceId] FOREIGN KEY ([AppliedToInvoiceId]) REFERENCES [Invoices] ([Id]) ON DELETE SET NULL,
+ CONSTRAINT [FK_Deposits_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]),
+ CONSTRAINT [FK_Deposits_Quotes_QuoteId] FOREIGN KEY ([QuoteId]) REFERENCES [Quotes] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576949Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576955Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T00:58:35.7576957Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE INDEX [IX_Deposits_AppliedToInvoiceId] ON [Deposits] ([AppliedToInvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE INDEX [IX_Deposits_CustomerId] ON [Deposits] ([CustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE INDEX [IX_Deposits_JobId] ON [Deposits] ([JobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE INDEX [IX_Deposits_QuoteId] ON [Deposits] ([QuoteId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ CREATE INDEX [IX_Deposits_RecordedById] ON [Deposits] ([RecordedById]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329005838_AddDeposits'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260329005838_AddDeposits', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ DROP INDEX [IX_Invoices_CompanyId_JobId] ON [Invoices];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ DROP INDEX [IX_Invoices_JobId] ON [Invoices];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ DECLARE @var2 sysname;
+ SELECT @var2 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Invoices]') AND [c].[name] = N'JobId');
+ IF @var2 IS NOT NULL EXEC(N'ALTER TABLE [Invoices] DROP CONSTRAINT [' + @var2 + '];');
+ ALTER TABLE [Invoices] ALTER COLUMN [JobId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [CatalogItemId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [CatalogItems] ADD [InventoryItemId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [CatalogItems] ADD [IsMerchandise] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [CatalogCategories] ADD [IsMerchandise] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176542Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176549Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T13:47:49.4176551Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ EXEC(N'CREATE UNIQUE INDEX [IX_Invoices_CompanyId_JobId] ON [Invoices] ([CompanyId], [JobId]) WHERE [JobId] IS NOT NULL');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ EXEC(N'CREATE UNIQUE INDEX [IX_Invoices_JobId] ON [Invoices] ([JobId]) WHERE [JobId] IS NOT NULL');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ CREATE INDEX [IX_InvoiceItems_CatalogItemId] ON [InvoiceItems] ([CatalogItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ CREATE INDEX [IX_CatalogItems_InventoryItemId] ON [CatalogItems] ([InventoryItemId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [CatalogItems] ADD CONSTRAINT [FK_CatalogItems_InventoryItems_InventoryItemId] FOREIGN KEY ([InventoryItemId]) REFERENCES [InventoryItems] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD CONSTRAINT [FK_InvoiceItems_CatalogItems_CatalogItemId] FOREIGN KEY ([CatalogItemId]) REFERENCES [CatalogItems] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329134753_AddMerchandise'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260329134753_AddMerchandise', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [GcExpiryDate] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [GcRecipientEmail] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [GcRecipientName] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [GeneratedGiftCertificateId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD [IsGiftCertificate] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [GiftCertificates] ADD [SourceInvoiceItemId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305437Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305443Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-29T14:11:34.2305445Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ CREATE INDEX [IX_InvoiceItems_GeneratedGiftCertificateId] ON [InvoiceItems] ([GeneratedGiftCertificateId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ ALTER TABLE [InvoiceItems] ADD CONSTRAINT [FK_InvoiceItems_GiftCertificates_GeneratedGiftCertificateId] FOREIGN KEY ([GeneratedGiftCertificateId]) REFERENCES [GiftCertificates] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260329141137_AddGiftCertificateInvoiceItems'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260329141137_AddGiftCertificateInvoiceItems', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [IsSalesItem] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [Sku] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ ALTER TABLE [JobItems] ADD [IsSalesItem] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ ALTER TABLE [JobItems] ADD [Sku] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483162Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483168Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-03-30T23:40:30.1483170Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260330234034_AddSalesItemFields'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260330234034_AddSalesItemFields', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [DepositAmountPaid] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [DepositPaymentIntentId] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [DepositPaymentLinkExpiresAt] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [DepositPaymentLinkToken] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808248Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808254Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T12:56:27.1808255Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401125630_AddQuoteDepositPaymentFields'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260401125630_AddQuoteDepositPaymentFields', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ DECLARE @var3 sysname;
+ SELECT @var3 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Bills]') AND [c].[name] = N'BalanceDue');
+ IF @var3 IS NOT NULL EXEC(N'ALTER TABLE [Bills] DROP CONSTRAINT [' + @var3 + '];');
+ ALTER TABLE [Bills] DROP COLUMN [BalanceDue];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ DECLARE @var4 sysname;
+ SELECT @var4 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[GiftCertificates]') AND [c].[name] = N'CertificateCode');
+ IF @var4 IS NOT NULL EXEC(N'ALTER TABLE [GiftCertificates] DROP CONSTRAINT [' + @var4 + '];');
+ ALTER TABLE [GiftCertificates] ALTER COLUMN [CertificateCode] nvarchar(450) NOT NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121883Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121891Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T13:17:21.8121893Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_GiftCertificates_CertificateCode] ON [GiftCertificates] ([CertificateCode]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401131724_AddUniqueDocumentNumberConstraints'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260401131724_AddUniqueDocumentNumberConstraints', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ DROP INDEX [IX_GiftCertificates_CertificateCode] ON [GiftCertificates];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887180Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887185Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-01T14:16:49.2887186Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_GiftCertificates_CompanyId_CertificateCode] ON [GiftCertificates] ([CompanyId], [CertificateCode]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260401141653_FixGiftCertificateUniqueIndexPerCompany'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260401141653_FixGiftCertificateUniqueIndexPerCompany', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ ALTER TABLE [Invoices] ADD [ExternalReference] nvarchar(450) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649199Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649205Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T01:54:18.8649206Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ CREATE INDEX [IX_Invoices_CompanyId_ExternalReference] ON [Invoices] ([CompanyId], [ExternalReference]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402015422_AddInvoiceExternalReference'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402015422_AddInvoiceExternalReference', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [MigratingFromQuickBooks] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005398Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005405Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T03:21:53.0005406Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402032156_AddMigratingFromQuickBooks'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402032156_AddMigratingFromQuickBooks', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [QbMigrationStateJson] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0246999Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0247004Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T16:57:55.0247006Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402165758_AddQbMigrationStateJson'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402165758_AddQbMigrationStateJson', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ DROP INDEX [IX_InventoryItems_SKU] ON [InventoryItems];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788284Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788291Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:47:18.8788292Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_InventoryItems_CompanyId_SKU] ON [InventoryItems] ([CompanyId], [SKU]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402184721_FixInventorySkuUniqueIndex'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402184721_FixInventorySkuUniqueIndex', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ DROP INDEX [IX_Jobs_ShopAccessCode] ON [Jobs];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857008Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857015Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T18:52:13.7857016Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_Jobs_CompanyId_ShopAccessCode] ON [Jobs] ([CompanyId], [ShopAccessCode]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402185216_FixJobShopAccessCodeUniqueIndex'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402185216_FixJobShopAccessCodeUniqueIndex', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402224949_AddDashboardTips'
+)
+BEGIN
+ CREATE TABLE [DashboardTips] (
+ [Id] int NOT NULL IDENTITY,
+ [TipText] nvarchar(max) NOT NULL,
+ [IsActive] bit NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ CONSTRAINT [PK_DashboardTips] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402224949_AddDashboardTips'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354841Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402224949_AddDashboardTips'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354847Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402224949_AddDashboardTips'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-02T22:49:46.0354849Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260402224949_AddDashboardTips'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260402224949_AddDashboardTips', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents'
+)
+BEGIN
+ CREATE TABLE [StripeWebhookEvents] (
+ [Id] bigint NOT NULL IDENTITY,
+ [EventId] nvarchar(max) NOT NULL,
+ [EventType] nvarchar(max) NOT NULL,
+ [CompanyId] int NULL,
+ [RawJson] nvarchar(max) NOT NULL,
+ [Status] int NOT NULL,
+ [ErrorMessage] nvarchar(max) NULL,
+ [ReceivedAt] datetime2 NOT NULL,
+ [ProcessedAt] datetime2 NULL,
+ CONSTRAINT [PK_StripeWebhookEvents] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783905Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783912Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-03T00:06:46.7783913Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260403000650_AddStripeWebhookEvents'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260403000650_AddStripeWebhookEvents', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAccounting] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541952Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541958Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T15:16:32.2541968Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404151636_AddAllowAccountingToPlan'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260404151636_AddAllowAccountingToPlan', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath'
+)
+BEGIN
+ ALTER TABLE [Bills] ADD [ReceiptFilePath] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540290Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540296Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-04T19:41:22.8540297Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260404194126_AddBillReceiptFilePath'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260404194126_AddBillReceiptFilePath', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862744Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862750Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T00:33:47.2862752Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_InventoryTransactions_TransactionType_TransactionDate] ON [InventoryTransactions] ([TransactionType], [TransactionDate]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_InventoryItems_CompanyId_IsActive] ON [InventoryItems] ([CompanyId], [IsActive]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_InventoryItems_IsActive] ON [InventoryItems] ([IsActive]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_Bills_CompanyId_Status] ON [Bills] ([CompanyId], [Status]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_Bills_DueDate] ON [Bills] ([DueDate]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_Bills_Status] ON [Bills] ([Status]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ CREATE INDEX [IX_Appointments_ScheduledStartTime] ON [Appointments] ([ScheduledStartTime]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405003350_AddPerformanceIndexes'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260405003350_AddPerformanceIndexes', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ CREATE TABLE [PlatformSettings] (
+ [Id] int NOT NULL IDENTITY,
+ [Key] nvarchar(200) NOT NULL,
+ [Value] nvarchar(max) NULL,
+ [Label] nvarchar(max) NULL,
+ [Description] nvarchar(max) NULL,
+ [GroupName] nvarchar(max) NULL,
+ [UpdatedAt] datetime2 NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_PlatformSettings] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_PlatformSettings_Key] ON [PlatformSettings] ([Key]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] ON;
+ EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName])
+ VALUES (1, N''AdminNotificationEmail'', NULL, N''Admin Notification Email'', N''Email address that receives platform event notifications (new signups, bug reports, subscription events). Leave blank to disable.'', N''Notifications'')');
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] OFF;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180443Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180449Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T15:56:49.8180450Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405155653_AddPlatformSettings'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260405155653_AddPlatformSettings', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ DECLARE @var5 sysname;
+ SELECT @var5 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[PlatformSettings]') AND [c].[name] = N'Key');
+ IF @var5 IS NOT NULL EXEC(N'ALTER TABLE [PlatformSettings] DROP CONSTRAINT [' + @var5 + '];');
+ ALTER TABLE [PlatformSettings] ALTER COLUMN [Key] nvarchar(200) NOT NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] ON;
+ EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName])
+ VALUES (2, N''BaseUrl'', NULL, N''Base URL'', N''Public URL of this application (e.g. https://app.powdercoatinglogix.com). Used in email links. Falls back to the current request URL if blank.'', N''General''),
+ (3, N''TrialPeriodDays'', N''7'', N''Trial Period (days)'', N''Number of days a new company gets on the free trial before their subscription expires.'', N''Subscriptions''),
+ (4, N''QuoteApprovalTokenDays'', N''30'', N''Quote Approval Token Validity (days)'', N''How many days a customer quote-approval link remains valid before expiring.'', N''Quotes''),
+ (5, N''AuditLogRetentionDays'', N''365'', N''Audit Log Retention (days)'', N''Audit log entries older than this many days are automatically purged by the nightly job.'', N''Data Retention''),
+ (6, N''StripeWebhookRetentionDays'', N''90'', N''Stripe Webhook Retention (days)'', N''Processed Stripe webhook events older than this many days are automatically purged.'', N''Data Retention'')');
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] OFF;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900904Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900913Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:12:38.5900914Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405161241_AddPlatformSettingsV2'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260405161241_AddPlatformSettingsV2', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription'
+)
+BEGIN
+ EXEC(N'UPDATE [PlatformSettings] SET [Description] = N''Email address(es) that receive platform event notifications (new signups, bug reports, subscription events). Separate multiple addresses with commas. Leave blank to disable.''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700837Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700844Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-05T16:21:34.4700846Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260405162137_UpdateAdminEmailDescription'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260405162137_UpdateAdminEmailDescription', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable'
+)
+BEGIN
+ DECLARE @var6 sysname;
+ SELECT @var6 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[BillLineItems]') AND [c].[name] = N'AccountId');
+ IF @var6 IS NOT NULL EXEC(N'ALTER TABLE [BillLineItems] DROP CONSTRAINT [' + @var6 + '];');
+ ALTER TABLE [BillLineItems] ALTER COLUMN [AccountId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157942Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157953Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-06T19:14:56.7157955Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260406191501_MakeBillLineItemAccountIdNullable'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260406191501_MakeBillLineItemAccountIdNullable', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IntakeCheckedByUserId] nvarchar(450) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IntakeConditionNotes] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IntakeDate] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD [IntakePartCount] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947842Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947847Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-08T20:53:42.2947849Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ CREATE INDEX [IX_Jobs_IntakeCheckedByUserId] ON [Jobs] ([IntakeCheckedByUserId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ ALTER TABLE [Jobs] ADD CONSTRAINT [FK_Jobs_AspNetUsers_IntakeCheckedByUserId] FOREIGN KEY ([IntakeCheckedByUserId]) REFERENCES [AspNetUsers] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260408205345_AddJobIntakeFields'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260408205345_AddJobIntakeFields', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ CREATE TABLE [InAppNotifications] (
+ [Id] int NOT NULL IDENTITY,
+ [Title] nvarchar(max) NOT NULL,
+ [Message] nvarchar(max) NOT NULL,
+ [Link] nvarchar(max) NULL,
+ [NotificationType] nvarchar(max) NOT NULL,
+ [IsRead] bit NOT NULL,
+ [ReadAt] datetime2 NULL,
+ [QuoteId] int NULL,
+ [InvoiceId] int NULL,
+ [CustomerId] int NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_InAppNotifications] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_InAppNotifications_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]),
+ CONSTRAINT [FK_InAppNotifications_Invoices_InvoiceId] FOREIGN KEY ([InvoiceId]) REFERENCES [Invoices] ([Id]),
+ CONSTRAINT [FK_InAppNotifications_Quotes_QuoteId] FOREIGN KEY ([QuoteId]) REFERENCES [Quotes] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630787Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630794Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-09T01:38:18.3630795Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ CREATE INDEX [IX_InAppNotifications_CustomerId] ON [InAppNotifications] ([CustomerId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ CREATE INDEX [IX_InAppNotifications_InvoiceId] ON [InAppNotifications] ([InvoiceId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ CREATE INDEX [IX_InAppNotifications_QuoteId] ON [InAppNotifications] ([QuoteId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260409013822_AddInAppNotifications'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260409013822_AddInAppNotifications', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [MarketingEmailOptOut] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [MarketingUnsubscribeToken] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+
+ UPDATE Companies
+ SET MarketingUnsubscribeToken = LOWER(REPLACE(NEWID(), '-', ''))
+ WHERE MarketingUnsubscribeToken IS NULL
+
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ DECLARE @var7 sysname;
+ SELECT @var7 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Companies]') AND [c].[name] = N'MarketingUnsubscribeToken');
+ IF @var7 IS NOT NULL EXEC(N'ALTER TABLE [Companies] DROP CONSTRAINT [' + @var7 + '];');
+ ALTER TABLE [Companies] ALTER COLUMN [MarketingUnsubscribeToken] nvarchar(max) NOT NULL;
+ ALTER TABLE [Companies] ADD DEFAULT N'' FOR [MarketingUnsubscribeToken];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ CREATE TABLE [TermsAcceptances] (
+ [Id] int NOT NULL IDENTITY,
+ [UserId] nvarchar(max) NOT NULL,
+ [CompanyId] int NOT NULL,
+ [TosVersion] nvarchar(max) NOT NULL,
+ [AcceptedAt] datetime2 NOT NULL,
+ [IpAddress] nvarchar(max) NULL,
+ [UserAgent] nvarchar(max) NULL,
+ CONSTRAINT [PK_TermsAcceptances] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105127Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105133Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:19:30.4105135Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410021934_AddLegalCompliance'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260410021934_AddLegalCompliance', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiInventoryAssist] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiPhotoQuotes] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828243Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828250Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T02:53:49.5828252Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410025353_AddAiFeaturesToPlanConfig'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260410025353_AddAiFeaturesToPlanConfig', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting'
+)
+BEGIN
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] ON;
+ EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName])
+ VALUES (7, N''TrialsEnabled'', N''true'', N''Free Trials Enabled'', N''When true (default), new signups start with a free trial period. When false, a credit card is required at signup — registrants are sent through Stripe Checkout before their account is created.'', N''Subscriptions'')');
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] OFF;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587184Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587190Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-10T03:20:23.1587191Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260410032027_AddTrialsEnabledSetting'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260410032027_AddTrialsEnabledSetting', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting'
+)
+BEGIN
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] ON;
+ EXEC(N'INSERT INTO [PlatformSettings] ([Id], [Key], [Value], [Label], [Description], [GroupName])
+ VALUES (8, N''MaxTenants'', N'''', N''Max Tenants'', N''Maximum number of tenant companies allowed to self-register. Leave blank or 0 for unlimited. Once the limit is reached, the signup page and login signup link are hidden.'', N''Access Control'')');
+ IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Key', N'Value', N'Label', N'Description', N'GroupName') AND [object_id] = OBJECT_ID(N'[PlatformSettings]'))
+ SET IDENTITY_INSERT [PlatformSettings] OFF;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071531Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071537Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T00:52:25.3071538Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412005228_AddMaxTenantsSetting'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260412005228_AddMaxTenantsSetting', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [AccountingOverride] bit NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [OnlinePaymentsOverride] bit NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142151Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142159Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T17:41:53.0142161Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412174157_AddCompanyFeatureOverrides'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260412174157_AddCompanyFeatureOverrides', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [IsAnnualBilling] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093047Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093054Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-12T18:34:08.9093055Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260412183411_AddIsAnnualBilling'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260412183411_AddIsAnnualBilling', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession'
+)
+BEGIN
+ CREATE TABLE [PendingRegistrationSessions] (
+ [Id] int NOT NULL IDENTITY,
+ [Token] nvarchar(max) NOT NULL,
+ [CompanyName] nvarchar(max) NOT NULL,
+ [CompanyPhone] nvarchar(max) NULL,
+ [FirstName] nvarchar(max) NOT NULL,
+ [LastName] nvarchar(max) NOT NULL,
+ [Email] nvarchar(max) NOT NULL,
+ [Plan] int NOT NULL,
+ [IsAnnual] bit NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [IsCompleted] bit NOT NULL,
+ CONSTRAINT [PK_PendingRegistrationSessions] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916607Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916613Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-14T13:58:07.0916614Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260414135810_AddPendingRegistrationSession'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260414135810_AddPendingRegistrationSession', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedAt] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedByName] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [SetupWizardCompletedByUserId] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083161Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083167Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-15T01:02:00.3083169Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415010203_AddSetupWizardCompletionTracking'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260415010203_AddSetupWizardCompletionTracking', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415120000_AddAuditLogTable'
+)
+BEGIN
+ CREATE TABLE [AuditLogs] (
+ [Id] bigint NOT NULL IDENTITY,
+ [UserId] nvarchar(max) NULL,
+ [UserName] nvarchar(max) NOT NULL,
+ [CompanyId] int NULL,
+ [CompanyName] nvarchar(max) NULL,
+ [Action] nvarchar(max) NOT NULL,
+ [EntityType] nvarchar(450) NOT NULL,
+ [EntityId] nvarchar(450) NULL,
+ [EntityDescription] nvarchar(max) NULL,
+ [OldValues] nvarchar(max) NULL,
+ [NewValues] nvarchar(max) NULL,
+ [IpAddress] nvarchar(max) NULL,
+ [Timestamp] datetime2 NOT NULL,
+ CONSTRAINT [PK_AuditLogs] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415120000_AddAuditLogTable'
+)
+BEGIN
+ CREATE INDEX [IX_AuditLogs_CompanyId_Timestamp] ON [AuditLogs] ([CompanyId], [Timestamp]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415120000_AddAuditLogTable'
+)
+BEGIN
+ CREATE INDEX [IX_AuditLogs_EntityType_EntityId] ON [AuditLogs] ([EntityType], [EntityId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260415120000_AddAuditLogTable'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260415120000_AddAuditLogTable', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ ALTER TABLE [InventoryTransactions] ADD [JobId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510259Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510266Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T04:42:51.1510268Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ CREATE INDEX [IX_InventoryTransactions_JobId] ON [InventoryTransactions] ([JobId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ ALTER TABLE [InventoryTransactions] ADD CONSTRAINT [FK_InventoryTransactions_Jobs_JobId] FOREIGN KEY ([JobId]) REFERENCES [Jobs] ([Id]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417044255_AddInventoryTransactionJobId'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260417044255_AddInventoryTransactionJobId', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ ALTER TABLE [InventoryItems] ADD [HasSamplePanel] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ ALTER TABLE [InventoryItems] ADD [IsCoating] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ ALTER TABLE [InventoryItems] ADD [SamplePanelNotes] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+
+ UPDATE ii
+ SET ii.IsCoating = 1
+ FROM InventoryItems ii
+ INNER JOIN InventoryCategoryLookups icl ON icl.Id = ii.InventoryCategoryId
+ WHERE icl.IsCoating = 1 AND icl.IsDeleted = 0
+
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326769Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326775Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T16:06:41.3326776Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417160645_AddSamplePanel'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260417160645_AddSamplePanel', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn'
+)
+BEGIN
+ DECLARE @var8 sysname;
+ SELECT @var8 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[InventoryItems]') AND [c].[name] = N'IsCoating');
+ IF @var8 IS NOT NULL EXEC(N'ALTER TABLE [InventoryItems] DROP CONSTRAINT [' + @var8 + '];');
+ ALTER TABLE [InventoryItems] DROP COLUMN [IsCoating];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377587Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377592Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-17T17:05:40.0377594Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260417170543_DropItemIsCoatingColumn'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260417170543_DropItemIsCoatingColumn', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ DECLARE @var9 sysname;
+ SELECT @var9 = [d].[name]
+ FROM [sys].[default_constraints] [d]
+ INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
+ WHERE ([d].[parent_object_id] = OBJECT_ID(N'[InventoryItems]') AND [c].[name] = N'SamplePanelNotes');
+ IF @var9 IS NOT NULL EXEC(N'ALTER TABLE [InventoryItems] DROP CONSTRAINT [' + @var9 + '];');
+ ALTER TABLE [InventoryItems] DROP COLUMN [SamplePanelNotes];
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [WoAccentColor] nvarchar(max) NOT NULL DEFAULT N'';
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [WoTerms] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580643Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580650Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-18T22:08:33.1580651Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260418220837_AddWorkOrderTemplate'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260418220837_AddWorkOrderTemplate', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix'
+)
+BEGIN
+ ALTER TABLE [CompanyPreferences] ADD [InvoiceNumberPrefix] nvarchar(max) NOT NULL DEFAULT N'';
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123100Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123106Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T20:18:14.2123107Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419201818_AddInvoiceNumberPrefix'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260419201818_AddInvoiceNumberPrefix', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419215302_AddContactSubmissions'
+)
+BEGIN
+ CREATE TABLE [ContactSubmissions] (
+ [Id] int NOT NULL IDENTITY,
+ [SenderName] nvarchar(max) NOT NULL,
+ [SenderEmail] nvarchar(max) NOT NULL,
+ [CompanyName] nvarchar(max) NOT NULL,
+ [Category] nvarchar(max) NOT NULL,
+ [Subject] nvarchar(max) NOT NULL,
+ [Message] nvarchar(max) NOT NULL,
+ [IsRead] bit NOT NULL,
+ [ReadAt] datetime2 NULL,
+ [ReadByUserId] nvarchar(max) NULL,
+ [ReadByUserName] nvarchar(max) NULL,
+ [AdminNotes] nvarchar(max) NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_ContactSubmissions] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419215302_AddContactSubmissions'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160772Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419215302_AddContactSubmissions'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160778Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419215302_AddContactSubmissions'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-19T21:52:59.4160779Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260419215302_AddContactSubmissions'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260419215302_AddContactSubmissions', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [BlastNozzleSize] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [BlastRateSqFtPerHourOverride] decimal(18,2) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [BlastSetupType] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [CoatingGunType] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [CoatingRateSqFtPerHourOverride] decimal(18,2) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [CompressorCfm] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [PrimaryBlastSubstrate] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [ShopCapabilityTier] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440591Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440597Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-20T23:36:06.0440599Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260420233610_AddShopCapabilityProfile'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260420233610_AddShopCapabilityProfile', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886131Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886138Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T00:33:43.0886139Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421003346_AddCompanyBlastSetups'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260421003346_AddCompanyBlastSetups', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ ALTER TABLE [QuoteItemPrepServices] ADD [BlastSetupId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ ALTER TABLE [PrepServices] ADD [RequiresBlastSetup] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ ALTER TABLE [JobItemPrepServices] ADD [BlastSetupId] int NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ CREATE TABLE [CompanyBlastSetups] (
+ [Id] int NOT NULL IDENTITY,
+ [Name] nvarchar(100) NOT NULL,
+ [SetupType] int NOT NULL,
+ [CompressorCfm] decimal(18,2) NOT NULL,
+ [BlastNozzleSize] int NOT NULL,
+ [PrimarySubstrate] int NOT NULL,
+ [BlastRateSqFtPerHourOverride] decimal(18,2) NULL,
+ [IsDefault] bit NOT NULL,
+ [IsActive] bit NOT NULL,
+ [DisplayOrder] int NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_CompanyBlastSetups] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_CompanyBlastSetups_Companies_CompanyId] FOREIGN KEY ([CompanyId]) REFERENCES [Companies] ([Id]) ON DELETE NO ACTION
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835208Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835215Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T01:24:05.9835220Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ CREATE INDEX [IX_QuoteItemPrepServices_BlastSetupId] ON [QuoteItemPrepServices] ([BlastSetupId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ CREATE INDEX [IX_JobItemPrepServices_BlastSetupId] ON [JobItemPrepServices] ([BlastSetupId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ CREATE INDEX [IX_CompanyBlastSetups_CompanyId] ON [CompanyBlastSetups] ([CompanyId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ ALTER TABLE [JobItemPrepServices] ADD CONSTRAINT [FK_JobItemPrepServices_CompanyBlastSetups_BlastSetupId] FOREIGN KEY ([BlastSetupId]) REFERENCES [CompanyBlastSetups] ([Id]) ON DELETE SET NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ ALTER TABLE [QuoteItemPrepServices] ADD CONSTRAINT [FK_QuoteItemPrepServices_CompanyBlastSetups_BlastSetupId] FOREIGN KEY ([BlastSetupId]) REFERENCES [CompanyBlastSetups] ([Id]) ON DELETE SET NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421012409_AddCompanyBlastSetupsTable'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260421012409_AddCompanyBlastSetupsTable', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [EquipmentCosts] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [LaborCosts] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [MaterialCosts] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982640Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982651Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T12:59:53.4982653Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421125956_AddQuoteCostBreakdownColumns2'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260421125956_AddQuoteCostBreakdownColumns2', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [ItemEquipmentCost] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [ItemLaborCost] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ ALTER TABLE [QuoteItems] ADD [ItemMaterialCost] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983052Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983058Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:21:19.6983060Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421132123_AddQuoteItemCostSnapshot'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260421132123_AddQuoteItemCostSnapshot', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ ALTER TABLE [Quotes] ADD [HideDiscountFromCustomer] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [PricingMode] int NOT NULL DEFAULT 0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [TargetMarginPercent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113639Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113645Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-21T13:59:19.8113646Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260421135923_AddPricingModeAndHideDiscount'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260421135923_AddPricingModeAndHideDiscount', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ ALTER TABLE [AspNetUsers] ADD [IsBanned] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ ALTER TABLE [AspNetUsers] ADD [BannedAt] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ ALTER TABLE [AspNetUsers] ADD [BanReason] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ ALTER TABLE [AspNetUsers] ADD [BannedByUserId] nvarchar(450) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182111Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182118Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:08:40.9182119Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422040844_AddUserBan'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260422040844_AddUserBan', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ CREATE TABLE [BannedIps] (
+ [Id] int NOT NULL IDENTITY,
+ [IpAddress] nvarchar(45) NOT NULL,
+ [Reason] nvarchar(500) NULL,
+ [BannedByUserId] nvarchar(450) NULL,
+ [BannedAt] datetime2 NOT NULL,
+ [ExpiresAt] datetime2 NULL,
+ [IsActive] bit NOT NULL,
+ CONSTRAINT [PK_BannedIps] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ CREATE INDEX [IX_BannedIps_IpAddress_IsActive] ON [BannedIps] ([IpAddress], [IsActive]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627092Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627100Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-22T04:21:26.9627102Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260422042129_AddBannedIps'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260422042129_AddBannedIps', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ CREATE TABLE [AiUsageLogs] (
+ [Id] bigint NOT NULL IDENTITY,
+ [CompanyId] int NOT NULL,
+ [UserId] nvarchar(max) NOT NULL,
+ [Feature] nvarchar(max) NOT NULL,
+ [Success] bit NOT NULL,
+ [InputLength] int NOT NULL,
+ [CalledAt] datetime2 NOT NULL,
+ CONSTRAINT [PK_AiUsageLogs] PRIMARY KEY ([Id]),
+ CONSTRAINT [FK_AiUsageLogs_Companies_CompanyId] FOREIGN KEY ([CompanyId]) REFERENCES [Companies] ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714669Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714674Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:19:33.1714676Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ CREATE INDEX [IX_AiUsageLogs_CompanyId_CalledAt] ON [AiUsageLogs] ([CompanyId], [CalledAt]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423011936_AddAiUsageLog'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260423011936_AddAiUsageLog', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt'
+)
+BEGIN
+ ALTER TABLE [Customers] ADD [SmsOptedOutAt] datetime2 NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815272Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815281Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-23T01:54:43.1815283Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260423015446_AddSmsOptedOutAt'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260423015446_AddSmsOptedOutAt', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyBillableHours] int NOT NULL DEFAULT 160;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyRent] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ ALTER TABLE [CompanyOperatingCosts] ADD [MonthlyUtilities] decimal(18,2) NOT NULL DEFAULT 0.0;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047155Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047162Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-24T23:28:22.1047164Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260424232825_AddFacilityOverheadFields'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260424232825_AddFacilityOverheadFields', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ ALTER TABLE [CatalogItems] ADD [ImagePath] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ ALTER TABLE [CatalogItems] ADD [ThumbnailPath] nvarchar(max) NULL;
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955147Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955155Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T12:32:52.2955156Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425123256_AddCatalogItemImages'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260425123256_AddCatalogItemImages', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ CREATE TABLE [UserPasskeys] (
+ [Id] int NOT NULL IDENTITY,
+ [UserId] nvarchar(max) NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CredentialId] varbinary(900) NOT NULL,
+ [PublicKey] varbinary(max) NOT NULL,
+ [UserHandle] varbinary(max) NOT NULL,
+ [SignCount] bigint NOT NULL,
+ [DeviceFriendlyName] nvarchar(max) NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [LastUsedAt] datetime2 NULL,
+ CONSTRAINT [PK_UserPasskeys] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374555Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374562Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T18:27:08.5374563Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ CREATE UNIQUE INDEX [IX_UserPasskeys_CredentialId] ON [UserPasskeys] ([CredentialId]);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425182712_AddUserPasskeys'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260425182712_AddUserPasskeys', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport'
+)
+BEGIN
+ CREATE TABLE [CatalogPriceCheckReports] (
+ [Id] int NOT NULL IDENTITY,
+ [RunAt] datetime2 NOT NULL,
+ [ItemsChecked] int NOT NULL,
+ [ResultsJson] nvarchar(max) NOT NULL,
+ [OperatingCostsSummary] nvarchar(max) NOT NULL,
+ [CompanyId] int NOT NULL,
+ [CreatedAt] datetime2 NOT NULL,
+ [UpdatedAt] datetime2 NULL,
+ [CreatedBy] nvarchar(max) NULL,
+ [UpdatedBy] nvarchar(max) NULL,
+ [IsDeleted] bit NOT NULL,
+ [DeletedAt] datetime2 NULL,
+ [DeletedBy] nvarchar(max) NULL,
+ CONSTRAINT [PK_CatalogPriceCheckReports] PRIMARY KEY ([Id])
+ );
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016987Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016993Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-25T22:34:50.0016994Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260425223454_AddCatalogPriceCheckReport'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260425223454_AddCatalogPriceCheckReport', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ ALTER TABLE [SubscriptionPlanConfigs] ADD [AllowAiCatalogPriceCheck] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ ALTER TABLE [Companies] ADD [AiCatalogPriceCheckEnabled] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM [PlatformSettings] WHERE [Key] = N'AiCatalogPriceCheckEnabled')
+ BEGIN
+ INSERT INTO [PlatformSettings] ([Key], [Value], [Label], [Description], [GroupName])
+ VALUES (N'AiCatalogPriceCheckEnabled', N'true', N'AI Catalog Price Check Enabled',
+ N'When true (default), the AI Catalog Price Check feature is available to companies on qualifying plans. Set to false to disable it platform-wide.',
+ N'AI Features');
+ END
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275012Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275018Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T12:26:21.7275020Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426122625_AddAiCatalogPriceCheckGating'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260426122625_AddAiCatalogPriceCheckGating', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
+BEGIN TRANSACTION;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed'
+)
+BEGIN
+ ALTER TABLE [AspNetUsers] ADD [PasskeyPromptDismissed] bit NOT NULL DEFAULT CAST(0 AS bit);
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545921Z''
+ WHERE [Id] = 1;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545931Z''
+ WHERE [Id] = 2;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed'
+)
+BEGIN
+ EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-04-26T14:28:21.4545932Z''
+ WHERE [Id] = 3;
+ SELECT @@ROWCOUNT');
+END;
+GO
+
+IF NOT EXISTS (
+ SELECT * FROM [__EFMigrationsHistory]
+ WHERE [MigrationId] = N'20260426142825_AddPasskeyPromptDismissed'
+)
+BEGIN
+ INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
+ VALUES (N'20260426142825_AddPasskeyPromptDismissed', N'8.0.11');
+END;
+GO
+
+COMMIT;
+GO
+
diff --git a/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs b/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs
new file mode 100644
index 0000000..d67328b
--- /dev/null
+++ b/src/PowderCoating.Application/Interfaces/IAiUsageReportService.cs
@@ -0,0 +1,29 @@
+namespace PowderCoating.Application.Interfaces;
+
+/// Pre-aggregated AI call counts for one company across four time windows.
+public record AiCompanyUsage(int CompanyId, int Today, int Last7Days, int Last30Days, int AllTime);
+
+/// Count of AI calls for a specific feature within a company (last 30 days).
+public record AiFeatureStat(int CompanyId, string Feature, int Count);
+
+/// Bundled result returned by .
+public record AiUsageReportData(
+ List UsageByCompany,
+ List FeatureStats,
+ Dictionary PhotoCountsByCompany);
+
+///
+/// Read-only service for the platform AI usage analytics report. Queries AiUsageLogs
+/// and QuotePhotos (cross-tenant, non-BaseEntity) via ApplicationDbContext
+/// directly so that does not need a direct DB context reference.
+/// Implemented in Infrastructure; used as Tier-3 aggregate report service.
+///
+public interface IAiUsageReportService
+{
+ ///
+ /// Returns all the aggregated AI usage data needed to render the platform AI usage report:
+ /// per-company call counts across today / 7-day / 30-day / all-time windows,
+ /// feature stats for the last 30 days, and AI photo upload counts per company.
+ ///
+ Task GetReportDataAsync();
+}
diff --git a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs
index 1a7f18c..21544f6 100644
--- a/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs
+++ b/src/PowderCoating.Application/Interfaces/IOperationalReportService.cs
@@ -1,3 +1,5 @@
+using PowderCoating.Core.Entities;
+
namespace PowderCoating.Application.Interfaces;
///
@@ -22,4 +24,25 @@ public interface IOperationalReportService
/// Returns powder usage (lbs and cost) broken down by color and vendor.
Task GetPowderUsageAsync(int companyId, int months);
+
+ ///
+ /// Returns all active (non-deleted, non-voided) bills with their Vendor and non-deleted
+ /// Payments navigations loaded. Used by Analytics, ExpensesAp, and AI accounting actions
+ /// so those controllers do not need a direct ApplicationDbContext reference.
+ ///
+ Task> GetActiveBillsAsync();
+
+ ///
+ /// Returns all non-deleted direct expenses with their ExpenseAccount navigation loaded.
+ /// Used by Analytics, ExpensesAp, and AI accounting actions so those controllers do not
+ /// need a direct ApplicationDbContext reference.
+ ///
+ Task> GetAllExpensesAsync();
+
+ ///
+ /// Returns the full job status history log with FromStatus and ToStatus navigations
+ /// loaded. Used by Analytics and JobCycleTime so those actions do not need a direct
+ /// ApplicationDbContext reference.
+ ///
+ Task> GetAllJobStatusHistoryAsync();
}
diff --git a/src/PowderCoating.Core/Interfaces/IPlainRepository.cs b/src/PowderCoating.Core/Interfaces/IPlainRepository.cs
new file mode 100644
index 0000000..9e402a1
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/IPlainRepository.cs
@@ -0,0 +1,29 @@
+using System.Linq.Expressions;
+
+namespace PowderCoating.Core.Interfaces;
+
+///
+/// Lightweight repository interface for platform-level entities that do not inherit
+/// (e.g. Announcement, BannedIp,
+/// DashboardTip, ReleaseNote). These entities have no CompanyId, no IsDeleted, and no
+/// soft-delete semantics — so the full IRepository<T> contract (SoftDeleteAsync,
+/// ignoreQueryFilters) does not apply.
+///
+/// Any EF-mapped class (does not need to inherit BaseEntity).
+public interface IPlainRepository where T : class
+{
+ Task GetByIdAsync(int id);
+ Task> GetAllAsync();
+ Task> FindAsync(Expression> predicate);
+ Task FirstOrDefaultAsync(Expression> predicate);
+ Task AnyAsync(Expression> predicate);
+ Task CountAsync(Expression>? predicate = null);
+
+ Task AddAsync(T entity);
+ Task> AddRangeAsync(IEnumerable entities);
+
+ Task UpdateAsync(T entity);
+
+ Task DeleteAsync(T entity);
+ Task DeleteAsync(int id);
+}
diff --git a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
index 48bc9c9..1e5df77 100644
--- a/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
+++ b/src/PowderCoating.Core/Interfaces/IUnitOfWork.cs
@@ -14,14 +14,14 @@ public interface IUnitOfWork : IDisposable
IRepository AiItemPredictions { get; }
// Powder Insights
- IRepository PowderUsageLogs { get; }
+ IPowderUsageLogRepository PowderUsageLogs { get; }
// Core entities — typed repositories for complex domains
ICustomerRepository Customers { get; }
IJobRepository Jobs { get; }
IRepository JobDailyPriorities { get; }
IRepository JobItems { get; }
- IRepository JobItemCoats { get; }
+ IJobItemCoatRepository JobItemCoats { get; }
IRepository JobItemPrepServices { get; }
IRepository JobChangeHistories { get; }
IRepository JobPrepServices { get; }
@@ -32,13 +32,13 @@ public interface IUnitOfWork : IDisposable
IRepository QuoteItemPrepServices { get; }
IRepository QuoteChangeHistories { get; }
IRepository InventoryItems { get; }
- IRepository InventoryTransactions { get; }
+ IInventoryTransactionRepository InventoryTransactions { get; }
IRepository Equipment { get; }
IRepository OvenCosts { get; }
IRepository BlastSetups { get; }
IRepository MaintenanceRecords { get; }
IRepository Vendors { get; }
- IRepository JobPhotos { get; }
+ IJobPhotoRepository JobPhotos { get; }
IRepository JobNotes { get; }
IRepository CustomerNotes { get; }
IRepository JobStatusHistory { get; }
@@ -97,13 +97,21 @@ public interface IUnitOfWork : IDisposable
IRepository SubscriptionPlanConfigs { get; }
// Job Templates
- IRepository JobTemplates { get; }
+ IJobTemplateRepository JobTemplates { get; }
IRepository JobTemplateItems { get; }
IRepository JobTemplateItemCoats { get; }
IRepository JobTemplateItemPrepServices { get; }
+ // Platform content (SuperAdmin-managed, no tenant filter, no soft delete)
+ IPlainRepository Announcements { get; }
+ IPlainRepository BannedIps { get; }
+ IPlainRepository DashboardTips { get; }
+ IRepository InAppNotifications { get; }
+ IPlainRepository ReleaseNotes { get; }
+
// Bug Reports
IRepository BugReports { get; }
+ IRepository BugReportAttachments { get; }
// Contact Us
IRepository ContactSubmissions { get; }
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs
index 58441a3..3ebc7df 100644
--- a/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IBillRepository.cs
@@ -39,4 +39,11 @@ public interface IBillRepository : IRepository
/// for sequential payment reference generation.
///
Task GetLastPaymentNumberAsync(string prefix);
+
+ ///
+ /// Returns all non-deleted bills whose BillDate falls within [,
+ /// ], with Vendor, LineItems → Account, and Payments loaded.
+ /// Used by the accounting data export to produce QuickBooks IIF / CSV files.
+ ///
+ Task> GetForDateRangeAsync(DateTime start, DateTime end);
}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs
new file mode 100644
index 0000000..32f1993
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IInventoryTransactionRepository.cs
@@ -0,0 +1,22 @@
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds a dynamic-filter
+/// query for the Inventory Ledger view on top of the generic CRUD interface.
+///
+public interface IInventoryTransactionRepository : IRepository
+{
+ ///
+ /// Returns up to 500 non-deleted inventory transactions matching the supplied filters,
+ /// ordered newest-first, with InventoryItem, PurchaseOrder, and Job navigations loaded.
+ /// Null parameter values are treated as "no filter" for that dimension.
+ ///
+ Task> GetForLedgerAsync(
+ int? itemId,
+ DateTime? from,
+ DateTime? to,
+ InventoryTransactionType? type);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs
new file mode 100644
index 0000000..e4a9387
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobItemCoatRepository.cs
@@ -0,0 +1,40 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds ThenInclude-based load methods the
+/// generic cannot express. Used by DashboardController for powder
+/// order marking, powder receipt, and custom powder inventory creation.
+///
+public interface IJobItemCoatRepository : IRepository
+{
+ ///
+ /// Loads a coat with the full vendor + job chain needed by the MarkPowderOrdered action:
+ /// JobItem → Job → Customer, InventoryItem → PrimaryVendor, and direct
+ /// Vendor. Returns if not found.
+ ///
+ Task LoadForOrderMarkingAsync(int id);
+
+ ///
+ /// Loads a coat with only InventoryItem included — used by ReceivePowder for
+ /// the initial stock update. Returns if not found.
+ ///
+ Task LoadWithInventoryAsync(int id);
+
+ ///
+ /// Loads a coat with JobItem → Job included — used by ReceivePowder to verify
+ /// company ownership when the initial load did not include the job chain. EF Core identity-map
+ /// fixup propagates JobItem back to any previously tracked instance of the same coat.
+ /// Returns if not found.
+ ///
+ Task LoadWithJobChainAsync(int id);
+
+ ///
+ /// Returns all non-deleted coats that have no linked inventory item and belong to
+ /// , excluding . Used by
+ /// AddCustomPowderToInventory to link sibling coats to the newly created item.
+ /// Entities are tracked so that InventoryItemId mutations are saved via UnitOfWork.
+ ///
+ Task> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs
new file mode 100644
index 0000000..e57fcee
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobPhotoRepository.cs
@@ -0,0 +1,28 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds inventory-specific photo lookup
+/// queries on top of the generic CRUD interface. These queries require multi-level
+/// ThenInclude chains and dynamic filtering that the generic
+/// cannot express.
+///
+public interface IJobPhotoRepository : IRepository
+{
+ ///
+ /// Returns all non-deleted, tagged job photos whose Tags field contains
+ /// or (SQL LIKE), ordered
+ /// newest-first, with Job → Customer navigation loaded. The caller performs an
+ /// exact-token match in memory to reject false positives before paginating.
+ ///
+ Task> GetTaggedPhotosAsync(string? colorName, string? itemName);
+
+ ///
+ /// Returns all non-deleted job photos from jobs that use a specific inventory item
+ /// in any coat, matched via JobItemCoat.InventoryItemId. Loads
+ /// Job → Customer and Job → JobItems → Coats navigations. Used by the
+ /// Photos by Powder panel on the inventory item detail page.
+ ///
+ Task> GetPhotosByPowderItemAsync(int inventoryItemId);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs
index 18c8c3c..4401b74 100644
--- a/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobRepository.cs
@@ -78,4 +78,11 @@ public interface IJobRepository : IRepository
/// Returns null if not found.
///
Task LoadForCostingAsync(int jobId, int companyId);
+
+ ///
+ /// Loads a single job with JobItems → Coats and JobItems → PrepServices for deep-copying
+ /// into a new via SaveJobAsTemplate.
+ /// Returns null if not found or soft-deleted.
+ ///
+ Task LoadForTemplateSnapshotAsync(int jobId);
}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs
new file mode 100644
index 0000000..92b3a10
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IJobTemplateRepository.cs
@@ -0,0 +1,25 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that extends the generic CRUD interface with
+/// domain-specific queries requiring multi-level include chains the generic
+/// cannot express.
+///
+public interface IJobTemplateRepository : IRepository
+{
+ ///
+ /// Loads a single template with the full include chain for the Details view:
+ /// Customer, Items (with Coats → InventoryItem and PrepServices → PrepService).
+ /// Returns null if not found or soft-deleted.
+ ///
+ Task LoadForDetailsAsync(int id);
+
+ ///
+ /// Returns all active, non-deleted templates for the current company with Customer,
+ /// Items → Coats, and Items → PrepServices → PrepService loaded. Used by the
+ /// GetTemplatesJson AJAX endpoint to hydrate the job creation wizard.
+ ///
+ Task> GetAllActiveWithFullIncludesAsync();
+}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs
index c5759a2..ad4421b 100644
--- a/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs
+++ b/src/PowderCoating.Core/Interfaces/Repositories/INotificationLogRepository.cs
@@ -1,4 +1,5 @@
using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Interfaces.Repositories;
@@ -27,4 +28,19 @@ public interface INotificationLogRepository : IRepository
/// Returns all notification log entries for the given job, newest-first.
Task> GetAllForJobAsync(int jobId);
+
+ ///
+ /// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
+ /// Job, and Quote navigations loaded. All filter parameters are optional — omit to include all.
+ /// Used by the company-scoped notification log index view.
+ ///
+ Task<(List Items, int TotalCount)> GetPagedFilteredAsync(
+ int pageNumber, int pageSize,
+ string? searchTerm = null,
+ NotificationChannel? channel = null,
+ NotificationStatus? status = null,
+ NotificationType? type = null,
+ int? jobId = null,
+ string sortColumn = "SentAt",
+ string sortDirection = "desc");
}
diff --git a/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs b/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs
new file mode 100644
index 0000000..ffffa6b
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Repositories/IPowderUsageLogRepository.cs
@@ -0,0 +1,17 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Repositories;
+
+///
+/// Typed repository for that adds a dynamic-filter
+/// query for the Inventory Ledger usage tab on top of the generic CRUD interface.
+///
+public interface IPowderUsageLogRepository : IRepository
+{
+ ///
+ /// Returns up to 500 non-deleted powder usage logs matching the supplied filters,
+ /// ordered newest-first, with Job → Customer, InventoryItem, and JobItemCoat
+ /// navigations loaded. Null parameter values are treated as "no filter".
+ ///
+ Task> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs b/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs
new file mode 100644
index 0000000..cb1b97f
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/IAuditLogService.cs
@@ -0,0 +1,24 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Services;
+
+///
+/// Writes platform-wide audit trail entries. is not a
+/// (no soft delete, no tenant filter), so it cannot use the generic
+/// ; this service provides the only write path for audit records
+/// and keeps ApplicationDbContext out of controller constructors.
+///
+public interface IAuditLogService
+{
+ ///
+ /// Persists to the audit log immediately.
+ ///
+ Task LogAsync(AuditLog entry);
+
+ ///
+ /// Returns the most recent audit log entries for the given user
+ /// where EntityType == "ApplicationUser", ordered newest-first. Used by the
+ /// SuperAdmin user login history panel.
+ ///
+ Task> GetUserActivityAsync(string userId, int limit = 50);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs
new file mode 100644
index 0000000..bfd5f55
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyDataPurgeService.cs
@@ -0,0 +1,26 @@
+namespace PowderCoating.Core.Interfaces.Services;
+
+///
+/// Destructive data-purge operations for the SuperAdmin company management UI.
+/// All methods use bulk ExecuteDeleteAsync against ApplicationDbContext directly;
+/// they are intentional exceptions to the IUnitOfWork pattern, mirroring
+/// DataPurgeController and AccountDataExportController in the documented exceptions list.
+///
+public interface ICompanyDataPurgeService
+{
+ ///
+ /// Deletes all business-data tables for but does NOT delete the
+ /// company record or Identity users. The caller is responsible for deleting users via
+ /// UserManager and the company record via after this call.
+ /// must be loaded beforehand so announcement-dismissal
+ /// records that reference users (rather than the company directly) can be cleaned up.
+ ///
+ Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList companyUserIds);
+
+ ///
+ /// Deletes all business data for while preserving the company
+ /// record, users, operating costs, preferences, and lookup tables. Also clears the
+ /// QuickBooks migration state from CompanyPreferences. Used by the ResetData action.
+ ///
+ Task ResetBusinessDataAsync(int companyId);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs
new file mode 100644
index 0000000..afe7632
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/ICompanyListService.cs
@@ -0,0 +1,39 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Services;
+
+///
+/// Wizard completion metadata surfaced in the company list view.
+///
+public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? CompletedByName);
+
+///
+/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
+///
+public record CompanyCountSummary(
+ IReadOnlyDictionary JobCounts,
+ IReadOnlyDictionary QuoteCounts,
+ IReadOnlyDictionary CustomerCounts,
+ IReadOnlyDictionary WizardInfo
+);
+
+///
+/// Read service for the SuperAdmin company list. Wraps queries that require
+/// IgnoreQueryFilters(), dynamic search/sort, and cross-entity GROUP BY aggregations —
+/// patterns the generic cannot express.
+///
+public interface ICompanyListService
+{
+ ///
+ /// Returns a paged, searched, and sorted slice of non-deleted companies together with the
+ /// total unfiltered count for pagination.
+ ///
+ Task<(List Companies, int TotalCount)> GetPagedAsync(
+ string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
+
+ ///
+ /// Returns job, quote, customer, and wizard completion counts for each of the supplied
+ /// company IDs in three GROUP BY queries instead of N+1 individual lookups.
+ ///
+ Task GetCountSummaryAsync(IReadOnlyList companyIds);
+}
diff --git a/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
new file mode 100644
index 0000000..c9e0435
--- /dev/null
+++ b/src/PowderCoating.Core/Interfaces/Services/IDashboardReadService.cs
@@ -0,0 +1,42 @@
+using PowderCoating.Core.Entities;
+
+namespace PowderCoating.Core.Interfaces.Services;
+
+///
+/// Result record carrying all pre-fetched entity lists and aggregates needed to render the operator
+/// dashboard index view. Raw entities are returned so the controller can apply in-memory
+/// filtering, grouping, and DTO projection without additional round-trips.
+///
+public record DashboardIndexData(
+ List ActiveJobs,
+ decimal MonthlyRevenue,
+ List TodaysAppointments,
+ List UpcomingMaintenance,
+ List PendingQuotes,
+ List OpenInvoices,
+ decimal InvoicedThisMonth,
+ decimal CollectedThisMonth,
+ List RecentPayments,
+ List RecentQuotes,
+ List RecentJobs,
+ List JobsNeedingPowder,
+ List JobsWithOrderedPowder,
+ List BillsDue,
+ string? TipOfTheDay
+);
+
+///
+/// Read-only service for the dashboard. All methods execute complex queries that require
+/// ThenInclude chains or navigation-property predicates beyond what the generic
+/// can express. Lives in Infrastructure so ApplicationDbContext
+/// is available; injected into the controller via DI.
+///
+public interface IDashboardReadService
+{
+ /// Fetches all data needed to render the tenant operator dashboard.
+ /// The local date used for date-range predicates (today, start-of-month, etc.).
+ Task GetIndexDataAsync(DateTime today);
+
+ /// Returns the total count of tenant users (CompanyId > 0) for the SuperAdmin dashboard.
+ Task GetTotalUserCountAsync();
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs
index 1c41dec..ef7eed9 100644
--- a/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/BillRepository.cs
@@ -89,4 +89,17 @@ public class BillRepository : Repository, IBillRepository
.Select(p => p.PaymentNumber)
.FirstOrDefaultAsync();
}
+
+ ///
+ public async Task> GetForDateRangeAsync(DateTime start, DateTime end)
+ {
+ return await _context.Bills
+ .Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
+ .Include(b => b.Vendor)
+ .Include(b => b.LineItems.Where(li => !li.IsDeleted))
+ .ThenInclude(li => li.Account)
+ .Include(b => b.Payments.Where(p => !p.IsDeleted))
+ .OrderBy(b => b.BillDate)
+ .ToListAsync();
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs b/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs
new file mode 100644
index 0000000..84d965a
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/InventoryTransactionRepository.cs
@@ -0,0 +1,45 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that adds a dynamic-filter
+/// ledger query on top of the generic .
+///
+public class InventoryTransactionRepository : Repository, IInventoryTransactionRepository
+{
+ public InventoryTransactionRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task> GetForLedgerAsync(
+ int? itemId,
+ DateTime? from,
+ DateTime? to,
+ InventoryTransactionType? type)
+ {
+ var query = _context.InventoryTransactions
+ .AsNoTracking()
+ .Include(t => t.InventoryItem)
+ .Include(t => t.PurchaseOrder)
+ .Include(t => t.Job)
+ .Where(t => !t.IsDeleted);
+
+ if (itemId.HasValue)
+ query = query.Where(t => t.InventoryItemId == itemId.Value);
+ if (from.HasValue)
+ query = query.Where(t => t.TransactionDate >= from.Value);
+ if (to.HasValue)
+ query = query.Where(t => t.TransactionDate < to.Value.AddDays(1));
+ if (type.HasValue)
+ query = query.Where(t => t.TransactionType == type.Value);
+
+ return await query
+ .OrderByDescending(t => t.TransactionDate)
+ .Take(500)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs
new file mode 100644
index 0000000..2dc2e1b
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/JobItemCoatRepository.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that adds ThenInclude-based load methods the
+/// generic cannot express.
+///
+public class JobItemCoatRepository : Repository, IJobItemCoatRepository
+{
+ public JobItemCoatRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForOrderMarkingAsync(int id)
+ {
+ return await _context.JobItemCoats
+ .Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
+ .Include(c => c.Vendor)
+ .Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
+ .FirstOrDefaultAsync(c => c.Id == id);
+ }
+
+ ///
+ public async Task LoadWithInventoryAsync(int id)
+ {
+ return await _context.JobItemCoats
+ .Include(c => c.InventoryItem)
+ .FirstOrDefaultAsync(c => c.Id == id);
+ }
+
+ ///
+ public async Task LoadWithJobChainAsync(int id)
+ {
+ return await _context.JobItemCoats
+ .Include(c => c.JobItem).ThenInclude(i => i.Job)
+ .FirstOrDefaultAsync(c => c.Id == id);
+ }
+
+ ///
+ public async Task> GetCandidateCoatsForLinkingAsync(int excludeCoatId, int companyId)
+ {
+ return await _context.JobItemCoats
+ .Include(c => c.JobItem)
+ .Where(c => c.Id != excludeCoatId
+ && c.InventoryItemId == null
+ && c.JobItem.CompanyId == companyId)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs
new file mode 100644
index 0000000..1693a0a
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/JobPhotoRepository.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides inventory-specific photo
+/// lookup queries requiring multi-level ThenInclude chains that the generic
+/// cannot express.
+///
+public class JobPhotoRepository : Repository, IJobPhotoRepository
+{
+ public JobPhotoRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task> GetTaggedPhotosAsync(string? colorName, string? itemName)
+ {
+ var query = _context.JobPhotos
+ .AsNoTracking()
+ .Include(p => p.Job)
+ .ThenInclude(j => j!.Customer)
+ .Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
+
+ if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(itemName) && colorName != itemName)
+ query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(itemName));
+ else if (!string.IsNullOrEmpty(colorName))
+ query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
+ else if (!string.IsNullOrEmpty(itemName))
+ query = query.Where(p => p.Tags!.ToLower().Contains(itemName));
+
+ return await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
+ }
+
+ ///
+ public async Task> GetPhotosByPowderItemAsync(int inventoryItemId)
+ {
+ return await _context.JobPhotos
+ .AsNoTracking()
+ .Include(p => p.Job)
+ .ThenInclude(j => j!.Customer)
+ .Include(p => p.Job)
+ .ThenInclude(j => j!.JobItems)
+ .ThenInclude(ji => ji.Coats)
+ .Where(p => !p.IsDeleted &&
+ p.Job != null &&
+ p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == inventoryItemId)))
+ .OrderByDescending(p => p.UploadedDate)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs
index 2504c0a..08ffe70 100644
--- a/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/JobRepository.cs
@@ -175,4 +175,16 @@ public class JobRepository : Repository, IJobRepository
.AsNoTracking()
.FirstOrDefaultAsync();
}
+
+ ///
+ public async Task LoadForTemplateSnapshotAsync(int jobId)
+ {
+ return await _context.Jobs
+ .Where(j => j.Id == jobId && !j.IsDeleted)
+ .Include(j => j.JobItems.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
+ .Include(j => j.JobItems.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
+ .FirstOrDefaultAsync();
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs b/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs
new file mode 100644
index 0000000..0f18fa8
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/JobTemplateRepository.cs
@@ -0,0 +1,47 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that provides domain-specific multi-level
+/// include queries that the generic cannot express.
+/// The base class handles all standard CRUD operations; this class adds the read queries
+/// that require ThenInclude chains for items, coats, and prep services.
+///
+public class JobTemplateRepository : Repository, IJobTemplateRepository
+{
+ public JobTemplateRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task LoadForDetailsAsync(int id)
+ {
+ return await _context.JobTemplates
+ .Where(t => t.Id == id && !t.IsDeleted)
+ .Include(t => t.Customer)
+ .Include(t => t.Items.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
+ .ThenInclude(c => c.InventoryItem)
+ .Include(t => t.Items.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
+ .ThenInclude(p => p.PrepService)
+ .FirstOrDefaultAsync();
+ }
+
+ ///
+ public async Task> GetAllActiveWithFullIncludesAsync()
+ {
+ return await _context.JobTemplates
+ .Where(t => !t.IsDeleted && t.IsActive)
+ .Include(t => t.Customer)
+ .Include(t => t.Items.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
+ .Include(t => t.Items.Where(i => !i.IsDeleted))
+ .ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
+ .ThenInclude(p => p.PrepService)
+ .OrderBy(t => t.Name)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs
index 86b356c..d6fd49e 100644
--- a/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/NotificationLogRepository.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
@@ -60,4 +61,69 @@ public class NotificationLogRepository : Repository, INotificat
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
+
+ ///
+ public async Task<(List Items, int TotalCount)> GetPagedFilteredAsync(
+ int pageNumber, int pageSize,
+ string? searchTerm = null,
+ NotificationChannel? channel = null,
+ NotificationStatus? status = null,
+ NotificationType? type = null,
+ int? jobId = null,
+ string sortColumn = "SentAt",
+ string sortDirection = "desc")
+ {
+ var query = _dbSet
+ .AsNoTracking()
+ .Include(n => n.Customer)
+ .Include(n => n.Job)
+ .Include(n => n.Quote)
+ .AsQueryable();
+
+ if (jobId.HasValue)
+ query = query.Where(n => n.JobId == jobId.Value);
+
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var s = searchTerm.ToLower();
+ query = query.Where(n =>
+ n.RecipientName.ToLower().Contains(s) ||
+ n.Recipient.ToLower().Contains(s) ||
+ (n.Subject != null && n.Subject.ToLower().Contains(s)) ||
+ (n.Job != null && n.Job.JobNumber.ToLower().Contains(s)) ||
+ (n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(s)));
+ }
+
+ if (channel.HasValue)
+ query = query.Where(n => n.Channel == channel.Value);
+
+ if (status.HasValue)
+ query = query.Where(n => n.Status == status.Value);
+
+ if (type.HasValue)
+ query = query.Where(n => n.NotificationType == type.Value);
+
+ var totalCount = await query.CountAsync();
+
+ query = (sortColumn, sortDirection) switch
+ {
+ ("RecipientName", "asc") => query.OrderBy(n => n.RecipientName),
+ ("RecipientName", _) => query.OrderByDescending(n => n.RecipientName),
+ ("Channel", "asc") => query.OrderBy(n => n.Channel),
+ ("Channel", _) => query.OrderByDescending(n => n.Channel),
+ ("Status", "asc") => query.OrderBy(n => n.Status),
+ ("Status", _) => query.OrderByDescending(n => n.Status),
+ ("Type", "asc") => query.OrderBy(n => n.NotificationType),
+ ("Type", _) => query.OrderByDescending(n => n.NotificationType),
+ (_, "asc") => query.OrderBy(n => n.SentAt),
+ _ => query.OrderByDescending(n => n.SentAt)
+ };
+
+ var items = await query
+ .Skip((pageNumber - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync();
+
+ return (items, totalCount);
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs
new file mode 100644
index 0000000..fe4b605
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/PlainRepository.cs
@@ -0,0 +1,73 @@
+using System.Linq.Expressions;
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Interfaces;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Generic repository for platform-level entities that do not inherit BaseEntity
+/// (Announcement, BannedIp, DashboardTip, ReleaseNote). No global query filters apply
+/// to these entities, so no IgnoreQueryFilters support is needed. All writes are staged
+/// in the EF change tracker — call IUnitOfWork.CompleteAsync() to flush.
+///
+public class PlainRepository : IPlainRepository where T : class
+{
+ protected readonly ApplicationDbContext _context;
+ protected readonly DbSet _dbSet;
+
+ public PlainRepository(ApplicationDbContext context)
+ {
+ _context = context;
+ _dbSet = context.Set();
+ }
+
+ public virtual async Task GetByIdAsync(int id)
+ => await _dbSet.FindAsync(id);
+
+ public virtual async Task> GetAllAsync()
+ => await _dbSet.ToListAsync();
+
+ public virtual async Task> FindAsync(Expression> predicate)
+ => await _dbSet.Where(predicate).ToListAsync();
+
+ public virtual async Task FirstOrDefaultAsync(Expression> predicate)
+ => await _dbSet.FirstOrDefaultAsync(predicate);
+
+ public virtual async Task AnyAsync(Expression> predicate)
+ => await _dbSet.AnyAsync(predicate);
+
+ public virtual async Task CountAsync(Expression>? predicate = null)
+ => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);
+
+ public virtual async Task AddAsync(T entity)
+ {
+ await _dbSet.AddAsync(entity);
+ return entity;
+ }
+
+ public virtual async Task> AddRangeAsync(IEnumerable entities)
+ {
+ await _dbSet.AddRangeAsync(entities);
+ return entities;
+ }
+
+ public virtual Task UpdateAsync(T entity)
+ {
+ _dbSet.Update(entity);
+ return Task.CompletedTask;
+ }
+
+ public virtual async Task DeleteAsync(T entity)
+ {
+ _dbSet.Remove(entity);
+ await Task.CompletedTask;
+ }
+
+ public virtual async Task DeleteAsync(int id)
+ {
+ var entity = await GetByIdAsync(id);
+ if (entity != null)
+ await DeleteAsync(entity);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs b/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs
new file mode 100644
index 0000000..fdfa50a
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Repositories/PowderUsageLogRepository.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Repositories;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Repositories;
+
+///
+/// Typed repository for that adds a dynamic-filter
+/// ledger query on top of the generic .
+///
+public class PowderUsageLogRepository : Repository, IPowderUsageLogRepository
+{
+ public PowderUsageLogRepository(ApplicationDbContext context) : base(context) { }
+
+ ///
+ public async Task> GetForLedgerAsync(int? itemId, DateTime? from, DateTime? to)
+ {
+ var query = _context.PowderUsageLogs
+ .AsNoTracking()
+ .Include(u => u.Job)
+ .ThenInclude(j => j!.Customer)
+ .Include(u => u.InventoryItem)
+ .Include(u => u.JobItemCoat)
+ .Where(u => !u.IsDeleted);
+
+ if (itemId.HasValue)
+ query = query.Where(u => u.InventoryItemId == itemId.Value);
+ if (from.HasValue)
+ query = query.Where(u => u.RecordedAt >= from.Value);
+ if (to.HasValue)
+ query = query.Where(u => u.RecordedAt < to.Value.AddDays(1));
+
+ return await query
+ .OrderByDescending(u => u.RecordedAt)
+ .Take(500)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
index a2d2c92..d20d2e1 100644
--- a/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
+++ b/src/PowderCoating.Infrastructure/Repositories/UnitOfWork.cs
@@ -41,14 +41,14 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _aiItemPredictions;
// Powder Insights
- private IRepository? _powderUsageLogs;
+ private IPowderUsageLogRepository? _powderUsageLogs;
// Core repositories
private ICustomerRepository? _customers;
private IJobRepository? _jobs;
private IRepository? _jobDailyPriorities;
private IRepository? _jobItems;
- private IRepository? _jobItemCoats;
+ private IJobItemCoatRepository? _jobItemCoats;
private IRepository? _jobItemPrepServices;
private IRepository? _jobChangeHistories;
private IRepository? _jobPrepServices;
@@ -59,13 +59,13 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _quoteItemPrepServices;
private IRepository? _quoteChangeHistories;
private IRepository? _inventoryItems;
- private IRepository? _inventoryTransactions;
+ private IInventoryTransactionRepository? _inventoryTransactions;
private IRepository? _equipment;
private IRepository? _ovenCosts;
private IRepository? _blastSetups;
private IRepository? _maintenanceRecords;
private IRepository? _vendors;
- private IRepository? _jobPhotos;
+ private IJobPhotoRepository? _jobPhotos;
private IRepository? _jobNotes;
private IRepository? _customerNotes;
private IRepository? _jobStatusHistory;
@@ -97,13 +97,21 @@ public class UnitOfWork : IUnitOfWork
private IRepository? _subscriptionPlanConfigs;
// Job Templates
- private IRepository? _jobTemplates;
+ private IJobTemplateRepository? _jobTemplates;
private IRepository? _jobTemplateItems;
private IRepository? _jobTemplateItemCoats;
private IRepository? _jobTemplateItemPrepServices;
+ // Platform content
+ private IPlainRepository? _announcements;
+ private IPlainRepository? _bannedIps;
+ private IPlainRepository? _dashboardTips;
+ private IRepository? _inAppNotifications;
+ private IPlainRepository? _releaseNotes;
+
// Bug Reports
private IRepository? _bugReports;
+ private IRepository? _bugReportAttachments;
private IRepository? _contactSubmissions;
private IRepository? _manufacturerLookupPatterns;
@@ -169,8 +177,8 @@ public class UnitOfWork : IUnitOfWork
// Powder Insights
/// Repository for records capturing per-coat powder consumption; used by powder-usage analytics.
- public IRepository PowderUsageLogs =>
- _powderUsageLogs ??= new Repository(_context);
+ public IPowderUsageLogRepository PowderUsageLogs =>
+ _powderUsageLogs ??= new PowderUsageLogRepository(_context);
// Core repositories
/// Repository for records (commercial and non-commercial); tenant-filtered with soft delete.
@@ -190,8 +198,8 @@ public class UnitOfWork : IUnitOfWork
_jobItems ??= new Repository(_context);
/// Repository for powder coat passes; tenant-filtered with soft delete.
- public IRepository JobItemCoats =>
- _jobItemCoats ??= new Repository(_context);
+ public IJobItemCoatRepository JobItemCoats =>
+ _jobItemCoats ??= new JobItemCoatRepository(_context);
public IRepository JobItemPrepServices =>
_jobItemPrepServices ??= new Repository(_context);
@@ -232,8 +240,8 @@ public class UnitOfWork : IUnitOfWork
_inventoryItems ??= new Repository(_context);
/// Repository for stock movements; tenant-filtered with soft delete.
- public IRepository InventoryTransactions =>
- _inventoryTransactions ??= new Repository(_context);
+ public IInventoryTransactionRepository InventoryTransactions =>
+ _inventoryTransactions ??= new InventoryTransactionRepository(_context);
/// Repository for records (ovens, sandblasters, booths); tenant-filtered with soft delete.
public IRepository Equipment =>
@@ -256,8 +264,8 @@ public class UnitOfWork : IUnitOfWork
_vendors ??= new Repository(_context);
/// Repository for attachments; tenant-filtered with soft delete.
- public IRepository JobPhotos =>
- _jobPhotos ??= new Repository(_context);
+ public IJobPhotoRepository JobPhotos =>
+ _jobPhotos ??= new JobPhotoRepository(_context);
/// Repository for free-text staff notes on jobs; tenant-filtered with soft delete.
public IRepository JobNotes =>
@@ -372,11 +380,35 @@ public class UnitOfWork : IUnitOfWork
public IRepository SubscriptionPlanConfigs =>
_subscriptionPlanConfigs ??= new Repository(_context);
+ // Platform content
+ /// Repository for platform-wide announcements; no tenant filter, no soft delete.
+ public IPlainRepository Announcements =>
+ _announcements ??= new PlainRepository(_context);
+
+ /// Repository for IP ban records; no tenant filter, no soft delete.
+ public IPlainRepository BannedIps =>
+ _bannedIps ??= new PlainRepository(_context);
+
+ /// Repository for rotating tip-of-the-day entries; no tenant filter, no soft delete.
+ public IPlainRepository DashboardTips =>
+ _dashboardTips ??= new PlainRepository(_context);
+
+ /// Repository for bell-notification records; tenant-filtered with soft delete.
+ public IRepository InAppNotifications =>
+ _inAppNotifications ??= new Repository(_context);
+
+ /// Repository for platform changelog entries; no tenant filter, no soft delete.
+ public IPlainRepository ReleaseNotes =>
+ _releaseNotes ??= new PlainRepository(_context);
+
// Bug Reports
/// Repository for user-submitted bug reports; tenant-filtered with soft delete.
public IRepository BugReports =>
_bugReports ??= new Repository(_context);
+ public IRepository BugReportAttachments =>
+ _bugReportAttachments ??= new Repository(_context);
+
// Contact Us
/// Repository for contact form submissions; platform admins see all, company users see their own.
public IRepository ContactSubmissions =>
@@ -397,8 +429,8 @@ public class UnitOfWork : IUnitOfWork
// Job Templates
/// Repository for reusable job blueprints; tenant-filtered with soft delete.
- public IRepository JobTemplates =>
- _jobTemplates ??= new Repository(_context);
+ public IJobTemplateRepository JobTemplates =>
+ _jobTemplates ??= new JobTemplateRepository(_context);
/// Repository for item definitions within a job template.
public IRepository JobTemplateItems =>
diff --git a/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs b/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs
new file mode 100644
index 0000000..0820cf2
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/AiUsageReportService.cs
@@ -0,0 +1,55 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements by querying AiUsageLogs and
+/// QuotePhotos directly via ApplicationDbContext. Both tables either are
+/// not BaseEntity (AiUsageLog) or require cross-tenant GROUP BY aggregations that must
+/// execute in SQL; this service encapsulates those queries so the controller stays clean.
+///
+public class AiUsageReportService : IAiUsageReportService
+{
+ private readonly ApplicationDbContext _context;
+
+ public AiUsageReportService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public async Task GetReportDataAsync()
+ {
+ var now = DateTime.UtcNow;
+ var todayStart = now.Date;
+ var last7Start = todayStart.AddDays(-7);
+ var last30Start = todayStart.AddDays(-30);
+
+ var usageByCompany = await _context.AiUsageLogs
+ .GroupBy(l => l.CompanyId)
+ .Select(g => new AiCompanyUsage(
+ g.Key,
+ g.Count(l => l.CalledAt >= todayStart),
+ g.Count(l => l.CalledAt >= last7Start),
+ g.Count(l => l.CalledAt >= last30Start),
+ g.Count()))
+ .ToListAsync();
+
+ var featureStats = await _context.AiUsageLogs
+ .Where(l => l.CalledAt >= last30Start)
+ .GroupBy(l => new { l.CompanyId, l.Feature })
+ .Select(g => new AiFeatureStat(g.Key.CompanyId, g.Key.Feature, g.Count()))
+ .ToListAsync();
+
+ var photoCounts = await _context.QuotePhotos
+ .IgnoreQueryFilters()
+ .Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
+ .GroupBy(p => p.CompanyId)
+ .Select(g => new { CompanyId = g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.CompanyId, x => x.Count);
+
+ return new AiUsageReportData(usageByCompany, featureStats, photoCounts);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/AuditLogService.cs b/src/PowderCoating.Infrastructure/Services/AuditLogService.cs
new file mode 100644
index 0000000..3dcd3f4
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/AuditLogService.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Services;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Concrete implementation of that writes
+/// entries via directly. AuditLog does not inherit
+/// from BaseEntity so it cannot be managed through the generic repository; this service
+/// owns that write path and keeps ApplicationDbContext out of controller constructors.
+///
+public class AuditLogService : IAuditLogService
+{
+ private readonly ApplicationDbContext _context;
+
+ public AuditLogService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public async Task LogAsync(AuditLog entry)
+ {
+ _context.AuditLogs.Add(entry);
+ await _context.SaveChangesAsync();
+ }
+
+ ///
+ public async Task> GetUserActivityAsync(string userId, int limit = 50)
+ {
+ return await _context.AuditLogs
+ .AsNoTracking()
+ .Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
+ .OrderByDescending(l => l.Timestamp)
+ .Take(limit)
+ .ToListAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs b/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs
new file mode 100644
index 0000000..dfc3391
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/CompanyDataPurgeService.cs
@@ -0,0 +1,180 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Interfaces.Services;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements via bulk ExecuteDeleteAsync against
+/// directly. This is an intentional exception to the
+/// IUnitOfWork pattern — identical to the rationale for DataPurgeController in the
+/// documented permanent exceptions list. Each ExecuteDeleteAsync call commits immediately
+/// at the database level so no SaveChangesAsync is needed for the bulk tiers.
+///
+public class CompanyDataPurgeService : ICompanyDataPurgeService
+{
+ private readonly ApplicationDbContext _context;
+
+ public CompanyDataPurgeService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public async Task DeleteAllBusinessDataAsync(int companyId, IReadOnlyList companyUserIds)
+ {
+ // ── Tier 1: Leaf children ─────────────────────────────────────────────
+ await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+
+ // Announcement dismissals referencing the company's users or company-targeted announcements
+ var announcementIds = await _context.Announcements
+ .Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
+ await _context.AnnouncementDismissals.IgnoreQueryFilters()
+ .Where(x => companyUserIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
+ .ExecuteDeleteAsync();
+
+ // ── Tier 2: Mid-level children ────────────────────────────────────────
+ await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+
+ // ── Tier 3: Top-level company entities ───────────────────────────────
+ await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
+ await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+
+ // ── Tier 4: Company configs and lookup tables ─────────────────────────
+ await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ // Note: company record and users are left for the caller to handle via UserManager and UnitOfWork
+ }
+
+ ///
+ public async Task ResetBusinessDataAsync(int companyId)
+ {
+ // ── Tier 0: Grandchildren ─────────────────────────────────────────────
+ await _context.JobTemplateItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobTemplateItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.GiftCertificateRedemptions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CreditMemoApplications.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.OvenBatchItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+
+ var announcementIds = await _context.Announcements
+ .Where(a => a.TargetCompanyId == companyId).Select(a => a.Id).ToListAsync();
+ if (announcementIds.Count > 0)
+ await _context.AnnouncementDismissals.IgnoreQueryFilters()
+ .Where(x => announcementIds.Contains(x.AnnouncementId))
+ .ExecuteDeleteAsync();
+
+ // ── Tier 1: Children ──────────────────────────────────────────────────
+ await _context.JobTemplateItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobTimeEntries.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.ReworkRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.QuotePhotos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Deposits.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.GiftCertificates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+
+ // ── Tier 2: Top-level business entities ──────────────────────────────
+ await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.JobTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PurchaseOrders.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
+ await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
+
+ // Clear QuickBooks migration wizard progress (tracked update, not bulk delete)
+ var prefs = await _context.CompanyPreferences
+ .IgnoreQueryFilters()
+ .FirstOrDefaultAsync(p => p.CompanyId == companyId);
+ if (prefs?.QbMigrationStateJson != null)
+ {
+ prefs.QbMigrationStateJson = null;
+ prefs.UpdatedAt = DateTime.UtcNow;
+ await _context.SaveChangesAsync();
+ }
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/CompanyListService.cs b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs
new file mode 100644
index 0000000..0da7e5c
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/CompanyListService.cs
@@ -0,0 +1,103 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Interfaces.Services;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements using directly.
+/// Queries require IgnoreQueryFilters() (to bypass the tenant filter and see all companies),
+/// dynamic sort expressions, and cross-entity GROUP BY aggregations — all of which are beyond the
+/// generic .
+///
+public class CompanyListService : ICompanyListService
+{
+ private readonly ApplicationDbContext _context;
+
+ public CompanyListService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public async Task<(List Companies, int TotalCount)> GetPagedAsync(
+ string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
+ {
+ var query = _context.Companies
+ .AsNoTracking()
+ .IgnoreQueryFilters()
+ .Where(c => !c.IsDeleted)
+ .AsQueryable();
+
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var s = searchTerm.ToLower();
+ query = query.Where(c =>
+ c.CompanyName.ToLower().Contains(s) ||
+ (c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
+ (c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
+ (c.Phone != null && c.Phone.ToLower().Contains(s)));
+ }
+
+ query = (sortColumn, sortDirection == "asc") switch
+ {
+ ("CompanyName", true) => query.OrderBy(c => c.CompanyName),
+ ("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
+ ("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
+ ("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
+ ("Status", true) => query.OrderBy(c => c.IsActive),
+ ("Status", false) => query.OrderByDescending(c => c.IsActive),
+ ("Created", true) => query.OrderBy(c => c.CreatedAt),
+ ("Created", false) => query.OrderByDescending(c => c.CreatedAt),
+ _ => query.OrderBy(c => c.CompanyName)
+ };
+
+ var totalCount = await query.CountAsync();
+
+ var companies = await query
+ .Include(c => c.Users)
+ .Skip((page - 1) * pageSize)
+ .Take(pageSize)
+ .ToListAsync();
+
+ return (companies, totalCount);
+ }
+
+ ///
+ public async Task GetCountSummaryAsync(IReadOnlyList companyIds)
+ {
+ var jobCounts = await _context.Jobs
+ .IgnoreQueryFilters()
+ .Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
+ .GroupBy(j => j.CompanyId)
+ .Select(g => new { g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.Key, x => x.Count);
+
+ var quoteCounts = await _context.Quotes
+ .IgnoreQueryFilters()
+ .Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted)
+ .GroupBy(q => q.CompanyId)
+ .Select(g => new { g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.Key, x => x.Count);
+
+ var customerCounts = await _context.Customers
+ .IgnoreQueryFilters()
+ .Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted)
+ .GroupBy(c => c.CompanyId)
+ .Select(g => new { g.Key, Count = g.Count() })
+ .ToDictionaryAsync(x => x.Key, x => x.Count);
+
+ var wizardRaw = await _context.CompanyPreferences
+ .IgnoreQueryFilters()
+ .Where(p => companyIds.Contains(p.CompanyId) && p.SetupWizardCompleted)
+ .Select(p => new { p.CompanyId, p.SetupWizardCompletedAt, p.SetupWizardCompletedByName })
+ .ToListAsync();
+
+ var wizardInfo = wizardRaw.ToDictionary(
+ x => x.CompanyId,
+ x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
+
+ return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs
new file mode 100644
index 0000000..7cad882
--- /dev/null
+++ b/src/PowderCoating.Infrastructure/Services/DashboardReadService.cs
@@ -0,0 +1,225 @@
+using Microsoft.EntityFrameworkCore;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
+using PowderCoating.Core.Interfaces.Services;
+using PowderCoating.Infrastructure.Data;
+
+namespace PowderCoating.Infrastructure.Services;
+
+///
+/// Implements using directly.
+/// All queries require ThenInclude chains or navigation-property predicates (e.g. JobStatus.StatusCode)
+/// that cannot be expressed through the generic .
+///
+public class DashboardReadService : IDashboardReadService
+{
+ private static readonly string[] CompletedStatusCodes =
+ [
+ "COMPLETED",
+ "READY_FOR_PICKUP",
+ "DELIVERED",
+ "CANCELLED"
+ ];
+
+ private readonly ApplicationDbContext _context;
+
+ public DashboardReadService(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ ///
+ public async Task GetIndexDataAsync(DateTime today)
+ {
+ var startOfMonth = new DateTime(today.Year, today.Month, 1);
+ var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
+ var tomorrow = today.AddDays(1);
+ var lookAheadDate = today.AddDays(7);
+ var last30Days = today.AddDays(-30);
+
+ var openInvoiceStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
+
+ // All active jobs (for today/overdue/in-progress panels)
+ var activeJobs = await _context.Jobs
+ .AsNoTracking()
+ .Include(j => j.Customer)
+ .Include(j => j.AssignedUser)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobPriority)
+ .Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
+ .ToListAsync();
+
+ // Monthly revenue — sum completed jobs updated in current month
+ var monthlyRevenue = await _context.Jobs
+ .Include(j => j.JobStatus)
+ .Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
+ && j.UpdatedAt >= startOfMonth
+ && j.UpdatedAt <= endOfMonth)
+ .SumAsync(j => j.FinalPrice);
+
+ // Today's appointments (non-cancelled)
+ var todaysAppointments = await _context.Appointments
+ .AsNoTracking()
+ .Include(a => a.Customer)
+ .Include(a => a.AppointmentType)
+ .Include(a => a.AppointmentStatus)
+ .Include(a => a.AssignedUser)
+ .Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
+ && a.AppointmentStatus.StatusCode != "CANCELLED")
+ .OrderBy(a => a.ScheduledStartTime)
+ .ToListAsync();
+
+ // Upcoming/overdue maintenance
+ var upcomingMaintenance = await _context.MaintenanceRecords
+ .AsNoTracking()
+ .Include(m => m.Equipment)
+ .Include(m => m.AssignedUser)
+ .Where(m => (m.Status == MaintenanceStatus.Scheduled
+ || m.Status == MaintenanceStatus.InProgress
+ || m.Status == MaintenanceStatus.Overdue)
+ && (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
+ .OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
+ .ThenByDescending(m => m.Priority)
+ .ThenBy(m => m.ScheduledDate)
+ .Take(10)
+ .ToListAsync();
+
+ // Pending quotes (SENT status)
+ var pendingQuotes = await _context.Quotes
+ .AsNoTracking()
+ .Include(q => q.Customer)
+ .Include(q => q.QuoteStatus)
+ .Where(q => q.QuoteStatus.StatusCode == "SENT")
+ .ToListAsync();
+
+ // Open invoices (for AR aging + overdue list)
+ var openInvoices = await _context.Invoices
+ .AsNoTracking()
+ .Include(i => i.Customer)
+ .Where(i => openInvoiceStatuses.Contains(i.Status))
+ .ToListAsync();
+
+ // Invoiced this month
+ var invoicedThisMonth = await _context.Invoices
+ .Where(i => i.Status != InvoiceStatus.Draft
+ && i.Status != InvoiceStatus.Voided
+ && i.Status != InvoiceStatus.WrittenOff
+ && i.InvoiceDate >= startOfMonth
+ && i.InvoiceDate <= endOfMonth)
+ .SumAsync(i => i.Total);
+
+ // Collected this month
+ var collectedThisMonth = await _context.Payments
+ .Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
+ .SumAsync(p => p.Amount);
+
+ // Recent payments with Invoice → Customer
+ var recentPayments = await _context.Payments
+ .AsNoTracking()
+ .Include(p => p.Invoice).ThenInclude(i => i!.Customer)
+ .OrderByDescending(p => p.PaymentDate)
+ .Take(6)
+ .ToListAsync();
+
+ // Recent quotes (last 30 days)
+ var recentQuotes = await _context.Quotes
+ .AsNoTracking()
+ .Include(q => q.Customer)
+ .Include(q => q.QuoteStatus)
+ .Where(q => q.CreatedAt >= last30Days)
+ .OrderByDescending(q => q.CreatedAt)
+ .Take(5)
+ .ToListAsync();
+
+ // Recent jobs (last 30 days)
+ var recentJobs = await _context.Jobs
+ .AsNoTracking()
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Where(j => j.CreatedAt >= last30Days)
+ .OrderByDescending(j => j.CreatedAt)
+ .Take(5)
+ .ToListAsync();
+
+ // Jobs needing powder (not yet ordered, insufficient stock)
+ var jobsNeedingPowder = await _context.Jobs
+ .AsNoTracking()
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobItems)
+ .ThenInclude(i => i.Coats)
+ .ThenInclude(c => c.InventoryItem)
+ .ThenInclude(inv => inv!.PrimaryVendor)
+ .Include(j => j.JobItems)
+ .ThenInclude(i => i.Coats)
+ .ThenInclude(c => c.Vendor)
+ .Where(j => !j.IsDeleted
+ && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
+ && j.JobItems.Any(i => i.Coats.Any(c =>
+ !c.IsDeleted &&
+ !c.PowderOrdered &&
+ c.PowderToOrder > 0 &&
+ (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
+ .ToListAsync();
+
+ // Jobs with powder already ordered but not yet received
+ var jobsWithOrderedPowder = await _context.Jobs
+ .AsNoTracking()
+ .Include(j => j.Customer)
+ .Include(j => j.JobStatus)
+ .Include(j => j.JobItems)
+ .ThenInclude(i => i.Coats)
+ .ThenInclude(c => c.InventoryItem)
+ .ThenInclude(inv => inv!.PrimaryVendor)
+ .Include(j => j.JobItems)
+ .ThenInclude(i => i.Coats)
+ .ThenInclude(c => c.Vendor)
+ .Where(j => !j.IsDeleted
+ && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
+ && j.JobItems.Any(i => i.Coats.Any(c =>
+ !c.IsDeleted &&
+ c.PowderOrdered &&
+ !c.PowderReceived)))
+ .ToListAsync();
+
+ // Bills due (open/partial, balance remaining)
+ var billsDue = await _context.Bills
+ .AsNoTracking()
+ .Include(b => b.Vendor)
+ .Where(b => (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid)
+ && b.Total > b.AmountPaid)
+ .OrderBy(b => b.DueDate)
+ .Take(15)
+ .ToListAsync();
+
+ // Random tip of the day
+ var tips = await _context.DashboardTips.Where(t => t.IsActive).ToListAsync();
+ var tipOfTheDay = tips.Count > 0 ? tips[Random.Shared.Next(tips.Count)].TipText : null;
+
+ return new DashboardIndexData(
+ ActiveJobs: activeJobs,
+ MonthlyRevenue: monthlyRevenue,
+ TodaysAppointments: todaysAppointments,
+ UpcomingMaintenance: upcomingMaintenance,
+ PendingQuotes: pendingQuotes,
+ OpenInvoices: openInvoices,
+ InvoicedThisMonth: invoicedThisMonth,
+ CollectedThisMonth: collectedThisMonth,
+ RecentPayments: recentPayments,
+ RecentQuotes: recentQuotes,
+ RecentJobs: recentJobs,
+ JobsNeedingPowder: jobsNeedingPowder,
+ JobsWithOrderedPowder: jobsWithOrderedPowder,
+ BillsDue: billsDue,
+ TipOfTheDay: tipOfTheDay
+ );
+ }
+
+ ///
+ public async Task GetTotalUserCountAsync()
+ {
+ return await _context.Users
+ .Where(u => u.CompanyId > 0)
+ .CountAsync();
+ }
+}
diff --git a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
index 1463116..6563c80 100644
--- a/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
+++ b/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs
@@ -1,14 +1,18 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Accounting;
using PowderCoating.Application.Interfaces;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
///
-/// Implements financial aggregate reports using direct DbContext access with AsNoTracking.
-/// Query logic is migrated here from ReportsController as each report action is
-/// converted during Phase 2/3 of the data-access architecture migration.
+/// Implements financial aggregate reports (P&L, Balance Sheet, AR Aging, Sales & Income)
+/// using direct DbContext access with AsNoTracking. Migrated from inline queries in
+/// ReportsController as part of Phase 2 of the data-access architecture migration.
+/// The four report types each have matching PDF export paths in the controller that
+/// share the same data by calling these methods, eliminating the previous duplication.
/// See docs/DATA_ACCESS_ARCHITECTURE.md for the full migration plan.
///
public class FinancialReportService : IFinancialReportService
@@ -21,19 +25,415 @@ public class FinancialReportService : IFinancialReportService
}
///
- /// Implemented — migrated from ReportsController.ProfitAndLoss in Phase 2.
- public Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
- => throw new NotImplementedException("Migrate from ReportsController.ProfitAndLoss — Phase 2.");
+ public async Task GetProfitAndLossAsync(int companyId, DateTime from, DateTime to)
+ {
+ var toEnd = to.AddDays(1).AddTicks(-1);
+ var companyName = await GetCompanyNameAsync(companyId);
+
+ // Revenue: InvoiceItems posted to revenue accounts
+ var revenueByAccount = await _context.InvoiceItems
+ .Where(ii => ii.RevenueAccountId != null
+ && ii.Invoice.Status != InvoiceStatus.Draft
+ && ii.Invoice.Status != InvoiceStatus.Voided
+ && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
+ .GroupBy(ii => ii.RevenueAccountId!.Value)
+ .Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
+ .ToListAsync();
+
+ var unlinkedRevenue = await _context.InvoiceItems
+ .Where(ii => ii.RevenueAccountId == null
+ && ii.Invoice.Status != InvoiceStatus.Draft
+ && ii.Invoice.Status != InvoiceStatus.Voided
+ && ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
+ .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
+
+ var revenueAccounts = await _context.Accounts
+ .Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
+ .ToDictionaryAsync(a => a.Id);
+
+ var revenueLines = revenueByAccount
+ .Where(r => revenueAccounts.ContainsKey(r.AccountId))
+ .Select(r => new FinancialReportLine
+ {
+ AccountId = r.AccountId,
+ AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
+ AccountName = revenueAccounts[r.AccountId].Name,
+ Amount = r.Amount
+ })
+ .OrderBy(l => l.AccountNumber)
+ .ToList();
+
+ if (unlinkedRevenue > 0)
+ revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
+
+ // COGS & Expenses: direct Expenses + BillLineItems merged per account
+ var directByAccount = await _context.Expenses
+ .Where(e => e.Date >= from && e.Date <= toEnd)
+ .GroupBy(e => e.ExpenseAccountId)
+ .Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
+ .ToListAsync();
+
+ var billLinesByAccount = await _context.BillLineItems
+ .Where(bli => bli.AccountId != null
+ && bli.Bill.Status != BillStatus.Draft
+ && bli.Bill.Status != BillStatus.Voided
+ && bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
+ .GroupBy(bli => bli.AccountId!.Value)
+ .Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
+ .ToListAsync();
+
+ var expenseAmounts = new Dictionary();
+ foreach (var e in directByAccount)
+ expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
+ foreach (var b in billLinesByAccount)
+ expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
+
+ var expAccounts = await _context.Accounts
+ .Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
+ .ToDictionaryAsync(a => a.Id);
+
+ var cogsLines = new List();
+ var expenseLines = new List();
+
+ foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
+ {
+ if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
+ var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
+ if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
+ else expenseLines.Add(line);
+ }
+
+ return new ProfitAndLossDto
+ {
+ From = from,
+ To = to,
+ CompanyName = companyName,
+ RevenueLines = revenueLines,
+ TotalRevenue = revenueLines.Sum(l => l.Amount),
+ CogsLines = cogsLines,
+ TotalCogs = cogsLines.Sum(l => l.Amount),
+ ExpenseLines = expenseLines,
+ TotalExpenses = expenseLines.Sum(l => l.Amount),
+ };
+ }
///
- public Task GetBalanceSheetAsync(int companyId, DateTime asOf)
- => throw new NotImplementedException("Migrate from ReportsController.BalanceSheet — Phase 2.");
+ public async Task GetBalanceSheetAsync(int companyId, DateTime asOf)
+ {
+ var asOfEnd = asOf.AddDays(1).AddTicks(-1);
+ var companyName = await GetCompanyNameAsync(companyId);
+
+ // Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
+
+ var depositsByAcct = await _context.Payments
+ .Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
+ && p.Invoice.Status != InvoiceStatus.Voided
+ && p.Invoice.Status != InvoiceStatus.WrittenOff)
+ .GroupBy(p => p.DepositAccountId!.Value)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var expFromByAcct = await _context.Expenses
+ .Where(e => e.Date <= asOfEnd)
+ .GroupBy(e => e.PaymentAccountId)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var bpFromByAcct = await _context.BillPayments
+ .Where(bp => bp.PaymentDate <= asOfEnd)
+ .GroupBy(bp => bp.BankAccountId)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var billsByApAcct = await _context.Bills
+ .Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
+ .GroupBy(b => b.APAccountId)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var bpByApAcct = await _context.BillPayments
+ .Where(bp => bp.PaymentDate <= asOfEnd)
+ .GroupBy(bp => bp.Bill.APAccountId)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var taxByAcct = await _context.Invoices
+ .Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
+ && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
+ && i.InvoiceDate <= asOfEnd)
+ .GroupBy(i => i.SalesTaxAccountId!.Value)
+ .Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
+ .ToDictionaryAsync(g => g.Id, g => g.Amount);
+
+ var arDebits = await _context.Invoices
+ .Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
+ .SumAsync(i => (decimal?)i.Total) ?? 0;
+ var arCredits = await _context.Payments
+ .Where(p => p.PaymentDate <= asOfEnd
+ && p.Invoice.Status != InvoiceStatus.Voided
+ && p.Invoice.Status != InvoiceStatus.WrittenOff)
+ .SumAsync(p => (decimal?)p.Amount) ?? 0;
+
+ // Retained earnings = net P&L from inception through asOf
+ var lifetimeRevenue = await _context.InvoiceItems
+ .Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
+ .SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
+ var lifetimeCogs = await _context.Expenses
+ .Where(e => e.Date <= asOfEnd)
+ .SumAsync(e => (decimal?)e.Amount) ?? 0;
+ var lifetimeBillCosts = await _context.BillLineItems
+ .Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
+ .SumAsync(bli => (decimal?)bli.Amount) ?? 0;
+ var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
+
+ var accounts = await _context.Accounts
+ .Where(a => a.IsActive)
+ .OrderBy(a => a.AccountNumber)
+ .ToListAsync();
+
+ // Standard double-entry: assets have normal debit balance; liabilities+equity have normal credit balance.
+ decimal ComputeBalance(Account a)
+ {
+ bool normalDebit = a.AccountType == AccountType.Asset;
+ decimal debits = 0, credits = 0;
+
+ if (a.AccountSubType == AccountSubType.AccountsReceivable)
+ {
+ debits = arDebits; credits = arCredits;
+ }
+ else if (a.AccountSubType == AccountSubType.AccountsPayable)
+ {
+ credits = billsByApAcct.GetValueOrDefault(a.Id);
+ debits = bpByApAcct.GetValueOrDefault(a.Id);
+ }
+ else
+ {
+ debits += depositsByAcct.GetValueOrDefault(a.Id);
+ credits += expFromByAcct.GetValueOrDefault(a.Id);
+ credits += bpFromByAcct.GetValueOrDefault(a.Id);
+ credits += taxByAcct.GetValueOrDefault(a.Id);
+ }
+
+ decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
+ ? a.OpeningBalance : 0;
+ decimal net = normalDebit ? debits - credits : credits - debits;
+ return opening + net;
+ }
+
+ FinancialReportLine ToLine(Account a) => new()
+ {
+ AccountId = a.Id,
+ AccountNumber = a.AccountNumber,
+ AccountName = a.Name,
+ Amount = ComputeBalance(a)
+ };
+
+ var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
+ var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
+ var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
+
+ var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
+ var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
+ var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
+ var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
+ var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
+ var equityLines = equityAccts.Select(ToLine).ToList();
+
+ var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
+ var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
+ var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
+
+ return new BalanceSheetDto
+ {
+ AsOf = asOf,
+ CompanyName = companyName,
+ CurrentAssets = currentAssets,
+ FixedAssets = fixedAssets,
+ OtherAssets = otherAssets,
+ TotalAssets = totalAssets,
+ CurrentLiabilities = currentLiabilities,
+ LongTermLiabilities = longTermLiabilities,
+ TotalLiabilities = totalLiabilities,
+ EquityLines = equityLines,
+ RetainedEarnings = retainedEarnings,
+ TotalEquity = totalEquity,
+ };
+ }
///
- public Task GetArAgingAsync(int companyId, DateTime asOf)
- => throw new NotImplementedException("Migrate from ReportsController.ArAging — Phase 2.");
+ public async Task GetArAgingAsync(int companyId, DateTime asOf)
+ {
+ var asOfEnd = asOf.AddDays(1).AddTicks(-1);
+ var companyName = await GetCompanyNameAsync(companyId);
+
+ var openInvoices = await _context.Invoices
+ .Include(i => i.Customer)
+ .Where(i => i.Status != InvoiceStatus.Draft
+ && i.Status != InvoiceStatus.Voided
+ && i.Status != InvoiceStatus.Paid
+ && i.InvoiceDate <= asOfEnd
+ && (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
+ .OrderBy(i => i.Customer!.CompanyName)
+ .ThenBy(i => i.DueDate)
+ .ToListAsync();
+
+ static string AgingBucket(int d) => d switch
+ {
+ <= 0 => "current",
+ <= 30 => "1-30",
+ <= 60 => "31-60",
+ <= 90 => "61-90",
+ _ => "90+"
+ };
+
+ var customerDtos = new List();
+
+ foreach (var grp in openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial }))
+ {
+ var customerName = grp.Key.IsCommercial
+ ? grp.Key.CompanyName
+ : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
+
+ var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
+
+ foreach (var inv in grp)
+ {
+ var balance = inv.BalanceDue;
+ var daysOverdue = inv.DueDate.HasValue ? (int)(asOf - inv.DueDate.Value.Date).TotalDays : 0;
+
+ custDto.Invoices.Add(new ArAgingInvoiceDto
+ {
+ InvoiceId = inv.Id,
+ InvoiceNumber = inv.InvoiceNumber,
+ InvoiceDate = inv.InvoiceDate,
+ DueDate = inv.DueDate,
+ BalanceDue = balance,
+ DaysOverdue = daysOverdue
+ });
+
+ switch (AgingBucket(daysOverdue))
+ {
+ case "current": custDto.TotalCurrent += balance; break;
+ case "1-30": custDto.Total1to30 += balance; break;
+ case "31-60": custDto.Total31to60 += balance; break;
+ case "61-90": custDto.Total61to90 += balance; break;
+ default: custDto.TotalOver90 += balance; break;
+ }
+ }
+
+ customerDtos.Add(custDto);
+ }
+
+ var sorted = customerDtos.OrderByDescending(c => c.TotalBalance).ToList();
+
+ return new ArAgingReportDto
+ {
+ AsOf = asOf,
+ CompanyName = companyName,
+ Customers = sorted,
+ TotalCurrent = sorted.Sum(c => c.TotalCurrent),
+ Total1to30 = sorted.Sum(c => c.Total1to30),
+ Total31to60 = sorted.Sum(c => c.Total31to60),
+ Total61to90 = sorted.Sum(c => c.Total61to90),
+ TotalOver90 = sorted.Sum(c => c.TotalOver90),
+ };
+ }
///
- public Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
- => throw new NotImplementedException("Migrate from ReportsController.SalesAndIncome — Phase 2.");
+ public async Task GetSalesAndIncomeAsync(int companyId, DateTime from, DateTime to)
+ {
+ var toEnd = to.AddDays(1).AddTicks(-1);
+ var companyName = await GetCompanyNameAsync(companyId);
+
+ var invoices = await _context.Invoices
+ .Include(i => i.Customer)
+ .Include(i => i.Payments)
+ .Where(i => i.Status != InvoiceStatus.Draft
+ && i.Status != InvoiceStatus.Voided
+ && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
+ .OrderBy(i => i.InvoiceDate)
+ .ToListAsync();
+
+ var collectedInPeriod = await _context.Payments
+ .Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
+ .SumAsync(p => (decimal?)p.Amount) ?? 0;
+
+ var byCustomer = invoices
+ .GroupBy(i => new
+ {
+ i.CustomerId,
+ Name = i.Customer!.IsCommercial
+ ? i.Customer.CompanyName
+ : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
+ })
+ .Select(g => new SalesByCustomerDto
+ {
+ CustomerId = g.Key.CustomerId,
+ CustomerName = g.Key.Name,
+ InvoiceCount = g.Count(),
+ TotalInvoiced = g.Sum(i => i.Total),
+ TotalPaid = g.Sum(i => i.AmountPaid),
+ BalanceDue = g.Sum(i => i.BalanceDue),
+ })
+ .OrderByDescending(c => c.TotalInvoiced)
+ .ToList();
+
+ var byMonth = invoices
+ .GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
+ .Select(g => new SalesByMonthDto
+ {
+ Year = g.Key.Year,
+ Month = g.Key.Month,
+ Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
+ TotalInvoiced = g.Sum(i => i.Total),
+ TotalCollected = g.Sum(i => i.AmountPaid),
+ InvoiceCount = g.Count(),
+ })
+ .OrderBy(m => m.Year).ThenBy(m => m.Month)
+ .ToList();
+
+ var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
+ {
+ InvoiceId = i.Id,
+ InvoiceNumber = i.InvoiceNumber,
+ CustomerName = i.Customer!.IsCommercial
+ ? i.Customer.CompanyName
+ : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
+ InvoiceDate = i.InvoiceDate,
+ DueDate = i.DueDate,
+ Status = i.Status.ToString(),
+ SubTotal = i.SubTotal,
+ TaxAmount = i.TaxAmount,
+ Total = i.Total,
+ AmountPaid = i.AmountPaid,
+ BalanceDue = i.BalanceDue,
+ }).ToList();
+
+ return new SalesIncomeReportDto
+ {
+ From = from,
+ To = to,
+ CompanyName = companyName,
+ TotalInvoiced = invoices.Sum(i => i.Total),
+ TotalCollected = collectedInPeriod,
+ TotalTax = invoices.Sum(i => i.TaxAmount),
+ TotalDiscount = invoices.Sum(i => i.DiscountAmount),
+ InvoiceCount = invoices.Count,
+ CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
+ ByCustomer = byCustomer,
+ ByMonth = byMonth,
+ Invoices = invoiceLines,
+ };
+ }
+
+ ///
+ /// Looks up the company name by ID for report headers and AI prompt injection.
+ /// Falls back to "Your Company" if the record is not found.
+ ///
+ private async Task GetCompanyNameAsync(int companyId)
+ {
+ if (companyId <= 0) return "Your Company";
+ var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
+ return company?.CompanyName ?? "Your Company";
+ }
}
diff --git a/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
index b080f2c..4d8bea5 100644
--- a/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
+++ b/src/PowderCoating.Infrastructure/Services/OperationalReportService.cs
@@ -1,4 +1,7 @@
+using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
+using PowderCoating.Core.Entities;
+using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Services;
@@ -19,10 +22,124 @@ public class OperationalReportService : IOperationalReportService
}
///
- public Task GetJobCycleTimeAsync(int companyId, int months)
- => throw new NotImplementedException("Migrate from ReportsController.JobCycleTime — Phase 2.");
+ public async Task GetJobCycleTimeAsync(int companyId, int months)
+ {
+ var completedCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
+
+ var completedJobs = await _context.Jobs
+ .Include(j => j.JobStatus)
+ .Where(j => !j.IsDeleted && completedCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue)
+ .AsNoTracking()
+ .ToListAsync();
+
+ var history = await _context.JobStatusHistory
+ .Include(h => h.FromStatus)
+ .Include(h => h.ToStatus)
+ .AsNoTracking()
+ .ToListAsync();
+
+ var historyByJob = history
+ .GroupBy(h => h.JobId)
+ .ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
+
+ var statusTimings = new Dictionary Days)>();
+
+ foreach (var job in completedJobs)
+ {
+ if (!historyByJob.TryGetValue(job.Id, out var jobHistory) || !jobHistory.Any()) continue;
+
+ var prevDate = job.CreatedAt;
+ foreach (var entry in jobHistory)
+ {
+ var fc = entry.FromStatus?.StatusCode;
+ var fn = entry.FromStatus?.DisplayName;
+ if (fc == null) { prevDate = entry.ChangedDate; continue; }
+ var d = (entry.ChangedDate - prevDate).TotalDays;
+ if (d >= 0 && d <= 365)
+ {
+ if (!statusTimings.ContainsKey(fc)) statusTimings[fc] = (fn ?? fc, new List());
+ statusTimings[fc].Days.Add(d);
+ }
+ prevDate = entry.ChangedDate;
+ }
+
+ var last = jobHistory.Last();
+ var tc = last.ToStatus?.StatusCode;
+ var tn = last.ToStatus?.DisplayName;
+ if (tc != null)
+ {
+ var d = (job.CompletedDate!.Value - last.ChangedDate).TotalDays;
+ if (d >= 0 && d <= 365)
+ {
+ if (!statusTimings.ContainsKey(tc)) statusTimings[tc] = (tn ?? tc, new List());
+ statusTimings[tc].Days.Add(d);
+ }
+ }
+ }
+
+ var rows = statusTimings
+ .Where(kv => kv.Value.Days.Any())
+ .Select(kv => new JobCycleTimeRow(kv.Value.DisplayName, Math.Round(kv.Value.Days.Average(), 1), kv.Value.Days.Count))
+ .ToList();
+
+ return new JobCycleTimeReport(rows, months);
+ }
///
- public Task GetPowderUsageAsync(int companyId, int months)
- => throw new NotImplementedException("Migrate from ReportsController.PowderUsage — Phase 2.");
+ public async Task GetPowderUsageAsync(int companyId, int months)
+ {
+ var startDate = DateTime.UtcNow.AddMonths(-months);
+
+ var transactions = await _context.InventoryTransactions
+ .Include(t => t.InventoryItem)
+ .Where(t => !t.IsDeleted
+ && t.TransactionType == InventoryTransactionType.JobUsage
+ && t.TransactionDate >= startDate)
+ .AsNoTracking()
+ .ToListAsync();
+
+ var rows = transactions
+ .Where(t => t.InventoryItem != null)
+ .GroupBy(t => t.InventoryItemId)
+ .Select(g => new PowderUsageRow(
+ ColorName: g.First().InventoryItem!.ColorName ?? g.First().InventoryItem!.Name,
+ VendorName: g.First().InventoryItem!.Manufacturer ?? string.Empty,
+ TotalLbs: g.Sum(t => Math.Abs(t.Quantity)),
+ TotalCost: g.Sum(t => Math.Abs(t.TotalCost))))
+ .OrderByDescending(r => r.TotalLbs)
+ .ToList();
+
+ return new PowderUsageReport(rows, months);
+ }
+
+ ///
+ public async Task> GetActiveBillsAsync()
+ {
+ return await _context.Bills
+ .Include(b => b.Vendor)
+ .Include(b => b.Payments.Where(p => !p.IsDeleted))
+ .Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
+ .AsNoTracking()
+ .ToListAsync();
+ }
+
+ ///
+ public async Task> GetAllExpensesAsync()
+ {
+ return await _context.Expenses
+ .Include(e => e.ExpenseAccount)
+ .Where(e => !e.IsDeleted)
+ .AsNoTracking()
+ .ToListAsync();
+ }
+
+ ///
+ public async Task> GetAllJobStatusHistoryAsync()
+ {
+ return await _context.JobStatusHistory
+ .Include(h => h.FromStatus)
+ .Include(h => h.ToStatus)
+ .AsNoTracking()
+ .ToListAsync();
+ }
}
diff --git a/src/PowderCoating.Web/Controllers/AccountingExportController.cs b/src/PowderCoating.Web/Controllers/AccountingExportController.cs
index 433734b..3d5039f 100644
--- a/src/PowderCoating.Web/Controllers/AccountingExportController.cs
+++ b/src/PowderCoating.Web/Controllers/AccountingExportController.cs
@@ -1,9 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
using System.IO.Compression;
using System.Text;
@@ -12,14 +10,14 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class AccountingExportController : Controller
{
- private readonly ApplicationDbContext _context;
+ private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
- public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext,
+ public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
PowderCoating.Application.Interfaces.IAuditService auditService)
{
- _context = context;
+ _unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_auditService = auditService;
}
@@ -60,42 +58,33 @@ public class AccountingExportController : Controller
[ValidateAntiForgeryToken]
public async Task Export(DateTime startDate, DateTime endDate, string format)
{
- var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
// ── Load data ─────────────────────────────────────────────────────────
- var invoices = await _context.Invoices
- .Include(i => i.InvoiceItems)
- .Include(i => i.Payments)
- .Include(i => i.Customer)
- .Where(i => !i.IsDeleted && i.CompanyId == companyId
- && i.InvoiceDate >= start && i.InvoiceDate <= end)
+ var invoices = (await _unitOfWork.Invoices.FindAsync(
+ i => i.InvoiceDate >= start && i.InvoiceDate <= end,
+ false,
+ i => i.InvoiceItems,
+ i => i.Payments,
+ i => i.Customer))
.OrderBy(i => i.InvoiceDate)
- .ToListAsync();
+ .ToList();
- var expenses = await _context.Set()
- .Include(e => e.Vendor)
- .Include(e => e.ExpenseAccount)
- .Include(e => e.PaymentAccount)
- .Where(e => !e.IsDeleted && e.CompanyId == companyId
- && e.Date >= start && e.Date <= end)
+ var expenses = (await _unitOfWork.Expenses.FindAsync(
+ e => e.Date >= start && e.Date <= end,
+ false,
+ e => e.Vendor,
+ e => e.ExpenseAccount,
+ e => e.PaymentAccount))
.OrderBy(e => e.Date)
- .ToListAsync();
+ .ToList();
- var bills = await _context.Set()
- .Include(b => b.Vendor)
- .Include(b => b.LineItems).ThenInclude(l => l.Account)
- .Include(b => b.Payments)
- .Where(b => !b.IsDeleted && b.CompanyId == companyId
- && b.BillDate >= start && b.BillDate <= end)
- .OrderBy(b => b.BillDate)
- .ToListAsync();
+ var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
- var customers = await _context.Customers
- .Where(c => !c.IsDeleted && c.CompanyId == companyId)
+ var customers = (await _unitOfWork.Customers.GetAllAsync())
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
- .ToListAsync();
+ .ToList();
// ── Build ZIP ─────────────────────────────────────────────────────────
using var ms = new MemoryStream();
diff --git a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
index f95a314..e111049 100644
--- a/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
+++ b/src/PowderCoating.Web/Controllers/AiQuickQuoteController.cs
@@ -8,7 +8,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -19,7 +18,6 @@ public class AiQuickQuoteController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly IAiQuickQuoteService _aiService;
private readonly IPricingCalculationService _pricingService;
- private readonly ApplicationDbContext _context;
private readonly UserManager _userManager;
private readonly ILogger _logger;
@@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller
IUnitOfWork unitOfWork,
IAiQuickQuoteService aiService,
IPricingCalculationService pricingService,
- ApplicationDbContext context,
UserManager userManager,
ILogger logger)
{
_unitOfWork = unitOfWork;
_aiService = aiService;
_pricingService = pricingService;
- _context = context;
_userManager = userManager;
_logger = logger;
}
@@ -106,9 +102,7 @@ public class AiQuickQuoteController : Controller
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
// Draft status — nullable FK, gracefully absent if lookup not seeded
- var draftStatus = await _context.QuoteStatusLookups
- .Where(s => s.StatusCode == "DRAFT")
- .FirstOrDefaultAsync();
+ var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var now = DateTime.UtcNow;
@@ -300,21 +294,13 @@ public class AiQuickQuoteController : Controller
{
var now = DateTime.UtcNow;
- var prefs = await _context.CompanyPreferences
- .IgnoreQueryFilters()
- .Where(p => p.CompanyId == companyId && !p.IsDeleted)
- .Select(p => new { p.QuoteNumberPrefix })
- .FirstOrDefaultAsync();
+ var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
+ p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
- var lastQuoteNumber = await _context.Quotes
- .IgnoreQueryFilters()
- .Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
- .OrderByDescending(q => q.QuoteNumber)
- .Select(q => q.QuoteNumber)
- .FirstOrDefaultAsync();
+ var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
int nextNumber = 1;
if (lastQuoteNumber != null)
diff --git a/src/PowderCoating.Web/Controllers/AiUsageReportController.cs b/src/PowderCoating.Web/Controllers/AiUsageReportController.cs
index ddc64b0..2d6987e 100644
--- a/src/PowderCoating.Web/Controllers/AiUsageReportController.cs
+++ b/src/PowderCoating.Web/Controllers/AiUsageReportController.cs
@@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Application.Interfaces;
+using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -9,108 +9,74 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AiUsageReportController : Controller
{
- private readonly ApplicationDbContext _context;
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly IAiUsageReportService _aiUsageReport;
private readonly ILogger _logger;
- public AiUsageReportController(ApplicationDbContext context, ILogger logger)
+ public AiUsageReportController(
+ IUnitOfWork unitOfWork,
+ IAiUsageReportService aiUsageReport,
+ ILogger logger)
{
- _context = context;
+ _unitOfWork = unitOfWork;
+ _aiUsageReport = aiUsageReport;
_logger = logger;
}
///
/// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top
/// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants.
- /// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
+ /// Companies and plan configs come from IUnitOfWork; AiUsageLogs aggregations and photo counts
+ /// come from IAiUsageReportService (which runs SQL GROUP BY queries via ApplicationDbContext).
///
public async Task Index()
{
try
{
- var now = DateTime.UtcNow;
- var todayStart = now.Date;
- var last7Start = todayStart.AddDays(-7);
- var last30Start = todayStart.AddDays(-30);
-
- // Companies (non-deleted only)
- var companies = await _context.Companies
- .IgnoreQueryFilters()
+ var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
.Where(c => !c.IsDeleted)
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
- .ToListAsync();
+ .ToList();
- // Plan display names from SubscriptionPlanConfig
- var planConfigs = await _context.Set()
- .IgnoreQueryFilters()
- .Where(p => !p.IsDeleted)
- .Select(p => new { p.Plan, p.DisplayName })
- .ToListAsync();
+ var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync();
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
- // All-time usage grouped by company — the four count windows are computed in SQL
- var usageByCompany = await _context.AiUsageLogs
- .GroupBy(l => l.CompanyId)
- .Select(g => new
- {
- CompanyId = g.Key,
- Today = g.Count(l => l.CalledAt >= todayStart),
- Last7Days = g.Count(l => l.CalledAt >= last7Start),
- Last30Days = g.Count(l => l.CalledAt >= last30Start),
- AllTime = g.Count()
- })
- .ToListAsync();
+ var data = await _aiUsageReport.GetReportDataAsync();
- // Top feature per company over the last 30 days
- var featureStats = await _context.AiUsageLogs
- .Where(l => l.CalledAt >= last30Start)
- .GroupBy(l => new { l.CompanyId, l.Feature })
- .Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() })
- .ToListAsync();
-
- var topFeatureByCompany = featureStats
+ var topFeatureByCompany = data.FeatureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(f => f.Count).First().Feature);
- // Feature breakdown per company (last 30 days)
- var featureBreakdownByCompany = featureStats
+ var featureBreakdownByCompany = data.FeatureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(f => f.Feature, f => f.Count));
- // Total AI photos per company (all time, including deleted photos)
- var photoCounts = await _context.QuotePhotos
- .IgnoreQueryFilters()
- .Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
- .GroupBy(p => p.CompanyId)
- .Select(g => new { CompanyId = g.Key, Count = g.Count() })
- .ToDictionaryAsync(x => x.CompanyId, x => x.Count);
-
- // Build report rows
- var usageDict = usageByCompany.ToDictionary(u => u.CompanyId);
+ var usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId);
var rows = companies.Select(c =>
{
usageDict.TryGetValue(c.Id, out var u);
- photoCounts.TryGetValue(c.Id, out var photos);
+ data.PhotoCountsByCompany.TryGetValue(c.Id, out var photos);
topFeatureByCompany.TryGetValue(c.Id, out var topFeature);
featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown);
planNames.TryGetValue(c.SubscriptionPlan, out var planName);
return new AiUsageReportRow
{
- CompanyId = c.Id,
- CompanyName = c.CompanyName,
- Plan = planName ?? $"Plan {c.SubscriptionPlan}",
- IsActive = c.IsActive,
- Today = u?.Today ?? 0,
- Last7Days = u?.Last7Days ?? 0,
- Last30Days = u?.Last30Days ?? 0,
- AllTime = u?.AllTime ?? 0,
- PhotoCount = photos,
- TopFeature = topFeature,
+ CompanyId = c.Id,
+ CompanyName = c.CompanyName,
+ Plan = planName ?? $"Plan {c.SubscriptionPlan}",
+ IsActive = c.IsActive,
+ Today = u?.Today ?? 0,
+ Last7Days = u?.Last7Days ?? 0,
+ Last30Days = u?.Last30Days ?? 0,
+ AllTime = u?.AllTime ?? 0,
+ PhotoCount = photos,
+ TopFeature = topFeature,
FeatureBreakdown = breakdown ?? []
};
})
@@ -118,15 +84,14 @@ public class AiUsageReportController : Controller
.ThenByDescending(r => r.AllTime)
.ToList();
- // Platform totals for summary cards
var vm = new AiUsageReportViewModel
{
- Rows = rows,
- TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
- TotalCallsToday = rows.Sum(r => r.Today),
- CompaniesActiveToday = rows.Count(r => r.Today > 0),
- TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
- MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
+ Rows = rows,
+ TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
+ TotalCallsToday = rows.Sum(r => r.Today),
+ CompaniesActiveToday = rows.Count(r => r.Today > 0),
+ TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
+ MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
};
return View(vm);
diff --git a/src/PowderCoating.Web/Controllers/AnnouncementsController.cs b/src/PowderCoating.Web/Controllers/AnnouncementsController.cs
index 4e6614c..f01f85d 100644
--- a/src/PowderCoating.Web/Controllers/AnnouncementsController.cs
+++ b/src/PowderCoating.Web/Controllers/AnnouncementsController.cs
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AnnouncementsController : Controller
{
- private readonly ApplicationDbContext _db;
+ private readonly IUnitOfWork _unitOfWork;
private readonly IInAppNotificationService _inApp;
- public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
+ public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
{
- _db = db;
+ _unitOfWork = unitOfWork;
_inApp = inApp;
}
@@ -25,18 +24,18 @@ public class AnnouncementsController : Controller
///
public async Task Index()
{
- var announcements = await _db.Announcements
+ var announcements = (await _unitOfWork.Announcements.GetAllAsync())
.OrderByDescending(a => a.CreatedAt)
- .ToListAsync();
+ .ToList();
return View(announcements);
}
///
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
///
- public IActionResult Create()
+ public async Task Create()
{
- PopulateDropdowns();
+ await PopulateDropdownsAsync();
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
}
@@ -46,7 +45,7 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task Create(Announcement model)
{
- if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
+ if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
@@ -54,10 +53,9 @@ public class AnnouncementsController : Controller
model.StartsAt = model.StartsAt.ToUniversalTime();
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
- _db.Announcements.Add(model);
- await _db.SaveChangesAsync();
+ await _unitOfWork.Announcements.AddAsync(model);
+ await _unitOfWork.CompleteAsync();
- // Dispatch as in-app notifications to targeted companies
await DispatchNotificationsAsync(model);
TempData["Success"] = "Announcement created and sent as notifications.";
@@ -69,9 +67,9 @@ public class AnnouncementsController : Controller
///
public async Task Edit(int id)
{
- var announcement = await _db.Announcements.FindAsync(id);
+ var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
if (announcement == null) return NotFound();
- PopulateDropdowns();
+ await PopulateDropdownsAsync();
return View(announcement);
}
@@ -81,9 +79,9 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task Edit(int id, Announcement model)
{
- if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
+ if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
- var existing = await _db.Announcements.FindAsync(id);
+ var existing = await _unitOfWork.Announcements.GetByIdAsync(id);
if (existing == null) return NotFound();
existing.Title = model.Title;
@@ -98,7 +96,7 @@ public class AnnouncementsController : Controller
existing.IsActive = model.IsActive;
existing.UpdatedAt = DateTime.UtcNow;
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
TempData["Success"] = "Announcement updated.";
return RedirectToAction(nameof(Index));
}
@@ -109,49 +107,42 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task Delete(int id)
{
- var announcement = await _db.Announcements.FindAsync(id);
+ var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
if (announcement == null) return NotFound();
- _db.Announcements.Remove(announcement);
- await _db.SaveChangesAsync();
+ await _unitOfWork.Announcements.DeleteAsync(announcement);
+ await _unitOfWork.CompleteAsync();
TempData["Success"] = "Announcement deleted.";
return RedirectToAction(nameof(Index));
}
///
- /// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
+ /// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification.
///
private async Task DispatchNotificationsAsync(Announcement model)
{
- IQueryable companyQuery = _db.Companies
- .IgnoreQueryFilters()
- .Where(c => !c.IsDeleted && c.IsActive);
+ var companies = (await _unitOfWork.Companies.FindAsync(
+ c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList();
if (model.Target == "Plan" && model.TargetPlan.HasValue)
- companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
+ companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList();
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
- companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
+ companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList();
- var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
-
- foreach (var companyId in companyIds)
- {
- await _inApp.CreateAsync(
- companyId,
- model.Title,
- model.Message,
- "Announcement");
- }
+ foreach (var company in companies)
+ await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement");
}
///
- /// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
+ /// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
///
- private void PopulateDropdowns()
+ private async Task PopulateDropdownsAsync()
{
- ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
- .Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
- .Select(c => new { c.Id, c.CompanyName }).ToList();
- ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
- .Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
+ ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
+ .OrderBy(c => c.CompanyName)
+ .Select(c => new { c.Id, c.CompanyName })
+ .ToList();
+ ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true))
+ .OrderBy(p => p.SortOrder)
+ .ToList();
}
}
diff --git a/src/PowderCoating.Web/Controllers/AuditLogController.cs b/src/PowderCoating.Web/Controllers/AuditLogController.cs
index 86702e0..7d88735 100644
--- a/src/PowderCoating.Web/Controllers/AuditLogController.cs
+++ b/src/PowderCoating.Web/Controllers/AuditLogController.cs
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
/// the application — they are append-only by design to maintain an unambiguous
/// trail of changes across all tenants.
///
+// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AuditLogController : Controller
{
diff --git a/src/PowderCoating.Web/Controllers/BannedIpsController.cs b/src/PowderCoating.Web/Controllers/BannedIpsController.cs
index 9a93b3c..f8c7808 100644
--- a/src/PowderCoating.Web/Controllers/BannedIpsController.cs
+++ b/src/PowderCoating.Web/Controllers/BannedIpsController.cs
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -15,28 +14,26 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class BannedIpsController : Controller
{
- private readonly ApplicationDbContext _db;
+ private readonly IUnitOfWork _unitOfWork;
private readonly UserManager _userManager;
private readonly ILogger _logger;
public BannedIpsController(
- ApplicationDbContext db,
+ IUnitOfWork unitOfWork,
UserManager userManager,
ILogger logger)
{
- _db = db;
+ _unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
}
/// Lists all banned IPs, showing active and expired separately.
- // GET: BannedIps
public async Task Index()
{
- var bans = await _db.BannedIps
+ var bans = (await _unitOfWork.BannedIps.GetAllAsync())
.OrderByDescending(b => b.BannedAt)
- .ToListAsync();
-
+ .ToList();
return View(bans);
}
@@ -44,9 +41,7 @@ public class BannedIpsController : Controller
/// Adds a new IP ban. Rejects obviously invalid formats but doesn't require
/// a perfect regex — admins are trusted to enter valid IPs.
///
- // POST: BannedIps/Add
- [HttpPost]
- [ValidateAntiForgeryToken]
+ [HttpPost, ValidateAntiForgeryToken]
public async Task Add(string ipAddress, string? reason, DateTime? expiresAt)
{
if (string.IsNullOrWhiteSpace(ipAddress))
@@ -57,15 +52,13 @@ public class BannedIpsController : Controller
ipAddress = ipAddress.Trim();
- // Basic sanity check — must look like an IPv4 or IPv6 address
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
{
TempData["Error"] = $"'{ipAddress}' is not a valid IP address.";
return RedirectToAction(nameof(Index));
}
- // Don't duplicate an active ban for the same IP
- var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
+ var existing = await _unitOfWork.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
if (existing != null)
{
TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy}).";
@@ -74,7 +67,7 @@ public class BannedIpsController : Controller
var currentUser = await _userManager.GetUserAsync(User);
- _db.BannedIps.Add(new BannedIp
+ await _unitOfWork.BannedIps.AddAsync(new BannedIp
{
IpAddress = ipAddress,
Reason = reason?.Trim(),
@@ -83,8 +76,7 @@ public class BannedIpsController : Controller
ExpiresAt = expiresAt,
IsActive = true
});
-
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
_logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason);
TempData["Success"] = $"{ipAddress} has been banned.";
@@ -92,12 +84,10 @@ public class BannedIpsController : Controller
}
/// Lifts a ban immediately by marking IsActive = false.
- // POST: BannedIps/Lift/5
- [HttpPost]
- [ValidateAntiForgeryToken]
+ [HttpPost, ValidateAntiForgeryToken]
public async Task Lift(int id)
{
- var ban = await _db.BannedIps.FindAsync(id);
+ var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
if (ban == null)
{
TempData["Error"] = "Ban not found.";
@@ -105,7 +95,7 @@ public class BannedIpsController : Controller
}
ban.IsActive = false;
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
_logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted.";
@@ -113,25 +103,21 @@ public class BannedIpsController : Controller
}
/// Permanently deletes a ban record.
- // POST: BannedIps/Delete/5
- [HttpPost]
- [ValidateAntiForgeryToken]
+ [HttpPost, ValidateAntiForgeryToken]
public async Task Delete(int id)
{
- var ban = await _db.BannedIps.FindAsync(id);
+ var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
if (ban != null)
{
- _db.BannedIps.Remove(ban);
- await _db.SaveChangesAsync();
+ await _unitOfWork.BannedIps.DeleteAsync(ban);
+ await _unitOfWork.CompleteAsync();
_logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban record for {ban.IpAddress} deleted.";
}
-
return RedirectToAction(nameof(Index));
}
/// Returns the requesting client's IP so the admin can pre-fill it quickly.
- // GET: BannedIps/MyIp
public IActionResult MyIp()
{
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
diff --git a/src/PowderCoating.Web/Controllers/BugReportController.cs b/src/PowderCoating.Web/Controllers/BugReportController.cs
index 93fad69..307f74b 100644
--- a/src/PowderCoating.Web/Controllers/BugReportController.cs
+++ b/src/PowderCoating.Web/Controllers/BugReportController.cs
@@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -22,7 +21,6 @@ public class BugReportController : Controller
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly UserManager _userManager;
- private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
private readonly IAdminNotificationService _adminNotification;
private readonly IAzureBlobStorageService _blobService;
@@ -40,7 +38,6 @@ public class BugReportController : Controller
IMapper mapper,
ITenantContext tenantContext,
UserManager userManager,
- ApplicationDbContext context,
IEmailService emailService,
IAdminNotificationService adminNotification,
IAzureBlobStorageService blobService,
@@ -51,7 +48,6 @@ public class BugReportController : Controller
_mapper = mapper;
_tenantContext = tenantContext;
_userManager = userManager;
- _context = context;
_emailService = emailService;
_adminNotification = adminNotification;
_blobService = blobService;
@@ -153,7 +149,7 @@ public class BugReportController : Controller
ContentType = file.ContentType,
FileSizeBytes = file.Length
};
- _context.BugReportAttachments.Add(attachment);
+ await _unitOfWork.BugReportAttachments.AddAsync(attachment);
uploadedCount++;
}
else
@@ -164,7 +160,7 @@ public class BugReportController : Controller
}
if (uploadedCount > 0)
- await _context.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
}
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
@@ -211,16 +207,13 @@ public class BugReportController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
- var query = _context.BugReports
- .AsNoTracking()
- .IgnoreQueryFilters()
- .Where(r => !r.IsDeleted)
- .AsQueryable();
+ var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
+ .AsEnumerable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
- query = query.Where(r =>
+ allReports = allReports.Where(r =>
r.Title.ToLower().Contains(search) ||
r.Description.ToLower().Contains(search) ||
r.SubmittedByUserName.ToLower().Contains(search));
@@ -228,31 +221,31 @@ public class BugReportController : Controller
if (!string.IsNullOrWhiteSpace(statusFilter) &&
Enum.TryParse(statusFilter, out var status))
- query = query.Where(r => r.Status == status);
+ allReports = allReports.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse(priorityFilter, out var priority))
- query = query.Where(r => r.Priority == priority);
+ allReports = allReports.Where(r => r.Priority == priority);
- query = (sortColumn, sortDirection == "asc") switch
+ allReports = (sortColumn, sortDirection == "asc") switch
{
- ("Title", true) => query.OrderBy(r => r.Title),
- ("Title", false) => query.OrderByDescending(r => r.Title),
- ("Status", true) => query.OrderBy(r => r.Status),
- ("Status", false) => query.OrderByDescending(r => r.Status),
- ("Priority", true) => query.OrderBy(r => r.Priority),
- ("Priority", false) => query.OrderByDescending(r => r.Priority),
- ("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
- ("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
- (_, true) => query.OrderBy(r => r.CreatedAt),
- _ => query.OrderByDescending(r => r.CreatedAt)
+ ("Title", true) => allReports.OrderBy(r => r.Title),
+ ("Title", false) => allReports.OrderByDescending(r => r.Title),
+ ("Status", true) => allReports.OrderBy(r => r.Status),
+ ("Status", false) => allReports.OrderByDescending(r => r.Status),
+ ("Priority", true) => allReports.OrderBy(r => r.Priority),
+ ("Priority", false) => allReports.OrderByDescending(r => r.Priority),
+ ("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
+ ("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
+ (_, true) => allReports.OrderBy(r => r.CreatedAt),
+ _ => allReports.OrderByDescending(r => r.CreatedAt)
};
- var totalCount = await query.CountAsync();
- var items = await query
+ var totalCount = allReports.Count();
+ var items = allReports
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
- .ToListAsync();
+ .ToList();
var dtos = _mapper.Map>(items);
@@ -291,12 +284,10 @@ public class BugReportController : Controller
var dto = _mapper.Map(bugReport);
- var attachments = await _context.BugReportAttachments
- .AsNoTracking()
- .IgnoreQueryFilters()
- .Where(a => a.BugReportId == id && !a.IsDeleted)
+ var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
+ a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
.OrderBy(a => a.CreatedAt)
- .ToListAsync();
+ .ToList();
dto.Attachments = _mapper.Map>(attachments);
@@ -319,10 +310,7 @@ public class BugReportController : Controller
[HttpGet]
public async Task Attachment(int id)
{
- var attachment = await _context.BugReportAttachments
- .AsNoTracking()
- .IgnoreQueryFilters()
- .FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
+ var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
if (attachment == null)
return NotFound();
diff --git a/src/PowderCoating.Web/Controllers/CompaniesController.cs b/src/PowderCoating.Web/Controllers/CompaniesController.cs
index 5e70f69..2db07b0 100644
--- a/src/PowderCoating.Web/Controllers/CompaniesController.cs
+++ b/src/PowderCoating.Web/Controllers/CompaniesController.cs
@@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using System.Security.Claims;
@@ -24,7 +24,9 @@ public class CompaniesController : Controller
private readonly IMapper _mapper;
private readonly UserManager _userManager;
private readonly ISeedDataService _seedDataService;
- private readonly ApplicationDbContext _context;
+ private readonly ICompanyListService _companyList;
+ private readonly ICompanyDataPurgeService _companyPurge;
+ private readonly IAuditLogService _auditLog;
private readonly IInAppNotificationService _inApp;
private readonly ILogger _logger;
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
IMapper mapper,
UserManager userManager,
ISeedDataService seedDataService,
- ApplicationDbContext context,
+ ICompanyListService companyList,
+ ICompanyDataPurgeService companyPurge,
+ IAuditLogService auditLog,
IInAppNotificationService inApp,
ILogger logger)
{
@@ -41,7 +45,9 @@ public class CompaniesController : Controller
_mapper = mapper;
_userManager = userManager;
_seedDataService = seedDataService;
- _context = context;
+ _companyList = companyList;
+ _companyPurge = companyPurge;
+ _auditLog = auditLog;
_inApp = inApp;
_logger = logger;
}
@@ -67,88 +73,27 @@ public class CompaniesController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
- var query = _context.Companies
- .AsNoTracking()
- .IgnoreQueryFilters()
- .Where(c => !c.IsDeleted)
- .AsQueryable();
-
- if (!string.IsNullOrWhiteSpace(searchTerm))
- {
- var s = searchTerm.ToLower();
- query = query.Where(c =>
- c.CompanyName.ToLower().Contains(s) ||
- (c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
- (c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
- (c.Phone != null && c.Phone.ToLower().Contains(s)));
- }
-
- query = (sortColumn, sortDirection == "asc") switch
- {
- ("CompanyName", true) => query.OrderBy(c => c.CompanyName),
- ("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
- ("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
- ("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
- ("Status", true) => query.OrderBy(c => c.IsActive),
- ("Status", false) => query.OrderByDescending(c => c.IsActive),
- ("Created", true) => query.OrderBy(c => c.CreatedAt),
- ("Created", false) => query.OrderByDescending(c => c.CreatedAt),
- _ => query.OrderBy(c => c.CompanyName)
- };
-
- var totalCount = await query.CountAsync();
- var companies = await query
- .Include(c => c.Users)
- .Skip((pageNumber - 1) * pageSize)
- .Take(pageSize)
- .ToListAsync();
+ var (companies, totalCount) = await _companyList.GetPagedAsync(
+ searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var companyDtos = _mapper.Map>(companies);
- // Populate job/quote/customer counts efficiently via group queries
- if (companyDtos.Any())
+ if (companyDtos.Count > 0)
{
var ids = companyDtos.Select(c => c.Id).ToList();
-
- var jobCounts = await _context.Jobs.IgnoreQueryFilters()
- .Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
- .GroupBy(j => j.CompanyId)
- .Select(g => new { g.Key, Count = g.Count() })
- .ToDictionaryAsync(x => x.Key, x => x.Count);
-
- var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
- .Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
- .GroupBy(q => q.CompanyId)
- .Select(g => new { g.Key, Count = g.Count() })
- .ToDictionaryAsync(x => x.Key, x => x.Count);
-
- var customerCounts = await _context.Customers.IgnoreQueryFilters()
- .Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
- .GroupBy(c => c.CompanyId)
- .Select(g => new { g.Key, Count = g.Count() })
- .ToDictionaryAsync(x => x.Key, x => x.Count);
-
- var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
- .Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
- .Select(p => new
- {
- p.CompanyId,
- p.SetupWizardCompletedAt,
- p.SetupWizardCompletedByName
- })
- .ToDictionaryAsync(x => x.CompanyId);
+ var summary = await _companyList.GetCountSummaryAsync(ids);
foreach (var dto in companyDtos)
{
- dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
- dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
- dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
+ dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
+ dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
+ dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
- if (wizardData.TryGetValue(dto.Id, out var w))
+ if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
{
dto.WizardCompleted = true;
- dto.WizardCompletedAt = w.SetupWizardCompletedAt;
- dto.WizardCompletedByName = w.SetupWizardCompletedByName;
+ dto.WizardCompletedAt = w.CompletedAt;
+ dto.WizardCompletedByName = w.CompletedByName;
}
}
}
@@ -380,8 +325,6 @@ public class CompaniesController : Controller
}
else
{
- // If user creation failed, we should consider rolling back company creation
- // For now, log the error and inform the user
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
@@ -441,14 +384,10 @@ public class CompaniesController : Controller
public async Task Edit(int id, UpdateCompanyDto model)
{
if (id != model.Id)
- {
return NotFound();
- }
if (!ModelState.IsValid)
- {
return View(model);
- }
try
{
@@ -473,9 +412,7 @@ public class CompaniesController : Controller
}
}
- // Update company properties
_mapper.Map(model, company);
-
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
@@ -560,7 +497,6 @@ public class CompaniesController : Controller
var companyName = company.CompanyName;
var userCount = company.Users.Count;
- // Soft-delete the company and deactivate all users
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
@@ -573,8 +509,7 @@ public class CompaniesController : Controller
await _unitOfWork.CompleteAsync();
- // Write audit log
- _context.AuditLogs.Add(new AuditLog
+ await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -588,7 +523,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
- await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
companyName, id, adminName);
@@ -625,8 +559,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
- var company = await _context.Companies.IgnoreQueryFilters()
- .FirstOrDefaultAsync(c => c.Id == id);
+ var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -640,91 +573,25 @@ public class CompaniesController : Controller
try
{
- // ── Tier 1: Leaf children (must go before their parents) ─────────────
- // JobItem children
- await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
-
- // QuoteItem children
- await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
-
- // AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
+ // Load user IDs first — needed for announcement-dismissal cleanup in the purge service
var userIds = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
- var announcementIds = await _context.Announcements
- .Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
- await _context.AnnouncementDismissals.IgnoreQueryFilters()
- .Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
- .ExecuteDeleteAsync();
- // ── Tier 2: Mid-level children ────────────────────────────────────────
- await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
+ // Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
+ await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
- // ── Tier 3: Top-level company entities ───────────────────────────────
- // Order matters: child-side of FK must be deleted before parent-side.
- // Invoices/Appointments → Customers; Bills/Expenses → Vendors
- await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- // Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
- await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
- await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
-
- // ── Tier 4: Company configs and lookup tables ─────────────────────────
- await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
-
- // ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
+ // Tier 5: delete Identity users so AspNetUser* tables cascade correctly
var users = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).ToListAsync();
var userCount = users.Count;
foreach (var user in users)
await _userManager.DeleteAsync(user);
- // ── Tier 6: Company record ────────────────────────────────────────────
- await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
+ // Tier 6: delete company record
+ await _unitOfWork.Companies.DeleteAsync(company);
+ await _unitOfWork.CompleteAsync();
- // Write audit log (use platform default company context — no companyId since it's gone)
- _context.AuditLogs.Add(new AuditLog
+ await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -738,7 +605,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
- await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
companyName, id, adminName, userCount);
@@ -764,8 +630,6 @@ public class CompaniesController : Controller
/// to record the action. This operation is irreversible.
///
// POST: Companies/ResetData/5
- // Permanently hard-deletes all business data for a company while keeping the company record,
- // its users, operating costs, preferences, and lookup tables intact.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task ResetData(int id, string confirmation)
@@ -776,8 +640,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
- var company = await _context.Companies.IgnoreQueryFilters()
- .FirstOrDefaultAsync(c => c.Id == id);
+ var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -791,94 +654,9 @@ public class CompaniesController : Controller
try
{
- // ── Tier 0: Grandchildren ─────────────────────────────────────────────
- await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
+ await _companyPurge.ResetBusinessDataAsync(id);
- // AnnouncementDismissals for company-targeted announcements
- var announcementIds = await _context.Announcements
- .Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
- if (announcementIds.Any())
- await _context.AnnouncementDismissals.IgnoreQueryFilters()
- .Where(x => announcementIds.Contains(x.AnnouncementId))
- .ExecuteDeleteAsync();
-
- // ── Tier 1: Children ──────────────────────────────────────────────────
- await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
-
- // ── Tier 2: Top-level business entities ──────────────────────────────
- // Order matters: child-side of FK must be deleted before parent-side.
- // Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
- await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
- // Company-targeted announcements only (platform-wide announcements are left alone)
- await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
-
- // Reset QB migration wizard progress
- var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
- .FirstOrDefaultAsync(p => p.CompanyId == id);
- if (prefs?.QbMigrationStateJson != null)
- {
- prefs.QbMigrationStateJson = null;
- prefs.UpdatedAt = DateTime.UtcNow;
- }
-
- // Audit log
- _context.AuditLogs.Add(new AuditLog
+ await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -892,7 +670,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
- await _context.SaveChangesAsync();
_logger.LogWarning(
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
@@ -960,9 +737,7 @@ public class CompaniesController : Controller
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company != null)
- {
model.CompanyName = company.CompanyName;
- }
return View(model);
}
@@ -976,7 +751,6 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
- // Check if user already exists
var existingUser = await _userManager.FindByEmailAsync(model.Email);
if (existingUser != null)
{
@@ -985,7 +759,6 @@ public class CompaniesController : Controller
return View(model);
}
- // Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.Email,
@@ -1018,9 +791,7 @@ public class CompaniesController : Controller
else
{
foreach (var error in result.Errors)
- {
ModelState.AddModelError("", error.Description);
- }
model.CompanyName = company.CompanyName;
return View(model);
}
@@ -1054,21 +825,13 @@ public class CompaniesController : Controller
return NotFound(new { error = "User not found." });
// Use the viewed company's timezone so timestamps match the tenant's local time
- var tz = await _context.Companies
- .Where(c => c.Id == companyId)
- .Select(c => c.TimeZone)
- .FirstOrDefaultAsync();
+ var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
+ var tz = company?.TimeZone;
var logs = new List();
try
{
- var rawLogs = await _context.AuditLogs
- .AsNoTracking()
- .Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
- .OrderByDescending(l => l.Timestamp)
- .Take(50)
- .Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
- .ToListAsync();
+ var rawLogs = await _auditLog.GetUserActivityAsync(userId);
logs = rawLogs.Select(l => (dynamic)new
{
diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
index 546c5e2..9eb6a06 100644
--- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
+++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs
@@ -12,6 +12,7 @@ using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
+using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
@@ -29,7 +30,7 @@ public class CompanySettingsController : Controller
private readonly ILookupCacheService _lookupCache;
private readonly IStripeConnectService _stripeConnect;
private readonly IConfiguration _configuration;
- private readonly ApplicationDbContext _context;
+ private readonly IAuditLogService _auditLog;
private readonly UserManager _userManager;
private readonly SignInManager _signInManager;
@@ -42,7 +43,7 @@ public class CompanySettingsController : Controller
ILookupCacheService lookupCache,
IStripeConnectService stripeConnect,
IConfiguration configuration,
- ApplicationDbContext context,
+ IAuditLogService auditLog,
UserManager userManager,
SignInManager signInManager)
{
@@ -54,7 +55,7 @@ public class CompanySettingsController : Controller
_lookupCache = lookupCache;
_stripeConnect = stripeConnect;
_configuration = configuration;
- _context = context;
+ _auditLog = auditLog;
_userManager = userManager;
_signInManager = signInManager;
}
@@ -126,9 +127,7 @@ public class CompanySettingsController : Controller
var dto = _mapper.Map(company);
// Populate AllowOnlinePayments from subscription plan config
- var planConfig = await _context.Set()
- .AsNoTracking()
- .FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
+ var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
// Flag whether Stripe Connect is configured (non-placeholder client ID)
@@ -2805,10 +2804,10 @@ public class CompanySettingsController : Controller
if (company == null) return NotFound();
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
- var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
- var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
- var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
- var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
+ var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
+ var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
+ var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
+ var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
ViewBag.CompanyName = company.CompanyName;
ViewBag.UserCount = userCount;
@@ -2859,10 +2858,10 @@ public class CompanySettingsController : Controller
// ── Gather counts for the audit snapshot ─────────────────────────
var userCount = company.Users.Count;
- var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
- var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
- var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
- var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
+ var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
+ var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
+ var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
+ var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
// ── Soft-delete the company ───────────────────────────────────────
var now = DateTime.UtcNow;
@@ -2881,7 +2880,7 @@ public class CompanySettingsController : Controller
await _unitOfWork.CompleteAsync();
// ── Write audit log ───────────────────────────────────────────────
- _context.AuditLogs.Add(new AuditLog
+ await _auditLog.LogAsync(new AuditLog
{
UserId = requestingUserId,
UserName = requestingUserName,
@@ -2899,7 +2898,6 @@ public class CompanySettingsController : Controller
Timestamp = now,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
- await _context.SaveChangesAsync();
_logger.LogWarning(
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
diff --git a/src/PowderCoating.Web/Controllers/CompanyUsersController.cs b/src/PowderCoating.Web/Controllers/CompanyUsersController.cs
index 201b847..548f0d1 100644
--- a/src/PowderCoating.Web/Controllers/CompanyUsersController.cs
+++ b/src/PowderCoating.Web/Controllers/CompanyUsersController.cs
@@ -9,7 +9,6 @@ using PowderCoating.Application.DTOs.User;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -25,7 +24,6 @@ public class CompanyUsersController : Controller
private readonly ILogger _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ISubscriptionService _subscriptionService;
- private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
public CompanyUsersController(
@@ -34,7 +32,6 @@ public class CompanyUsersController : Controller
ILogger logger,
IUnitOfWork unitOfWork,
ISubscriptionService subscriptionService,
- ApplicationDbContext context,
IEmailService emailService)
{
_userManager = userManager;
@@ -42,7 +39,6 @@ public class CompanyUsersController : Controller
_logger = logger;
_unitOfWork = unitOfWork;
_subscriptionService = subscriptionService;
- _context = context;
_emailService = emailService;
}
@@ -372,8 +368,8 @@ public class CompanyUsersController : Controller
CompanyId = companyId!.Value
};
- await _context.ShopWorkers.AddAsync(shopWorker);
- await _context.SaveChangesAsync();
+ await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
+ await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
@@ -639,9 +635,8 @@ public class CompanyUsersController : Controller
{
// Search by oldEmail so we find the record even when the email just changed
var lookupEmail = emailChanged ? oldEmail : user.Email;
- var existingShopWorker = await _context.ShopWorkers
- .Where(sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)
- .ToListAsync();
+ var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
+ sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
if (!existingShopWorker.Any())
{
@@ -656,8 +651,8 @@ public class CompanyUsersController : Controller
CompanyId = user.CompanyId
};
- await _context.ShopWorkers.AddAsync(shopWorker);
- await _context.SaveChangesAsync();
+ await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
+ await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
@@ -684,7 +679,7 @@ public class CompanyUsersController : Controller
shopWorker.Phone = user.PhoneNumber;
if (shopWorkerDirty)
- await _context.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
}
}
diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs
index 902d1dd..ce32d63 100644
--- a/src/PowderCoating.Web/Controllers/DashboardController.cs
+++ b/src/PowderCoating.Web/Controllers/DashboardController.cs
@@ -1,12 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Dashboard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
-using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus
+using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
@@ -17,7 +16,9 @@ public class DashboardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger _logger;
- private readonly ApplicationDbContext _context;
+ private readonly IDashboardReadService _dashboardRead;
+ private readonly ITenantContext _tenantContext;
+ private readonly ICompanyConfigHealthService _configHealth;
private static readonly string[] CompletedStatusCodes =
[
@@ -39,14 +40,16 @@ public class DashboardController : Controller
"QUALITY_CHECK"
];
- private readonly ITenantContext _tenantContext;
- private readonly ICompanyConfigHealthService _configHealth;
-
- public DashboardController(IUnitOfWork unitOfWork, ILogger logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
+ public DashboardController(
+ IUnitOfWork unitOfWork,
+ ILogger logger,
+ IDashboardReadService dashboardRead,
+ ITenantContext tenantContext,
+ ICompanyConfigHealthService configHealth)
{
_unitOfWork = unitOfWork;
_logger = logger;
- _context = context;
+ _dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
}
@@ -66,23 +69,14 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
- var startOfMonth = new DateTime(today.Year, today.Month, 1);
- var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
- var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
+ var lookAheadDate = today.AddDays(7);
- // Active jobs — filter completed/cancelled statuses at database level
- var activeJobs = await _context.Jobs
- .Include(j => j.Customer)
- .Include(j => j.AssignedUser)
- .Include(j => j.JobStatus)
- .Include(j => j.JobPriority)
- .Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
- .ToListAsync();
+ var data = await _dashboardRead.GetIndexDataAsync(today);
- var tomorrow = today.AddDays(1);
-
- // Today's Jobs
- var todaysJobsFiltered = activeJobs
+ // ---------------------------------------------------------------
+ // Job panels — in-memory split of the pre-fetched activeJobs list
+ // ---------------------------------------------------------------
+ var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
@@ -93,8 +87,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
- // Overdue Jobs
- var overdueJobsFiltered = activeJobs
+ var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
@@ -104,8 +97,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
- // In-Progress Jobs
- var inProgressJobs = activeJobs
+ var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
@@ -113,26 +105,11 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
- // Monthly Revenue — aggregate at database level (no need to load all jobs)
- var monthlyRevenue = await _context.Jobs
- .Include(j => j.JobStatus)
- .Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
- && j.UpdatedAt >= startOfMonth
- && j.UpdatedAt <= endOfMonth)
- .SumAsync(j => j.FinalPrice);
-
- // Today's Appointments — filter at database level
- var todaysAppointmentsRaw = await _context.Appointments
- .Include(a => a.Customer)
- .Include(a => a.AppointmentType)
- .Include(a => a.AppointmentStatus)
- .Include(a => a.AssignedUser)
- .Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
- && a.AppointmentStatus.StatusCode != "CANCELLED")
- .OrderBy(a => a.ScheduledStartTime)
- .ToListAsync();
- var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
- var todaysAppointments = todaysAppointmentsRaw
+ // ---------------------------------------------------------------
+ // Appointments
+ // ---------------------------------------------------------------
+ var todaysAppointmentsCount = data.TodaysAppointments.Count;
+ var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
@@ -150,9 +127,11 @@ public class DashboardController : Controller
AssignedWorkerName = a.AssignedUser?.FullName
}).ToList();
+ // ---------------------------------------------------------------
// Low stock items
+ // ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
- i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
+ i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
@@ -168,21 +147,10 @@ public class DashboardController : Controller
UnitOfMeasure = i.UnitOfMeasure
}).ToList();
- // Maintenance records — filter to pending/overdue at database level
- var upcomingMaintenance = await _context.MaintenanceRecords
- .Include(m => m.Equipment)
- .Include(m => m.AssignedUser)
- .Where(m => (m.Status == MaintenanceStatus.Scheduled
- || m.Status == MaintenanceStatus.InProgress
- || m.Status == MaintenanceStatus.Overdue)
- && (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
- .OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
- .ThenByDescending(m => m.Priority)
- .ThenBy(m => m.ScheduledDate)
- .Take(10)
- .ToListAsync();
-
- var upcomingMaintenanceDtos = upcomingMaintenance
+ // ---------------------------------------------------------------
+ // Maintenance
+ // ---------------------------------------------------------------
+ var upcomingMaintenanceDtos = data.UpcomingMaintenance
.Select(m => new DashboardMaintenanceDto
{
Id = m.Id,
@@ -195,14 +163,10 @@ public class DashboardController : Controller
AssignedWorkerName = m.AssignedUser?.FullName
}).ToList();
- // Pending Quotes — filter to SENT status at database level
- var pendingQuotesData = await _context.Quotes
- .Include(q => q.Customer)
- .Include(q => q.QuoteStatus)
- .Where(q => q.QuoteStatus.StatusCode == "SENT")
- .ToListAsync();
-
- var pendingQuotes = pendingQuotesData
+ // ---------------------------------------------------------------
+ // Quotes
+ // ---------------------------------------------------------------
+ var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
@@ -221,10 +185,9 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
- var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
+ var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
- // Expiring Quotes (next 7 days) - filter at database level
- var expiringQuotes = pendingQuotesData
+ var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
@@ -246,33 +209,17 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
- // Active Customers
+ // ---------------------------------------------------------------
+ // Active customers
+ // ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
- // Financial data — Invoices & Payments
+ // Invoices & AR aging
// ---------------------------------------------------------------
- var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
+ var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
- // Open invoices only — filter at database level
- var openInvoices = await _context.Invoices
- .Include(i => i.Customer)
- .Where(i => openStatuses.Contains(i.Status))
- .ToListAsync();
-
- var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
-
- // Invoiced this month — aggregate at database level
- var invoicedThisMonth = await _context.Invoices
- .Where(i => i.Status != InvoiceStatus.Draft
- && i.Status != InvoiceStatus.Voided
- && i.Status != InvoiceStatus.WrittenOff
- && i.InvoiceDate >= startOfMonth
- && i.InvoiceDate <= endOfMonth)
- .SumAsync(i => i.Total);
-
- // Overdue invoices: open and past due date
- var overdueInvoicesList = openInvoices
+ var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
@@ -295,9 +242,9 @@ public class DashboardController : Controller
})
.ToList();
- // AR Aging — bucket open invoices by days past due
+ // AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
- foreach (var inv in openInvoices)
+ foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
@@ -313,17 +260,10 @@ public class DashboardController : Controller
}
}
- // Payments this month — aggregate at database level
- var collectedThisMonth = await _context.Payments
- .Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
- .SumAsync(p => p.Amount);
-
- // Recent payments — load only the 6 most recent
- var recentPayments = (await _context.Payments
- .Include(p => p.Invoice).ThenInclude(i => i!.Customer)
- .OrderByDescending(p => p.PaymentDate)
- .Take(6)
- .ToListAsync())
+ // ---------------------------------------------------------------
+ // Payments
+ // ---------------------------------------------------------------
+ var recentPayments = data.RecentPayments
.Select(p => new DashboardPaymentDto
{
Id = p.Id,
@@ -335,44 +275,39 @@ public class DashboardController : Controller
PaymentDate = p.PaymentDate,
PaymentMethodDisplay = p.PaymentMethod switch
{
- PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
- PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
- PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
- PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
- PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
+ PaymentMethod.Cash => "Cash",
+ PaymentMethod.Check => "Check",
+ PaymentMethod.CreditDebitCard => "Card",
+ PaymentMethod.BankTransferACH => "ACH",
+ PaymentMethod.DigitalPayment => "Digital",
_ => "Other"
}
})
.ToList();
- // Equipment Alerts - filter at database level
+ // ---------------------------------------------------------------
+ // Equipment alerts
+ // ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
- e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
- e.Status == Core.Enums.EquipmentStatus.OutOfService))
- .OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
+ e => e.Status == EquipmentStatus.NeedsMaintenance ||
+ e.Status == EquipmentStatus.OutOfService))
+ .OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
EquipmentName = e.EquipmentName,
EquipmentType = e.EquipmentType,
- Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
- Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
+ Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
+ Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
LastMaintenanceDate = e.LastMaintenanceDate,
- NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
+ NextMaintenanceDue = null
}).ToList();
- // Recent Activity (last 10 quotes or jobs created in last 30 days)
- var last30Days = today.AddDays(-30);
-
- // Recent quotes — filter to last 30 days at database level
- var recentQuotes = (await _context.Quotes
- .Include(q => q.Customer)
- .Include(q => q.QuoteStatus)
- .Where(q => q.CreatedAt >= last30Days)
- .OrderByDescending(q => q.CreatedAt)
- .Take(5)
- .ToListAsync())
+ // ---------------------------------------------------------------
+ // Recent activity
+ // ---------------------------------------------------------------
+ var recentQuoteDtos = data.RecentQuotes
.Select(q => new DashboardRecentActivityDto
{
Id = q.Id,
@@ -390,14 +325,7 @@ public class DashboardController : Controller
Amount = q.Total
});
- // Recent jobs — filter to last 30 days at database level
- var recentJobs = (await _context.Jobs
- .Include(j => j.Customer)
- .Include(j => j.JobStatus)
- .Where(j => j.CreatedAt >= last30Days)
- .OrderByDescending(j => j.CreatedAt)
- .Take(5)
- .ToListAsync())
+ var recentJobDtos = data.RecentJobs
.Select(j => new DashboardRecentActivityDto
{
Id = j.Id,
@@ -413,33 +341,15 @@ public class DashboardController : Controller
Amount = j.FinalPrice
});
- var recentActivity = recentQuotes.Concat(recentJobs)
+ var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
.OrderByDescending(a => a.ActivityDate)
.Take(10)
.ToList();
- // === POWDER ORDERS NEEDED ===
- var jobsNeedingPowder = await _context.Jobs
- .Include(j => j.Customer)
- .Include(j => j.JobStatus)
- .Include(j => j.JobItems)
- .ThenInclude(i => i.Coats)
- .ThenInclude(c => c.InventoryItem)
- .ThenInclude(inv => inv!.PrimaryVendor)
- .Include(j => j.JobItems)
- .ThenInclude(i => i.Coats)
- .ThenInclude(c => c.Vendor)
- .Where(j => !j.IsDeleted
- && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
- && j.JobItems.Any(i => i.Coats.Any(c =>
- !c.IsDeleted &&
- !c.PowderOrdered &&
- c.PowderToOrder > 0 &&
- (c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
- .ToListAsync();
-
- // Flatten to individual coat lines that need ordering (with vendor info for grouping)
- var powderFlat = jobsNeedingPowder
+ // ---------------------------------------------------------------
+ // Powder orders needed
+ // ---------------------------------------------------------------
+ var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
@@ -500,26 +410,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
- // === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
- var jobsWithOrderedPowder = await _context.Jobs
- .Include(j => j.Customer)
- .Include(j => j.JobStatus)
- .Include(j => j.JobItems)
- .ThenInclude(i => i.Coats)
- .ThenInclude(c => c.InventoryItem)
- .ThenInclude(inv => inv!.PrimaryVendor)
- .Include(j => j.JobItems)
- .ThenInclude(i => i.Coats)
- .ThenInclude(c => c.Vendor)
- .Where(j => !j.IsDeleted
- && !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
- && j.JobItems.Any(i => i.Coats.Any(c =>
- !c.IsDeleted &&
- c.PowderOrdered &&
- !c.PowderReceived)))
- .ToListAsync();
-
- var placedFlat = jobsWithOrderedPowder
+ // ---------------------------------------------------------------
+ // Powder orders placed
+ // ---------------------------------------------------------------
+ var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
@@ -584,16 +478,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
- // === BILLS DUE ===
- var billsDueRaw = await _context.Bills
- .Include(b => b.Vendor)
- .Where(b => !b.IsDeleted &&
- (b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
- b.Total > b.AmountPaid)
- .OrderBy(b => b.DueDate)
- .Take(15)
- .ToListAsync();
- var billsDue = billsDueRaw.Select(b => new DashboardBillDto
+ // ---------------------------------------------------------------
+ // Bills due
+ // ---------------------------------------------------------------
+ var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
@@ -608,21 +496,21 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
- ActiveJobsCount = activeJobs.Count(),
+ ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
- PendingMaintenanceCount = upcomingMaintenance.Count,
- PendingQuotesCount = pendingQuotesData.Count(),
+ PendingMaintenanceCount = data.UpcomingMaintenance.Count,
+ PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
- MonthlyRevenue = monthlyRevenue,
+ MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
- CollectedThisMonth = collectedThisMonth,
- InvoicedThisMonth = invoicedThisMonth,
+ CollectedThisMonth = data.CollectedThisMonth,
+ InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
@@ -654,7 +542,9 @@ public class DashboardController : Controller
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersPlaced = powderPlacedGroups,
- PowderOrdersPlacedCount = placedFlat.Count
+ PowderOrdersPlacedCount = placedFlat.Count,
+
+ TipOfTheDay = data.TipOfTheDay
};
// Dropdowns for the "Add Custom Powder to Inventory" modal
@@ -671,13 +561,6 @@ public class DashboardController : Controller
ViewBag.InventoryCategories = inventoryCategories;
ViewBag.VendorList = vendors;
- // Random tip of the day
- var tips = await _context.DashboardTips
- .Where(t => t.IsActive)
- .ToListAsync();
- if (tips.Count > 0)
- vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
-
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
@@ -705,11 +588,7 @@ public class DashboardController : Controller
{
try
{
- var coat = await _context.JobItemCoats
- .Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
- .Include(c => c.Vendor)
- .Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
- .FirstOrDefaultAsync(c => c.Id == coatId);
+ var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
@@ -722,7 +601,7 @@ public class DashboardController : Controller
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
- await _context.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
@@ -761,9 +640,9 @@ public class DashboardController : Controller
/// Records receipt of a powder shipment against an existing powder order. Sets
/// PowderReceived, PowderReceivedLbs, and PowderReceivedAt on the coat,
/// and — when the coat is linked to an inventory item — increases QuantityOnHand and
- /// writes a Purchase so
- /// the stock movement is fully traceable. Company ownership is verified through the parent job
- /// because JobItemCoat carries no CompanyId of its own.
+ /// writes a Purchase so the stock movement is fully
+ /// traceable. Company ownership is verified through the parent job because JobItemCoat
+ /// carries no CompanyId of its own.
///
[HttpPost]
[ValidateAntiForgeryToken]
@@ -771,9 +650,8 @@ public class DashboardController : Controller
{
try
{
- var coat = await _context.JobItemCoats
- .Include(c => c.InventoryItem)
- .FirstOrDefaultAsync(c => c.Id == coatId);
+ // Load coat with inventory item for the stock update
+ var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -781,29 +659,25 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
- // Verify ownership — JobItemCoat has no CompanyId, check via parent job
- // (We need the job for company check; load it if not already included)
+ // Verify ownership — JobItemCoat has no CompanyId, check via parent job.
+ // If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
+ // query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
if (coatJobCompanyId == null)
{
- // Reload with parent chain if not included
- var coatWithJob = await _context.JobItemCoats
- .Include(c => c.JobItem).ThenInclude(i => i.Job)
- .FirstOrDefaultAsync(c => c.Id == coatId);
- coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
+ await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
+ coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
}
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
- // Mark coat as received
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
- // Update inventory if this coat is linked to an inventory item
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
{
var item = coat.InventoryItem;
@@ -813,26 +687,24 @@ public class DashboardController : Controller
if (coat.PowderCostPerLb.HasValue)
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
- // Record purchase transaction
- var transaction = new PowderCoating.Core.Entities.InventoryTransaction
+ var transaction = new InventoryTransaction
{
CompanyId = item.CompanyId,
InventoryItemId = item.Id,
- TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
+ TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
TransactionDate = DateTime.UtcNow,
- Reference = coat.JobItem != null ? null : null, // loaded below if needed
Notes = $"Received {lbsReceived:N2} lbs for job order",
BalanceAfter = previousBalance + lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
- _context.Set().Add(transaction);
+ await _unitOfWork.InventoryTransactions.AddAsync(transaction);
}
- await _context.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
}
@@ -864,7 +736,7 @@ public class DashboardController : Controller
{
try
{
- var coat = await _context.JobItemCoats.FindAsync(coatId);
+ var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -877,16 +749,13 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
- // Resolve company id from tenant context
- var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
- // More reliably get CompanyId from the job chain
- var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
- if (jobItem?.Job != null)
- companyId = jobItem.Job.CompanyId;
+ // Resolve company id from the job chain; fall back to tenant context
+ var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
+ i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
+ var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
- var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
- if (existingSku)
+ if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -925,10 +794,10 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow,
};
- _context.InventoryItems.Add(inventoryItem);
- await _context.SaveChangesAsync();
+ await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
+ await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
- // Record opening stock transaction
+ // Opening stock transaction
var transaction = new InventoryTransaction
{
CompanyId = companyId,
@@ -943,7 +812,7 @@ public class DashboardController : Controller
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
- _context.Set().Add(transaction);
+ await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
@@ -953,19 +822,12 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
- // Scan for other active job coats using the same custom powder and link them
- var candidateCoats = await _context.JobItemCoats
- .Include(c => c.JobItem)
- .Where(c => !c.IsDeleted
- && c.Id != coatId
- && c.InventoryItemId == null
- && c.JobItem.CompanyId == companyId)
- .ToListAsync();
+ // Scan for sibling coats with the same custom powder and link them to the new item
+ var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0;
foreach (var other in candidateCoats)
{
- // Match by color code first (most specific), then fall back to color name
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
@@ -973,7 +835,6 @@ public class DashboardController : Controller
if (!colorMatch) continue;
- // If both coats have a vendor set, they must agree
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
@@ -985,7 +846,7 @@ public class DashboardController : Controller
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
- await _context.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
@@ -1014,13 +875,10 @@ public class DashboardController : Controller
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
- var totalUsers = await _context.Users
- .Where(u => u.CompanyId > 0)
- .CountAsync();
+ var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
- // Load plan configs from DB so plan display names and distribution are DB-driven
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
@@ -1029,7 +887,6 @@ public class DashboardController : Controller
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
- // Companies needing attention: expired (past grace) or in grace period
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
@@ -1066,7 +923,6 @@ public class DashboardController : Controller
})
.ToList();
- // Build plan distribution from DB config (sorted by SortOrder)
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
diff --git a/src/PowderCoating.Web/Controllers/DashboardTipsController.cs b/src/PowderCoating.Web/Controllers/DashboardTipsController.cs
index 0e2d36b..7263166 100644
--- a/src/PowderCoating.Web/Controllers/DashboardTipsController.cs
+++ b/src/PowderCoating.Web/Controllers/DashboardTipsController.cs
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
-using PowderCoating.Infrastructure.Data;
+using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DashboardTipsController : Controller
{
- private readonly ApplicationDbContext _db;
+ private readonly IUnitOfWork _unitOfWork;
- public DashboardTipsController(ApplicationDbContext db)
+ public DashboardTipsController(IUnitOfWork unitOfWork)
{
- _db = db;
+ _unitOfWork = unitOfWork;
}
///
/// Returns a paginated, optionally filtered list of all dashboard tips.
/// Active tips are sorted first (then by newest Id) to make the currently
- /// live pool easy to review at a glance. ViewBag includes both the filtered
- /// count and the global active/total counts for the header summary cards.
+ /// live pool easy to review at a glance.
///
- // GET: /DashboardTips
public async Task Index(string? search, bool? activeOnly, int page = 1)
{
const int pageSize = 25;
- var query = _db.DashboardTips.AsQueryable();
+ var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList();
if (!string.IsNullOrWhiteSpace(search))
- query = query.Where(t => t.TipText.Contains(search));
+ all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
if (activeOnly == true)
- query = query.Where(t => t.IsActive);
+ all = all.Where(t => t.IsActive).ToList();
- var total = await query.CountAsync();
- var tips = await query
+ var total = all.Count;
+ var tips = all
.OrderByDescending(t => t.IsActive)
.ThenByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
- .ToListAsync();
+ .ToList();
ViewBag.Search = search;
ViewBag.ActiveOnly = activeOnly ?? false;
@@ -58,23 +55,19 @@ public class DashboardTipsController : Controller
ViewBag.PageSize = pageSize;
ViewBag.Total = total;
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
- ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
- ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
+ ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive);
+ ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync();
return View(tips);
}
/// Returns the Create form with an empty model.
- // GET: /DashboardTips/Create
public IActionResult Create() => View(new DashboardTip());
///
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
/// whitespace-only entries from appearing as blank tiles on the dashboard.
- /// Model validation is done manually (rather than relying solely on
- /// [Required] attributes) to ensure a meaningful error message is shown.
///
- // POST: /DashboardTips/Create
[HttpPost, ValidateAntiForgeryToken]
public async Task Create(DashboardTip model)
{
@@ -84,37 +77,33 @@ public class DashboardTipsController : Controller
return View(model);
}
- _db.DashboardTips.Add(new DashboardTip
+ await _unitOfWork.DashboardTips.AddAsync(new DashboardTip
{
TipText = model.TipText.Trim(),
IsActive = model.IsActive,
CreatedAt = DateTime.UtcNow
});
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip added successfully.";
return RedirectToAction(nameof(Index));
}
/// Returns the Edit form for an existing tip, or 404 if not found.
- // GET: /DashboardTips/Edit/5
public async Task Edit(int id)
{
- var tip = await _db.DashboardTips.FindAsync(id);
+ var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip == null) return NotFound();
return View(tip);
}
///
- /// Updates text and active flag for an existing tip. Returns the tracked entity
- /// (not the posted model) to the view on validation failure so the form shows
- /// the database version rather than potentially mangled posted data.
+ /// Updates text and active flag for an existing tip.
///
- // POST: /DashboardTips/Edit/5
[HttpPost, ValidateAntiForgeryToken]
public async Task Edit(int id, DashboardTip model)
{
- var tip = await _db.DashboardTips.FindAsync(id);
+ var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip == null) return NotFound();
if (string.IsNullOrWhiteSpace(model.TipText))
@@ -125,27 +114,25 @@ public class DashboardTipsController : Controller
tip.TipText = model.TipText.Trim();
tip.IsActive = model.IsActive;
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip updated.";
return RedirectToAction(nameof(Index));
}
///
- /// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
+ /// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
/// they do not use soft delete — they have no foreign-key relationships to
- /// tenant data and nothing references a deleted tip's Id. A missing Id is
- /// silently ignored to keep the action idempotent.
+ /// tenant data and nothing references a deleted tip's Id.
///
- // POST: /DashboardTips/Delete/5
[HttpPost, ValidateAntiForgeryToken]
public async Task Delete(int id)
{
- var tip = await _db.DashboardTips.FindAsync(id);
+ var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip != null)
{
- _db.DashboardTips.Remove(tip);
- await _db.SaveChangesAsync();
+ await _unitOfWork.DashboardTips.DeleteAsync(tip);
+ await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip deleted.";
}
return RedirectToAction(nameof(Index));
@@ -153,19 +140,15 @@ public class DashboardTipsController : Controller
///
/// Flips the IsActive flag on a tip without a full edit round-trip.
- /// This lets operators quickly remove a tip from the rotation (deactivate)
- /// without deleting it, preserving the ability to reactivate it later.
- /// A missing Id is silently ignored to keep the action idempotent.
///
- // POST: /DashboardTips/ToggleActive/5
[HttpPost, ValidateAntiForgeryToken]
public async Task ToggleActive(int id)
{
- var tip = await _db.DashboardTips.FindAsync(id);
+ var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip != null)
{
tip.IsActive = !tip.IsActive;
- await _db.SaveChangesAsync();
+ await _unitOfWork.CompleteAsync();
}
return RedirectToAction(nameof(Index));
}
diff --git a/src/PowderCoating.Web/Controllers/DepositsController.cs b/src/PowderCoating.Web/Controllers/DepositsController.cs
index 506d649..4d26ab0 100644
--- a/src/PowderCoating.Web/Controllers/DepositsController.cs
+++ b/src/PowderCoating.Web/Controllers/DepositsController.cs
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Company;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
-using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
@@ -21,18 +20,15 @@ public class DepositsController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager _userManager;
private readonly ILogger _logger;
- private readonly ApplicationDbContext _context;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager userManager,
- ILogger logger,
- ApplicationDbContext context)
+ ILogger