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:
@@ -9,6 +9,7 @@ using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using Stripe;
|
||||
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -26,6 +27,7 @@ public class PaymentController : Controller
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<PaymentController> _logger;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public PaymentController(
|
||||
ApplicationDbContext context,
|
||||
@@ -33,7 +35,8 @@ public class PaymentController : Controller
|
||||
INotificationService notificationService,
|
||||
IInAppNotificationService inApp,
|
||||
IConfiguration configuration,
|
||||
ILogger<PaymentController> logger)
|
||||
ILogger<PaymentController> logger,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_context = context;
|
||||
_stripeConnect = stripeConnect;
|
||||
@@ -41,6 +44,7 @@ public class PaymentController : Controller
|
||||
_inApp = inApp;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
// ─── GET /pay/{token} ────────────────────────────────────────────────────
|
||||
@@ -378,8 +382,30 @@ public class PaymentController : Controller
|
||||
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
_context.Update(invoice);
|
||||
|
||||
// Create a Payment record so the payment appears in AR and bank reports, and make the
|
||||
// matching GL entries. Manual payments go through RecordPayment which does the same thing;
|
||||
// this makes Stripe payments consistent with that path.
|
||||
var (arAcctId, checkingAcctId) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
var stripePayment = new Core.Entities.Payment
|
||||
{
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = netPayment,
|
||||
PaymentDate = DateTime.UtcNow,
|
||||
PaymentMethod = PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reference = intent.Id,
|
||||
Notes = $"Online payment via Stripe. Surcharge: {surcharge:C}",
|
||||
DepositAccountId = checkingAcctId,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Payments.Add(stripePayment);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, netPayment);
|
||||
await _accountBalanceService.CreditAsync(arAcctId, netPayment);
|
||||
|
||||
_logger.LogInformation("Online payment of {Amount:C} received for invoice {InvoiceId}", amountPaidDollars, invoiceId);
|
||||
|
||||
await _notificationService.NotifyOnlinePaymentReceivedAsync(invoice, netPayment, surcharge, intent.Id);
|
||||
@@ -553,19 +579,22 @@ public class PaymentController : Controller
|
||||
|
||||
var refundAmountDollars = latestRefund.Amount / 100m;
|
||||
|
||||
var (arAcctIdR, checkingAcctIdR) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
|
||||
var refund = new Core.Entities.Refund
|
||||
{
|
||||
CompanyId = invoice.CompanyId,
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = refundAmountDollars,
|
||||
RefundDate = latestRefund.Created,
|
||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reason = latestRefund.Reason ?? "Stripe refund",
|
||||
Reference = latestRefund.Id,
|
||||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
CompanyId = invoice.CompanyId,
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = refundAmountDollars,
|
||||
RefundDate = latestRefund.Created,
|
||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reason = latestRefund.Reason ?? "Stripe refund",
|
||||
Reference = latestRefund.Id,
|
||||
Notes = $"Automatic refund via Stripe. PaymentIntent: {charge.PaymentIntentId}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
DepositAccountId = checkingAcctIdR,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Refunds.Add(refund);
|
||||
|
||||
@@ -588,6 +617,10 @@ public class PaymentController : Controller
|
||||
_context.Update(invoice);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// GL: DR AR (customer owes again) / CR Checking (cash left the bank)
|
||||
await _accountBalanceService.DebitAsync(arAcctIdR, refundAmountDollars);
|
||||
await _accountBalanceService.CreditAsync(checkingAcctIdR, refundAmountDollars);
|
||||
|
||||
_logger.LogInformation("Refund of {Amount:C} recorded for invoice {InvoiceId} (Stripe refund {RefundId})",
|
||||
refundAmountDollars, invoice.Id, latestRefund.Id);
|
||||
}
|
||||
@@ -652,19 +685,22 @@ public class PaymentController : Controller
|
||||
if (alreadyRecorded) return;
|
||||
|
||||
var amount = dispute.Amount / 100m;
|
||||
var (arAcctIdD, checkingAcctIdD) = await GetGlAccountIdsAsync(invoice.CompanyId);
|
||||
|
||||
var refund = new Core.Entities.Refund
|
||||
{
|
||||
CompanyId = invoice.CompanyId,
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = amount,
|
||||
RefundDate = DateTime.UtcNow,
|
||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reason = "Chargeback lost — funds returned to customer",
|
||||
Reference = dispute.Id,
|
||||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
CompanyId = invoice.CompanyId,
|
||||
InvoiceId = invoice.Id,
|
||||
Amount = amount,
|
||||
RefundDate = DateTime.UtcNow,
|
||||
RefundMethod = Core.Enums.PaymentMethod.CreditDebitCard,
|
||||
Reason = "Chargeback lost — funds returned to customer",
|
||||
Reference = dispute.Id,
|
||||
Notes = $"Automatic chargeback loss via Stripe. Dispute ID: {dispute.Id}",
|
||||
Status = Core.Enums.RefundStatus.Issued,
|
||||
IssuedDate = DateTime.UtcNow,
|
||||
DepositAccountId = checkingAcctIdD,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_context.Refunds.Add(refund);
|
||||
|
||||
@@ -687,6 +723,9 @@ public class PaymentController : Controller
|
||||
_context.Update(invoice);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await _accountBalanceService.DebitAsync(arAcctIdD, amount);
|
||||
await _accountBalanceService.CreditAsync(checkingAcctIdD, amount);
|
||||
|
||||
_logger.LogWarning("Chargeback lost for invoice {InvoiceId}, {Amount:C} reversed", invoice.Id, amount);
|
||||
}
|
||||
}
|
||||
@@ -696,6 +735,27 @@ public class PaymentController : Controller
|
||||
/// where the invoice ID is not in the Stripe metadata. <c>IgnoreQueryFilters</c> is required
|
||||
/// because there is no authenticated tenant context in webhook handlers.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Resolves the primary AR and Checking/Cash account IDs for a company, used by webhook handlers
|
||||
/// to make GL entries without an authenticated tenant context. Returns nulls gracefully so
|
||||
/// IAccountBalanceService.DebitAsync/CreditAsync silently skips missing accounts.
|
||||
/// </summary>
|
||||
private async Task<(int? ArAccountId, int? CheckingAccountId)> GetGlAccountIdsAsync(int companyId)
|
||||
{
|
||||
var ar = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||||
&& a.AccountSubType == AccountSubTypeEnum.AccountsReceivable)
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
var checking = await _context.Accounts
|
||||
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted
|
||||
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash))
|
||||
.Select(a => (int?)a.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
return (ar, checking);
|
||||
}
|
||||
|
||||
private async Task<Core.Entities.Invoice?> FindInvoiceByPaymentIntentAsync(string? paymentIntentId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(paymentIntentId)) return null;
|
||||
|
||||
Reference in New Issue
Block a user