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:
@@ -196,6 +196,105 @@ public class JournalEntriesController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
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 ──────────────────────────────────────────────────────────────
|
// ── Reverse ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost]
|
[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 — 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…</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>
|
<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") – @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
|
<p class="text-muted mb-0">@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy") · @(Model.TaxableInvoiceCount + Model.NonTaxableInvoiceCount) invoices</p>
|
||||||
<div class="ms-auto d-flex gap-2">
|
<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") })"
|
<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">
|
class="btn btn-sm btn-outline-success no-print">
|
||||||
<i class="bi bi-filetype-csv me-1"></i>Export CSV
|
<i class="bi bi-filetype-csv me-1"></i>Export CSV
|
||||||
|
|||||||
Reference in New Issue
Block a user