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
@@ -66,6 +66,9 @@ public class AccountsController : Controller
.OrderBy(g => (int)g.Key)
.ToList();
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
return View(grouped);
}
@@ -322,20 +325,49 @@ public class AccountsController : Controller
}
/// <summary>
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
/// balances as positive amounts with the credit/debit nature implied by account type. This
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
/// accounts (Inventory sub-type first) so a company that classified its inventory account
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
/// </summary>
// POST: /Accounts/FixOpeningBalanceSigns
// 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.
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
{
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]
[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();
if (companyId == null)
@@ -346,30 +378,37 @@ public class AccountsController : Controller
try
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
int fixed_ = 0;
foreach (var acct in accounts)
var prefs = await _unitOfWork.CompanyPreferences
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
if (prefs == null)
{
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);
fixed_++;
}
TempData["Error"] = "Company preferences not found.";
return RedirectToAction(nameof(Index));
}
// 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();
TempData["Success"] = fixed_ > 0
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
: "No accounts needed fixing — all opening balances already have the correct sign.";
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while fixing opening balances.";
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
TempData["Error"] = "An error occurred while saving the default accounts.";
}
return RedirectToAction(nameof(Index));