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>
This commit is contained in:
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
if (unlinkedRevenue > 0)
|
||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||
|
||||
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||
var periodDiscounts = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
var periodCredits = await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
var totalDeductions = periodDiscounts + periodCredits;
|
||||
if (totalDeductions > 0)
|
||||
revenueLines.Add(new FinancialReportLine
|
||||
{
|
||||
AccountNumber = "4950",
|
||||
AccountName = "Less: Sales Discounts & Credits",
|
||||
Amount = -totalDeductions
|
||||
});
|
||||
|
||||
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||
var periodGcReclassified = await _context.InvoiceItems
|
||||
.Where(ii => ii.IsGiftCertificate
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||
if (periodGcReclassified > 0)
|
||||
revenueLines.Add(new FinancialReportLine
|
||||
{
|
||||
AccountNumber = "2500",
|
||||
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
|
||||
Amount = -periodGcReclassified
|
||||
});
|
||||
|
||||
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||
var periodGcBreakage = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||
if (periodGcBreakage > 0)
|
||||
revenueLines.Add(new FinancialReportLine
|
||||
{
|
||||
AccountNumber = "—",
|
||||
AccountName = "Gift Certificate Breakage Income",
|
||||
Amount = periodGcBreakage
|
||||
});
|
||||
}
|
||||
|
||||
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
||||
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||
arCredits += await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
||||
arCredits -= await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||
|
||||
// Retained earnings = net P&L from inception through asOf
|
||||
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||
var refundsByAcctBs = await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.GroupBy(r => r.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||
var depositsByAcctDepBs = await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.GroupBy(d => d.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||
|
||||
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||
var custDepositsAcctIdBs = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
|
||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||
var gcLiabilityAcctIdBs = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||
? (await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||
? ((await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||
+ (await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||
|
||||
// Retained earnings = net P&L from inception through asOf, covering four sources:
|
||||
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
|
||||
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||
var lifetimeRevenue = await _context.InvoiceItems
|
||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||
var lifetimeCogs = await _context.Expenses
|
||||
var lifetimeDiscounts = isCash ? 0m
|
||||
: (await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
||||
var lifetimeCreditMemos = isCash ? 0m
|
||||
: (await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
||||
var lifetimeDirectExp = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||
var lifetimeBillCosts = await _context.BillLineItems
|
||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
||||
|
||||
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||
var revenueAcctIds = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
|
||||
.Select(a => a.Id).ToListAsync();
|
||||
var expCogsAcctIds = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId
|
||||
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
|
||||
&& !a.IsDeleted)
|
||||
.Select(a => a.Id).ToListAsync();
|
||||
|
||||
var jeRevNet = revenueAcctIds.Count > 0
|
||||
? (await _context.JournalEntryLines
|
||||
.Where(l => revenueAcctIds.Contains(l.AccountId)
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
|
||||
: 0m;
|
||||
|
||||
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
|
||||
var jeExpNet = expCogsAcctIds.Count > 0
|
||||
? (await _context.JournalEntryLines
|
||||
.Where(l => expCogsAcctIds.Contains(l.AccountId)
|
||||
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
|
||||
: 0m;
|
||||
|
||||
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||
.Where(ii => ii.IsGiftCertificate
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||
|
||||
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||
- lifetimeDiscounts
|
||||
- lifetimeCreditMemos
|
||||
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||
- lifetimeDirectExp
|
||||
- lifetimeBillCosts
|
||||
- jeExpNet;
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.IsActive)
|
||||
@@ -246,8 +413,9 @@ public class FinancialReportService : IFinancialReportService
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||
{
|
||||
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||
}
|
||||
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||
{
|
||||
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||
debits += custDepositsDebitsBs; // deposits applied → DR liability
|
||||
}
|
||||
}
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Balances are computed dynamically from transaction tables using the same pre-computed
|
||||
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
|
||||
/// date is respected. This replaces the previous implementation that read the denormalised
|
||||
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
|
||||
/// what date was selected.
|
||||
/// </remarks>
|
||||
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||
{
|
||||
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
|
||||
|
||||
// Bank/cash: customer payments deposited here (DR)
|
||||
var depositsByAcct = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.GroupBy(p => p.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||
// issues a credit note and it is matched against a specific bill.
|
||||
var vcByApAcct = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Bank/cash: expenses paid from here (CR)
|
||||
var expFromByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.PaymentAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Bank/cash: bill payments made from here (CR)
|
||||
var bpFromByAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.BankAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AP: bills increase AP (CR)
|
||||
var billsByApAcct = await _context.Bills
|
||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||
.GroupBy(b => b.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AP: bill payments reduce AP (DR)
|
||||
var bpByApAcct = await _context.BillPayments
|
||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||
.GroupBy(bp => bp.Bill.APAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Tax liability: sales tax collected (CR)
|
||||
var taxByAcct = await _context.Invoices
|
||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Revenue accounts: invoice line items (CR)
|
||||
var revenueByAcct = await _context.InvoiceItems
|
||||
.Where(ii => ii.RevenueAccountId != null
|
||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Expense accounts: direct expenses (DR)
|
||||
var expenseByAcct = await _context.Expenses
|
||||
.Where(e => e.Date <= asOfEnd)
|
||||
.GroupBy(e => e.ExpenseAccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Expense/COGS accounts: vendor bill line items (DR)
|
||||
var billLinesByAcct = await _context.BillLineItems
|
||||
.Where(bli => bli.AccountId != null
|
||||
&& bli.Bill.Status != BillStatus.Draft
|
||||
&& bli.Bill.Status != BillStatus.Voided
|
||||
&& bli.Bill.BillDate <= asOfEnd)
|
||||
.GroupBy(bli => bli.AccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||
var discountAcctId = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
discountAcctId ??= await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
|
||||
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var cmApplied = await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate <= asOfEnd
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||
|
||||
var discountsByAcct = new Dictionary<int, decimal>();
|
||||
if (discountAcctId.HasValue)
|
||||
{
|
||||
var totalDiscounts = await _context.Invoices
|
||||
.Where(i => i.DiscountAmount > 0
|
||||
&& i.Status != InvoiceStatus.Draft
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||
if (totalDiscounts + cmApplied > 0)
|
||||
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
||||
}
|
||||
|
||||
// JE lines: posted entries debit/credit all account types
|
||||
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.GroupBy(l => l.AccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||
.GroupBy(l => l.AccountId)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// AR totals (single AR account assumed per standard small-business chart of accounts).
|
||||
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||
// when a customer credit is applied against a specific invoice).
|
||||
var arTotalDebits = await _context.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate <= asOfEnd)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||
var arTotalCredits = await _context.Payments
|
||||
.Where(p => p.PaymentDate <= asOfEnd
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||
|
||||
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
||||
var refundTotal = await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||
arTotalCredits -= refundTotal;
|
||||
|
||||
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||
var refundsByAcct = await _context.Refunds
|
||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||
.GroupBy(r => r.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||
var depositsByAcctDep = await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||
.GroupBy(d => d.DepositAccountId!.Value)
|
||||
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||
|
||||
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||
var custDepositsAcctId = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||
? (await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||
|
||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||
var gcLiabilityAcctId = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||
? (await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||
? ((await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||
+ (await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||
|
||||
// ── Per-account balance computation ─────────────────────────────────────────────────
|
||||
|
||||
var accounts = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.ToListAsync();
|
||||
|
||||
var lines = new List<TrialBalanceLine>();
|
||||
decimal ComputeAsOfBalance(Account a)
|
||||
{
|
||||
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
|
||||
decimal debits = 0m, credits = 0m;
|
||||
|
||||
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||
{
|
||||
debits = arTotalDebits;
|
||||
credits = arTotalCredits;
|
||||
}
|
||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
{
|
||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||
}
|
||||
else
|
||||
{
|
||||
// All other accounts: sum contributions from each transaction source that can
|
||||
// post to this account. Dictionaries only contain entries for relevant account IDs,
|
||||
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
|
||||
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||
credits += revenueByAcct.GetValueOrDefault(a.Id);
|
||||
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||
{
|
||||
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||
debits += gcLiabilityDebits; // redeemed/voided → DR liability
|
||||
}
|
||||
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
|
||||
{
|
||||
credits += custDepositsCredits; // deposits taken → CR liability
|
||||
debits += custDepositsDebits; // deposits applied → DR liability
|
||||
}
|
||||
}
|
||||
|
||||
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
|
||||
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
|
||||
|
||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||
? a.OpeningBalance : 0m;
|
||||
decimal net = isDebitNormal ? debits - credits : credits - debits;
|
||||
return opening + net;
|
||||
}
|
||||
|
||||
var lines = new List<TrialBalanceLine>();
|
||||
foreach (var acct in accounts)
|
||||
{
|
||||
if (acct.CurrentBalance == 0) continue;
|
||||
var balance = ComputeAsOfBalance(acct);
|
||||
if (balance == 0m) continue;
|
||||
|
||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||
var line = new TrialBalanceLine
|
||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
||||
if (isDebitNormal)
|
||||
{
|
||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
||||
else line.CreditBalance = -acct.CurrentBalance;
|
||||
if (balance >= 0m) line.DebitBalance = balance;
|
||||
else line.CreditBalance = -balance;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
||||
else line.DebitBalance = -acct.CurrentBalance;
|
||||
if (balance >= 0m) line.CreditBalance = balance;
|
||||
else line.DebitBalance = -balance;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
|
||||
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
var depositedDeposits = await _context.Deposits
|
||||
.Where(d => d.DepositAccountId == accountId
|
||||
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var d in depositedDeposits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate,
|
||||
Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit",
|
||||
Description = d.Notes ?? d.Reference,
|
||||
Debit = d.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Jobs",
|
||||
LinkId = d.JobId
|
||||
});
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
var refundsPaidFrom = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.DepositAccountId == accountId
|
||||
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in refundsPaidFrom)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = 0,
|
||||
Credit = r.Amount,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
|
||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||
// e.g. Checking account used to pay an expense
|
||||
var expensesPaidFrom = await _context.Expenses
|
||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Invoices",
|
||||
LinkId = p.InvoiceId
|
||||
});
|
||||
|
||||
// Credit memo applications reduce open AR (CREDIT)
|
||||
var arCreditMemos = await _context.CreditMemoApplications
|
||||
.Include(a => a.Invoice)
|
||||
.Include(a => a.CreditMemo)
|
||||
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
|
||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var cm in arCreditMemos)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = cm.AppliedDate,
|
||||
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
|
||||
Source = "Credit Memo",
|
||||
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
|
||||
Debit = 0,
|
||||
Credit = cm.AmountApplied,
|
||||
LinkController = "Invoices",
|
||||
LinkId = cm.InvoiceId
|
||||
});
|
||||
|
||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||
var arRefunds = await _context.Refunds
|
||||
.Include(r => r.Invoice)
|
||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var r in arRefunds)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RefundDate,
|
||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||
Source = "Refund",
|
||||
Description = r.Reason,
|
||||
Debit = r.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "Invoices",
|
||||
LinkId = r.InvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
|
||||
LinkController = "Bills",
|
||||
LinkId = bp.BillId
|
||||
});
|
||||
|
||||
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
|
||||
var apVendorCredits = await _context.VendorCreditApplications
|
||||
.Include(vca => vca.VendorCredit)
|
||||
.Include(vca => vca.Bill)
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId
|
||||
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var vca in apVendorCredits)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = vca.AppliedDate,
|
||||
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
|
||||
Source = "Vendor Credit",
|
||||
Description = $"Credit applied to {vca.Bill?.BillNumber}",
|
||||
Debit = vca.Amount,
|
||||
Credit = 0,
|
||||
LinkController = "VendorCredits",
|
||||
LinkId = vca.VendorCreditId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
|
||||
// CR when GC is issued; DR when redeemed or voided with remaining balance.
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
var gcIssued = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcIssued)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.IssueDate, Reference = gc.CertificateCode,
|
||||
Source = "Gift Certificate", Description = "GC issued",
|
||||
Debit = 0, Credit = gc.OriginalAmount,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
|
||||
var gcRedemptions = await _context.GiftCertificateRedemptions
|
||||
.Include(r => r.GiftCertificate)
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var r in gcRedemptions)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||
Source = "GC Redemption", Description = "GC applied to invoice",
|
||||
Debit = r.AmountRedeemed, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
|
||||
});
|
||||
|
||||
var gcVoided = await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
|
||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.ToListAsync();
|
||||
foreach (var gc in gcVoided)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
|
||||
Source = "GC Voided", Description = "Breakage income",
|
||||
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
|
||||
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||
});
|
||||
}
|
||||
|
||||
// ── 12. Customer Deposits liability (account 2300) ────────────────────
|
||||
// CR when deposit is recorded; DR when deposit is applied to an invoice.
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
var depositsRecorded = await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsRecorded)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
|
||||
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
|
||||
Debit = 0, Credit = d.Amount,
|
||||
LinkController = "Jobs", LinkId = d.JobId
|
||||
});
|
||||
|
||||
var depositsApplied = await _context.Deposits
|
||||
.Include(d => d.AppliedToInvoice)
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
|
||||
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
|
||||
.ToListAsync();
|
||||
foreach (var d in depositsApplied)
|
||||
entries.Add(new LedgerEntryDto
|
||||
{
|
||||
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
|
||||
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
|
||||
Debit = d.Amount, Credit = 0,
|
||||
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
|
||||
});
|
||||
}
|
||||
|
||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
|
||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
|
||||
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||
credits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
|
||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||
credits += await _context.Expenses
|
||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
|
||||
credits += await _context.Payments
|
||||
.Where(p => p.PaymentDate < beforeDate)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
credits += await _context.CreditMemoApplications
|
||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
debits += await _context.Refunds
|
||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 9. Accounts Payable
|
||||
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
|
||||
debits += await _context.BillPayments
|
||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
|
||||
debits += await _context.VendorCreditApplications
|
||||
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
|
||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 11. GC Liability (account 2500)
|
||||
if (account.AccountNumber == "2500")
|
||||
{
|
||||
credits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
|
||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
|
||||
debits += await _context.GiftCertificateRedemptions
|
||||
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
|
||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||
debits += await _context.GiftCertificates
|
||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
|
||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
|
||||
}
|
||||
|
||||
// 12. Customer Deposits liability (account 2300)
|
||||
if (account.AccountNumber == "2300")
|
||||
{
|
||||
credits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
debits += await _context.Deposits
|
||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
|
||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||
}
|
||||
|
||||
// 10. Posted journal entry lines touching this account (prior to period)
|
||||
|
||||
@@ -70,6 +70,10 @@ public partial class SeedDataService
|
||||
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
||||
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
|
||||
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||
// reducing net revenue to match the discounted AR amount that was posted.
|
||||
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||
@@ -96,4 +100,44 @@ public partial class SeedDataService
|
||||
|
||||
return accounts.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
|
||||
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
|
||||
/// call repeatedly from the "Seed Lookup Tables" flow.
|
||||
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
|
||||
/// companies get all accounts in one pass while existing companies receive only the missing ones.
|
||||
/// </summary>
|
||||
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
|
||||
private async Task<int> EnsureSystemAccountsAsync(Company company)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
int added = 0;
|
||||
|
||||
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
|
||||
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
|
||||
var has4950 = await _context.Set<Account>()
|
||||
.IgnoreQueryFilters()
|
||||
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
|
||||
|
||||
if (!has4950)
|
||||
{
|
||||
_context.Set<Account>().Add(new Account
|
||||
{
|
||||
AccountNumber = "4950",
|
||||
Name = "Sales Discounts",
|
||||
AccountType = AccountType.Revenue,
|
||||
AccountSubType = AccountSubType.OtherIncome,
|
||||
IsSystem = true,
|
||||
IsActive = true,
|
||||
Description = "Contra-revenue for invoice discounts granted to customers",
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now
|
||||
});
|
||||
await _context.SaveChangesAsync();
|
||||
added++;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
|
||||
result.ItemsSeeded += accountsSeeded;
|
||||
}
|
||||
|
||||
// Backfill any system accounts added after the initial seed (idempotent).
|
||||
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||
if (systemAccountsAdded > 0)
|
||||
{
|
||||
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||
result.ItemsSeeded += systemAccountsAdded;
|
||||
}
|
||||
|
||||
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||
result.Details = details;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user