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:
@@ -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; }
|
||||
|
||||
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+95
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10600
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10603
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>
|
||||
|
||||
Reference in New Issue
Block a user