From 27cf4532cf7088687b9ae7f919da097d63fcb1b2 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 20 Jun 2026 18:06:06 -0400 Subject: [PATCH] Reconcile account type/sub-type in QBO + CSV imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining type/sub-type-consistency vector (create/edit + IIF were already covered). The sign convention keys off sub-type, so a mismatched pair posts with the wrong debit/credit sign. - New AccountClassification.DefaultSubTypeForType(type). - QBO import: QBO Type is reliable but DetailType frequently isn't mappable and fell back to AccountSubType.Other (an expense-range sub-type) — so an unmapped Liability/Equity/Revenue account would have posted debit-normal. Now reconciles the sub-type to the type when they disagree. - CSV import: type and sub-type came from independent columns; now derives the type from the sub-type (sub-type authoritative, matching create/edit). - IIF import already returns consistent (type, sub-type) tuples — unchanged. Build clean; 293 unit tests pass. Co-Authored-By: Claude Opus 4.8 --- .../Enums/AccountClassification.cs | 17 +++++++++++++++++ .../Services/CsvImportService.cs | 4 ++++ .../Services/QuickBooksOnlineService.cs | 9 ++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/PowderCoating.Core/Enums/AccountClassification.cs b/src/PowderCoating.Core/Enums/AccountClassification.cs index 20ee752..af11a84 100644 --- a/src/PowderCoating.Core/Enums/AccountClassification.cs +++ b/src/PowderCoating.Core/Enums/AccountClassification.cs @@ -28,4 +28,21 @@ public static class AccountClassification // All expense sub-types (enum values >= 50) and any future additions default to Expense. _ => AccountType.Expense }; + + /// + /// Returns a sensible generic for a given . + /// Used by importers (e.g. QuickBooks) to reconcile a sub-type back to its parent type when the + /// source's detail-type couldn't be mapped to a specific sub-type — without this, an unmapped + /// liability/equity/revenue account would fall back to Other (an expense-range sub-type) + /// and post with the wrong debit/credit sign, since the sign convention keys off sub-type. + /// + public static AccountSubType DefaultSubTypeForType(AccountType type) => type switch + { + AccountType.Asset => AccountSubType.OtherCurrentAsset, + AccountType.Liability => AccountSubType.OtherCurrentLiability, + AccountType.Equity => AccountSubType.OwnersEquity, + AccountType.Revenue => AccountSubType.OtherIncome, + AccountType.CostOfGoods => AccountSubType.CostOfGoodsSold, + _ => AccountSubType.Other + }; } diff --git a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs index 84cae98..04cb433 100644 --- a/src/PowderCoating.Infrastructure/Services/CsvImportService.cs +++ b/src/PowderCoating.Infrastructure/Services/CsvImportService.cs @@ -2866,6 +2866,10 @@ public class CsvImportService : ICsvImportService continue; } + // Sub-type is authoritative (matches account create/edit): derive the parent type + // from it so a mismatched CSV pair can't post with the wrong debit/credit sign. + accountType = AccountClassification.TypeForSubType(accountSubType); + DateTime? openingBalanceDate = null; if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate) && DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate)) diff --git a/src/PowderCoating.Web/Services/QuickBooksOnlineService.cs b/src/PowderCoating.Web/Services/QuickBooksOnlineService.cs index b937917..12243a3 100644 --- a/src/PowderCoating.Web/Services/QuickBooksOnlineService.cs +++ b/src/PowderCoating.Web/Services/QuickBooksOnlineService.cs @@ -565,7 +565,14 @@ public class QuickBooksOnlineService var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null; result.TotalRecords++; - rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType))); + // QBO Type (high-level) is reliable; DetailType often isn't mappable and falls back to + // Other. Reconcile so sub-type's parent always matches the type — otherwise an unmapped + // liability/equity/revenue would get an expense-range sub-type and post with the wrong sign. + var acctType = MapQboAccountType(typeStr); + var subType = MapQboDetailType(detailType); + if (AccountClassification.TypeForSubType(subType) != acctType) + subType = AccountClassification.DefaultSubTypeForType(acctType); + rows.Add((displayName, parentName, number, desc, acctType, subType)); } // Pass 1: upsert every account WITHOUT parent links so they all get IDs.