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:
2026-05-13 12:42:46 -04:00
parent 787d1504ef
commit 27bfd4db4d
24 changed files with 33296 additions and 83 deletions
@@ -36,6 +36,8 @@ public class IssueRefundDto
public decimal Amount { get; set; }
public DateTime RefundDate { get; set; } = DateTime.Today;
public PaymentMethod RefundMethod { get; set; }
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
public int? DepositAccountId { get; set; }
public string Reason { get; set; } = string.Empty;
public string? Reference { get; set; }
public string? Notes { get; set; }
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
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!;
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
public string? Notes { get; set; }
public string? RecordedById { get; set; }
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
/// so the Trial Balance can immediately debit the correct bank account.</summary>
public int? DepositAccountId { get; set; }
// Applied to invoice when invoice is created
public int? AppliedToInvoiceId { get; set; }
public DateTime? AppliedDate { get; set; }
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
public DateTime? IssuedDate { get; set; }
public string? IssuedById { get; set; }
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
/// the Trial Balance can credit this account when computing bank balance.</summary>
public int? DepositAccountId { get; set; }
// For store-credit refunds: the CreditMemo created on their behalf
public int? CreditMemoId { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SeedSalesDiscountsAccount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Insert the 4950 Sales Discounts contra-revenue account for every company that does
// not already have it. The account is credit-normal (AccountType=4 Revenue,
// AccountSubType=32 OtherIncome) and is debited when invoice discounts are applied so
// the GL balances (DR Sales Discounts / gap between CR Revenue and DR AR).
// Idempotent: the WHERE NOT EXISTS guard means re-running the migration is safe.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'4950',
'Sales Discounts',
4, -- AccountType.Revenue
32, -- AccountSubType.OtherIncome
1, -- IsSystem = true
1, -- IsActive = true
'Contra-revenue for invoice discounts granted to customers',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '4950'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingGapsPhase2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "PostedDate",
table: "VendorCredits",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Refunds",
type: "int",
nullable: true);
// Seed the Gift Certificate Liability account (2500) for every company that doesn't
// already have it. Credit-normal OtherCurrentLiability account; credited when a GC is
// issued and debited when redeemed or voided. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2500',
'Gift Certificate Liability',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Outstanding gift certificate obligations owed to certificate holders',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2500'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PostedDate",
table: "VendorCredits");
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Refunds");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AccountingDepositsGL : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DepositAccountId",
table: "Deposits",
type: "int",
nullable: true);
// Seed account 2300 "Customer Deposits" (Liability / OtherCurrentLiability) for every
// company that doesn't already have it. Credited when a deposit is taken; debited when
// the deposit is applied to an invoice. Idempotent guard prevents double-seeding.
migrationBuilder.Sql(@"
INSERT INTO Accounts
(AccountNumber, Name, AccountType, AccountSubType,
IsSystem, IsActive, Description,
CompanyId, CreatedAt, IsDeleted,
CurrentBalance, OpeningBalance)
SELECT
'2300',
'Customer Deposits',
2, -- AccountType.Liability
12, -- AccountSubType.OtherCurrentLiability
1, -- IsSystem = true
1, -- IsActive = true
'Deposits received from customers before an invoice is created; cleared when deposit is applied to invoice',
c.Id,
GETUTCDATE(),
0, -- IsDeleted = false
0, -- CurrentBalance
0 -- OpeningBalance
FROM Companies c
WHERE c.IsDeleted = 0
AND NOT EXISTS (
SELECT 1 FROM Accounts a
WHERE a.CompanyId = c.Id
AND a.AccountNumber = '2300'
AND a.IsDeleted = 0
);
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DepositAccountId",
table: "Deposits");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
}
}
}
@@ -2892,6 +2892,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -6574,7 +6577,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377),
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6585,7 +6588,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383),
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6596,7 +6599,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385),
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7654,6 +7657,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("DepositAccountId")
.HasColumnType("int");
b.Property<int>("InvoiceId")
.HasColumnType("int");
@@ -8384,6 +8390,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Memo")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("PostedDate")
.HasColumnType("datetime2");
b.Property<decimal>("RemainingAmount")
.HasColumnType("decimal(18,2)");
@@ -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;
}
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
@@ -15,6 +16,9 @@ namespace PowderCoating.Web.Controllers;
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
/// customer.CreditBalance atomically inside a transaction.
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
/// a revenue deduction and an AR reduction.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CreditMemosController : Controller
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<CreditMemosController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public CreditMemosController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ILogger<CreditMemosController> logger)
ILogger<CreditMemosController> logger,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
@@ -245,6 +252,20 @@ public class CreditMemosController : Controller
await _unitOfWork.Invoices.UpdateAsync(invoice);
}
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
// The dynamic report computation attributes credit memo applications to both
// accounts already; this call keeps Account.CurrentBalance in sync for
// RecalculateAllAsync and any tools that read it directly.
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountNumber == "4950" && a.IsActive)
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.AccountType == AccountType.Revenue && a.IsActive
&& a.Name.ToLower().Contains("discount"));
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
await _unitOfWork.CompleteAsync();
});
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -22,17 +23,20 @@ public class DepositsController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
_logoService = logoService;
_accountBalanceService = accountBalanceService;
}
// -----------------------------------------------------------------------
@@ -76,27 +80,34 @@ public class DepositsController : Controller
if (currentUser == null) return Unauthorized();
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
var deposit = new Deposit
{
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
ReceiptNumber = receiptNumber,
CustomerId = customerId,
JobId = jobId,
QuoteId = quoteId,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = reference,
Notes = notes,
DepositAccountId = checkingAcctId,
RecordedById = currentUser.Id,
CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email
};
await _unitOfWork.Deposits.AddAsync(deposit);
await _unitOfWork.CompleteAsync();
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
return Json(new
{
success = true,
@@ -137,6 +148,11 @@ public class DepositsController : Controller
if (deposit.AppliedToInvoiceId != null)
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
// Reverse the GL entry made at recording time: CR Checking / DR Customer Deposits 2300.
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(deposit.CompanyId);
await _accountBalanceService.CreditAsync(deposit.DepositAccountId, deposit.Amount);
await _accountBalanceService.DebitAsync(custDepositsAcctId, deposit.Amount);
await _unitOfWork.Deposits.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
@@ -419,6 +435,24 @@ public class DepositsController : Controller
return hex.StartsWith("#") ? hex : fallback;
}
/// <summary>Returns the first active Checking or Cash account for the company, or null.</summary>
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
return acct?.Id;
}
/// <summary>Returns account 2300 "Customer Deposits" liability for the company, or null.</summary>
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
return acct?.Id;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Helpers;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly IPdfService _pdfService;
private readonly ICompanyLogoService _logoService;
private readonly IAccountBalanceService _accountBalanceService;
public GiftCertificatesController(
IUnitOfWork unitOfWork,
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
ILogger<GiftCertificatesController> logger,
UserManager<ApplicationUser> userManager,
IPdfService pdfService,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IAccountBalanceService accountBalanceService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
_userManager = userManager;
_pdfService = pdfService;
_logoService = logoService;
_accountBalanceService = accountBalanceService;
}
/// <summary>
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
// GL: CR Gift Certificate Liability (2500) for the face value.
// Debit side varies by reason:
// Sold → DR Checking (received cash outside invoice flow)
// Others → DR Sales Discounts 4950 (promotional/goodwill cost)
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
}
else
{
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
}
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
return RedirectToAction(nameof(Details), new { id = cert.Id });
}
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
var remaining = cert.RemainingBalance;
cert.Status = GiftCertificateStatus.Voided;
cert.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
await _unitOfWork.CompleteAsync();
// GL: DR GC Liability / CR Other Income (breakage — the company keeps the unredeemed amount)
if (remaining > 0)
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
}
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
return RedirectToAction(nameof(Index));
}
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
ViewBag.Customers = list;
}
/// <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;
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{
if (company == null) return (null, null);
@@ -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.";
}
@@ -9,6 +9,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using Stripe;
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
namespace PowderCoating.Web.Controllers;
@@ -26,6 +27,7 @@ public class PaymentController : Controller
private readonly IInAppNotificationService _inApp;
private readonly IConfiguration _configuration;
private readonly ILogger<PaymentController> _logger;
private readonly IAccountBalanceService _accountBalanceService;
public PaymentController(
ApplicationDbContext context,
@@ -33,7 +35,8 @@ public class PaymentController : Controller
INotificationService notificationService,
IInAppNotificationService inApp,
IConfiguration configuration,
ILogger<PaymentController> logger)
ILogger<PaymentController> logger,
IAccountBalanceService accountBalanceService)
{
_context = context;
_stripeConnect = stripeConnect;
@@ -41,6 +44,7 @@ public class PaymentController : Controller
_inApp = inApp;
_configuration = configuration;
_logger = logger;
_accountBalanceService = accountBalanceService;
}
// ─── GET /pay/{token} ────────────────────────────────────────────────────
@@ -378,8 +382,30 @@ public class PaymentController : Controller
invoice.UpdatedAt = DateTime.UtcNow;
_context.Update(invoice);
// Create a Payment record so the payment appears in AR and bank reports, and make the
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
// this makes Stripe payments consistent with that path.
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
var stripePayment = new Core.Entities.Payment
{
InvoiceId = invoice.Id,
Amount = netPayment,
PaymentDate = DateTime.UtcNow,
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
Reference = intent.Id,
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
DepositAccountId = checkingAcctId,
CompanyId = invoice.CompanyId,
CreatedAt = DateTime.UtcNow
};
_context.Payments.Add(stripePayment);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
@@ -553,19 +579,22 @@ public class PaymentController : Controller
var refundAmountDollars = latestRefund.Amount / 100m;
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = refundAmountDollars,
RefundDate = latestRefund.Created,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = latestRefund.Reason ?? "Stripe refund",
Reference = latestRefund.Id,
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdR,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
@@ -588,6 +617,10 @@ public class PaymentController : Controller
_context.Update(invoice);
await _context.SaveChangesAsync();
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
refundAmountDollars, invoice.Id, latestRefund.Id);
}
@@ -652,19 +685,22 @@ public class PaymentController : Controller
if (alreadyRecorded) return;
var amount = dispute.Amount / 100m;
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
var refund = new Core.Entities.Refund
{
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
CompanyId = invoice.CompanyId,
InvoiceId = invoice.Id,
Amount = amount,
RefundDate = DateTime.UtcNow,
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
Reason = "Chargeback lost — funds returned to customer",
Reference = dispute.Id,
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
Status = Core.Enums.RefundStatus.Issued,
IssuedDate = DateTime.UtcNow,
DepositAccountId = checkingAcctIdD,
CreatedAt = DateTime.UtcNow
};
_context.Refunds.Add(refund);
@@ -687,6 +723,9 @@ public class PaymentController : Controller
_context.Update(invoice);
await _context.SaveChangesAsync();
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
}
}
@@ -696,6 +735,27 @@ public class PaymentController : Controller
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
/// because there is no authenticated tenant context in webhook handlers.
/// </summary>
/// <summary>
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
/// </summary>
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
{
var ar = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
var checking = await _context.Accounts
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash))
.Select(a => (int?)a.Id)
.FirstOrDefaultAsync();
return (ar, checking);
}
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
{
if (string.IsNullOrEmpty(paymentIntentId)) return null;
@@ -195,6 +195,10 @@ public class VendorCreditsController : Controller
foreach (var line in vc.LineItems)
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
// Record posting date so Void() can reverse only if GL entries were actually made.
vc.PostedDate = DateTime.UtcNow;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
// Status stays Open — the credit is now in the GL but not yet applied to a bill
await _unitOfWork.CompleteAsync();
});
@@ -260,6 +264,12 @@ public class VendorCreditsController : Controller
// ── Void ─────────────────────────────────────────────────────────────────
/// <summary>
/// Voids a vendor credit. If the credit was previously posted (PostedDate is set), reverses the
/// original GL entries: CR Accounts Payable / DR each expense line item, restoring both balances.
/// Only the unapplied RemainingAmount of AP is reversed — applied portions reduced bill balances
/// that are already settled and remain part of the immutable audit trail.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
[ValidateAntiForgeryToken]
@@ -267,7 +277,10 @@ public class VendorCreditsController : Controller
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var vc = await _unitOfWork.VendorCredits.GetByIdAsync(id);
var vc = (await _unitOfWork.VendorCredits.FindAsync(
v => v.Id == id, false,
v => v.LineItems))
.FirstOrDefault();
if (vc == null) return NotFound();
if (vc.Status == VendorCreditStatus.Applied)
@@ -276,9 +289,25 @@ public class VendorCreditsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
vc.Status = VendorCreditStatus.Voided;
vc.RemainingAmount = 0;
await _unitOfWork.CompleteAsync();
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
// Reverse GL only if Post() was previously called; unposted credits have no GL entries.
if (vc.PostedDate.HasValue && vc.RemainingAmount > 0)
{
// CR AP for the unapplied amount (undoes the debit made at Post time)
await _accountBalanceService.CreditAsync(vc.APAccountId, vc.RemainingAmount);
// DR each expense line proportionally (unapplied fraction of each line)
var applyRatio = vc.Total > 0 ? vc.RemainingAmount / vc.Total : 1m;
foreach (var line in vc.LineItems)
await _accountBalanceService.DebitAsync(line.AccountId, line.Amount * applyRatio);
}
vc.Status = VendorCreditStatus.Voided;
vc.RemainingAmount = 0;
await _unitOfWork.VendorCredits.UpdateAsync(vc);
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
return RedirectToAction(nameof(Index));
@@ -1145,6 +1145,20 @@
<option value="5">Store Credit</option>
</select>
</div>
<div class="mb-3" id="refundDepositAccountRow">
<label class="form-label fw-semibold">Refund From Account</label>
<select name="DepositAccountId" class="form-select">
<option value="">(Not tracked)</option>
@if (ViewBag.BankAccounts != null)
{
@foreach (var acct in (IEnumerable<SelectListItem>)ViewBag.BankAccounts)
{
<option value="@acct.Value">@acct.Text</option>
}
}
</select>
<div class="form-text">Bank or cash account the refund is paid from.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
<input type="text" name="Reason" class="form-control" placeholder="e.g. Warranty claim, duplicate charge..." required />
@@ -1171,6 +1185,7 @@
document.getElementById('refundAlertCash').classList.toggle('d-none', isCredit);
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
document.getElementById('refundDepositAccountRow').classList.toggle('d-none', isCredit);
});
</script>
</div>