Add company default GL accounts; move QB sign-fix to platform
Default accounts: companies can now set a default Revenue, COGS, and Inventory account (Chart of Accounts -> "Default Accounts" card). Stored on CompanyPreferences (3 nullable FKs + migration). Used as the fallback when an item or invoice line leaves its account blank: - Invoice lines fall back to the default Revenue account, then to 4000. - New inventory/catalog items are pre-filled with the COGS/Inventory defaults, so the value is stored on the item and both live posting and the balance-recompute path stay consistent. Blank defaults = unchanged behavior, so nothing changes until a company opts in. Setting both COGS + Inventory enables perpetual-inventory COGS posting (warned in the UI and help docs). Help KB + Settings article updated. Also moves the "Fix QB Import Signs" tool off the company Chart of Accounts page (was CompanyAdmin-visible) to the SuperAdmin-only platform Company Details page, operating on the target company. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,18 @@ public class CompanyPreferences : BaseEntity
|
|||||||
public string InvoiceNumberPrefix { get; set; } = "INV";
|
public string InvoiceNumberPrefix { get; set; } = "INV";
|
||||||
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
|
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
|
||||||
|
|
||||||
|
// Default GL Accounts — used as the fallback when an item leaves its account field blank.
|
||||||
|
// Null means "no default": revenue falls back to account 4000, and inventory-consumption
|
||||||
|
// COGS simply isn't posted (consistent with expensing materials at purchase). A company
|
||||||
|
// only opts into perpetual-inventory COGS posting by setting both the COGS and Inventory
|
||||||
|
// defaults (or the per-item accounts). FKs are nullable with no cascade — accounts soft-delete.
|
||||||
|
/// <summary>Default Revenue account for invoice lines that don't specify one (fallback before account 4000).</summary>
|
||||||
|
public int? DefaultRevenueAccountId { get; set; }
|
||||||
|
/// <summary>Default COGS account pre-filled on new inventory/catalog items. Drives inventory-consumption COGS posting when paired with an inventory account.</summary>
|
||||||
|
public int? DefaultCogsAccountId { get; set; }
|
||||||
|
/// <summary>Default Inventory asset account pre-filled on new inventory items. Drives inventory-consumption COGS posting when paired with a COGS account.</summary>
|
||||||
|
public int? DefaultInventoryAccountId { get; set; }
|
||||||
|
|
||||||
// Job / Workflow Defaults
|
// Job / Workflow Defaults
|
||||||
public string DefaultJobPriority { get; set; } = "Normal";
|
public string DefaultJobPriority { get; set; } = "Normal";
|
||||||
public bool RequireCustomerPO { get; set; } = false;
|
public bool RequireCustomerPO { get; set; } = false;
|
||||||
|
|||||||
@@ -946,6 +946,26 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(i => i.CogsAccountId)
|
.HasForeignKey(i => i.CogsAccountId)
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
// CompanyPreferences → Default Revenue / COGS / Inventory accounts (nullable, no cascade,
|
||||||
|
// no navigation property — accounts use soft delete and these are config pointers only).
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultRevenueAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultCogsAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultInventoryAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
||||||
modelBuilder.Entity<CatalogItem>()
|
modelBuilder.Entity<CatalogItem>()
|
||||||
.HasOne(ci => ci.RevenueAccount)
|
.HasOne(ci => ci.RevenueAccount)
|
||||||
|
|||||||
Generated
+11412
File diff suppressed because it is too large
Load Diff
+151
@@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCompanyDefaultGlAccounts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultCogsAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultInventoryAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultRevenueAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultCogsAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultInventoryAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultRevenueAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2185,6 +2185,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultCogsAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultCurrency")
|
b.Property<string>("DefaultCurrency")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2193,6 +2196,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultInventoryAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultJobPriority")
|
b.Property<string>("DefaultJobPriority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2204,6 +2210,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("DefaultQuoteValidityDays")
|
b.Property<int>("DefaultQuoteValidityDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultRevenueAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultTimeFormat")
|
b.Property<string>("DefaultTimeFormat")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2380,6 +2389,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.HasIndex("CompanyId")
|
b.HasIndex("CompanyId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("DefaultCogsAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("DefaultInventoryAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("DefaultRevenueAccountId");
|
||||||
|
|
||||||
b.ToTable("CompanyPreferences");
|
b.ToTable("CompanyPreferences");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7241,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7252,7 +7267,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7263,7 +7278,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -9580,6 +9595,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultCogsAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultInventoryAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultRevenueAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
b.Navigation("Company");
|
b.Navigation("Company");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ public class AccountsController : Controller
|
|||||||
.OrderBy(g => (int)g.Key)
|
.OrderBy(g => (int)g.Key)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
|
||||||
|
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
|
||||||
|
|
||||||
return View(grouped);
|
return View(grouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,20 +325,49 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
|
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
|
||||||
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
|
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
|
||||||
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
|
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
|
||||||
/// balances as positive amounts with the credit/debit nature implied by account type. This
|
/// accounts (Inventory sub-type first) so a company that classified its inventory account
|
||||||
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
|
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
|
||||||
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
|
|
||||||
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: /Accounts/FixOpeningBalanceSigns
|
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
|
||||||
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances.
|
{
|
||||||
// This flips them to positive so the chart of accounts displays correctly.
|
SelectListItem Item(Account a) => new($"{a.AccountNumber} – {a.Name}", a.Id.ToString());
|
||||||
|
|
||||||
|
ViewBag.DefaultRevenueAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
|
||||||
|
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
ViewBag.DefaultCogsAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
|
||||||
|
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
ViewBag.DefaultInventoryAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
|
||||||
|
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||||||
|
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
|
||||||
|
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
|
||||||
|
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
|
||||||
|
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
|
||||||
|
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
|
||||||
|
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
|
||||||
|
/// company and to be of the expected account type before it is stored; an invalid or cleared
|
||||||
|
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
|
||||||
|
/// </summary>
|
||||||
|
// POST: /Accounts/SaveDefaultAccounts
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
public async Task<IActionResult> FixOpeningBalanceSigns()
|
public async Task<IActionResult> SaveDefaultAccounts(
|
||||||
|
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
|
||||||
{
|
{
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
if (companyId == null)
|
if (companyId == null)
|
||||||
@@ -346,30 +378,37 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
int fixed_ = 0;
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
|
||||||
foreach (var acct in accounts)
|
if (prefs == null)
|
||||||
{
|
{
|
||||||
if (acct.OpeningBalance < 0 &&
|
TempData["Error"] = "Company preferences not found.";
|
||||||
acct.AccountType is Core.Enums.AccountType.Revenue
|
return RedirectToAction(nameof(Index));
|
||||||
or Core.Enums.AccountType.Liability
|
|
||||||
or Core.Enums.AccountType.Equity)
|
|
||||||
{
|
|
||||||
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
|
||||||
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
|
||||||
await _unitOfWork.Accounts.UpdateAsync(acct);
|
|
||||||
fixed_++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate each pick belongs to this company, is active, and is of the right type.
|
||||||
|
// Explicit CompanyId predicate (defense in depth) alongside the global tenant filter.
|
||||||
|
async Task<int?> Validate(int? id, params AccountType[] allowed)
|
||||||
|
{
|
||||||
|
if (id == null) return null;
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
|
||||||
|
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
|
||||||
|
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
|
||||||
|
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
|
||||||
|
|
||||||
|
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = fixed_ > 0
|
|
||||||
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
|
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
|
||||||
: "No accounts needed fixing — all opening balances already have the correct sign.";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
|
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
|
||||||
TempData["Error"] = "An error occurred while fixing opening balances.";
|
TempData["Error"] = "An error occurred while saving the default accounts.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
|
|||||||
@@ -208,10 +208,16 @@ namespace PowderCoating.Web.Controllers
|
|||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
|
|
||||||
|
// Pre-fill the GL account dropdowns from the company's configured defaults.
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
var model = new CreateCatalogItemDto
|
var model = new CreateCatalogItemDto
|
||||||
{
|
{
|
||||||
CategoryId = categoryId ?? 0,
|
CategoryId = categoryId ?? 0,
|
||||||
DisplayOrder = 0
|
DisplayOrder = 0,
|
||||||
|
RevenueAccountId = prefs?.DefaultRevenueAccountId,
|
||||||
|
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
|
|||||||
@@ -754,6 +754,69 @@ public class CompaniesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time data repair for a company whose chart of accounts was imported from QuickBooks
|
||||||
|
/// IIF files. QuickBooks stores credit-normal account opening balances as negative numbers
|
||||||
|
/// (e.g. Revenue, Liability, Equity), but this app's convention is positive opening balances
|
||||||
|
/// with the debit/credit nature implied by account type. This flips negative opening balances
|
||||||
|
/// on Revenue/Liability/Equity accounts to their absolute values so the chart of accounts
|
||||||
|
/// reads correctly. Afterward, Recalculate Balances (on the Chart of Accounts page) should be
|
||||||
|
/// run to propagate the corrected opening balances into CurrentBalance.
|
||||||
|
/// <para>
|
||||||
|
/// This is a SuperAdmin-only platform tool — it operates on the target company identified by
|
||||||
|
/// <paramref name="id"/> (not the caller's tenant), so it uses <c>ignoreQueryFilters</c> to
|
||||||
|
/// reach across the multi-tenancy boundary. It was deliberately moved here from the company
|
||||||
|
/// Chart of Accounts page so normal company admins can't see or trigger it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
// POST: Companies/FixOpeningBalanceSigns/5
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> FixOpeningBalanceSigns(int id)
|
||||||
|
{
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||||
|
if (company == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Company not found.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Explicit CompanyId predicate + ignoreQueryFilters: SuperAdmin acts on another
|
||||||
|
// tenant, so the global multi-tenancy filter must be bypassed but scoping kept tight.
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.CompanyId == id && a.IsActive, ignoreQueryFilters: true);
|
||||||
|
|
||||||
|
int fixedCount = 0;
|
||||||
|
foreach (var acct in accounts)
|
||||||
|
{
|
||||||
|
if (acct.OpeningBalance < 0 &&
|
||||||
|
acct.AccountType is Core.Enums.AccountType.Revenue
|
||||||
|
or Core.Enums.AccountType.Liability
|
||||||
|
or Core.Enums.AccountType.Equity)
|
||||||
|
{
|
||||||
|
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
||||||
|
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
||||||
|
await _unitOfWork.Accounts.UpdateAsync(acct);
|
||||||
|
fixedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData[fixedCount > 0 ? "Success" : "Info"] = fixedCount > 0
|
||||||
|
? $"Fixed {fixedCount} account(s) with negative opening balances for '{company.CompanyName}'. Run Recalculate Balances on the company's Chart of Accounts to update CurrentBalance."
|
||||||
|
: $"No accounts needed fixing for '{company.CompanyName}' — all opening balances already have the correct sign.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", id);
|
||||||
|
TempData["Error"] = "An error occurred while fixing opening balances.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
|
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
|
||||||
/// Used when a company needs more than one admin or when the original admin's account must
|
/// Used when a company needs more than one admin or when the original admin's account must
|
||||||
|
|||||||
@@ -275,10 +275,18 @@ public class InventoryController : Controller
|
|||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
||||||
|
|
||||||
|
// Pre-fill the GL account dropdowns from the company's configured defaults so new items
|
||||||
|
// inherit them (the user can still change or clear them on the form).
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
return View(new CreateInventoryItemDto
|
return View(new CreateInventoryItemDto
|
||||||
{
|
{
|
||||||
CoverageSqFtPerLb = 30,
|
CoverageSqFtPerLb = 30,
|
||||||
TransferEfficiency = 65
|
TransferEfficiency = 65,
|
||||||
|
InventoryAccountId = prefs?.DefaultInventoryAccountId,
|
||||||
|
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -412,8 +412,12 @@ public class InvoicesController : Controller
|
|||||||
.ToDictionary(ci => ci.Id)
|
.ToDictionary(ci => ci.Id)
|
||||||
: new Dictionary<int, CatalogItem>();
|
: new Dictionary<int, CatalogItem>();
|
||||||
|
|
||||||
// Fall back to the default revenue account (4000) if a catalog item has no specific account
|
// Fall back to the company's configured default revenue account when a catalog item
|
||||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
// has no specific account; if none is configured, fall back to the seeded 4000 account.
|
||||||
|
Account? defaultRevenueAccount = null;
|
||||||
|
if (prefs?.DefaultRevenueAccountId != null)
|
||||||
|
defaultRevenueAccount = await _unitOfWork.Accounts.GetByIdAsync(prefs.DefaultRevenueAccountId.Value);
|
||||||
|
defaultRevenueAccount ??= await _unitOfWork.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||||
|
|
||||||
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
||||||
|
|||||||
@@ -678,6 +678,9 @@ public static class HelpKnowledgeBase
|
|||||||
**Step 5 — Set up your Chart of Accounts (for billing/AP)**
|
**Step 5 — Set up your Chart of Accounts (for billing/AP)**
|
||||||
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
|
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
|
||||||
|
|
||||||
|
**Default accounts (Chart of Accounts → Set Defaults)**
|
||||||
|
On the Chart of Accounts page, the "Default Accounts" card lets you choose a default Revenue, COGS, and Inventory account for your company. These are used automatically when an item or invoice line doesn't specify one: invoice lines fall back to your default Revenue account (then to account 4000 if none is set), and new inventory and catalog items are pre-filled with your default COGS/Inventory accounts. Leave any blank to keep the current behavior. Note: setting BOTH a COGS and an Inventory Asset default makes new items post inventory-consumption COGS (perpetual inventory) — leave them blank if you expense materials when you purchase them.
|
||||||
|
|
||||||
**What happens if Operating Costs are zero?**
|
**What happens if Operating Costs are zero?**
|
||||||
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
|
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,6 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-end mb-4">
|
<div class="d-flex justify-content-end mb-4">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<form asp-action="FixOpeningBalanceSigns" method="post"
|
|
||||||
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<button type="submit" class="btn btn-outline-warning"
|
|
||||||
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
|
||||||
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
|
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
|
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
|
||||||
@@ -93,6 +85,80 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
var revenueAccts = ViewBag.DefaultRevenueAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
var cogsAccts = ViewBag.DefaultCogsAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
var inventoryAccts = ViewBag.DefaultInventoryAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
int? selRevenue = ViewBag.DefaultRevenueAccountId as int?;
|
||||||
|
int? selCogs = ViewBag.DefaultCogsAccountId as int?;
|
||||||
|
int? selInventory = ViewBag.DefaultInventoryAccountId as int?;
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="card-title mb-0"><i class="bi bi-gear me-2 text-primary"></i>Default Accounts</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#defaultAccountsBody">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Set Defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="defaultAccountsBody" class="collapse">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
These accounts are used automatically when an item or invoice line doesn't specify one.
|
||||||
|
Leave any blank to keep the current behavior.
|
||||||
|
</p>
|
||||||
|
<form asp-action="SaveDefaultAccounts" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Revenue</label>
|
||||||
|
<select name="defaultRevenueAccountId" class="form-select">
|
||||||
|
<option value="">(No default — uses 4000)</option>
|
||||||
|
@foreach (var o in revenueAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selRevenue?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Fallback revenue account for invoice lines.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">COGS</label>
|
||||||
|
<select name="defaultCogsAccountId" class="form-select">
|
||||||
|
<option value="">(No default)</option>
|
||||||
|
@foreach (var o in cogsAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selCogs?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Pre-fills new inventory & catalog items.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Inventory Asset</label>
|
||||||
|
<select name="defaultInventoryAccountId" class="form-select">
|
||||||
|
<option value="">(No default)</option>
|
||||||
|
@foreach (var o in inventoryAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selInventory?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Pre-fills new inventory items.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning alert-permanent small mt-3 mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items post
|
||||||
|
inventory-consumption COGS (perpetual inventory). Leave these blank if you expense materials
|
||||||
|
when purchased.
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save Defaults
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!Model.Any())
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
|
|||||||
@@ -618,6 +618,36 @@
|
|||||||
|
|
||||||
</div><!-- /tab-content -->
|
</div><!-- /tab-content -->
|
||||||
|
|
||||||
|
<!-- Maintenance Tools (SuperAdmin platform utilities) -->
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="bi bi-tools me-2"></i>Maintenance Tools
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start border rounded p-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><i class="bi bi-sign-stop me-1"></i>Fix QuickBooks Import Signs</h6>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Flips negative opening balances on Revenue, Liability, and Equity accounts to positive.
|
||||||
|
QuickBooks IIF exports store these credit-normal accounts as negative numbers; this
|
||||||
|
corrects them so the Chart of Accounts reads correctly. Run <strong>Recalculate Balances</strong>
|
||||||
|
on the company's Chart of Accounts afterward. Safe to run more than once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form asp-action="FixOpeningBalanceSigns" asp-route-id="@Model.Id" method="post"
|
||||||
|
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks for this company?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-outline-warning ms-3 text-nowrap"
|
||||||
|
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
||||||
|
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone (outside tabs — always present) -->
|
<!-- Danger Zone (outside tabs — always present) -->
|
||||||
<div class="card shadow-sm border-danger mt-4">
|
<div class="card shadow-sm border-danger mt-4">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
|
|||||||
@@ -237,6 +237,31 @@
|
|||||||
The chart of accounts is typically configured once during initial setup. You can add new accounts
|
The chart of accounts is typically configured once during initial setup. You can add new accounts
|
||||||
at any time if your accounting needs expand.
|
at any time if your accounting needs expand.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2">Default Accounts</h3>
|
||||||
|
<p>
|
||||||
|
The <strong>Default Accounts</strong> card at the top of the Chart of Accounts page lets you choose a
|
||||||
|
default <strong>Revenue</strong>, <strong>COGS</strong>, and <strong>Inventory Asset</strong> account
|
||||||
|
for your company. These are used automatically when an item or invoice line doesn't specify its own:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
<li class="mb-1"><strong>Revenue</strong> — invoice lines without a specific revenue account fall back to this one (and to account 4000 if you haven't set a default).</li>
|
||||||
|
<li class="mb-1"><strong>COGS</strong> and <strong>Inventory Asset</strong> — new inventory and catalog items are pre-filled with these, so you don't have to pick them every time. You can still change or clear them on each item.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Leave any of them blank to keep the current behavior. Click <strong>Set Defaults</strong> to expand the
|
||||||
|
card, choose your accounts, and save.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items record
|
||||||
|
inventory-consumption cost (COGS) to the general ledger as they're used — this is
|
||||||
|
perpetual-inventory accounting. If you expense materials when you <em>purchase</em> them, leave
|
||||||
|
these two blank to avoid double-counting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user