Guard money-account selections; derive account type from sub-type
Item 1 — server-side guard (defense in depth) on payment-source / deposit / reconcilable account selections. New AccountGuard.IsValidMoneyAccountAsync checks the submitted account is active, company-owned, and an Asset or Liability before any GL posting, at: bill RecordPayment, bill Create (payNow), bill EditPayment, BankReconciliation.Create, and deposit Record. The dropdowns already constrain normal users; this rejects tampered/stale POSTs. Per the "trust the operator" decision it still allows A/R etc. (any Asset/Liability) — it only blocks non-money types. Item 2 — account AccountType is now derived from the chosen AccountSubType on create/edit via the new AccountClassification.TypeForSubType (single source of truth, also used by the Create pre-select). The two can no longer disagree, so the sub-type-based debit/credit sign convention is always consistent with the account's type. A read-only sweep of the dev DB found 0 existing mismatches, so no repair tool was built. Audit doc updated: both backlog items marked resolved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -91,18 +91,7 @@ public class AccountsController : Controller
|
||||
if (preSubType.HasValue)
|
||||
{
|
||||
dto.AccountSubType = preSubType.Value;
|
||||
dto.AccountType = preSubType.Value switch
|
||||
{
|
||||
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
||||
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
||||
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
||||
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
|
||||
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
|
||||
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
|
||||
_ => AccountType.Expense
|
||||
};
|
||||
dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
|
||||
}
|
||||
ViewBag.Inline = inline;
|
||||
if (inline)
|
||||
@@ -151,6 +140,9 @@ public class AccountsController : Controller
|
||||
var account = _mapper.Map<Account>(dto);
|
||||
account.CompanyId = currentUser!.CompanyId;
|
||||
account.CreatedBy = currentUser.Email;
|
||||
// Derive the parent type from the chosen sub-type so the two can never disagree —
|
||||
// a mismatch would post with the wrong debit/credit sign (sign keys off sub-type).
|
||||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||
|
||||
await _unitOfWork.Accounts.AddAsync(account);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -226,6 +218,9 @@ public class AccountsController : Controller
|
||||
}
|
||||
|
||||
_mapper.Map(dto, account);
|
||||
// Keep type consistent with the chosen sub-type (see Create) so the sign convention,
|
||||
// which keys off sub-type, can never be at odds with the displayed account type.
|
||||
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||
account.UpdatedAt = DateTime.UtcNow;
|
||||
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -73,6 +74,13 @@ public class BankReconciliationsController : Controller
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// The account being reconciled must be a real money account (Asset/Liability).
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, model.AccountId, companyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank, cash, or credit account to reconcile.";
|
||||
return RedirectToAction(nameof(Create));
|
||||
}
|
||||
|
||||
// Set beginning balance from last completed reconciliation for this account, or 0
|
||||
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||
br => br.CompanyId == companyId
|
||||
|
||||
@@ -340,6 +340,16 @@ public class BillsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the pay-from account before entering the transaction so an invalid
|
||||
// selection rejects the whole request rather than saving a bill with no payment.
|
||||
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue && currentUser != null
|
||||
&& !await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, bankAccountId, currentUser.CompanyId))
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Choose a valid bank or credit account to record the payment.");
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
Bill? bill = null;
|
||||
|
||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||
@@ -718,6 +728,14 @@ public class BillsController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
// The pay-from account must be a real money account (Asset/Liability) — defense in depth
|
||||
// against a tampered or stale selection before we post to it.
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, bill.CompanyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank or credit account to pay from.";
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var payment = _mapper.Map<BillPayment>(dto);
|
||||
@@ -843,6 +861,13 @@ public class BillsController : Controller
|
||||
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
|
||||
if (payment == null) return NotFound();
|
||||
|
||||
// Reject an invalid new pay-from account before we move the balance to it.
|
||||
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, payment.CompanyId))
|
||||
{
|
||||
TempData["Error"] = "Select a valid bank or credit account.";
|
||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// If the bank account changed, reverse the old balance entry and apply the new one
|
||||
|
||||
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.Helpers;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
@@ -87,13 +88,10 @@ public class DepositsController : Controller
|
||||
// auto-pick of the first Checking/Cash account. Validate any user-supplied id
|
||||
// belongs to this company (defense in depth — the global filter alone isn't enough).
|
||||
int? depositAcctId = null;
|
||||
if (depositAccountId.HasValue)
|
||||
if (depositAccountId.HasValue &&
|
||||
await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, depositAccountId, currentUser.CompanyId))
|
||||
{
|
||||
var chosen = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.Id == depositAccountId.Value
|
||||
&& a.CompanyId == currentUser.CompanyId
|
||||
&& a.IsActive);
|
||||
depositAcctId = chosen?.Id;
|
||||
depositAcctId = depositAccountId;
|
||||
}
|
||||
depositAcctId ??= await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side validation for account selections that must be a "money" account — a payment
|
||||
/// source (bill payment), deposit target, or reconcilable account. The dropdowns already limit
|
||||
/// the choices, so this is defense in depth against tampered or stale POSTs (e.g. an account
|
||||
/// deleted/retyped between page load and submit): it rejects anything that isn't an active,
|
||||
/// company-owned Asset or Liability account before a GL posting is made against it.
|
||||
/// </summary>
|
||||
internal static class AccountGuard
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true when <paramref name="accountId"/> identifies an active account belonging to
|
||||
/// <paramref name="companyId"/> whose top-level type is Asset or Liability. Filters CompanyId
|
||||
/// explicitly (defense in depth alongside the global tenant filter).
|
||||
/// </summary>
|
||||
internal static async Task<bool> IsValidMoneyAccountAsync(IUnitOfWork unitOfWork, int? accountId, int companyId)
|
||||
{
|
||||
if (accountId == null) return false;
|
||||
var account = await unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.Id == accountId.Value && a.CompanyId == companyId && a.IsActive);
|
||||
return account != null
|
||||
&& (account.AccountType == AccountType.Asset || account.AccountType == AccountType.Liability);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user