Scope GL posting account lookups by CompanyId; cap sales-tax remittance (audit O3, O4)

O3: defense-in-depth on the write/posting path. Finding #7 scoped the report
(read) path; this scopes every GL posting-path account lookup that determines
where money lands, so a SuperAdmin acting in a company context can never post to
another tenant's account:
  - InvoicesController: all account-resolver helpers (checking, customer deposits,
    sales returns, customer credits, AR, bad debt, sales tax, sales discount, GC
    liability) plus the bank-account and write-off expense dropdowns
  - CreditMemosController: Create/Apply/Void GL lookups (scoped via the in-scope
    customer/invoice/memo)
  - GiftCertificatesController: Create/BulkCreate/Void GL lookups + GC liability helper
  - BillsController: AP/expense account resolution that pre-fills APAccountId
DepositsController and JournalEntriesController.SalesTaxPayment were already scoped.

O4: SalesTaxPayment now rejects a remittance greater than the outstanding Sales
Tax Payable balance (0.005 rounding tolerance), so a typo can no longer drive
2200 into an abnormal debit balance.

Remaining pure read-path dropdown lookups (app-wide, lower risk) are documented
in docs/ACCOUNTING_AUDIT.md as a separate follow-up. All audit findings O1-O4 are
now resolved. Build clean; 291 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 19:48:53 -04:00
parent 517e452c64
commit 7576761b70
6 changed files with 65 additions and 49 deletions
@@ -305,8 +305,9 @@ public class InvoicesController : Controller
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
// Expense accounts for the write-off bad-debt modal
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
a => a.CompanyId == expenseCompanyId && a.IsActive && a.AccountType == AccountType.Expense);
ViewBag.ExpenseAccounts = expenseAccounts
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -2458,7 +2459,8 @@ public class InvoicesController : Controller
/// </summary>
private async Task PopulateBankAccountsAsync()
{
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Cash ||
a.AccountSubType == AccountSubType.Checking ||
a.AccountSubType == AccountSubType.Savings));
@@ -2473,7 +2475,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Cash));
return acct?.Id;
}
@@ -2482,7 +2484,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2300");
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
@@ -2490,7 +2492,7 @@ public class InvoicesController : Controller
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4960");
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4960");
return acct?.Id;
}
@@ -2498,7 +2500,7 @@ public class InvoicesController : Controller
private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2350");
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2350");
return acct?.Id;
}
@@ -2506,7 +2508,7 @@ public class InvoicesController : Controller
private async Task<int?> GetArAccountIdAsync(int companyId)
{
var accounts = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
return accounts.FirstOrDefault()?.Id;
}
@@ -2517,7 +2519,7 @@ public class InvoicesController : Controller
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
{
var expenses = await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && a.AccountType == AccountType.Expense);
a => a.CompanyId == companyId && a.IsActive && a.AccountType == AccountType.Expense);
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
?? expenses.FirstOrDefault()?.Id;
@@ -2548,9 +2550,9 @@ public class InvoicesController : Controller
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
{
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "2200" && a.IsActive);
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
return taxAccount?.Id;
}
@@ -2562,9 +2564,9 @@ public class InvoicesController : Controller
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
{
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive);
a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
return discountAccount?.Id;
}
@@ -2572,7 +2574,7 @@ public class InvoicesController : Controller
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "2500");
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
return acct?.Id;
}