404ab3c45d
181 tests total passing. Covers all 9 LedgerService transaction sources, debit/credit balance mechanics, AR/AP movements, date range filtering, running balance computation, and future-dated opening balance exclusion. Also covers deposit recording/deletion, gift certificate lifecycle (issue, void, lazy expiry), and account balance recalculation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
617 lines
25 KiB
C#
617 lines
25 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Infrastructure.Services;
|
|
|
|
namespace PowderCoating.UnitTests;
|
|
|
|
public class LedgerServiceTests
|
|
{
|
|
private static readonly DateTime PeriodStart = new DateTime(2026, 4, 1);
|
|
private static readonly DateTime PeriodEnd = new DateTime(2026, 4, 30);
|
|
private static readonly DateTime InPeriod = new DateTime(2026, 4, 15);
|
|
|
|
// ── Returns null for unknown account ─────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_WhenAccountDoesNotExist_ReturnsNull()
|
|
{
|
|
await using var context = CreateContext();
|
|
var service = CreateService(context);
|
|
|
|
var result = await service.GetAccountLedgerAsync(999, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
// ── Source 1: customer payment deposited (DEBIT) ─────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source1_CustomerPaymentDeposited_CreatesDebitEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Checking);
|
|
SeedInvoice(context, id: 99);
|
|
context.Payments.Add(new Payment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
InvoiceId = 99,
|
|
DepositAccountId = 1,
|
|
Amount = 250m,
|
|
PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Check
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Customer Payment", entry.Source);
|
|
Assert.Equal(250m, entry.Debit);
|
|
Assert.Equal(0m, entry.Credit);
|
|
}
|
|
|
|
// ── Source 2: expense paid FROM account (CREDIT) ──────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source2_ExpensePaidFromAccount_CreatesCreditEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Checking);
|
|
context.Expenses.Add(new Expense
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
ExpenseNumber = "EXP-001",
|
|
PaymentAccountId = 1, // Checking account pays the expense
|
|
ExpenseAccountId = 999, // Different account — so Source 6 does not also fire
|
|
Amount = 80m,
|
|
Date = InPeriod,
|
|
PaymentMethod = PaymentMethod.Check
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Expense", entry.Source);
|
|
Assert.Equal(0m, entry.Debit);
|
|
Assert.Equal(80m, entry.Credit);
|
|
}
|
|
|
|
// ── Source 3: bill payment made FROM account (CREDIT) ─────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source3_BillPaymentFromAccount_CreatesCreditEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Checking);
|
|
SeedBill(context, id: 99); // Include(bp => bp.Bill) requires the Bill to exist
|
|
context.BillPayments.Add(new BillPayment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
PaymentNumber = "BP-001",
|
|
BillId = 99,
|
|
VendorId = 1,
|
|
BankAccountId = 1, // Checking account used to pay the bill
|
|
Amount = 150m,
|
|
PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Check
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Bill Payment", entry.Source);
|
|
Assert.Equal(0m, entry.Debit);
|
|
Assert.Equal(150m, entry.Credit);
|
|
}
|
|
|
|
// ── Source 4: invoice revenue line items (CREDIT) ─────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source4_InvoiceLineItem_CreatesCreditEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Sales);
|
|
context.Invoices.Add(new Invoice
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
InvoiceNumber = "INV-001",
|
|
CustomerId = 1,
|
|
Status = InvoiceStatus.Sent,
|
|
InvoiceDate = InPeriod,
|
|
Total = 500m
|
|
});
|
|
context.InvoiceItems.Add(new InvoiceItem
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
InvoiceId = 10,
|
|
RevenueAccountId = 1,
|
|
Description = "Powder Coating",
|
|
Quantity = 1, UnitPrice = 500m, TotalPrice = 500m
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Invoice", entry.Source);
|
|
Assert.Equal(0m, entry.Debit);
|
|
Assert.Equal(500m, entry.Credit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source4_DraftAndVoidedInvoicesExcluded()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Sales);
|
|
context.Invoices.AddRange(
|
|
new Invoice { Id = 10, CompanyId = 1, InvoiceNumber = "INV-DRAFT", CustomerId = 1, Status = InvoiceStatus.Draft, InvoiceDate = InPeriod },
|
|
new Invoice { Id = 11, CompanyId = 1, InvoiceNumber = "INV-VOID", CustomerId = 1, Status = InvoiceStatus.Voided, InvoiceDate = InPeriod });
|
|
context.InvoiceItems.AddRange(
|
|
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
|
|
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 11, RevenueAccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m });
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Empty(ledger!.Entries);
|
|
}
|
|
|
|
// ── Source 5: sales tax collected (CREDIT) ────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source5_SalesTaxCollected_CreatesCreditEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.OtherCurrentLiability);
|
|
context.Invoices.Add(new Invoice
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
InvoiceNumber = "INV-TAX",
|
|
CustomerId = 1,
|
|
Status = InvoiceStatus.Sent,
|
|
InvoiceDate = InPeriod,
|
|
TaxAmount = 42m,
|
|
SalesTaxAccountId = 1,
|
|
Total = 542m
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Sales Tax", entry.Source);
|
|
Assert.Equal(0m, entry.Debit);
|
|
Assert.Equal(42m, entry.Credit);
|
|
}
|
|
|
|
// ── Source 6: expense categorized to account (DEBIT) ──────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source6_ExpenseCategorizedToAccount_CreatesDebitEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Advertising);
|
|
context.Expenses.Add(new Expense
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
ExpenseNumber = "EXP-002",
|
|
ExpenseAccountId = 1, // Advertising account receives the expense
|
|
PaymentAccountId = 999, // Different account — so Source 2 does not also fire
|
|
Amount = 300m,
|
|
Date = InPeriod,
|
|
PaymentMethod = PaymentMethod.Check
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Expense", entry.Source);
|
|
Assert.Equal(300m, entry.Debit);
|
|
Assert.Equal(0m, entry.Credit);
|
|
}
|
|
|
|
// ── Source 7: bill line items to expense account (DEBIT) ──────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source7_BillLineItem_CreatesDebitEntry()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
|
|
context.Bills.Add(new Bill
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
BillNumber = "BILL-001",
|
|
VendorId = 1,
|
|
APAccountId = 999, // AP account different from test account
|
|
Status = BillStatus.Open,
|
|
BillDate = InPeriod,
|
|
Total = 200m
|
|
});
|
|
context.BillLineItems.Add(new BillLineItem
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
BillId = 10,
|
|
AccountId = 1,
|
|
Description = "Powder supplies",
|
|
Quantity = 1, UnitPrice = 200m, Amount = 200m
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal("Bill", entry.Source);
|
|
Assert.Equal(200m, entry.Debit);
|
|
Assert.Equal(0m, entry.Credit);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source7_DraftAndVoidedBillsExcluded()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.SuppliesMaterials);
|
|
context.Bills.AddRange(
|
|
new Bill { Id = 10, CompanyId = 1, BillNumber = "B-DRAFT", VendorId = 1, APAccountId = 999, Status = BillStatus.Draft, BillDate = InPeriod, Total = 100m },
|
|
new Bill { Id = 11, CompanyId = 1, BillNumber = "B-VOID", VendorId = 1, APAccountId = 999, Status = BillStatus.Voided, BillDate = InPeriod, Total = 100m });
|
|
context.BillLineItems.AddRange(
|
|
new BillLineItem { Id = 1, CompanyId = 1, BillId = 10, AccountId = 1, Description = "D", Quantity = 1, UnitPrice = 100m, Amount = 100m },
|
|
new BillLineItem { Id = 2, CompanyId = 1, BillId = 11, AccountId = 1, Description = "V", Quantity = 1, UnitPrice = 100m, Amount = 100m });
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Empty(ledger!.Entries);
|
|
}
|
|
|
|
// ── Source 8: Accounts Receivable ─────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source8_AR_InvoicesDebitPaymentsCredit()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.AccountsReceivable);
|
|
SeedCustomer(context, id: 1); // Include(i => i.Customer) requires the Customer to exist
|
|
context.Invoices.Add(new Invoice
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
InvoiceNumber = "INV-AR",
|
|
CustomerId = 1,
|
|
Status = InvoiceStatus.Sent,
|
|
InvoiceDate = InPeriod,
|
|
Total = 400m
|
|
});
|
|
context.Payments.Add(new Payment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
InvoiceId = 10,
|
|
Amount = 100m,
|
|
PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Cash
|
|
// No DepositAccountId — so Source 1 does not also fire for this account
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Equal(2, ledger!.Entries.Count);
|
|
var invoiceEntry = ledger.Entries.Single(e => e.Source == "Invoice");
|
|
var paymentEntry = ledger.Entries.Single(e => e.Source == "Invoice Payment");
|
|
Assert.Equal(400m, invoiceEntry.Debit);
|
|
Assert.Equal(0m, invoiceEntry.Credit);
|
|
Assert.Equal(0m, paymentEntry.Debit);
|
|
Assert.Equal(100m, paymentEntry.Credit);
|
|
Assert.Equal(300m, ledger.ClosingBalance); // debit-normal: 400 - 100
|
|
}
|
|
|
|
// ── Source 9: Accounts Payable ────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_Source9_AP_BillsCreditBillPaymentsDebit()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.AccountsPayable);
|
|
SeedVendor(context, id: 1); // Include(b => b.Vendor) requires the Vendor to exist
|
|
context.Bills.Add(new Bill
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
BillNumber = "BILL-AP",
|
|
VendorId = 1,
|
|
APAccountId = 1, // This is the AP account under test
|
|
Status = BillStatus.Open,
|
|
BillDate = InPeriod,
|
|
Total = 400m
|
|
});
|
|
// Bill must be seeded because the WHERE for BillPayments navigates through Bill.APAccountId
|
|
context.BillPayments.Add(new BillPayment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
PaymentNumber = "BP-AP",
|
|
BillId = 10,
|
|
VendorId = 1,
|
|
BankAccountId = 999, // Different account — so Source 3 does not also fire
|
|
Amount = 150m,
|
|
PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Check
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Equal(2, ledger!.Entries.Count);
|
|
var billEntry = ledger.Entries.Single(e => e.Source == "Bill");
|
|
var paymentEntry = ledger.Entries.Single(e => e.Source == "Bill Payment");
|
|
Assert.Equal(0m, billEntry.Debit);
|
|
Assert.Equal(400m, billEntry.Credit);
|
|
Assert.Equal(150m, paymentEntry.Debit);
|
|
Assert.Equal(0m, paymentEntry.Credit);
|
|
Assert.Equal(250m, ledger.ClosingBalance); // credit-normal: 400 - 150
|
|
}
|
|
|
|
// ── Date range filtering ──────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_DateRangeFiltering_ExcludesEntriesOutsideRange()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Checking);
|
|
var dates = new[]
|
|
{
|
|
new DateTime(2026, 3, 15), // before period — excluded from entries, goes to priorBalance
|
|
new DateTime(2026, 4, 15), // in period — included
|
|
new DateTime(2026, 5, 15), // after period — excluded
|
|
};
|
|
var i = 0;
|
|
foreach (var date in dates)
|
|
{
|
|
context.Expenses.Add(new Expense
|
|
{
|
|
Id = ++i, CompanyId = 1,
|
|
ExpenseNumber = $"EXP-{i:D3}",
|
|
PaymentAccountId = 1,
|
|
ExpenseAccountId = 999,
|
|
Amount = 100m,
|
|
Date = date,
|
|
PaymentMethod = PaymentMethod.Cash
|
|
});
|
|
}
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
var entry = Assert.Single(ledger!.Entries);
|
|
Assert.Equal(InPeriod.Date, entry.Date.Date);
|
|
}
|
|
|
|
// ── Running balance ───────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_RunningBalance_DebitNormalAccount_CorrectlyComputed()
|
|
{
|
|
await using var context = CreateContext();
|
|
// Opening balance of 100 on Checking (debit-normal); null date means always applied
|
|
SeedAccount(context, id: 1, AccountSubType.Checking, openingBalance: 100m, openingBalanceDate: null);
|
|
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
|
|
context.Payments.AddRange(
|
|
new Payment { Id = 1, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 50m, PaymentDate = new DateTime(2026, 4, 10), PaymentMethod = PaymentMethod.Cash },
|
|
new Payment { Id = 2, CompanyId = 1, InvoiceId = 10, DepositAccountId = 1, Amount = 75m, PaymentDate = new DateTime(2026, 4, 20), PaymentMethod = PaymentMethod.Cash });
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Equal(100m, ledger!.OpeningBalance);
|
|
Assert.Equal(2, ledger.Entries.Count);
|
|
Assert.Equal(150m, ledger.Entries[0].RunningBalance); // 100 + 50
|
|
Assert.Equal(225m, ledger.Entries[1].RunningBalance); // 150 + 75
|
|
Assert.Equal(225m, ledger.ClosingBalance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_RunningBalance_CreditNormalAccount_CorrectlyComputed()
|
|
{
|
|
await using var context = CreateContext();
|
|
// Opening balance of 200 on Sales (credit-normal); null date means always applied
|
|
SeedAccount(context, id: 1, AccountSubType.Sales, openingBalance: 200m, openingBalanceDate: null);
|
|
// Source 4: InvoiceItems must have the Invoice seeded because the WHERE navigates through Invoice
|
|
context.Invoices.Add(new Invoice
|
|
{
|
|
Id = 10, CompanyId = 1,
|
|
InvoiceNumber = "INV-001",
|
|
CustomerId = 1,
|
|
Status = InvoiceStatus.Sent,
|
|
InvoiceDate = InPeriod,
|
|
Total = 150m
|
|
});
|
|
context.InvoiceItems.AddRange(
|
|
new InvoiceItem { Id = 1, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "A", Quantity = 1, UnitPrice = 100m, TotalPrice = 100m },
|
|
new InvoiceItem { Id = 2, CompanyId = 1, InvoiceId = 10, RevenueAccountId = 1, Description = "B", Quantity = 1, UnitPrice = 50m, TotalPrice = 50m });
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Equal(200m, ledger!.OpeningBalance);
|
|
Assert.Equal(2, ledger.Entries.Count);
|
|
// Credit-normal: runningBalance += Credit - Debit
|
|
// After entry 1 (Credit=100): 200 + 100 = 300
|
|
// After entry 2 (Credit=50): 300 + 50 = 350
|
|
Assert.Equal(350m, ledger.ClosingBalance);
|
|
}
|
|
|
|
// ── Opening balance — future-dated excluded ───────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_FutureDatedOpeningBalance_ExcludedFromPriorBalance()
|
|
{
|
|
await using var context = CreateContext();
|
|
// Opening balance dated 2027-01-01 — later than PeriodEnd (2026-04-30), so excluded
|
|
SeedAccount(context, id: 1, AccountSubType.Checking,
|
|
openingBalance: 500m, openingBalanceDate: new DateTime(2027, 1, 1));
|
|
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
|
|
context.Payments.Add(new Payment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
InvoiceId = 10, DepositAccountId = 1,
|
|
Amount = 100m, PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Cash
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
// OpeningBalance (prior balance) = 0, not 500 — future-dated opening balance excluded
|
|
Assert.Equal(0m, ledger!.OpeningBalance);
|
|
Assert.Equal(100m, ledger.ClosingBalance); // 0 + 100
|
|
}
|
|
|
|
// ── Period totals ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetAccountLedgerAsync_PeriodTotals_CorrectlySumDebitsAndCredits()
|
|
{
|
|
await using var context = CreateContext();
|
|
SeedAccount(context, id: 1, AccountSubType.Checking);
|
|
SeedInvoice(context, id: 10); // Include(p => p.Invoice) requires the Invoice to exist
|
|
// Source 1: payment deposited → DEBIT 200
|
|
context.Payments.Add(new Payment
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
InvoiceId = 10, DepositAccountId = 1,
|
|
Amount = 200m, PaymentDate = InPeriod,
|
|
PaymentMethod = PaymentMethod.Cash
|
|
});
|
|
// Source 2: expense paid from account → CREDIT 50
|
|
context.Expenses.Add(new Expense
|
|
{
|
|
Id = 1, CompanyId = 1,
|
|
ExpenseNumber = "EXP-001",
|
|
PaymentAccountId = 1, // Checking pays
|
|
ExpenseAccountId = 999, // Expense account (different) — Source 6 does not also fire
|
|
Amount = 50m,
|
|
Date = InPeriod,
|
|
PaymentMethod = PaymentMethod.Cash
|
|
});
|
|
await context.SaveChangesAsync();
|
|
|
|
var ledger = await CreateService(context).GetAccountLedgerAsync(1, PeriodStart, PeriodEnd);
|
|
|
|
Assert.Equal(200m, ledger!.PeriodDebits);
|
|
Assert.Equal(50m, ledger.PeriodCredits);
|
|
Assert.Equal(150m, ledger.ClosingBalance); // debit-normal: 200 - 50
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
private static LedgerService CreateService(ApplicationDbContext context)
|
|
=> new LedgerService(context, Mock.Of<ILogger<LedgerService>>());
|
|
|
|
private static Account SeedAccount(
|
|
ApplicationDbContext context,
|
|
int id,
|
|
AccountSubType subType,
|
|
int companyId = 1,
|
|
decimal openingBalance = 0m,
|
|
DateTime? openingBalanceDate = null)
|
|
{
|
|
var account = new Account
|
|
{
|
|
Id = id,
|
|
CompanyId = companyId,
|
|
AccountNumber = $"ACCT-{id:D4}",
|
|
Name = $"Account {id}",
|
|
AccountSubType = subType,
|
|
OpeningBalance = openingBalance,
|
|
OpeningBalanceDate = openingBalanceDate,
|
|
IsActive = true
|
|
};
|
|
context.Accounts.Add(account);
|
|
return account;
|
|
}
|
|
|
|
private static Invoice SeedInvoice(ApplicationDbContext context, int id, int companyId = 1)
|
|
{
|
|
var invoice = new Invoice
|
|
{
|
|
Id = id,
|
|
CompanyId = companyId,
|
|
InvoiceNumber = $"INV-{id:D4}",
|
|
CustomerId = 1,
|
|
Status = InvoiceStatus.Sent,
|
|
InvoiceDate = InPeriod,
|
|
Total = 0m
|
|
};
|
|
context.Invoices.Add(invoice);
|
|
return invoice;
|
|
}
|
|
|
|
private static void SeedVendor(ApplicationDbContext context, int id, int companyId = 1)
|
|
{
|
|
context.Vendors.Add(new Vendor
|
|
{
|
|
Id = id,
|
|
CompanyId = companyId,
|
|
CompanyName = $"Vendor {id}"
|
|
});
|
|
}
|
|
|
|
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId = 1)
|
|
{
|
|
context.Customers.Add(new Customer
|
|
{
|
|
Id = id,
|
|
CompanyId = companyId,
|
|
CompanyName = $"Customer {id}"
|
|
});
|
|
}
|
|
|
|
private static void SeedBill(ApplicationDbContext context, int id, int companyId = 1)
|
|
{
|
|
context.Bills.Add(new Bill
|
|
{
|
|
Id = id,
|
|
CompanyId = companyId,
|
|
BillNumber = $"BILL-{id:D4}",
|
|
VendorId = 999,
|
|
APAccountId = 999,
|
|
Status = BillStatus.Open,
|
|
BillDate = InPeriod,
|
|
Total = 0m
|
|
});
|
|
}
|
|
|
|
private static ApplicationDbContext CreateContext()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
// Use a SuperAdmin user (no CompanyId claim) so that IsPlatformAdmin = true and the
|
|
// global query filter evaluates to !IsDeleted only — not !IsDeleted && CompanyId == null,
|
|
// which would filter out every row. DefaultHttpContext.Session throws, so we mock
|
|
// HttpContext itself and stub Session.TryGetValue to return false (no impersonation).
|
|
var identity = new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
byte[]? noBytes = null;
|
|
var sessionMock = new Mock<ISession>();
|
|
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
|
|
|
var httpContextMock = new Mock<HttpContext>();
|
|
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
|
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
|
|
|
var accessor = new Mock<IHttpContextAccessor>();
|
|
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
|
|
|
return new ApplicationDbContext(options, accessor.Object, null!);
|
|
}
|
|
}
|