Credit AR for gift-certificate redemptions in balance recompute (audit O7)

ApplyGiftCertificate posts DR 2500 Gift Certificate Liability / CR AR, but the AR
recompute only subtracted payments and credit-memo applications — so the redemption's
2500 debit was recomputed while its AR credit was not, leaving the Trial Balance out
of balance by the total gift-certificate amount redeemed and overstating AR on the
Balance Sheet.

Subtract GC redemptions from AR in both recompute engines:
  - FinancialReportService: Balance Sheet (gcRedeemedBs) and Trial Balance (gcRedeemedTb)
  - LedgerService: AR section (dated rows) and ComputePriorBalanceAsync (prior balance)

AR Aging was already correct (uses BalanceDue, which includes GiftCertificateRedeemed).
Adds a LedgerService regression test. Build clean; 292 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 21:08:16 -04:00
parent 08a5cd39d4
commit 91ed19c2b1
4 changed files with 136 additions and 2 deletions
@@ -365,6 +365,13 @@ public class FinancialReportService : IFinancialReportService
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
arCredits += cmAppliedBs;
// Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR).
// Mirror the posting here so AR is not overstated and the entry's two sides stay balanced.
var gcRedeemedBs = await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
&& r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
arCredits += gcRedeemedBs;
// Customer Credits (2350): a credit memo books DR Sales Discounts / CR Customer Credits on issue,
// then DR Customer Credits / CR AR on apply. Contra-revenue (retained earnings) = issued amount
@@ -1132,6 +1139,13 @@ public class FinancialReportService : IFinancialReportService
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
// 2500 debit is recomputed but its AR credit is not, leaving the trial balance out of balance.
var gcRedeemedTb = await _context.GiftCertificateRedemptions
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
&& r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m;
arTotalCredits += gcRedeemedTb;
// Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
@@ -350,6 +350,27 @@ public class LedgerService : ILedgerService
LinkId = cm.InvoiceId
});
// Gift-certificate redemptions reduce open AR (CREDIT) — ApplyGiftCertificate posts DR 2500 / CR AR.
var arGcRedemptions = await _context.GiftCertificateRedemptions
.Include(r => r.Invoice)
.Include(r => r.GiftCertificate)
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
&& r.Invoice.Status != InvoiceStatus.Voided)
.ToListAsync();
foreach (var r in arGcRedemptions)
entries.Add(new LedgerEntryDto
{
Date = r.RedeemedDate,
Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
Source = "Gift Certificate",
Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}",
Debit = 0,
Credit = r.AmountRedeemed,
LinkController = "Invoices",
LinkId = r.InvoiceId
});
// NOTE: cash refunds no longer touch AR. Under the "reverse the sale" model they debit
// Sales Returns + Sales Tax Payable and credit the bank (see section 5b above).
}
@@ -751,6 +772,11 @@ public class LedgerService : ILedgerService
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
// Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync.
credits += await _context.GiftCertificateRedemptions
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate && r.Invoice.Status != InvoiceStatus.Voided)
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
// NOTE: cash refunds no longer debit AR — they reverse the sale (Sales Returns + Sales Tax),
// handled in section 5b above.
}