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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user