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 decimal Amount { get; set; }
|
||||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||||
public PaymentMethod RefundMethod { get; set; }
|
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 Reason { get; set; } = string.Empty;
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
|
|||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
public decimal RemainingAmount { get; set; }
|
public decimal RemainingAmount { get; set; }
|
||||||
public string? Memo { 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
|
// Navigation
|
||||||
public virtual Vendor Vendor { get; set; } = null!;
|
public virtual Vendor Vendor { get; set; } = null!;
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? RecordedById { 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
|
// Applied to invoice when invoice is created
|
||||||
public int? AppliedToInvoiceId { get; set; }
|
public int? AppliedToInvoiceId { get; set; }
|
||||||
public DateTime? AppliedDate { get; set; }
|
public DateTime? AppliedDate { get; set; }
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
|||||||
public DateTime? IssuedDate { get; set; }
|
public DateTime? IssuedDate { get; set; }
|
||||||
public string? IssuedById { 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
|
// For store-credit refunds: the CreditMemo created on their behalf
|
||||||
public int? CreditMemoId { get; set; }
|
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")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -6574,7 +6577,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
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",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6585,7 +6588,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
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",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6596,7 +6599,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
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",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7654,6 +7657,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("InvoiceId")
|
b.Property<int>("InvoiceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8384,6 +8390,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal>("RemainingAmount")
|
b.Property<decimal>("RemainingAmount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
if (unlinkedRevenue > 0)
|
if (unlinkedRevenue > 0)
|
||||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
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
|
// 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) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.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
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& 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.Voided
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.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
|
var lifetimeRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.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)
|
.Where(e => e.Date <= asOfEnd)
|
||||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||||
var lifetimeBillCosts = await _context.BillLineItems
|
var lifetimeBillCosts = await _context.BillLineItems
|
||||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
.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
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.IsActive)
|
.Where(a => a.IsActive)
|
||||||
@@ -246,8 +413,9 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
{
|
{
|
||||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += taxByAcct.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)
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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)
|
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||||
{
|
{
|
||||||
|
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||||
var companyName = await GetCompanyNameAsync(companyId);
|
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
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToListAsync();
|
.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)
|
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 isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||||
var line = new TrialBalanceLine
|
var line = new TrialBalanceLine
|
||||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
if (isDebitNormal)
|
if (isDebitNormal)
|
||||||
{
|
{
|
||||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.DebitBalance = balance;
|
||||||
else line.CreditBalance = -acct.CurrentBalance;
|
else line.CreditBalance = -balance;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.CreditBalance = balance;
|
||||||
else line.DebitBalance = -acct.CurrentBalance;
|
else line.DebitBalance = -balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.Add(line);
|
lines.Add(line);
|
||||||
|
|||||||
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
|||||||
LinkId = p.InvoiceId
|
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) ────────────────
|
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||||
// e.g. Checking account used to pay an expense
|
// e.g. Checking account used to pay an expense
|
||||||
var expensesPaidFrom = await _context.Expenses
|
var expensesPaidFrom = await _context.Expenses
|
||||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Invoices",
|
LinkController = "Invoices",
|
||||||
LinkId = p.InvoiceId
|
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 ────────────────────────────────────────────────
|
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||||
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Bills",
|
LinkController = "Bills",
|
||||||
LinkId = bp.BillId
|
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 ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
|
|||||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.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)
|
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||||
credits += await _context.Expenses
|
credits += await _context.Expenses
|
||||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||||
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
|
|||||||
credits += await _context.Payments
|
credits += await _context.Payments
|
||||||
.Where(p => p.PaymentDate < beforeDate)
|
.Where(p => p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.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
|
// 9. Accounts Payable
|
||||||
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
|
|||||||
debits += await _context.BillPayments
|
debits += await _context.BillPayments
|
||||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
.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)
|
// 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 = "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 = "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 },
|
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 ────────────────────────────────────────────
|
// ── 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 },
|
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;
|
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;
|
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.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||||
result.Details = details;
|
result.Details = details;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
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
|
/// 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
|
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||||
/// customer.CreditBalance atomically inside a transaction.
|
/// 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>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
public class CreditMemosController : Controller
|
public class CreditMemosController : Controller
|
||||||
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
|
|||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<CreditMemosController> _logger;
|
private readonly ILogger<CreditMemosController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public CreditMemosController(
|
public CreditMemosController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<CreditMemosController> logger)
|
ILogger<CreditMemosController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
/// <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);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
|
|||||||
using QuestPDF.Fluent;
|
using QuestPDF.Fluent;
|
||||||
using QuestPDF.Helpers;
|
using QuestPDF.Helpers;
|
||||||
using QuestPDF.Infrastructure;
|
using QuestPDF.Infrastructure;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -22,17 +23,20 @@ public class DepositsController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<DepositsController> _logger;
|
private readonly ILogger<DepositsController> _logger;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public DepositsController(
|
public DepositsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<DepositsController> logger,
|
ILogger<DepositsController> logger,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -76,27 +80,34 @@ public class DepositsController : Controller
|
|||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||||
|
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||||
|
|
||||||
var deposit = new Deposit
|
var deposit = new Deposit
|
||||||
{
|
{
|
||||||
ReceiptNumber = receiptNumber,
|
ReceiptNumber = receiptNumber,
|
||||||
CustomerId = customerId,
|
CustomerId = customerId,
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
QuoteId = quoteId,
|
QuoteId = quoteId,
|
||||||
Amount = amount,
|
Amount = amount,
|
||||||
PaymentMethod = method,
|
PaymentMethod = method,
|
||||||
ReceivedDate = receivedDate,
|
ReceivedDate = receivedDate,
|
||||||
Reference = reference,
|
Reference = reference,
|
||||||
Notes = notes,
|
Notes = notes,
|
||||||
RecordedById = currentUser.Id,
|
DepositAccountId = checkingAcctId,
|
||||||
CompanyId = currentUser.CompanyId,
|
RecordedById = currentUser.Id,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedBy = currentUser.Email
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CreatedBy = currentUser.Email
|
||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||||
await _unitOfWork.CompleteAsync();
|
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
|
return Json(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -137,6 +148,11 @@ public class DepositsController : Controller
|
|||||||
if (deposit.AppliedToInvoiceId != null)
|
if (deposit.AppliedToInvoiceId != null)
|
||||||
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
|
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.Deposits.SoftDeleteAsync(id);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -419,6 +435,24 @@ public class DepositsController : Controller
|
|||||||
return hex.StartsWith("#") ? hex : fallback;
|
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)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using PowderCoating.Core.Enums;
|
|||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using PowderCoating.Web.Helpers;
|
using PowderCoating.Web.Helpers;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public class GiftCertificatesController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IPdfService _pdfService;
|
private readonly IPdfService _pdfService;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public GiftCertificatesController(
|
public GiftCertificatesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -38,7 +40,8 @@ public class GiftCertificatesController : Controller
|
|||||||
ILogger<GiftCertificatesController> logger,
|
ILogger<GiftCertificatesController> logger,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPdfService pdfService,
|
IPdfService pdfService,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -46,6 +49,7 @@ public class GiftCertificatesController : Controller
|
|||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_pdfService = pdfService;
|
_pdfService = pdfService;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -240,6 +244,26 @@ public class GiftCertificatesController : Controller
|
|||||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = $"Gift certificate {code} for {dto.Amount:C} created successfully.";
|
||||||
return RedirectToAction(nameof(Details), new { id = cert.Id });
|
return RedirectToAction(nameof(Details), new { id = cert.Id });
|
||||||
}
|
}
|
||||||
@@ -272,11 +296,24 @@ public class GiftCertificatesController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remaining = cert.RemainingBalance;
|
||||||
cert.Status = GiftCertificateStatus.Voided;
|
cert.Status = GiftCertificateStatus.Voided;
|
||||||
cert.UpdatedAt = DateTime.UtcNow;
|
cert.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
|
await _unitOfWork.GiftCertificates.UpdateAsync(cert);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = $"Gift certificate {cert.CertificateCode} has been voided.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
@@ -395,6 +432,14 @@ public class GiftCertificatesController : Controller
|
|||||||
ViewBag.Customers = list;
|
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)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -677,19 +677,22 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
foreach (var deposit in pendingDeposits)
|
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
|
var payment = new Payment
|
||||||
{
|
{
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
Amount = deposit.Amount,
|
Amount = deposit.Amount,
|
||||||
PaymentDate = deposit.ReceivedDate,
|
PaymentDate = deposit.ReceivedDate,
|
||||||
PaymentMethod = deposit.PaymentMethod,
|
PaymentMethod = deposit.PaymentMethod,
|
||||||
Reference = $"Deposit {deposit.ReceiptNumber}",
|
Reference = $"Deposit {deposit.ReceiptNumber}",
|
||||||
Notes = deposit.Notes,
|
Notes = deposit.Notes,
|
||||||
RecordedById = currentUser.Id,
|
DepositAccountId = null,
|
||||||
CompanyId = currentUser.CompanyId,
|
RecordedById = currentUser.Id,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedBy = currentUser.Email
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CreatedBy = currentUser.Email
|
||||||
};
|
};
|
||||||
await _unitOfWork.Payments.AddAsync(payment);
|
await _unitOfWork.Payments.AddAsync(payment);
|
||||||
|
|
||||||
@@ -719,13 +722,31 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
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);
|
var arAccountId = await GetArAccountIdAsync(currentUser.CompanyId);
|
||||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
||||||
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
await _accountBalanceService.CreditAsync(item.RevenueAccountId, item.TotalPrice);
|
||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
await _accountBalanceService.CreditAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
||||||
await _accountBalanceService.DebitAsync(arAccountId, invoice.Total);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Auto-generate gift certificates for any GC line items
|
// Auto-generate gift certificates for any GC line items
|
||||||
@@ -873,8 +894,17 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
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)
|
// Recalculate totals (tax is applied after discount, consistent with quotes)
|
||||||
var oldTotal = invoice.Total;
|
|
||||||
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
var subTotal = dto.InvoiceItems.Sum(i => i.TotalPrice);
|
||||||
var taxableAmount = subTotal - dto.DiscountAmount;
|
var taxableAmount = subTotal - dto.DiscountAmount;
|
||||||
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
var taxAmount = Math.Round(taxableAmount * dto.TaxPercent / 100, 2);
|
||||||
@@ -940,6 +970,31 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
TempData["Success"] = "Invoice updated successfully.";
|
||||||
|
|
||||||
// Optionally re-send the updated invoice PDF to the customer
|
// Optionally re-send the updated invoice PDF to the customer
|
||||||
@@ -1347,29 +1402,49 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Void any gift certificates that were generated from this invoice
|
// Void any gift certificates that were generated from this invoice.
|
||||||
var gcItemIds = invoice.InvoiceItems
|
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||||
.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue)
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||||
.Select(i => i.GeneratedGiftCertificateId!.Value)
|
var gcRemainingByItemId = new Dictionary<int, decimal>(); // invoiceItemId → remaining balance
|
||||||
.ToList();
|
foreach (var gcItem in invoice.InvoiceItems.Where(i => !i.IsDeleted && i.IsGiftCertificate && i.GeneratedGiftCertificateId.HasValue))
|
||||||
foreach (var gcId in gcItemIds)
|
|
||||||
{
|
{
|
||||||
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcId);
|
var gc = await _unitOfWork.GiftCertificates.GetByIdAsync(gcItem.GeneratedGiftCertificateId!.Value);
|
||||||
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
if (gc != null && gc.Status != GiftCertificateStatus.FullyRedeemed)
|
||||||
{
|
{
|
||||||
gc.Status = GiftCertificateStatus.Voided;
|
gcRemainingByItemId[gcItem.Id] = gc.RemainingBalance;
|
||||||
|
gc.Status = GiftCertificateStatus.Voided;
|
||||||
gc.UpdatedAt = DateTime.UtcNow;
|
gc.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.GiftCertificates.UpdateAsync(gc);
|
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);
|
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||||
foreach (var item in invoice.InvoiceItems.Where(i => !i.IsDeleted))
|
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)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
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.Status = InvoiceStatus.Voided;
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -1721,13 +1796,30 @@ public class InvoicesController : Controller
|
|||||||
deposit.UpdatedAt = DateTime.UtcNow;
|
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);
|
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);
|
await _accountBalanceService.CreditAsync(arAccountId, invoice.Total);
|
||||||
foreach (var item in invoiceItems)
|
foreach (var item in invoiceItems)
|
||||||
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
await _accountBalanceService.DebitAsync(item.RevenueAccountId, item.TotalPrice);
|
||||||
if (invoice.TaxAmount > 0)
|
if (invoice.TaxAmount > 0)
|
||||||
await _accountBalanceService.DebitAsync(invoice.SalesTaxAccountId, invoice.TaxAmount);
|
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
|
// 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.
|
// 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;
|
item.GeneratedGiftCertificateId = cert.Id;
|
||||||
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -2083,6 +2181,24 @@ public class InvoicesController : Controller
|
|||||||
.ToList();
|
.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>
|
/// <summary>Returns the AR account ID for the given company (first active AccountsReceivable account).</summary>
|
||||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
@@ -2135,6 +2251,28 @@ public class InvoicesController : Controller
|
|||||||
return taxAccount?.Id;
|
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
|
public static string GetStatusColorClass(InvoiceStatus status) => status switch
|
||||||
{
|
{
|
||||||
InvoiceStatus.Draft => "secondary",
|
InvoiceStatus.Draft => "secondary",
|
||||||
@@ -2191,6 +2329,8 @@ public class InvoicesController : Controller
|
|||||||
Amount = dto.Amount,
|
Amount = dto.Amount,
|
||||||
RefundDate = dto.RefundDate,
|
RefundDate = dto.RefundDate,
|
||||||
RefundMethod = dto.RefundMethod,
|
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,
|
Reason = dto.Reason,
|
||||||
Reference = dto.Reference,
|
Reference = dto.Reference,
|
||||||
Notes = dto.Notes,
|
Notes = dto.Notes,
|
||||||
@@ -2249,6 +2389,14 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
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.";
|
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;
|
customer.CurrentBalance += refund.Amount;
|
||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
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;
|
refund.Status = RefundStatus.Cancelled;
|
||||||
@@ -2469,6 +2622,12 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // end ExecuteInTransactionAsync
|
||||||
@@ -2629,6 +2788,13 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
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();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = $"Gift certificate {cert.CertificateCode} — {applyAmount:C} applied to invoice.";
|
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.Infrastructure.Data;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ public class PaymentController : Controller
|
|||||||
private readonly IInAppNotificationService _inApp;
|
private readonly IInAppNotificationService _inApp;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<PaymentController> _logger;
|
private readonly ILogger<PaymentController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public PaymentController(
|
public PaymentController(
|
||||||
ApplicationDbContext context,
|
ApplicationDbContext context,
|
||||||
@@ -33,7 +35,8 @@ public class PaymentController : Controller
|
|||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IInAppNotificationService inApp,
|
IInAppNotificationService inApp,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<PaymentController> logger)
|
ILogger<PaymentController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
_stripeConnect = stripeConnect;
|
_stripeConnect = stripeConnect;
|
||||||
@@ -41,6 +44,7 @@ public class PaymentController : Controller
|
|||||||
_inApp = inApp;
|
_inApp = inApp;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
||||||
@@ -378,8 +382,30 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
_context.Update(invoice);
|
_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 _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);
|
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
|
||||||
|
|
||||||
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
||||||
@@ -553,19 +579,22 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var refundAmountDollars = latestRefund.Amount / 100m;
|
var refundAmountDollars = latestRefund.Amount / 100m;
|
||||||
|
|
||||||
|
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||||
|
|
||||||
var refund = new Core.Entities.Refund
|
var refund = new Core.Entities.Refund
|
||||||
{
|
{
|
||||||
CompanyId = invoice.CompanyId,
|
CompanyId = invoice.CompanyId,
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
Amount = refundAmountDollars,
|
Amount = refundAmountDollars,
|
||||||
RefundDate = latestRefund.Created,
|
RefundDate = latestRefund.Created,
|
||||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||||
Reason = latestRefund.Reason ?? "Stripe refund",
|
Reason = latestRefund.Reason ?? "Stripe refund",
|
||||||
Reference = latestRefund.Id,
|
Reference = latestRefund.Id,
|
||||||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||||||
Status = Core.Enums.RefundStatus.Issued,
|
Status = Core.Enums.RefundStatus.Issued,
|
||||||
IssuedDate = DateTime.UtcNow,
|
IssuedDate = DateTime.UtcNow,
|
||||||
CreatedAt = DateTime.UtcNow
|
DepositAccountId = checkingAcctIdR,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_context.Refunds.Add(refund);
|
_context.Refunds.Add(refund);
|
||||||
|
|
||||||
@@ -588,6 +617,10 @@ public class PaymentController : Controller
|
|||||||
_context.Update(invoice);
|
_context.Update(invoice);
|
||||||
await _context.SaveChangesAsync();
|
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})",
|
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
|
||||||
refundAmountDollars, invoice.Id, latestRefund.Id);
|
refundAmountDollars, invoice.Id, latestRefund.Id);
|
||||||
}
|
}
|
||||||
@@ -652,19 +685,22 @@ public class PaymentController : Controller
|
|||||||
if (alreadyRecorded) return;
|
if (alreadyRecorded) return;
|
||||||
|
|
||||||
var amount = dispute.Amount / 100m;
|
var amount = dispute.Amount / 100m;
|
||||||
|
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||||
|
|
||||||
var refund = new Core.Entities.Refund
|
var refund = new Core.Entities.Refund
|
||||||
{
|
{
|
||||||
CompanyId = invoice.CompanyId,
|
CompanyId = invoice.CompanyId,
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
Amount = amount,
|
Amount = amount,
|
||||||
RefundDate = DateTime.UtcNow,
|
RefundDate = DateTime.UtcNow,
|
||||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||||
Reason = "Chargeback lost — funds returned to customer",
|
Reason = "Chargeback lost — funds returned to customer",
|
||||||
Reference = dispute.Id,
|
Reference = dispute.Id,
|
||||||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||||||
Status = Core.Enums.RefundStatus.Issued,
|
Status = Core.Enums.RefundStatus.Issued,
|
||||||
IssuedDate = DateTime.UtcNow,
|
IssuedDate = DateTime.UtcNow,
|
||||||
CreatedAt = DateTime.UtcNow
|
DepositAccountId = checkingAcctIdD,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
_context.Refunds.Add(refund);
|
_context.Refunds.Add(refund);
|
||||||
|
|
||||||
@@ -687,6 +723,9 @@ public class PaymentController : Controller
|
|||||||
_context.Update(invoice);
|
_context.Update(invoice);
|
||||||
await _context.SaveChangesAsync();
|
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);
|
_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
|
/// 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.
|
/// because there is no authenticated tenant context in webhook handlers.
|
||||||
/// </summary>
|
/// </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)
|
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ public class VendorCreditsController : Controller
|
|||||||
foreach (var line in vc.LineItems)
|
foreach (var line in vc.LineItems)
|
||||||
await _accountBalanceService.CreditAsync(line.AccountId, line.Amount);
|
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
|
// Status stays Open — the credit is now in the GL but not yet applied to a bill
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
@@ -260,6 +264,12 @@ public class VendorCreditsController : Controller
|
|||||||
|
|
||||||
// ── Void ─────────────────────────────────────────────────────────────────
|
// ── 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]
|
[HttpPost]
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
@@ -267,7 +277,10 @@ public class VendorCreditsController : Controller
|
|||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
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 == null) return NotFound();
|
||||||
|
|
||||||
if (vc.Status == VendorCreditStatus.Applied)
|
if (vc.Status == VendorCreditStatus.Applied)
|
||||||
@@ -276,9 +289,25 @@ public class VendorCreditsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
vc.Status = VendorCreditStatus.Voided;
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
vc.RemainingAmount = 0;
|
{
|
||||||
await _unitOfWork.CompleteAsync();
|
// 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.";
|
TempData["Success"] = $"Vendor credit {vc.CreditNumber} voided.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
|
|||||||
@@ -1145,6 +1145,20 @@
|
|||||||
<option value="5">Store Credit</option>
|
<option value="5">Store Credit</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
<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 />
|
<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('refundAlertCash').classList.toggle('d-none', isCredit);
|
||||||
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
|
document.getElementById('refundAlertCredit').classList.toggle('d-none', !isCredit);
|
||||||
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
|
document.getElementById('refundReferenceRow').classList.toggle('d-none', isCredit);
|
||||||
|
document.getElementById('refundDepositAccountRow').classList.toggle('d-none', isCredit);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -292,7 +292,8 @@ public class DepositsControllerTests
|
|||||||
uow,
|
uow,
|
||||||
userManager.Object,
|
userManager.Object,
|
||||||
Mock.Of<ILogger<DepositsController>>(),
|
Mock.Of<ILogger<DepositsController>>(),
|
||||||
Mock.Of<ICompanyLogoService>());
|
Mock.Of<ICompanyLogoService>(),
|
||||||
|
Mock.Of<IAccountBalanceService>());
|
||||||
|
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ public class GiftCertificatesControllerTests
|
|||||||
Mock.Of<ILogger<GiftCertificatesController>>(),
|
Mock.Of<ILogger<GiftCertificatesController>>(),
|
||||||
userManager.Object,
|
userManager.Object,
|
||||||
Mock.Of<IPdfService>(),
|
Mock.Of<IPdfService>(),
|
||||||
Mock.Of<ICompanyLogoService>());
|
Mock.Of<ICompanyLogoService>(),
|
||||||
|
Mock.Of<IAccountBalanceService>());
|
||||||
|
|
||||||
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
controller.ControllerContext = new ControllerContext { HttpContext = httpContext };
|
||||||
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
|
controller.TempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
|
||||||
|
|||||||
Reference in New Issue
Block a user