Refactor: centralize accounting helpers, status constants, and query deduplication

- AccountingDropdownHelper: wired into BillsController and ExpensesController,
  replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
  magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
  AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
  helper; removed duplicate private method from AccountBalanceService and
  LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
  includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
  entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
  ExecuteInTransactionAsync; blob uploads moved after commit to keep
  financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
  GetAllAsync to prefix-filtered FindAsync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:42:39 -04:00
parent edd7389d7d
commit 379b0de885
15 changed files with 394 additions and 359 deletions
@@ -46,7 +46,7 @@ public class AccountBalanceService : IAccountBalanceService
// Debit increases debit-normal accounts (Assets/Expenses/COGS)
// Debit decreases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? amount : -amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
@@ -65,7 +65,7 @@ public class AccountBalanceService : IAccountBalanceService
// Credit decreases debit-normal accounts (Assets/Expenses/COGS)
// Credit increases credit-normal accounts (Liabilities/Equity/Revenue)
account.CurrentBalance += IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
account.CurrentBalance += AccountingRules.IsNormalDebitBalance(account.AccountSubType) ? -amount : amount;
await _unitOfWork.Accounts.UpdateAsync(account);
}
@@ -109,28 +109,4 @@ public class AccountBalanceService : IAccountBalanceService
await _unitOfWork.CompleteAsync();
}
/// <summary>
/// Returns <c>true</c> for account sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). This mirrors the identical helper in <see cref="LedgerService"/>
/// and is the single source of truth for how <see cref="DebitAsync"/> and <see cref="CreditAsync"/>
/// decide the direction of the balance adjustment.
/// </summary>
private static bool IsNormalDebitBalance(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 => true,
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
_ => false
};
}
@@ -0,0 +1,41 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Single source of truth for double-entry sign conventions shared by
/// <see cref="AccountBalanceService"/> and <see cref="LedgerService"/>.
/// Centralised here so that adding a new AccountSubType only requires
/// one edit rather than two independently maintained switch expressions.
/// </summary>
internal static class AccountingRules
{
/// <summary>
/// Returns <c>true</c> for sub-types whose normal balance is a debit
/// (Assets, COGS, Expenses). Sub-type is used rather than AccountType
/// because it is constrained to a known enum set and cannot be
/// misconfigured by a user. Expense enum values are ≥ 50 by convention,
/// allowing a catch-all range match for any future expense sub-types.
/// </summary>
internal static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
}
@@ -306,7 +306,7 @@ public class LedgerService : ILedgerService
// Derive normal-debit-balance flag from AccountSubType (more authoritative than AccountType,
// since users could misconfigure AccountType while SubType is picked from a constrained list).
bool normalDebitBalance = IsNormalDebitBalance(account.AccountSubType);
bool normalDebitBalance = AccountingRules.IsNormalDebitBalance(account.AccountSubType);
// Compute the balance before the selected period
decimal priorBalance = await ComputePriorBalanceAsync(account, fromDate, to.Date, normalDebitBalance);
@@ -338,36 +338,6 @@ public class LedgerService : ILedgerService
};
}
/// <summary>
/// Returns <c>true</c> if the account sub-type has a normal debit balance (Assets, Expenses, COGS),
/// <c>false</c> for normal credit balance (Liabilities, Equity, Revenue).
/// <see cref="AccountSubType"/> is used rather than <see cref="PowderCoating.Core.Enums.AccountType"/>
/// because sub-type is constrained to a known set of values and cannot be misconfigured by a user,
/// whereas <c>AccountType</c> is a broader category that a user might set incorrectly.
/// Expense enum values are ≥ 50 by convention, allowing a catch-all range match.
/// </summary>
private static bool IsNormalDebitBalance(AccountSubType subType) => subType switch
{
// Asset subtypes → normal debit balance
AccountSubType.Cash
or AccountSubType.Checking
or AccountSubType.Savings
or AccountSubType.AccountsReceivable
or AccountSubType.Inventory
or AccountSubType.FixedAsset
or AccountSubType.OtherCurrentAsset
or AccountSubType.OtherAsset => true,
// COGS → normal debit balance
AccountSubType.CostOfGoodsSold => true,
// Expense subtypes (enum values ≥ 50) → normal debit balance
var st when (int)st >= 50 => true,
// Liability subtypes (AP, CreditCard, etc.), Equity, Revenue → normal credit balance
_ => false
};
/// <summary>
/// Computes the account balance on the day immediately before <paramref name="beforeDate"/>
/// by summing all activity prior to that date across every transaction source and adding
@@ -375,7 +345,7 @@ public class LedgerService : ILedgerService
/// date is on or before <paramref name="periodEnd"/> — a future-dated opening balance (e.g.
/// from a mid-year chart-of-accounts migration) should not pollute earlier period reports.
/// A null <c>OpeningBalanceDate</c> means the balance predates all transactions and always applies.
/// The sign convention follows <see cref="IsNormalDebitBalance"/>: debits increase debit-normal
/// The sign convention follows <see cref="AccountingRules.IsNormalDebitBalance"/>: debits increase debit-normal
/// accounts and credits increase credit-normal accounts.
/// </summary>
private async Task<decimal> ComputePriorBalanceAsync(