Add company default GL accounts; move QB sign-fix to platform

Default accounts: companies can now set a default Revenue, COGS, and
Inventory account (Chart of Accounts -> "Default Accounts" card). Stored
on CompanyPreferences (3 nullable FKs + migration). Used as the fallback
when an item or invoice line leaves its account blank:
 - Invoice lines fall back to the default Revenue account, then to 4000.
 - New inventory/catalog items are pre-filled with the COGS/Inventory
   defaults, so the value is stored on the item and both live posting and
   the balance-recompute path stay consistent.
Blank defaults = unchanged behavior, so nothing changes until a company
opts in. Setting both COGS + Inventory enables perpetual-inventory COGS
posting (warned in the UI and help docs). Help KB + Settings article
updated.

Also moves the "Fix QB Import Signs" tool off the company Chart of
Accounts page (was CompanyAdmin-visible) to the SuperAdmin-only platform
Company Details page, operating on the target company.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 10:03:11 -04:00
parent 687aedf7a4
commit ee86d7aaf6
14 changed files with 11913 additions and 44 deletions
@@ -18,6 +18,18 @@ public class CompanyPreferences : BaseEntity
public string InvoiceNumberPrefix { get; set; } = "INV"; public string InvoiceNumberPrefix { get; set; } = "INV";
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg) public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
// Default GL Accounts — used as the fallback when an item leaves its account field blank.
// Null means "no default": revenue falls back to account 4000, and inventory-consumption
// COGS simply isn't posted (consistent with expensing materials at purchase). A company
// only opts into perpetual-inventory COGS posting by setting both the COGS and Inventory
// defaults (or the per-item accounts). FKs are nullable with no cascade — accounts soft-delete.
/// <summary>Default Revenue account for invoice lines that don't specify one (fallback before account 4000).</summary>
public int? DefaultRevenueAccountId { get; set; }
/// <summary>Default COGS account pre-filled on new inventory/catalog items. Drives inventory-consumption COGS posting when paired with an inventory account.</summary>
public int? DefaultCogsAccountId { get; set; }
/// <summary>Default Inventory asset account pre-filled on new inventory items. Drives inventory-consumption COGS posting when paired with a COGS account.</summary>
public int? DefaultInventoryAccountId { get; set; }
// Job / Workflow Defaults // Job / Workflow Defaults
public string DefaultJobPriority { get; set; } = "Normal"; public string DefaultJobPriority { get; set; } = "Normal";
public bool RequireCustomerPO { get; set; } = false; public bool RequireCustomerPO { get; set; } = false;
@@ -946,6 +946,26 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(i => i.CogsAccountId) .HasForeignKey(i => i.CogsAccountId)
.OnDelete(DeleteBehavior.NoAction); .OnDelete(DeleteBehavior.NoAction);
// CompanyPreferences → Default Revenue / COGS / Inventory accounts (nullable, no cascade,
// no navigation property — accounts use soft delete and these are config pointers only).
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultRevenueAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultCogsAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<CompanyPreferences>()
.HasOne<Account>()
.WithMany()
.HasForeignKey(p => p.DefaultInventoryAccountId)
.OnDelete(DeleteBehavior.NoAction);
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete) // CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
modelBuilder.Entity<CatalogItem>() modelBuilder.Entity<CatalogItem>()
.HasOne(ci => ci.RevenueAccount) .HasOne(ci => ci.RevenueAccount)
@@ -0,0 +1,151 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCompanyDefaultGlAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DefaultCogsAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DefaultInventoryAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DefaultRevenueAccountId",
table: "CompanyPreferences",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultCogsAccountId",
table: "CompanyPreferences",
column: "DefaultCogsAccountId");
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
table: "CompanyPreferences",
column: "DefaultInventoryAccountId");
migrationBuilder.CreateIndex(
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
table: "CompanyPreferences",
column: "DefaultRevenueAccountId");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
table: "CompanyPreferences",
column: "DefaultCogsAccountId",
principalTable: "Accounts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
table: "CompanyPreferences",
column: "DefaultInventoryAccountId",
principalTable: "Accounts",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
table: "CompanyPreferences",
column: "DefaultRevenueAccountId",
principalTable: "Accounts",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropForeignKey(
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropIndex(
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultCogsAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultInventoryAccountId",
table: "CompanyPreferences");
migrationBuilder.DropColumn(
name: "DefaultRevenueAccountId",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
}
}
}
@@ -2185,6 +2185,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultCogsAccountId")
.HasColumnType("int");
b.Property<string>("DefaultCurrency") b.Property<string>("DefaultCurrency")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2193,6 +2196,9 @@ namespace PowderCoating.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("DefaultInventoryAccountId")
.HasColumnType("int");
b.Property<string>("DefaultJobPriority") b.Property<string>("DefaultJobPriority")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2204,6 +2210,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("DefaultQuoteValidityDays") b.Property<int>("DefaultQuoteValidityDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("DefaultRevenueAccountId")
.HasColumnType("int");
b.Property<string>("DefaultTimeFormat") b.Property<string>("DefaultTimeFormat")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2380,6 +2389,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CompanyId") b.HasIndex("CompanyId")
.IsUnique(); .IsUnique();
b.HasIndex("DefaultCogsAccountId");
b.HasIndex("DefaultInventoryAccountId");
b.HasIndex("DefaultRevenueAccountId");
b.ToTable("CompanyPreferences"); b.ToTable("CompanyPreferences");
}); });
@@ -7241,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7252,7 +7267,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7263,7 +7278,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982), CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -9580,6 +9595,21 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultCogsAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultInventoryAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("PowderCoating.Core.Entities.Account", null)
.WithMany()
.HasForeignKey("DefaultRevenueAccountId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("Company"); b.Navigation("Company");
}); });
@@ -66,6 +66,9 @@ public class AccountsController : Controller
.OrderBy(g => (int)g.Key) .OrderBy(g => (int)g.Key)
.ToList(); .ToList();
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
return View(grouped); return View(grouped);
} }
@@ -322,20 +325,49 @@ public class AccountsController : Controller
} }
/// <summary> /// <summary>
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks /// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative /// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening /// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
/// balances as positive amounts with the credit/debit nature implied by account type. This /// accounts (Inventory sub-type first) so a company that classified its inventory account
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their /// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// </summary> /// </summary>
// POST: /Accounts/FixOpeningBalanceSigns private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances. {
// This flips them to positive so the chart of accounts displays correctly. SelectListItem Item(Account a) => new($"{a.AccountNumber} {a.Name}", a.Id.ToString());
ViewBag.DefaultRevenueAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
ViewBag.DefaultCogsAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
ViewBag.DefaultInventoryAccounts = accounts
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
}
/// <summary>
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
/// company and to be of the expected account type before it is stored; an invalid or cleared
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
/// </summary>
// POST: /Accounts/SaveDefaultAccounts
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> FixOpeningBalanceSigns() public async Task<IActionResult> SaveDefaultAccounts(
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
{ {
var companyId = _tenantContext.GetCurrentCompanyId(); var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) if (companyId == null)
@@ -346,30 +378,37 @@ public class AccountsController : Controller
try try
{ {
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive); var prefs = await _unitOfWork.CompanyPreferences
int fixed_ = 0; .FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
foreach (var acct in accounts) if (prefs == null)
{ {
if (acct.OpeningBalance < 0 && TempData["Error"] = "Company preferences not found.";
acct.AccountType is Core.Enums.AccountType.Revenue return RedirectToAction(nameof(Index));
or Core.Enums.AccountType.Liability
or Core.Enums.AccountType.Equity)
{
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
await _unitOfWork.Accounts.UpdateAsync(acct);
fixed_++;
}
} }
// Validate each pick belongs to this company, is active, and is of the right type.
// Explicit CompanyId predicate (defense in depth) alongside the global tenant filter.
async Task<int?> Validate(int? id, params AccountType[] allowed)
{
if (id == null) return null;
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
}
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
TempData["Success"] = fixed_ > 0
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance." TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
: "No accounts needed fixing — all opening balances already have the correct sign.";
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId); _logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while fixing opening balances."; TempData["Error"] = "An error occurred while saving the default accounts.";
} }
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
@@ -208,10 +208,16 @@ namespace PowderCoating.Web.Controllers
var useMetric = await _tenantContext.UseMetricSystemAsync(); var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric); ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
// Pre-fill the GL account dropdowns from the company's configured defaults.
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
var model = new CreateCatalogItemDto var model = new CreateCatalogItemDto
{ {
CategoryId = categoryId ?? 0, CategoryId = categoryId ?? 0,
DisplayOrder = 0 DisplayOrder = 0,
RevenueAccountId = prefs?.DefaultRevenueAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}; };
return View(model); return View(model);
@@ -754,6 +754,69 @@ public class CompaniesController : Controller
} }
} }
/// <summary>
/// One-time data repair for a company whose chart of accounts was imported from QuickBooks
/// IIF files. QuickBooks stores credit-normal account opening balances as negative numbers
/// (e.g. Revenue, Liability, Equity), but this app's convention is positive opening balances
/// with the debit/credit nature implied by account type. This flips negative opening balances
/// on Revenue/Liability/Equity accounts to their absolute values so the chart of accounts
/// reads correctly. Afterward, Recalculate Balances (on the Chart of Accounts page) should be
/// run to propagate the corrected opening balances into CurrentBalance.
/// <para>
/// This is a SuperAdmin-only platform tool — it operates on the target company identified by
/// <paramref name="id"/> (not the caller's tenant), so it uses <c>ignoreQueryFilters</c> to
/// reach across the multi-tenancy boundary. It was deliberately moved here from the company
/// Chart of Accounts page so normal company admins can't see or trigger it.
/// </para>
/// </summary>
// POST: Companies/FixOpeningBalanceSigns/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> FixOpeningBalanceSigns(int id)
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
try
{
// Explicit CompanyId predicate + ignoreQueryFilters: SuperAdmin acts on another
// tenant, so the global multi-tenancy filter must be bypassed but scoping kept tight.
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == id && a.IsActive, ignoreQueryFilters: true);
int fixedCount = 0;
foreach (var acct in accounts)
{
if (acct.OpeningBalance < 0 &&
acct.AccountType is Core.Enums.AccountType.Revenue
or Core.Enums.AccountType.Liability
or Core.Enums.AccountType.Equity)
{
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
await _unitOfWork.Accounts.UpdateAsync(acct);
fixedCount++;
}
}
await _unitOfWork.CompleteAsync();
TempData[fixedCount > 0 ? "Success" : "Info"] = fixedCount > 0
? $"Fixed {fixedCount} account(s) with negative opening balances for '{company.CompanyName}'. Run Recalculate Balances on the company's Chart of Accounts to update CurrentBalance."
: $"No accounts needed fixing for '{company.CompanyName}' — all opening balances already have the correct sign.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", id);
TempData["Error"] = "An error occurred while fixing opening balances.";
}
return RedirectToAction(nameof(Details), new { id });
}
/// <summary> /// <summary>
/// Renders the form for adding an additional CompanyAdmin user to an existing company. /// Renders the form for adding an additional CompanyAdmin user to an existing company.
/// Used when a company needs more than one admin or when the original admin's account must /// Used when a company needs more than one admin or when the original admin's account must
@@ -275,10 +275,18 @@ public class InventoryController : Controller
ViewBag.UseMetric = useMetric; ViewBag.UseMetric = useMetric;
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric); ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
// Pre-fill the GL account dropdowns from the company's configured defaults so new items
// inherit them (the user can still change or clear them on the form).
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return View(new CreateInventoryItemDto return View(new CreateInventoryItemDto
{ {
CoverageSqFtPerLb = 30, CoverageSqFtPerLb = 30,
TransferEfficiency = 65 TransferEfficiency = 65,
InventoryAccountId = prefs?.DefaultInventoryAccountId,
CogsAccountId = prefs?.DefaultCogsAccountId
}); });
} }
@@ -412,8 +412,12 @@ public class InvoicesController : Controller
.ToDictionary(ci => ci.Id) .ToDictionary(ci => ci.Id)
: new Dictionary<int, CatalogItem>(); : new Dictionary<int, CatalogItem>();
// Fall back to the default revenue account (4000) if a catalog item has no specific account // Fall back to the company's configured default revenue account when a catalog item
var defaultRevenueAccount = await _unitOfWork.Accounts // has no specific account; if none is configured, fall back to the seeded 4000 account.
Account? defaultRevenueAccount = null;
if (prefs?.DefaultRevenueAccountId != null)
defaultRevenueAccount = await _unitOfWork.Accounts.GetByIdAsync(prefs.DefaultRevenueAccountId.Value);
defaultRevenueAccount ??= await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); .FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount, // Deserialize the job's pricing snapshot up front — it is authoritative for discount,
@@ -678,6 +678,9 @@ public static class HelpKnowledgeBase
**Step 5 Set up your Chart of Accounts (for billing/AP)** **Step 5 Set up your Chart of Accounts (for billing/AP)**
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically. If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
**Default accounts (Chart of Accounts Set Defaults)**
On the Chart of Accounts page, the "Default Accounts" card lets you choose a default Revenue, COGS, and Inventory account for your company. These are used automatically when an item or invoice line doesn't specify one: invoice lines fall back to your default Revenue account (then to account 4000 if none is set), and new inventory and catalog items are pre-filled with your default COGS/Inventory accounts. Leave any blank to keep the current behavior. Note: setting BOTH a COGS and an Inventory Asset default makes new items post inventory-consumption COGS (perpetual inventory) leave them blank if you expense materials when you purchase them.
**What happens if Operating Costs are zero?** **What happens if Operating Costs are zero?**
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix. If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
@@ -38,14 +38,6 @@
<div class="d-flex justify-content-end mb-4"> <div class="d-flex justify-content-end mb-4">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<form asp-action="FixOpeningBalanceSigns" method="post"
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning"
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
</button>
</form>
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post"> <form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
@Html.AntiForgeryToken() @Html.AntiForgeryToken()
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary" <button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
@@ -93,6 +85,80 @@
</div> </div>
} }
@if (Model.Any())
{
var revenueAccts = ViewBag.DefaultRevenueAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
var cogsAccts = ViewBag.DefaultCogsAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
var inventoryAccts = ViewBag.DefaultInventoryAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
int? selRevenue = ViewBag.DefaultRevenueAccountId as int?;
int? selCogs = ViewBag.DefaultCogsAccountId as int?;
int? selInventory = ViewBag.DefaultInventoryAccountId as int?;
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0"><i class="bi bi-gear me-2 text-primary"></i>Default Accounts</h6>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#defaultAccountsBody">
<i class="bi bi-pencil me-1"></i>Set Defaults
</button>
</div>
<div id="defaultAccountsBody" class="collapse">
<div class="card-body">
<p class="text-muted small mb-3">
These accounts are used automatically when an item or invoice line doesn&apos;t specify one.
Leave any blank to keep the current behavior.
</p>
<form asp-action="SaveDefaultAccounts" method="post">
@Html.AntiForgeryToken()
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Revenue</label>
<select name="defaultRevenueAccountId" class="form-select">
<option value="">(No default &mdash; uses 4000)</option>
@foreach (var o in revenueAccts)
{
<option value="@o.Value" selected="@(selRevenue?.ToString() == o.Value)">@o.Text</option>
}
</select>
<small class="form-text text-muted">Fallback revenue account for invoice lines.</small>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">COGS</label>
<select name="defaultCogsAccountId" class="form-select">
<option value="">(No default)</option>
@foreach (var o in cogsAccts)
{
<option value="@o.Value" selected="@(selCogs?.ToString() == o.Value)">@o.Text</option>
}
</select>
<small class="form-text text-muted">Pre-fills new inventory &amp; catalog items.</small>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Inventory Asset</label>
<select name="defaultInventoryAccountId" class="form-select">
<option value="">(No default)</option>
@foreach (var o in inventoryAccts)
{
<option value="@o.Value" selected="@(selInventory?.ToString() == o.Value)">@o.Text</option>
}
</select>
<small class="form-text text-muted">Pre-fills new inventory items.</small>
</div>
</div>
<div class="alert alert-warning alert-permanent small mt-3 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items post
inventory-consumption COGS (perpetual inventory). Leave these blank if you expense materials
when purchased.
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-check-lg me-1"></i>Save Defaults
</button>
</form>
</div>
</div>
</div>
}
@if (!Model.Any()) @if (!Model.Any())
{ {
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
@@ -618,6 +618,36 @@
</div><!-- /tab-content --> </div><!-- /tab-content -->
<!-- Maintenance Tools (SuperAdmin platform utilities) -->
<div class="card shadow-sm mt-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0">
<i class="bi bi-tools me-2"></i>Maintenance Tools
</h6>
</div>
<div class="card-body d-flex flex-column gap-3">
<div class="d-flex justify-content-between align-items-start border rounded p-3">
<div>
<h6 class="mb-1"><i class="bi bi-sign-stop me-1"></i>Fix QuickBooks Import Signs</h6>
<p class="text-muted small mb-0">
Flips negative opening balances on Revenue, Liability, and Equity accounts to positive.
QuickBooks IIF exports store these credit-normal accounts as negative numbers; this
corrects them so the Chart of Accounts reads correctly. Run <strong>Recalculate Balances</strong>
on the company's Chart of Accounts afterward. Safe to run more than once.
</p>
</div>
<form asp-action="FixOpeningBalanceSigns" asp-route-id="@Model.Id" method="post"
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks for this company?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-warning ms-3 text-nowrap"
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
</button>
</form>
</div>
</div>
</div>
<!-- Danger Zone (outside tabs &mdash; always present) --> <!-- Danger Zone (outside tabs &mdash; always present) -->
<div class="card shadow-sm border-danger mt-4"> <div class="card shadow-sm border-danger mt-4">
<div class="card-header bg-light"> <div class="card-header bg-light">
@@ -237,6 +237,31 @@
The chart of accounts is typically configured once during initial setup. You can add new accounts The chart of accounts is typically configured once during initial setup. You can add new accounts
at any time if your accounting needs expand. at any time if your accounting needs expand.
</p> </p>
<h3 class="h6 fw-semibold mt-3 mb-2">Default Accounts</h3>
<p>
The <strong>Default Accounts</strong> card at the top of the Chart of Accounts page lets you choose a
default <strong>Revenue</strong>, <strong>COGS</strong>, and <strong>Inventory Asset</strong> account
for your company. These are used automatically when an item or invoice line doesn&apos;t specify its own:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Revenue</strong> &mdash; invoice lines without a specific revenue account fall back to this one (and to account 4000 if you haven&apos;t set a default).</li>
<li class="mb-1"><strong>COGS</strong> and <strong>Inventory Asset</strong> &mdash; new inventory and catalog items are pre-filled with these, so you don&apos;t have to pick them every time. You can still change or clear them on each item.</li>
</ul>
<p>
Leave any of them blank to keep the current behavior. Click <strong>Set Defaults</strong> to expand the
card, choose your accounts, and save.
</p>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items record
inventory-consumption cost (COGS) to the general ledger as they&apos;re used &mdash; this is
perpetual-inventory accounting. If you expense materials when you <em>purchase</em> them, leave
these two blank to avoid double-counting.
</div>
</div>
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert"> <div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i> <i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div> <div>