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
@@ -202,14 +202,14 @@ public class BillsController : Controller
}
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable);
a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
// Vendor default expense account, fall back to first expense/COGS account
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
if (!defaultExpenseAccountId.HasValue)
{
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
defaultExpenseAccountId = fallbackAccount?.Id;
}
@@ -272,8 +272,9 @@ public class BillsController : Controller
};
// Pre-fill AP account
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsPayable);
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
dto.APAccountId = apAccount?.Id ?? 0;
// Pre-fill default expense account for vendor