Files
PowderCoatingLogix/src/PowderCoating.Core/Entities/Accounting.cs
T
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00

459 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Chart of Accounts entry. Supports a flat or one-level hierarchy via ParentAccountId.
/// </summary>
public class Account : BaseEntity
{
public string AccountNumber { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public AccountType AccountType { get; set; }
public AccountSubType AccountSubType { get; set; }
public string? Description { get; set; }
/// <summary>Nullable FK for sub-accounts (one level deep).</summary>
public int? ParentAccountId { get; set; }
/// <summary>System accounts cannot be deleted (seeded defaults).</summary>
public bool IsSystem { get; set; } = false;
public bool IsActive { get; set; } = true;
/// <summary>Starting balance when the account was first set up in this system.</summary>
public decimal OpeningBalance { get; set; } = 0;
/// <summary>The date the opening balance is as-of. Null means it pre-dates all transactions.</summary>
public DateTime? OpeningBalanceDate { get; set; }
/// <summary>
/// Denormalized running balance kept in sync with each transaction. Positive = normal-balance direction
/// (debit-normal for Assets/Expenses/COGS; credit-normal for Liabilities/Equity/Revenue).
/// Use AccountsController.RecalculateBalances to rebuild from scratch if needed.
/// </summary>
public decimal CurrentBalance { get; set; } = 0;
// Navigation
public virtual Account? ParentAccount { get; set; }
public virtual ICollection<Account> SubAccounts { get; set; } = new List<Account>();
public virtual ICollection<BillLineItem> BillLineItems { get; set; } = new List<BillLineItem>();
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual ICollection<Expense> ExpensePaymentAccounts { get; set; } = new List<Expense>();
}
/// <summary>
/// Vendor bill (accounts payable). Represents money owed to a supplier.
/// </summary>
public class Bill : BaseEntity
{
public string BillNumber { get; set; } = string.Empty;
/// <summary>Vendor's own invoice/reference number.</summary>
public string? VendorInvoiceNumber { get; set; }
public int VendorId { get; set; }
/// <summary>Which AP account this bill posts to (default: Accounts Payable 2000).</summary>
public int APAccountId { get; set; }
public DateTime BillDate { get; set; } = DateTime.UtcNow;
public DateTime? DueDate { get; set; }
public BillStatus Status { get; set; } = BillStatus.Draft;
public string? Terms { get; set; }
public string? Memo { get; set; }
// Financials
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public decimal BalanceDue => Total - AmountPaid;
/// <summary>Blob path to an attached receipt/invoice document (PDF or image).</summary>
public string? ReceiptFilePath { get; set; }
// Navigation
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account APAccount { get; set; } = null!;
public virtual ICollection<BillLineItem> LineItems { get; set; } = new List<BillLineItem>();
public virtual ICollection<BillPayment> Payments { get; set; } = new List<BillPayment>();
}
/// <summary>
/// A single line on a vendor bill, posted to an expense or asset account.
/// </summary>
public class BillLineItem : BaseEntity
{
public int BillId { get; set; }
/// <summary>Expense/asset account this line item is categorized under. Nullable for QB-imported bills where account is unknown.</summary>
public int? AccountId { get; set; }
/// <summary>Optional job costing link.</summary>
public int? JobId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; } = 1;
public decimal UnitPrice { get; set; }
public decimal Amount { get; set; }
public int DisplayOrder { get; set; }
// Navigation
public virtual Bill Bill { get; set; } = null!;
public virtual Account? Account { get; set; }
public virtual Job? Job { get; set; }
}
/// <summary>
/// A payment made against a vendor bill.
/// </summary>
public class BillPayment : BaseEntity
{
public string PaymentNumber { get; set; } = string.Empty;
public int BillId { get; set; }
/// <summary>Denormalized for AP reporting without joining through Bill.</summary>
public int VendorId { get; set; }
/// <summary>Bank/cash account the payment came out of.</summary>
public int BankAccountId { get; set; }
public DateTime PaymentDate { get; set; } = DateTime.UtcNow;
public decimal Amount { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public string? CheckNumber { get; set; }
public string? Memo { get; set; }
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation
public virtual Bill Bill { get; set; } = null!;
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account BankAccount { get; set; } = null!;
}
/// <summary>
/// A direct expense paid immediately (not via a bill). Covers cash/card purchases.
/// </summary>
public class Expense : BaseEntity
{
public string ExpenseNumber { get; set; } = string.Empty;
public DateTime Date { get; set; } = DateTime.UtcNow;
/// <summary>Optional vendor the expense was paid to.</summary>
public int? VendorId { get; set; }
/// <summary>Expense category account (e.g. 6200 Powder & Materials).</summary>
public int ExpenseAccountId { get; set; }
/// <summary>Account money came out of (e.g. 1000 Checking, 2100 Credit Card).</summary>
public int PaymentAccountId { get; set; }
/// <summary>Optional job costing link.</summary>
public int? JobId { get; set; }
public PaymentMethod PaymentMethod { get; set; }
public decimal Amount { get; set; }
public string? Memo { get; set; }
public string? ReceiptFilePath { get; set; }
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
public bool IsCleared { get; set; } = false;
public DateTime? ClearedDate { get; set; }
// Navigation
public virtual Vendor? Vendor { get; set; }
public virtual Account ExpenseAccount { get; set; } = null!;
public virtual Account PaymentAccount { get; set; } = null!;
public virtual Job? Job { get; set; }
}
/// <summary>
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
/// </summary>
public class JournalEntry : BaseEntity
{
public string EntryNumber { get; set; } = string.Empty;
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
public string? Reference { get; set; }
public string? Description { get; set; }
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
public bool IsReversal { get; set; } = false;
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
public int? ReversalOfId { get; set; }
public DateTime? PostedAt { get; set; }
public string? PostedBy { get; set; }
// Navigation
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
public virtual JournalEntry? ReversalOf { get; set; }
}
/// <summary>
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
/// should be non-zero per line (not both). LineOrder controls display sequence.
/// </summary>
public class JournalEntryLine : BaseEntity
{
public int JournalEntryId { get; set; }
public int AccountId { get; set; }
public decimal DebitAmount { get; set; }
public decimal CreditAmount { get; set; }
public string? Description { get; set; }
public int LineOrder { get; set; }
// Navigation
public virtual JournalEntry JournalEntry { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A bank reconciliation session for a single bank/cash account against a statement.
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
/// </summary>
public class BankReconciliation : BaseEntity
{
/// <summary>Must be a bank/cash subtype account.</summary>
public int AccountId { get; set; }
public DateTime StatementDate { get; set; }
public decimal BeginningBalance { get; set; }
public decimal EndingBalance { get; set; }
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
public DateTime? CompletedAt { get; set; }
public string? CompletedBy { get; set; }
public string? Notes { get; set; }
// Navigation
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
/// Numbering: VC-YYMM-####
/// </summary>
public class VendorCredit : BaseEntity
{
public string CreditNumber { get; set; } = string.Empty;
public int VendorId { get; set; }
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
public int APAccountId { get; set; }
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
public decimal Total { get; set; }
public decimal RemainingAmount { get; set; }
public string? Memo { get; set; }
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
public DateTime? PostedDate { get; set; }
// Navigation
public virtual Vendor Vendor { get; set; } = null!;
public virtual Account APAccount { get; set; } = null!;
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
}
/// <summary>
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
/// </summary>
public class VendorCreditLineItem : BaseEntity
{
public int VendorCreditId { get; set; }
/// <summary>Expense/COGS account being reversed by this line.</summary>
public int? AccountId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Account? Account { get; set; }
}
/// <summary>
/// Records the application of a vendor credit against a specific vendor bill.
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
/// </summary>
public class VendorCreditApplication : BaseEntity
{
public int VendorCreditId { get; set; }
public int BillId { get; set; }
public decimal Amount { get; set; }
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
// Navigation
public virtual VendorCredit VendorCredit { get; set; } = null!;
public virtual Bill Bill { get; set; } = null!;
}
/// <summary>
/// A saved recipe for a document that should be automatically created on a recurring schedule.
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
/// <para>
/// Bills are created as Draft so the user can review before posting.
/// Expenses are created immediately (already-paid transactions).
/// </para>
/// Numbering: REC-YYMM-####
/// </summary>
public class RecurringTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public RecurringTemplateType TemplateType { get; set; }
public RecurringFrequency Frequency { get; set; }
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
public int IntervalCount { get; set; } = 1;
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
public DateTime NextFireDate { get; set; }
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
public DateTime? EndDate { get; set; }
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
public int? MaxOccurrences { get; set; }
/// <summary>How many documents have been generated so far.</summary>
public int OccurrenceCount { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
public string TemplateData { get; set; } = "{}";
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
public string? LastError { get; set; }
}
/// <summary>
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
/// invoices when a taxable customer is selected. Companies can define multiple rates for
/// different jurisdictions and mark one as default.
/// </summary>
public class TaxRate : BaseEntity
{
public string Name { get; set; } = string.Empty;
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
public decimal Rate { get; set; }
public string? State { get; set; }
public string? Description { get; set; }
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
public bool IsDefault { get; set; }
public bool IsActive { get; set; } = true;
}
/// <summary>
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
/// to auto-post monthly depreciation journal entries.
/// </summary>
public class FixedAsset : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public DateTime PurchaseDate { get; set; }
public decimal PurchaseCost { get; set; }
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
public decimal SalvageValue { get; set; } = 0;
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
public int UsefulLifeMonths { get; set; }
/// <summary>Running total of depreciation posted so far.</summary>
public decimal AccumulatedDepreciation { get; set; } = 0;
public bool IsDisposed { get; set; } = false;
public DateTime? DisposalDate { get; set; }
// Computed — not persisted
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
/// <summary>Straight-line monthly depreciation amount.</summary>
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
public int? AssetAccountId { get; set; }
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
public int? DepreciationExpenseAccountId { get; set; }
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
public int? AccumDepreciationAccountId { get; set; }
// Navigation
public virtual Account? AssetAccount { get; set; }
public virtual Account? DepreciationExpenseAccount { get; set; }
public virtual Account? AccumDepreciationAccount { get; set; }
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
}
/// <summary>
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
/// month/year combination; linked to the JournalEntry that was created so the posting
/// can be traced back through the GL.
/// </summary>
public class FixedAssetDepreciationEntry : BaseEntity
{
public int FixedAssetId { get; set; }
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
public decimal Amount { get; set; }
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
public int? JournalEntryId { get; set; }
// Navigation
public virtual FixedAsset FixedAsset { get; set; } = null!;
public virtual JournalEntry? JournalEntry { get; set; }
}
/// <summary>
/// A named annual budget. Contains one BudgetLine per account per month. Supports
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
/// one is marked IsDefault for the Budget vs. Actual report.
/// </summary>
public class Budget : BaseEntity
{
public string Name { get; set; } = string.Empty;
public int FiscalYear { get; set; }
public string? Notes { get; set; }
public bool IsDefault { get; set; } = false;
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
}
/// <summary>
/// Monthly budget amount for one account within a Budget. JanDec stored as separate
/// columns so the grid editor can write them in a single POST without a line-item loop.
/// Annual is a computed property summing all twelve months.
/// </summary>
public class BudgetLine : BaseEntity
{
public int BudgetId { get; set; }
public int AccountId { get; set; }
public decimal Jan { get; set; }
public decimal Feb { get; set; }
public decimal Mar { get; set; }
public decimal Apr { get; set; }
public decimal May { get; set; }
public decimal Jun { get; set; }
public decimal Jul { get; set; }
public decimal Aug { get; set; }
public decimal Sep { get; set; }
public decimal Oct { get; set; }
public decimal Nov { get; set; }
public decimal Dec { get; set; }
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
public virtual Budget Budget { get; set; } = null!;
public virtual Account Account { get; set; } = null!;
}
/// <summary>
/// Records a completed year-end close. The close posts a JE that zeroes all
/// Revenue and Expense account balances into Retained Earnings, and marks
/// the year as closed so it cannot be closed again.
/// </summary>
public class YearEndClose : BaseEntity
{
public int ClosedYear { get; set; }
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
public string? ClosedBy { get; set; }
public int JournalEntryId { get; set; }
public virtual JournalEntry JournalEntry { get; set; } = null!;
}