Add sales tax remittance flow

Audit finding #4: Sales Tax Payable was credited on every invoice but never
relieved (no remittance flow), so the liability grew forever. Adds a dedicated
"Record Sales Tax Payment" action that posts a balanced journal entry — DR Sales
Tax Payable (2200) / CR the chosen bank account — honoring the period lock.

Implemented in JournalEntriesController (reuses its posting + numbering + period-
lock infrastructure): a GET form showing the current 2200 liability and a bank
picker, and a POST that creates the posted JE and updates balances. Reachable via
a "Record Payment" button on the Sales Tax report. No reporting changes needed —
posted JE lines are already accounted for across the trial balance / balance sheet
/ ledger. Build clean; 284 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:11:05 -04:00
parent c2cd19e475
commit 0c921ba180
3 changed files with 174 additions and 0 deletions
@@ -196,6 +196,105 @@ public class JournalEntriesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── Sales Tax Remittance ───────────────────────────────────────────────────
/// <summary>
/// Form to record a sales tax payment to the tax authority. Shows the current Sales Tax Payable
/// (2200) liability and a bank-account picker. Relieves the liability that invoices accumulate.
/// </summary>
// GET: /JournalEntries/SalesTaxPayment
public async Task<IActionResult> SalesTaxPayment()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
ViewBag.TaxLiability = taxAcct?.CurrentBalance ?? 0m;
ViewBag.TaxAccountFound = taxAcct != null;
var banks = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
&& (a.AccountSubType == AccountSubType.Checking
|| a.AccountSubType == AccountSubType.Savings
|| a.AccountSubType == AccountSubType.Cash));
ViewBag.BankAccounts = banks.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())).ToList();
return View();
}
/// <summary>
/// Records a sales tax remittance as a posted journal entry: DR Sales Tax Payable (2200) / CR the
/// chosen bank account. Honors the period lock. The reporting already accounts for posted JE lines,
/// so this is all that's needed to relieve the liability.
/// </summary>
// POST: /JournalEntries/SalesTaxPayment
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SalesTaxPayment(decimal amount, DateTime paymentDate, int bankAccountId, string? reference)
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
if (amount <= 0)
{
TempData["Error"] = "Enter a payment amount greater than zero.";
return RedirectToAction(nameof(SalesTaxPayment));
}
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
if (taxAcct == null)
{
TempData["Error"] = "No active Sales Tax Payable (2200) account found in your chart of accounts.";
return RedirectToAction(nameof(SalesTaxPayment));
}
var bankAcct = await _unitOfWork.Accounts.GetByIdAsync(bankAccountId);
if (bankAcct == null || bankAcct.CompanyId != companyId)
{
TempData["Error"] = "Select a valid bank account to pay from.";
return RedirectToAction(nameof(SalesTaxPayment));
}
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
if (Web.Helpers.AccountingPeriodValidator.IsLocked(paymentDate, company?.BookLockedThrough))
{
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
return RedirectToAction(nameof(SalesTaxPayment));
}
var entryNumber = await GenerateEntryNumberAsync(companyId);
var entry = new JournalEntry
{
EntryNumber = entryNumber,
EntryDate = paymentDate,
Reference = string.IsNullOrWhiteSpace(reference) ? "Sales tax remittance" : reference.Trim(),
Description = $"Sales tax remittance — {amount:C}",
Status = JournalEntryStatus.Posted,
PostedAt = DateTime.UtcNow,
PostedBy = User.Identity?.Name,
CompanyId = companyId,
Lines = new List<JournalEntryLine>
{
new() { AccountId = taxAcct.Id, DebitAmount = amount, CreditAmount = 0, Description = "Sales tax paid to authority", LineOrder = 0, CompanyId = companyId },
new() { AccountId = bankAcct.Id, DebitAmount = 0, CreditAmount = amount, Description = $"Paid from {bankAcct.Name}", LineOrder = 1, CompanyId = companyId }
}
};
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
await _unitOfWork.JournalEntries.AddAsync(entry);
await _accountBalanceService.DebitAsync(taxAcct.Id, amount); // reduce the liability
await _accountBalanceService.CreditAsync(bankAcct.Id, amount); // cash leaves the bank
await _unitOfWork.CompleteAsync();
});
TempData["Success"] = $"Recorded sales tax remittance of {amount:C} ({entryNumber}).";
return RedirectToAction(nameof(Details), new { id = entry.Id });
}
// ── Reverse ──────────────────────────────────────────────────────────────
[HttpPost]
@@ -0,0 +1,72 @@
@{
ViewData["Title"] = "Record Sales Tax Payment";
ViewData["PageIcon"] = "bi-cash-stack";
var taxLiability = (decimal)(ViewBag.TaxLiability ?? 0m);
var taxFound = (bool)(ViewBag.TaxAccountFound ?? false);
var banks = ViewBag.BankAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>();
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<h5 class="mb-0">Record Sales Tax Payment</h5>
</div>
@if (!taxFound)
{
<div class="alert alert-warning">
No active <strong>Sales Tax Payable (2200)</strong> account was found in your chart of accounts. Add one first.
</div>
}
else
{
<div class="row">
<div class="col-lg-7">
<div class="card shadow-sm mb-3">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<span class="text-muted d-block">Current Sales Tax Payable</span>
<span class="text-muted small">Tax collected on invoices and owed to the authority.</span>
</div>
<span class="h4 mb-0 @(taxLiability > 0 ? "text-danger" : "text-success")">@taxLiability.ToString("C")</span>
</div>
</div>
<form asp-action="SalesTaxPayment" method="post" class="card shadow-sm">
@Html.AntiForgeryToken()
<div class="card-body">
<div class="mb-3">
<label class="form-label">Amount paid</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" min="0.01" name="amount" class="form-control"
value="@(taxLiability > 0 ? taxLiability.ToString("0.00") : "")" required />
</div>
<div class="form-text">Defaults to the full balance &mdash; edit if you're paying a partial period.</div>
</div>
<div class="mb-3">
<label class="form-label">Payment date</label>
<input type="date" name="paymentDate" class="form-control" value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
</div>
<div class="mb-3">
<label class="form-label">Paid from (bank account)</label>
<select name="bankAccountId" class="form-select" required>
<option value="">Select an account&hellip;</option>
@foreach (var b in banks)
{
<option value="@b.Value">@b.Text</option>
}
</select>
</div>
<div class="mb-3">
<label class="form-label">Reference / period <span class="text-muted">(optional)</span></label>
<input type="text" name="reference" class="form-control" placeholder="e.g. Q2 2026 sales tax" />
</div>
<div class="alert alert-light border small mb-3">
Posts a journal entry: <strong>DR</strong> Sales Tax Payable / <strong>CR</strong> the chosen bank account.
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>Record payment</button>
</div>
</form>
</div>
</div>
}
@@ -31,6 +31,9 @@
<a asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<p class="text-muted mb-0">@Model.From.ToString("MMM d") &ndash; @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
<div class="ms-auto d-flex gap-2">
<a href="@Url.Action("SalesTaxPayment", "JournalEntries")" class="btn btn-sm btn-primary no-print">
<i class="bi bi-cash-stack me-1"></i>Record Payment
</a>
<a href="@Url.Action("SalesTaxCsv", new { from = Model.From.ToString("yyyy-MM-dd"), to = Model.To.ToString("yyyy-MM-dd") })"
class="btn btn-sm btn-outline-success no-print">
<i class="bi bi-filetype-csv me-1"></i>Export CSV