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:
2026-06-20 10:38:44 -04:00
parent 74d529f7d2
commit 774f916dae
7 changed files with 114 additions and 24 deletions
+11 -6
View File
@@ -230,16 +230,21 @@ deposit account. **No ledger-drift bugs found.** Notes:
- **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and - **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and
`LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default `LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default
deposit account recomputes correctly. deposit account recomputes correctly.
- **Bank / "pay-from" / bank-rec pickers** now list all Asset + Liability accounts with **no server-side type - **Bank / "pay-from" / bank-rec pickers — server-side guard added (2026-06-20):** the submitted account is
guard** — a user could pick a nonsensical source (e.g. A/R). Postings stay sign-correct now validated via `AccountGuard.IsValidMoneyAccountAsync` (active, company-owned, AccountType Asset or
(`AccountBalanceService` keys sign off `AccountSubType`), and this is the intended "trust the operator" Liability) before any posting, at bill `RecordPayment` / `Create(payNow)` / `EditPayment`,
tradeoff, but there is no longer a guardrail. Accepted; noted here for visibility. `BankReconciliation.Create`, and deposit `Record`. Defense in depth against tampered/stale POSTs. Per the
"trust the operator" decision this still allows e.g. A/R (an Asset) as a source — it only rejects
non-money types (Revenue/Expense/Equity/COGS).
- **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted - **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted
`CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the `CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the
`2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300` `2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300`
doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through. doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through.
- **Pre-existing type/sub-type mismatch risk:** account create does not enforce a valid type↔sub-type pairing, - **Type/sub-type mismatch risk — RESOLVED (2026-06-20):** account `AccountType` is now **derived** from the
and sign convention keys off sub-type — a mis-paired account would post with the wrong sign. Backlog item. chosen `AccountSubType` on create/edit via `AccountClassification.TypeForSubType` (single source of truth,
also used by the Create pre-select), so the two can never disagree and the sub-type-based sign convention is
always consistent with the displayed type. A read-only sweep of the dev DB (109 accounts) found **0** existing
mismatches, so no repair tool was needed.
## Status ## Status
**All findings O1O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at **All findings O1O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at
@@ -0,0 +1,31 @@
namespace PowderCoating.Core.Enums;
/// <summary>
/// Single source of truth mapping an <see cref="AccountSubType"/> to its parent
/// <see cref="AccountType"/>. Each sub-type belongs to exactly one type, so the type can always
/// be derived from the sub-type. Used on account create/edit to keep the two fields consistent
/// (a mismatched pair would post with the wrong debit/credit sign, since the sign convention keys
/// off the sub-type) and anywhere else that needs the canonical pairing.
/// </summary>
public static class AccountClassification
{
/// <summary>Returns the parent <see cref="AccountType"/> for a given <see cref="AccountSubType"/>.</summary>
public static AccountType TypeForSubType(AccountSubType subType) => subType 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,
// All expense sub-types (enum values >= 50) and any future additions default to Expense.
_ => AccountType.Expense
};
}
@@ -91,18 +91,7 @@ public class AccountsController : Controller
if (preSubType.HasValue) if (preSubType.HasValue)
{ {
dto.AccountSubType = preSubType.Value; dto.AccountSubType = preSubType.Value;
dto.AccountType = preSubType.Value switch dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
{
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
};
} }
ViewBag.Inline = inline; ViewBag.Inline = inline;
if (inline) if (inline)
@@ -151,6 +140,9 @@ public class AccountsController : Controller
var account = _mapper.Map<Account>(dto); var account = _mapper.Map<Account>(dto);
account.CompanyId = currentUser!.CompanyId; account.CompanyId = currentUser!.CompanyId;
account.CreatedBy = currentUser.Email; 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.Accounts.AddAsync(account);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -226,6 +218,9 @@ public class AccountsController : Controller
} }
_mapper.Map(dto, account); _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.UpdatedAt = DateTime.UtcNow;
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email; account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
@@ -8,6 +8,7 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -73,6 +74,13 @@ public class BankReconciliationsController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; 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 // Set beginning balance from last completed reconciliation for this account, or 0
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync( var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.CompanyId == companyId 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? bill = null;
// Bill entity, PO back-reference, and optional immediate payment all commit // 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 }); 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 currentUser = await _userManager.GetUserAsync(User);
var payment = _mapper.Map<BillPayment>(dto); var payment = _mapper.Map<BillPayment>(dto);
@@ -843,6 +861,13 @@ public class BillsController : Controller
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId); var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
if (payment == null) return NotFound(); 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); var currentUser = await _userManager.GetUserAsync(User);
// If the bank account changed, reverse the old balance entry and apply the new one // 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.Enums;
using PowderCoating.Core.Interfaces; using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using QuestPDF.Fluent; using QuestPDF.Fluent;
using QuestPDF.Helpers; using QuestPDF.Helpers;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
@@ -87,13 +88,10 @@ public class DepositsController : Controller
// auto-pick of the first Checking/Cash account. Validate any user-supplied id // 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). // belongs to this company (defense in depth — the global filter alone isn't enough).
int? depositAcctId = null; int? depositAcctId = null;
if (depositAccountId.HasValue) if (depositAccountId.HasValue &&
await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, depositAccountId, currentUser.CompanyId))
{ {
var chosen = await _unitOfWork.Accounts.FirstOrDefaultAsync( depositAcctId = depositAccountId;
a => a.Id == depositAccountId.Value
&& a.CompanyId == currentUser.CompanyId
&& a.IsActive);
depositAcctId = chosen?.Id;
} }
depositAcctId ??= await GetCheckingAccountIdAsync(currentUser.CompanyId); 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);
}
}