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:
@@ -677,19 +677,22 @@ public class InvoicesController : Controller
|
||||
|
||||
foreach (var deposit in pendingDeposits)
|
||||
{
|
||||
// Create a Payment record for each deposit
|
||||
// DepositAccountId is intentionally null: the bank account was already debited
|
||||
// when the deposit was recorded (DR Checking / CR Customer Deposits 2300).
|
||||
// Setting it here would double-count the bank debit in the Trial Balance.
|
||||
var payment = new Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = deposit.Amount,
|
||||
PaymentDate = deposit.ReceivedDate,
|
||||
PaymentMethod = deposit.PaymentMethod,
|
||||
Reference = $"Deposit {deposit.ReceiptNumber}",
|
||||
Notes = deposit.Notes,
|
||||
RecordedById = currentUser.Id,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser.Email
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = deposit.Amount,
|
||||
PaymentDate = deposit.ReceivedDate,
|
||||
PaymentMethod = deposit.PaymentMethod,
|
||||
Reference = $"Deposit {deposit.ReceiptNumber}",
|
||||
Notes = deposit.Notes,
|
||||
DepositAccountId = null,
|
||||
RecordedById = currentUser.Id,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser.Email
|
||||
};
|
||||
await _unitOfWork.Payments.AddAsync(payment);
|
||||
|
||||
@@ -719,13 +722,31 @@ public class InvoicesController : Controller
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Update account balances: debit AR, credit revenue accounts + sales tax
|
||||
// Update account balances: debit AR, credit revenue accounts + sales tax.
|
||||
// Discount contra-entry: DR Sales Discounts so the GL balances.
|
||||
// Without it, credits (revenue + tax) exceed the AR debit by the discount amount.
|
||||
var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(currentUser.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
// GL for auto-applied deposits: DR Customer Deposits 2300 (clears the liability) / CR AR.
|
||||
// The bank was already debited when the deposit was recorded, so Checking is not touched here.
|
||||
if (pendingDeposits.Any())
|
||||
{
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||
foreach (var dep in pendingDeposits)
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(custDepositsAcctId, dep.Amount);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, dep.Amount);
|
||||
}
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Auto-generate gift certificates for any GC line items
|
||||
@@ -873,8 +894,17 @@ public class InvoicesController : Controller
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// Capture GL state before any mutations so the reversal is exact.
|
||||
var oldTotal = invoice.Total;
|
||||
var oldTaxAmount = invoice.TaxAmount;
|
||||
var oldTaxAcctId = invoice.SalesTaxAccountId;
|
||||
var oldDiscountAmt = invoice.DiscountAmount;
|
||||
var oldItems = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted)
|
||||
.Select(i => (RevAcctId: i.RevenueAccountId, Price: i.TotalPrice))
|
||||
.ToList();
|
||||
|
||||
// Recalculate totals (tax is applied after discount, consistent with quotes)
|
||||
var oldTotal = invoice.Total;
|
||||
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||
var taxableAmount = subTotal - dto.DiscountAmount;
|
||||
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
||||
@@ -940,6 +970,31 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Reverse old GL entries then re-post new ones so account balances stay accurate.
|
||||
// Reversal is the mirror of the original Create double-entry: swap every Debit↔Credit.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
int? discAcctId = null;
|
||||
if (oldDiscountAmt > 0 || invoice.DiscountAmount > 0)
|
||||
discAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
|
||||
await _accountBalanceService.CreditAsync(arAccountId, oldTotal);
|
||||
foreach (var (revAcctId, price) in oldItems)
|
||||
await _accountBalanceService.DebitAsync(revAcctId, price);
|
||||
if (oldTaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(oldTaxAcctId, oldTaxAmount);
|
||||
if (oldDiscountAmt > 0)
|
||||
await _accountBalanceService.CreditAsync(discAcctId, oldDiscountAmt);
|
||||
|
||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(discAcctId, invoice.DiscountAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Invoice updated successfully.";
|
||||
|
||||
// Optionally re-send the updated invoice PDF to the customer
|
||||
@@ -1347,29 +1402,49 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||
}
|
||||
|
||||
// Void any gift certificates that were generated from this invoice
|
||||
var gcItemIds = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)
|
||||
.Select(i => i.GeneratedGiftCertificateId!.Value)
|
||||
.ToList();
|
||||
foreach (var gcId in gcItemIds)
|
||||
// Void any gift certificates that were generated from this invoice.
|
||||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
|
||||
foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
|
||||
{
|
||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId);
|
||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
|
||||
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
||||
{
|
||||
gc.Status = GiftCertificateStatus.Voided;
|
||||
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
|
||||
gc.Status = GiftCertificateStatus.Voided;
|
||||
gc.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.GiftCertificates.UpdateAsync(gc);
|
||||
}
|
||||
// FullyRedeemed GCs: not voided, nothing to reverse (GC Liability already at 0).
|
||||
}
|
||||
|
||||
// Reverse account balances: credit AR (open balance), debit revenue + sales tax
|
||||
// Reverse account balances: credit AR (open balance), debit revenue + sales tax.
|
||||
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
|
||||
// GC line items: instead of debiting revenue (which was already reclassified to GC Liability
|
||||
// at creation), debit GC Liability for the unredeemed portion, netting the obligation to 0.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
{
|
||||
if (item.IsGiftCertificate)
|
||||
{
|
||||
// GC item: debit GC Liability for unredeemed portion; skip fully-redeemed items.
|
||||
if (gcLiabilityAcctId.HasValue && gcRemainingByItemId.TryGetValue(item.Id, out var remaining) && remaining > 0)
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
}
|
||||
}
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
|
||||
invoice.Status = InvoiceStatus.Voided;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -1721,13 +1796,30 @@ public class InvoicesController : Controller
|
||||
deposit.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax
|
||||
// Reverse account balances (mirror of Create): credit AR, debit revenue + sales tax.
|
||||
// Also reverse the discount contra-entry (credit Sales Discounts) to unwind the original debit.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
// Reverse deposit-apply GL: DR AR / CR Customer Deposits 2300 for each previously applied
|
||||
// deposit. The deposits are now unapplied and the liability is restored.
|
||||
if (appliedDeposits.Any())
|
||||
{
|
||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||
foreach (var dep in appliedDeposits)
|
||||
{
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dep.Amount);
|
||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, dep.Amount);
|
||||
}
|
||||
}
|
||||
await _accountBalanceService.CreditAsync(arAccountId, invoice.Total);
|
||||
foreach (var item in invoiceItems)
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
if (invoice.TaxAmount > 0)
|
||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||
if (invoice.DiscountAmount > 0)
|
||||
{
|
||||
var discountAccountId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(discountAccountId, invoice.DiscountAmount);
|
||||
}
|
||||
|
||||
// Clear the JobId FK before soft-deleting so the unique index slot is freed
|
||||
// and a new invoice can be created for the same job if needed.
|
||||
@@ -1920,6 +2012,12 @@ public class InvoicesController : Controller
|
||||
|
||||
item.GeneratedGiftCertificateId = cert.Id;
|
||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||
|
||||
// GL: DR Revenue (line item account) / CR Gift Certificate Liability (2500).
|
||||
// Reclassifies the GC item's revenue as a deferred obligation until the cert is redeemed.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, item.TotalPrice);
|
||||
}
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -2083,6 +2181,24 @@ public class InvoicesController : Controller
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Returns the primary Checking or Cash account ID for the company, used as the
|
||||
/// deposit account when auto-applying deposits that were recorded without an explicit account.</summary>
|
||||
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Cash));
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns account 2300 "Customer Deposits" liability ID for the company, or null.</summary>
|
||||
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "2300");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
|
||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||
{
|
||||
@@ -2135,6 +2251,28 @@ public class InvoicesController : Controller
|
||||
return taxAccount?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the "4950 Sales Discounts" contra-revenue account for this company, falling back
|
||||
/// to any active Revenue account whose name contains "discount". Returns null only when no
|
||||
/// such account exists (e.g. for companies whose chart of accounts predates the 4950 seed).
|
||||
/// </summary>
|
||||
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
|
||||
{
|
||||
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountNumber == "4950" && a.IsActive);
|
||||
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
||||
return discountAccount?.Id;
|
||||
}
|
||||
|
||||
/// <summary>Returns the Gift Certificate Liability account ID (account 2500) for the company.</summary>
|
||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "2500");
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
public static string GetStatusColorClass(InvoiceStatus status) => status switch
|
||||
{
|
||||
InvoiceStatus.Draft => "secondary",
|
||||
@@ -2191,6 +2329,8 @@ public class InvoicesController : Controller
|
||||
Amount = dto.Amount,
|
||||
RefundDate = dto.RefundDate,
|
||||
RefundMethod = dto.RefundMethod,
|
||||
// DepositAccountId only applies to cash/card refunds; store-credit refunds have no bank movement.
|
||||
DepositAccountId = isStoreCredit ? null : dto.DepositAccountId,
|
||||
Reason = dto.Reason,
|
||||
Reference = dto.Reference,
|
||||
Notes = dto.Notes,
|
||||
@@ -2249,6 +2389,14 @@ public class InvoicesController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
|
||||
// Mirrors how FinancialReportService accounts for refunds:
|
||||
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
|
||||
var arAccountId = await GetArAccountIdAsync(companyId);
|
||||
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
|
||||
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
||||
|
||||
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||
}
|
||||
}
|
||||
@@ -2323,6 +2471,11 @@ public class InvoicesController : Controller
|
||||
customer.CurrentBalance += refund.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
|
||||
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
|
||||
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
||||
}
|
||||
|
||||
refund.Status = RefundStatus.Cancelled;
|
||||
@@ -2469,6 +2622,12 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
}); // end ExecuteInTransactionAsync
|
||||
@@ -2629,6 +2788,13 @@ public class InvoicesController : Controller
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
// GL: DR Gift Certificate Liability (2500) / CR AR.
|
||||
// Discharges the deferred obligation and reduces the invoice's outstanding AR balance.
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||
var arAcctId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, applyAmount);
|
||||
await _accountBalanceService.CreditAsync(arAcctId, applyAmount);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user